diff --git a/.borp.yaml b/.borp.yaml new file mode 100644 index 00000000000..8d6f28179b5 --- /dev/null +++ b/.borp.yaml @@ -0,0 +1,3 @@ +files: + - 'test/**/*.test.js' + - 'test/**/*.test.mjs' diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index 7c7b9ae4c12..00000000000 --- a/.eslintrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "root": true, - "extends": "standard" -} diff --git a/.gitattributes b/.gitattributes index cad1c32e3de..a0e7df931f9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,2 @@ -# Set the default behavior, in case people don't have core.autocrlf set -* text=auto - -# Require Unix line endings -* text eol=lf +# Set default behavior to automatically convert line endings +* text=auto eol=lf diff --git a/.github/dependabot.yml b/.github/dependabot.yml index dfa7fa6cba8..871f58a50e7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -2,12 +2,47 @@ version: 2 updates: - package-ecosystem: "github-actions" directory: "/" + commit-message: + # Prefix all commit messages with "chore: " + prefix: "chore" schedule: interval: "monthly" open-pull-requests-limit: 10 - package-ecosystem: "npm" directory: "/" + commit-message: + # Prefix all commit messages with "chore: " + prefix: "chore" schedule: - interval: "weekly" + interval: "monthly" open-pull-requests-limit: 10 + groups: + # Production dependencies without breaking changes + dependencies: + dependency-type: "production" + update-types: + - "minor" + - "patch" + # Production dependencies with breaking changes + dependencies-major: + dependency-type: "production" + update-types: + - "major" + # ESLint related dependencies + dev-dependencies-eslint: + patterns: + - "eslint" + - "neostandard" + - "@stylistic/*" + # TypeScript related dependencies + dev-dependencies-typescript: + patterns: + - "@types/*" + - "tsd" + - "typescript" + # Ajv related dependencies + dev-dependencies-ajv: + patterns: + - "ajv" + - "ajv-*" diff --git a/.github/labeler.yml b/.github/labeler.yml index 71d7124fbb9..701f3ba18ee 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,10 +1,16 @@ # PRs that only touch the docs folder documentation: -- all: ["docs/**/*"] +- changed-files: + - any-glob-to-any-file: docs/** + +"github actions": +- changed-files: + - any-glob-to-any-file: ".github/workflows/*" # PRs that only touch type files typescript: -- all: ["**/*[.|-]d.ts"] +- changed-files: + - any-glob-to-any-file: "**/*[.|-]d.ts" plugin: - all: ["docs/Guides/Ecosystem.md"] diff --git a/.github/scripts/lint-ecosystem.js b/.github/scripts/lint-ecosystem.js index f81546e9b77..217839a2d3f 100644 --- a/.github/scripts/lint-ecosystem.js +++ b/.github/scripts/lint-ecosystem.js @@ -1,59 +1,140 @@ 'use strict' -const path = require('path') -const fs = require('fs') -const readline = require('readline') +const path = require('node:path') +const fs = require('node:fs') +const readline = require('node:readline') -const ecosystemDocFile = path.join(__dirname, '..', '..', 'docs', 'Guides', 'Ecosystem.md') +const basePathEcosystemDocFile = path.join('docs', 'Guides', 'Ecosystem.md') +const ecosystemDocFile = path.join(__dirname, '..', '..', basePathEcosystemDocFile) +const failureTypes = { + improperFormat: 'improperFormat', + outOfOrderItem: 'outOfOrderItem' +} module.exports = async function ({ core }) { + const results = await runCheck() + await handleResults({ core }, results) +} + +async function runCheck () { const stream = await fs.createReadStream(ecosystemDocFile) const rl = readline.createInterface({ input: stream, crlfDelay: Infinity - }); + }) - const moduleNameRegex = /^\- \[\`(.+)\`\]/ - let hasOutOfOrderItem = false + const failures = [] + const successes = [] + const moduleNameRegex = /^- \[`(.+)`\]/ let lineNumber = 0 - let inCommmunitySection = false let modules = [] + let grouping = 'core' for await (const line of rl) { lineNumber += 1 if (line.startsWith('#### [Community]')) { - inCommmunitySection = true + grouping = 'community' + modules = [] } + if (line.startsWith('#### [Community Tools]')) { - inCommmunitySection = false + grouping = 'community-tools' + modules = [] } - if (inCommmunitySection === false) { + + if (line.startsWith('- [') !== true) { continue } - if (line.startsWith('- [`') !== true) { + const moduleNameTest = moduleNameRegex.exec(line) + + if (moduleNameTest === null) { + failures.push({ + lineNumber, + grouping, + moduleName: 'unknown', + type: failureTypes.improperFormat + }) continue } - const moduleName = moduleNameRegex.exec(line)[1] + const moduleName = moduleNameTest[1] if (modules.length > 0) { if (compare(moduleName, modules.at(-1)) > 0) { - core.error(`line ${lineNumber}: ${moduleName} not listed in alphabetical order`) - hasOutOfOrderItem = true + failures.push({ + lineNumber, + moduleName, + grouping, + type: failureTypes.outOfOrderItem + }) + } else { + successes.push({ moduleName, lineNumber, grouping }) } + } else { + // We have to push the first item found or we are missing items from the list + successes.push({ moduleName, lineNumber, grouping }) } modules.push(moduleName) } - if (hasOutOfOrderItem === true) { - core.setFailed('Some ecosystem modules are not in alphabetical order.') + return { failures, successes } +} + +async function handleResults (scriptLibs, results) { + const { core } = scriptLibs + const { failures, successes } = results + const isError = !!failures.length + + await core.summary + .addHeading(isError ? `❌ Ecosystem.md Lint (${failures.length} error${failures.length === 1 ? '' : 's'})` : '✅ Ecosystem Lint (no errors found)') + .addTable([ + [ + { data: 'Status', header: true }, + { data: 'Section', header: true }, + { data: 'Module', header: true }, + { data: 'Details', header: true }], + ...failures.map((failure) => [ + '❌', + failure.grouping, + failure.moduleName, + `Line Number: ${failure.lineNumber.toString()} - ${failure.type}` + ]), + ...successes.map((success) => [ + '✅', + success.grouping, + success.moduleName, + '-' + ]) + ]) + .write() + + if (isError) { + failures.forEach((failure) => { + if (failure.type === failureTypes.improperFormat) { + core.error('The module name should be enclosed with backticks', { + title: 'Improper format', + file: basePathEcosystemDocFile, + startLine: failure.lineNumber + }) + } else if (failure.type === failureTypes.outOfOrderItem) { + core.error(`${failure.moduleName} not listed in alphabetical order`, { + title: 'Out of Order', + file: basePathEcosystemDocFile, + startLine: failure.lineNumber + }) + } else { + core.error('Unknown error') + } + }) + + core.setFailed('Failed when linting Ecosystem.md') } } -function compare(current, previous) { +function compare (current, previous) { return previous.localeCompare( current, 'en', - {sensitivity: 'base'} + { sensitivity: 'base' } ) } diff --git a/.github/stale.yml b/.github/stale.yml deleted file mode 100644 index 5130bd817b1..00000000000 --- a/.github/stale.yml +++ /dev/null @@ -1,22 +0,0 @@ -# Number of days of inactivity before an issue becomes stale -daysUntilStale: 15 -# Number of days of inactivity before a stale issue is closed -daysUntilClose: 7 -# Issues with these labels will never be considered stale -exemptLabels: - - "discussion" - - "feature request" - - "bug" - - "help wanted" - - "plugin suggestion" - - "good first issue" - - "never stale" -# Label to use when marking an issue as stale -staleLabel: stale -# Comment to post when marking an issue as stale. Set to `false` to disable -markComment: > - This issue has been automatically marked as stale because it has not had - recent activity. It will be closed if no further activity occurs. Thank you - for your contributions. -# Comment to post when closing a stale issue. Set to `false` to disable -closeComment: false diff --git a/.github/tests_checker.yml b/.github/tests_checker.yml deleted file mode 100644 index 7d96d55bf97..00000000000 --- a/.github/tests_checker.yml +++ /dev/null @@ -1,9 +0,0 @@ -comment: | - Hello! Thank you for contributing! - It appears that you have changed the framework code, but the tests that verify your change are missing. Could you please add them? - -fileExtensions: - - '.ts' - - '.js' - -testDir: 'test' diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index 4621cf7e510..52b66aab703 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -6,12 +6,14 @@ on: - labeled permissions: - pull-requests: write - contents: write + contents: read jobs: backport: runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write if: > github.event.pull_request.merged && ( @@ -24,6 +26,6 @@ jobs: name: Backport steps: - name: Backport - uses: tibdex/backport@v2 + uses: tibdex/backport@9565281eda0731b1d20c4025c43339fb0a23812e # v2.0.4 with: github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml deleted file mode 100644 index d80490baeb6..00000000000 --- a/.github/workflows/benchmark.yml +++ /dev/null @@ -1,93 +0,0 @@ -name: Benchmark - -on: - pull_request_target: - types: [labeled] - -jobs: - benchmark: - if: ${{ github.event.label.name == 'benchmark' }} - runs-on: ubuntu-latest - permissions: - contents: read - outputs: - PR-BENCH-14: ${{ steps.benchmark-pr.outputs.BENCH_RESULT14 }} - PR-BENCH-16: ${{ steps.benchmark-pr.outputs.BENCH_RESULT16 }} - PR-BENCH-18: ${{ steps.benchmark-pr.outputs.BENCH_RESULT18 }} - MAIN-BENCH-14: ${{ steps.benchmark-main.outputs.BENCH_RESULT14 }} - MAIN-BENCH-16: ${{ steps.benchmark-main.outputs.BENCH_RESULT16 }} - MAIN-BENCH-18: ${{ steps.benchmark-main.outputs.BENCH_RESULT18 }} - strategy: - matrix: - node-version: [14, 16, 18] - steps: - - uses: actions/checkout@v3 - with: - persist-credentials: false - ref: ${{github.event.pull_request.head.sha}} - repository: ${{github.event.pull_request.head.repo.full_name}} - - - uses: actions/setup-node@v3 - with: - node-version: ${{ matrix.node-version }} - - - name: Install - run: | - npm install --only=production --ignore-scripts - - - name: Run benchmark - id: benchmark-pr - run: | - npm run --silent benchmark > ./bench-result.md - result=$(awk '/requests in/' ./bench-result.md) - echo "::set-output name=BENCH_RESULT${{matrix.node-version}}::$result" - - # main benchmark - - uses: actions/checkout@v3 - with: - persist-credentials: false - ref: 'main' - - - name: Install - run: | - npm install --only=production --ignore-scripts - - - name: Run benchmark - id: benchmark-main - run: | - npm run --silent benchmark > ./bench-result.md - result=$(awk '/requests in/' ./bench-result.md) - echo "::set-output name=BENCH_RESULT${{matrix.node-version}}::$result" - - output-benchmark: - needs: [benchmark] - runs-on: ubuntu-latest - permissions: - pull-requests: write - steps: - - name: Comment PR - uses: thollander/actions-comment-pull-request@v1 - with: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - message: | - **Node**: 14 - **PR**: ${{ needs.benchmark.outputs.PR-BENCH-14 }} - **MAIN**: ${{ needs.benchmark.outputs.MAIN-BENCH-14 }} - - --- - - **Node**: 16 - **PR**: ${{ needs.benchmark.outputs.PR-BENCH-16 }} - **MAIN**: ${{ needs.benchmark.outputs.MAIN-BENCH-16 }} - - --- - - **Node**: 18 - **PR**: ${{ needs.benchmark.outputs.PR-BENCH-18 }} - **MAIN**: ${{ needs.benchmark.outputs.MAIN-BENCH-18 }} - - - uses: actions-ecosystem/action-remove-labels@v1 - with: - labels: | - benchmark - github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci-alternative-runtime.yml b/.github/workflows/ci-alternative-runtime.yml new file mode 100644 index 00000000000..9100941f37a --- /dev/null +++ b/.github/workflows/ci-alternative-runtime.yml @@ -0,0 +1,111 @@ +name: ci Alternative Runtimes + +on: + push: + branches: + - main + - next + - 'v*' + paths-ignore: + - 'docs/**' + - '*.md' + pull_request: + paths-ignore: + - 'docs/**' + - '*.md' + +# This allows a subsequently queued workflow run to interrupt previous runs +concurrency: + group: "${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}" + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test-unit: + runs-on: ${{ matrix.os }} + continue-on-error: true + permissions: + contents: read + strategy: + matrix: + node-version: [20] + os: [macos-latest, ubuntu-latest, windows-latest] + include: + - runtime: nsolid + node-version: 20 + nsolid-version: 5 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: nodesource/setup-nsolid@1ca68d2589d3d56ecd3881dfe6ffa87eeda9c939 # v1.0.1 + if: ${{ matrix.runtime == 'nsolid' }} + with: + node-version: ${{ matrix.node-version }} + nsolid-version: ${{ matrix.nsolid-version }} + + - name: Install + run: | + npm install --ignore-scripts + + - name: Run tests + run: | + npm run unit + + test-typescript: + runs-on: 'ubuntu-latest' + permissions: + contents: read + + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: nodesource/setup-nsolid@1ca68d2589d3d56ecd3881dfe6ffa87eeda9c939 # v1.0.1 + with: + node-version: 20 + nsolid-version: 5 + + - name: Install + run: | + npm install --ignore-scripts + + - name: Run typescript tests + run: | + npm run test:typescript + env: + NODE_OPTIONS: no-network-family-autoselection + + package: + needs: + - test-typescript + - test-unit + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: nodesource/setup-nsolid@1ca68d2589d3d56ecd3881dfe6ffa87eeda9c939 # v1.0.1 + with: + nsolid-version: 5 + - name: install fastify + run: | + npm install --ignore-scripts + - name: install webpack stack + run: | + cd test/bundler/webpack && npm install + - name: Test webpack bundle + run: | + cd test/bundler/webpack && npm run test + - name: install esbuild stack + run: | + cd test/bundler/esbuild && npm install + - name: Test esbuild bundle + run: | + cd test/bundler/esbuild && npm run test diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd7afc16a59..5f13d0c0a81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,10 @@ name: ci on: push: + branches: + - main + - next + - '*.x' paths-ignore: - 'docs/**' - '*.md' @@ -9,12 +13,21 @@ on: paths-ignore: - 'docs/**' - '*.md' + workflow_dispatch: + inputs: + nodejs-version: + description: 'Node.js version to use (e.g., 24.0.0-rc.1)' + required: true + type: string # This allows a subsequently queued workflow run to interrupt previous runs concurrency: - group: "${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}" + group: "${{ github.workflow }}-${{ github.event.pull_request.head.label || github.head_ref || github.ref }}" cancel-in-progress: true +permissions: + contents: read + jobs: dependency-review: name: Dependency Review @@ -24,26 +37,56 @@ jobs: contents: read steps: - name: Check out repo - uses: actions/checkout@v3 + uses: actions/checkout@v6 with: persist-credentials: false - name: Dependency review - uses: actions/dependency-review-action@v2 + uses: actions/dependency-review-action@05fe4576374b728f0c523d6a13d64c25081e0803 # v4.8.3 - linter: + check-licenses: + name: Check licenses runs-on: ubuntu-latest permissions: contents: read steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 with: persist-credentials: false - name: Use Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v6 with: node-version: 'lts/*' + cache: 'npm' + cache-dependency-path: package.json + check-latest: true + + - name: Install + run: | + npm install --ignore-scripts + + - name: Check licenses + run: | + npx license-checker --production --summary --onlyAllow="0BSD;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC;MIT;" + + lint: + name: Lint + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version: 'lts/*' + cache: 'npm' + cache-dependency-path: package.json + check-latest: true - name: Install run: | @@ -54,101 +97,205 @@ jobs: npm run lint coverage-nix: + needs: + - lint permissions: contents: read uses: ./.github/workflows/coverage-nix.yml + coverage-win: + needs: + - lint permissions: contents: read uses: ./.github/workflows/coverage-win.yml - test: - needs: [linter, coverage-nix, coverage-win] + test-unit: + needs: + - lint + - coverage-nix + - coverage-win runs-on: ${{ matrix.os }} permissions: contents: read strategy: matrix: - node-version: [14, 16, 18] + node-version: [20, 22, 24] os: [macos-latest, ubuntu-latest, windows-latest] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 with: persist-credentials: false - name: Use Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} + cache: 'npm' + cache-dependency-path: package.json + check-latest: true - - uses: actions/cache@v3 - id: check-cache + - name: Install + run: | + npm install --ignore-scripts + + - name: Run tests + run: | + npm run unit + + # Useful for testing Release Candidates of Node.js + test-unit-custom: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Use Custom Node.js Version + uses: actions/setup-node@v6 with: - path: ~/.npm - key: ${{ runner.os }}-node-${{ matrix.node-version }}-${{ hashFiles('**/package.json') }} - restore-keys: | - ${{ runner.os }}-node-${{ matrix.node-version }}- + node-version: ${{ github.event.inputs.nodejs-version }} + cache: 'npm' + cache-dependency-path: package.json + check-latest: true - name: Install run: | npm install --ignore-scripts - - name: Check licenses + - name: Run tests run: | - npm run license-checker + npm run unit - - name: Run tests + test-typescript: + needs: + - lint + - coverage-nix + - coverage-win + runs-on: 'ubuntu-latest' + permissions: + contents: read + + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: package.json + check-latest: true + + - name: Install run: | - npm run test:ci + npm install --ignore-scripts - automerge: - if: > - github.event_name == 'pull_request' && - github.event.pull_request.user.login == 'dependabot[bot]' - needs: test + - name: Run typescript tests + run: | + npm run test:typescript + env: + NODE_OPTIONS: no-network-family-autoselection + + test-pino-compat: + name: Test pino compatibility + needs: + - lint + - coverage-nix + - coverage-win runs-on: ubuntu-latest permissions: - pull-requests: write - contents: write + contents: read + strategy: + matrix: + pino-version: ['^9', '^10'] + steps: - - uses: fastify/github-action-merge-dependabot@v3 + - uses: actions/checkout@v6 with: - github-token: ${{ secrets.GITHUB_TOKEN }} + persist-credentials: false + + - uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: package.json + check-latest: true + + - name: Install dependencies + run: | + npm install --ignore-scripts + + - name: Install pino ${{ matrix.pino-version }} + run: | + npm install --ignore-scripts --no-save pino@${{ matrix.pino-version }} + + - name: Run unit tests + run: | + npm run unit + + - name: Run typescript tests + run: | + npm run test:typescript + env: + NODE_OPTIONS: no-network-family-autoselection package: - needs: test + needs: + - test-typescript + - test-unit + - test-pino-compat runs-on: ubuntu-latest permissions: contents: read steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 with: persist-credentials: false - name: Use Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v6 with: node-version: 'lts/*' - - uses: actions/cache@v3 - id: check-cache - with: - path: ~/.npm - key: ${{ runner.os }}-node-${{ hashFiles('**/package.json') }} - restore-keys: | - ${{ runner.os }}-node- + cache: 'npm' + cache-dependency-path: package.json + check-latest: true - name: install fastify run: | npm install --ignore-scripts - name: install webpack stack run: | - cd test/bundler/webpack && npm install + cd test/bundler/webpack && npm install --ignore-scripts - name: Test webpack bundle run: | cd test/bundler/webpack && npm run test - name: install esbuild stack run: | - cd test/bundler/esbuild && npm install + cd test/bundler/esbuild && npm install --ignore-scripts - name: Test esbuild bundle run: | cd test/bundler/esbuild && npm run test + + automerge: + if: > + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.user.login == 'dependabot[bot]' + needs: + - test-typescript + - test-unit + - package + runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: write + steps: + - uses: fastify/github-action-merge-dependabot@v3 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/citgm-package.yml b/.github/workflows/citgm-package.yml new file mode 100644 index 00000000000..4658431c2dc --- /dev/null +++ b/.github/workflows/citgm-package.yml @@ -0,0 +1,106 @@ +name: CITGM Package + +on: + workflow_dispatch: + inputs: + package: + description: 'Package to test' + required: true + type: string + + node-version: + description: 'Node version to test' + required: true + type: choice + options: + - '20' + - '22' + - '24' + - 'lts/*' + - 'nightly' + - 'current' + default: '24' + os: + description: 'Operating System' + required: false + type: choice + default: 'ubuntu-latest' + options: + - 'ubuntu-latest' + - 'windows-latest' + - 'macos-latest' + workflow_call: + inputs: + package: + description: 'Package to test' + required: true + type: string + node-version: + description: 'Node version to test' + required: true + type: string + default: '20' + os: + description: 'Operating System' + required: false + type: string + default: 'ubuntu-latest' + +permissions: + contents: read + +jobs: + core-plugins: + name: CITGM + runs-on: ${{inputs.os}} + permissions: + contents: read + steps: + - name: Check out Fastify + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ inputs.node-version }} + cache: 'npm' + cache-dependency-path: package.json + check-latest: true + + - name: Install Dependencies for Fastify + run: | + npm install --production --ignore-scripts + - name: Npm Link Fastify + run: | + npm link + - name: Determine repository URL of ${{inputs.package}} + uses: actions/github-script@v8 + id: repository-url + with: + result-encoding: string + script: | + const response = await fetch('https://registry.npmjs.org/${{inputs.package}}') + const data = await response.json() + const repositoryUrl = data.repository.url + const result = repositoryUrl.match( /.*\/([a-zA-Z0-9-_]+\/[a-zA-Z0-9-_]+)\.git/)[1] + return result + - name: Check out ${{inputs.package}} + uses: actions/checkout@v6 + with: + repository: ${{ steps.repository-url.outputs.result }} + path: package + persist-credentials: false + - name: Install Dependencies for ${{inputs.package}} + working-directory: package + run: | + npm install --ignore-scripts + - name: Sym Link Fastify + working-directory: package + run: | + npm link fastify + - name: Run Tests of ${{inputs.package}} + working-directory: package + run: | + npm test diff --git a/.github/workflows/citgm.yml b/.github/workflows/citgm.yml new file mode 100644 index 00000000000..535fdcc7194 --- /dev/null +++ b/.github/workflows/citgm.yml @@ -0,0 +1,132 @@ +name: CITGM + +on: + pull_request: + types: [labeled] + + workflow_dispatch: + inputs: + node-version: + description: 'Node version' + required: true + type: choice + options: + - '20' + - '22' + - '24' + - 'lts/*' + - 'nightly' + - 'current' + - 'latest' + default: '24' + os: + description: 'Operating System' + required: false + type: choice + default: 'ubuntu-latest' + options: + - 'ubuntu-latest' + - 'windows-latest' + - 'macos-latest' + +permissions: + contents: read + +jobs: + core-plugins: + name: CITGM + if: ${{ github.event_name == 'workflow_dispatch' || github.event.label.name == 'citgm-core-plugins' }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + package: + - '@fastify/accepts' + - '@fastify/accepts-serializer' + - '@fastify/auth' + - '@fastify/autoload' + - '@fastify/awilix' + - '@fastify/aws-lambda' + - '@fastify/basic-auth' + - '@fastify/bearer-auth' + - '@fastify/caching' + - '@fastify/circuit-breaker' + - '@fastify/compress' + - '@fastify/cookie' + - '@fastify/cors' + - '@fastify/csrf-protection' + # - '@fastify/elasticsearch' + - '@fastify/env' + - '@fastify/etag' + - '@fastify/express' + - '@fastify/flash' + - '@fastify/formbody' + - '@fastify/funky' + - '@fastify/helmet' + - '@fastify/hotwire' + - '@fastify/http-proxy' + - '@fastify/jwt' + # - '@fastify/kafka' + - '@fastify/leveldb' + - '@fastify/middie' + # - '@fastify/mongodb' + - '@fastify/multipart' + # - '@fastify/mysql' + - '@fastify/nextjs' + - '@fastify/oauth2' + - '@fastify/one-line-logger' + - '@fastify/passport' + # - '@fastify/postgres' + # - '@fastify/rate-limit' + # - '@fastify/redis' + - '@fastify/reply-from' + - '@fastify/request-context' + - '@fastify/response-validation' + - '@fastify/routes' + - '@fastify/schedule' + - '@fastify/secure-session' + - '@fastify/sensible' + - '@fastify/session' + - '@fastify/sse' + - '@fastify/static' + - '@fastify/swagger' + - '@fastify/swagger-ui' + - '@fastify/throttle' + - '@fastify/type-provider-json-schema-to-ts' + - '@fastify/type-provider-typebox' + - '@fastify/under-pressure' + - '@fastify/url-data' + - '@fastify/view' + # - '@fastify/vite' + - '@fastify/websocket' + - '@fastify/zipkin' + uses: './.github/workflows/citgm-package.yml' + with: + os: ${{ github.event_name == 'workflow_dispatch' && inputs.os || 'ubuntu-latest' }} + package: ${{ matrix.package }} + node-version: ${{ github.event_name == 'workflow_dispatch' && inputs.node-version || '24' }} + + remove-label: + if: ${{ always() && github.event_name == 'pull_request' && github.event.action == 'labeled' && github.event.label.name == 'citgm-core-plugins' }} + needs: + - core-plugins + continue-on-error: true + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Remove citgm-core-plugins label + uses: octokit/request-action@v2.x + id: remove-label + with: + route: DELETE /repos/{repo}/issues/{issue_number}/labels/{name} + repo: ${{ github.repository }} + issue_number: ${{ github.event.pull_request.number }} + name: citgm-core-plugins + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - run: "echo Successfully removed label" + if: ${{ success() }} + - run: "echo Could not remove label" + if: ${{ failure() }} diff --git a/.github/workflows/coverage-nix.yml b/.github/workflows/coverage-nix.yml index 9945372d71d..2814e1dc80a 100644 --- a/.github/workflows/coverage-nix.yml +++ b/.github/workflows/coverage-nix.yml @@ -3,44 +3,30 @@ name: Code Coverage (*nix) on: workflow_call: +permissions: + contents: read + jobs: check-coverage: runs-on: ubuntu-latest permissions: contents: read steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 with: persist-credentials: false - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v6 with: node-version: 'lts/*' - - - uses: actions/cache@v3 - id: check-cache - with: - path: ~/.npm - key: ${{ runner.os }}-node-${{ hashFiles('**/package.json') }} - restore-keys: | - ${{ runner.os }}-node- + cache: 'npm' + cache-dependency-path: package.json + check-latest: true - name: Install run: | npm install --ignore-scripts - # We do not check coverage requirements here because the goal is to - # generate a report to upload as an artifact. - - name: Generate coverage report - run: | - npm run coverage:ci - - - uses: actions/upload-artifact@v3 - if: ${{ success() }} - with: - name: coverage-report-nix - path: ./coverage/lcov-report/ - # Here, we verify the coverage thresholds so that this workflow can pass # or fail and stop further workflows if this one fails. - name: Verify coverage meets thresholds diff --git a/.github/workflows/coverage-win.yml b/.github/workflows/coverage-win.yml index 176b8792aa5..85bd44370d9 100644 --- a/.github/workflows/coverage-win.yml +++ b/.github/workflows/coverage-win.yml @@ -3,44 +3,30 @@ name: Code Coverage (win) on: workflow_call: +permissions: + contents: read + jobs: check-coverage: runs-on: windows-latest permissions: contents: read steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 with: persist-credentials: false - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v6 with: node-version: 'lts/*' - - - uses: actions/cache@v3 - id: check-cache - with: - path: ~/.npm - key: ${{ runner.os }}-node-${{ hashFiles('**/package.json') }} - restore-keys: | - ${{ runner.os }}-node- + cache: 'npm' + cache-dependency-path: package.json + check-latest: true - name: Install run: | npm install --ignore-scripts - # We do not check coverage requirements here because the goal is to - # generate a report to upload as an artifact. - - name: Generate coverage report - run: | - npm run coverage:ci - - - uses: actions/upload-artifact@v3 - if: ${{ success() }} - with: - name: coverage-report-win - path: ./coverage/lcov-report/ - # Here, we verify the coverage thresholds so that this workflow can pass # or fail and stop further workflows if this one fails. - name: Verify coverage meets thresholds diff --git a/.github/workflows/integration-alternative-runtimes.yml b/.github/workflows/integration-alternative-runtimes.yml new file mode 100644 index 00000000000..bfba5975a89 --- /dev/null +++ b/.github/workflows/integration-alternative-runtimes.yml @@ -0,0 +1,63 @@ +name: integration Alternative Runtimes + +on: + push: + branches: + - main + - next + - 'v*' + paths-ignore: + - 'docs/**' + - '*.md' + pull_request: + paths-ignore: + - 'docs/**' + - '*.md' + +permissions: + contents: read + +jobs: + install-production: + runs-on: ${{ matrix.os }} + permissions: + contents: read + + strategy: + matrix: + os: [ubuntu-latest] + runtime: [nsolid] + node-version: [20] + pnpm-version: [8] + include: + - nsolid-version: 5 + node-version: 20 + runtime: nsolid + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: nodesource/setup-nsolid@1ca68d2589d3d56ecd3881dfe6ffa87eeda9c939 # v1.0.1 + if: ${{ matrix.runtime == 'nsolid' }} + with: + node-version: ${{ matrix.node-version }} + nsolid-version: ${{ matrix.nsolid-version }} + + - name: Install Pnpm + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + with: + version: ${{ matrix.pnpm-version }} + + - name: Install Production + run: | + pnpm install --ignore-scripts --prod + + - name: Run server + run: | + node integration/server.js & + + - name: Test + if: ${{ success() }} + run: | + bash integration/test.sh diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index ee1f6f22bef..c77b04bdf6e 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -2,6 +2,10 @@ name: integration on: push: + branches: + - main + - next + - 'v*' paths-ignore: - 'docs/**' - '*.md' @@ -10,34 +14,40 @@ on: - 'docs/**' - '*.md' +permissions: + contents: read + jobs: install-production: runs-on: ${{ matrix.os }} permissions: contents: read - + strategy: matrix: - node-version: [14, 16, 18] + node-version: [20, 22, 24] os: [ubuntu-latest] + pnpm-version: [8] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 with: persist-credentials: false - + - name: Use Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} + check-latest: true - name: Install Pnpm - run: | - npm i -g pnpm - + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + with: + version: ${{ matrix.pnpm-version }} + - name: Install Production run: | - pnpm install --prod + pnpm install --ignore-scripts --prod - name: Run server run: | diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index e0fdabd7396..6f8e7a924f6 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -3,12 +3,12 @@ on: pull_request_target permissions: contents: read - pull-requests: write jobs: label: + permissions: + contents: read + pull-requests: write runs-on: ubuntu-latest steps: - - uses: actions/labeler@v4 - with: - repo-token: "${{ secrets.GITHUB_TOKEN }}" + - uses: actions/labeler@v6 diff --git a/.github/workflows/links-check.yml b/.github/workflows/links-check.yml index 618df1305df..5123940808a 100644 --- a/.github/workflows/links-check.yml +++ b/.github/workflows/links-check.yml @@ -4,27 +4,59 @@ on: pull_request: paths: - 'docs/**' - - '*.md' + - '**/*.md' + - '.github/workflows/links-check.yml' + +permissions: + contents: read jobs: linkChecker: runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Check out repo - uses: actions/checkout@v3 + uses: actions/checkout@v6 with: persist-credentials: false + fetch-depth: 0 # It will be possible to check only for the links in the changed files # See: https://github.com/lycheeverse/lychee-action/issues/17 - name: Link Checker id: lychee - uses: lycheeverse/lychee-action@76ab977fedbeaeb32029313724a2e56a8a393548 + uses: lycheeverse/lychee-action@8646ba30535128ac92d33dfc9133794bfdd9b411 # 2.8.0 with: fail: true - # As external links behaviour is not predictable, we check only internal links + # As external links behavior is not predictable, we check only internal links # to ensure consistency. # See: https://github.com/lycheeverse/lychee-action/issues/17#issuecomment-1162586751 args: --offline --verbose --no-progress './**/*.md' env: GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + + - name: Collect changed markdown files + id: changed-md + run: | + # docs/Guides/Ecosystem.md is checked by scripts/validate-ecosystem-links.js + changed_files=$(git diff --name-only --diff-filter=ACMRT "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}" | grep -E '\.md$' | grep -v '^docs/Guides/Ecosystem\.md$' || true) + + if [ -z "$changed_files" ]; then + echo "files=" >> "$GITHUB_OUTPUT" + echo "No markdown files to check with linkinator." + exit 0 + fi + + files_as_space_list=$(echo "$changed_files" | tr '\n' ' ' | xargs) + echo "files=$files_as_space_list" >> "$GITHUB_OUTPUT" + echo "Checking markdown files: $files_as_space_list" + + - name: External Link Checker + if: steps.changed-md.outputs.files != '' + uses: JustinBeckwith/linkinator-action@f62ba0c110a76effb2ee6022cc6ce4ab161085e3 # 2.4.0 + with: + paths: ${{ steps.changed-md.outputs.files }} + retry: true + redirects: error + linksToSkip: "https://github.com/orgs/fastify/.*" diff --git a/.github/workflows/lint-ecosystem-order.yml b/.github/workflows/lint-ecosystem-order.yml index e93e37422b2..866f0e09f6a 100644 --- a/.github/workflows/lint-ecosystem-order.yml +++ b/.github/workflows/lint-ecosystem-order.yml @@ -1,34 +1,32 @@ name: Lint Ecosystem Order on: - push: - branches-ignore: - - master - - main - paths: - - "**/Ecosystem.md" pull_request: branches: - - master - main paths: - "**/Ecosystem.md" +permissions: + contents: read + jobs: build: name: Lint Ecosystem Order runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout Code - uses: actions/checkout@v3 + uses: actions/checkout@v6 with: persist-credentials: false - name: Lint Doc - uses: actions/github-script@v6 + uses: actions/github-script@v8 with: + github-token: ${{ secrets.GITHUB_TOKEN }} script: | const script = require('./.github/scripts/lint-ecosystem.js') await script({ core }) - diff --git a/.github/workflows/lock-threads.yml b/.github/workflows/lock-threads.yml index 416efa98817..883286d8476 100644 --- a/.github/workflows/lock-threads.yml +++ b/.github/workflows/lock-threads.yml @@ -6,8 +6,7 @@ on: workflow_dispatch: permissions: - issues: write - pull-requests: write + contents: read concurrency: group: lock @@ -15,8 +14,11 @@ concurrency: jobs: action: runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write steps: - - uses: jsumners/lock-threads@b27edac0ac998d42b2815e122b6c24b32b568321 + - uses: jsumners/lock-threads@e460dfeb36e731f3aeb214be6b0c9a9d9a67eda6 # v3.0.0 with: issue-inactive-days: '90' exclude-any-issue-labels: 'discussion,good first issue,help wanted' diff --git a/.github/workflows/md-lint.yml b/.github/workflows/md-lint.yml index ec0047c9f24..f10d39589e0 100644 --- a/.github/workflows/md-lint.yml +++ b/.github/workflows/md-lint.yml @@ -3,38 +3,42 @@ name: Lint Docs on: push: branches-ignore: - - master - main paths: - "**/*.md" pull_request: branches: - - master - main paths: - "**/*.md" +permissions: + contents: read + jobs: build: name: Lint Markdown runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout Code - uses: actions/checkout@v3 + uses: actions/checkout@v6 with: persist-credentials: false - + - name: Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v6 with: node-version: 'lts/*' + check-latest: true - name: Install Linter run: npm install --ignore-scripts - name: Add Matcher - uses: xt0rted/markdownlint-problem-matcher@b643b0751c371f357690337d4549221347c0e1bc + uses: xt0rted/markdownlint-problem-matcher@1a5fabfb577370cfdf5af944d418e4be3ea06f27 # v3.0.0 - name: Run Linter run: ./node_modules/.bin/markdownlint-cli2 diff --git a/.github/workflows/missing_types.yml b/.github/workflows/missing_types.yml index b6d77acb2f5..a182f14bd45 100644 --- a/.github/workflows/missing_types.yml +++ b/.github/workflows/missing_types.yml @@ -5,12 +5,14 @@ on: types: [closed] permissions: - issues: write - pull-requests: read + contents: read jobs: create_issue: runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: read if: | github.event.pull_request.merged && contains(github.event.pull_request.labels.*.name, 'missing-types') diff --git a/.github/workflows/package-manager-ci.yml b/.github/workflows/package-manager-ci.yml index 47c207a2d74..d0fa9d61932 100644 --- a/.github/workflows/package-manager-ci.yml +++ b/.github/workflows/package-manager-ci.yml @@ -11,56 +11,71 @@ permissions: jobs: pnpm: runs-on: ${{ matrix.os }} + permissions: + contents: read strategy: matrix: # Maintenance and active LTS - node-version: [14, 16] - os: [ubuntu-18.04] + node-version: [20, 22, 24] + os: [ubuntu-latest] + pnpm-version: [8] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 with: persist-credentials: false - name: Use Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} + check-latest: true - name: Install with pnpm - run: | - curl -L https://unpkg.com/@pnpm/self-installer | node - pnpm install + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + with: + version: ${{ matrix.pnpm-version }} + + - run: pnpm install --ignore-scripts - name: Run tests run: | pnpm run test:ci + env: + NODE_OPTIONS: no-network-family-autoselection yarn: runs-on: ${{ matrix.os }} + permissions: + contents: read strategy: matrix: # Maintenance and active LTS - node-version: [14, 16] - os: [ubuntu-18.04] + node-version: [20, 22, 24] + os: [ubuntu-latest] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 with: persist-credentials: false - name: Use Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} + check-latest: true - name: Install with yarn run: | curl -o- -L https://yarnpkg.com/install.sh | bash - yarn install --ignore-engines + yarn install --ignore-engines --ignore-scripts + + - run: yarn - name: Run tests run: | yarn run test:ci + env: + NODE_OPTIONS: no-network-family-autoselection diff --git a/.github/workflows/pull-request-title.yml b/.github/workflows/pull-request-title.yml new file mode 100644 index 00000000000..bfa000db945 --- /dev/null +++ b/.github/workflows/pull-request-title.yml @@ -0,0 +1,18 @@ +name: pull request title check +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +permissions: + contents: read + +jobs: + pull-request-title-check: + runs-on: ubuntu-latest + permissions: + pull-requests: read + steps: + - uses: fastify/action-pr-title@v0 + with: + regex: '/^(build|chore|ci|docs|feat|types|fix|perf|refactor|style|test)(?:\([^\):]*\))?!?:\s/' + github-token: ${{ github.token }} diff --git a/.github/workflows/test-compare.yml b/.github/workflows/test-compare.yml new file mode 100644 index 00000000000..c8689e237b6 --- /dev/null +++ b/.github/workflows/test-compare.yml @@ -0,0 +1,21 @@ +name: Test compare +on: + pull_request: + types: [opened, reopened, synchronize, labeled] + +permissions: + contents: read + +jobs: + run: + if: contains(github.event.pull_request.labels.*.name, 'test-compare') + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - name: Test compare + uses: nearform-actions/github-action-test-compare@d50bc37a05e736bb40db0eebc8fdad3e33ece136 # v1.0.26 + with: + label: test-compare + testCommand: 'npm run test:ci' diff --git a/.github/workflows/validate-ecosystem-links.yml b/.github/workflows/validate-ecosystem-links.yml new file mode 100644 index 00000000000..a841914dfa5 --- /dev/null +++ b/.github/workflows/validate-ecosystem-links.yml @@ -0,0 +1,31 @@ +name: Validate Ecosystem Links + +on: + schedule: + # Run every Sunday at 00:00 UTC + - cron: '0 0 * * 0' + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate-links: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check out repo + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + + - name: Validate ecosystem links + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/validate-ecosystem-links.js diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index 1ff589d3ba6..8b79fd67318 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -6,6 +6,7 @@ on: - main paths: - 'docs/**' + - '**.md' release: types: - released @@ -19,4 +20,6 @@ jobs: steps: - name: Build website run: | - curl -X POST --header 'Content-Type: application/json' "https://circleci.com/api/v1.1/project/github/fastify/website?circle-token=${{ secrets.circleci_token }}" + gh workflow run deploy-website.yml -R fastify/website + env: + GH_TOKEN: ${{ secrets.GHA_WEBSITE_FINE_TOKEN }} diff --git a/.gitignore b/.gitignore index 875f42e09fa..84ba3d43f0e 100644 --- a/.gitignore +++ b/.gitignore @@ -136,6 +136,7 @@ dist .DS_Store # lock files +bun.lockb package-lock.json pnpm-lock.yaml yarn.lock @@ -144,6 +145,9 @@ yarn.lock .vscode .idea +# tap files +.tap/ + # Optional compressed files (npm generated package, zip, etc) /*.zip @@ -166,3 +170,11 @@ out.tap test/https/fastify.cert test/https/fastify.key /test/types/import.js + +# Agents files +CLAUDE.md +AGENTS.md +.agents/ +.agent +.claude +.pi diff --git a/.gitpod.yml b/.gitpod.yml new file mode 100644 index 00000000000..93bdc1a22b8 --- /dev/null +++ b/.gitpod.yml @@ -0,0 +1,10 @@ +# Gitpod Configuration File +# SEE https://www.gitpod.io/docs/references/gitpod-yml + +tasks: + - init: npm install + name: Install Dependencies + +vscode: + extensions: + - "dbaeumer.vscode-eslint" diff --git a/.markdownlint-cli2.yaml b/.markdownlint-cli2.yaml index 5c56986701f..408be45225f 100644 --- a/.markdownlint-cli2.yaml +++ b/.markdownlint-cli2.yaml @@ -7,7 +7,7 @@ config: MD013: line_length: 80 code_block_line_length: 120 - headers: false + headings: false tables: false strict: false stern: false diff --git a/.npmignore b/.npmignore index 33caf9bf839..c2ed9cfe1f3 100644 --- a/.npmignore +++ b/.npmignore @@ -3,11 +3,21 @@ .gitignore .github .nyc_output +.DS_Store coverage/ tools/ +.tap/ CODE_OF_CONDUCT.md CONTRIBUTING.md +EXPENSE_POLICY.md .clinic +.gitpod.yml +.vscode/ +*.log + +# AI files +.claude/ +CLAUDE.md # test certification test/https/fastify.cert diff --git a/.npmrc b/.npmrc index 43c97e719a5..3757b3046ec 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,2 @@ +ignore-scripts=true package-lock=false diff --git a/.taprc b/.taprc deleted file mode 100644 index dd0fbcce796..00000000000 --- a/.taprc +++ /dev/null @@ -1,9 +0,0 @@ -ts: false -jsx: false -flow: false -check-coverage: true -coverage: true -node-arg: --allow-natives-syntax - -files: - - 'test/**/*.test.js' diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 80320a25a5d..9c322c23b55 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,178 +1,4 @@ # Code of Conduct -Fastify, as member project of the OpenJS Foundation, use [Contributor Covenant -v2.0](https://contributor-covenant.org/version/2/0/code_of_conduct) as their -code of conduct. The full text is included -[below](#contributor-covenant-code-of-conduct) in English, and translations are -available from the Contributor Covenant organisation: - -- [contributor-covenant.org/translations](https://www.contributor-covenant.org/translations) -- [github.com/ContributorCovenant](https://github.com/ContributorCovenant/contributor_covenant/tree/release/content/version/2/0) - -Refer to the sections on reporting and escalation in this document for the -specific emails that can be used to report and escalate issues. - -## Reporting - -### Project Spaces - -For reporting issues in spaces related to Fastify please use the email -`hello@matteocollina.com` or `tommydelved@gmail.com`. Fastify handles CoC issues -related to the spaces that it maintains. Projects maintainers commit to: - -- maintain the confidentiality with regard to the reporter of an incident -- to participate in the path for escalation as outlined in the section on - Escalation when required. - -### Foundation Spaces - -For reporting issues in spaces managed by the OpenJS Foundation, for example, -repositories within the OpenJS organization, use the email -`report@lists.openjsf.org`. The Cross Project Council (CPC) is responsible for -managing these reports and commits to: - -- maintain the confidentiality with regard to the reporter of an incident -- to participate in the path for escalation as outlined in the section on - Escalation when required. - -## Escalation - -The OpenJS Foundation maintains a Code of Conduct Panel (CoCP). This is a -foundation-wide team established to manage escalation when a reporter believes -that a report to a member project or the CPC has not been properly handled. In -order to escalate to the CoCP send an email to -`coc-escalation@lists.openjsf.org`. - -For more information, refer to the full [Code of Conduct governance -document](https://github.com/openjs-foundation/cross-project-council/blob/HEAD/CODE_OF_CONDUCT.md). - ---- - -## Contributor Covenant Code of Conduct v2.0 - -### Our Pledge - -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, religion, or sexual identity and -orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. - -### Our Standards - -Examples of behavior that contributes to a positive environment for our -community include: - -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -* Focusing on what is best not just for us as individuals, but for the overall - community - -Examples of unacceptable behavior include: - -* The use of sexualized language or imagery, and sexual attention or advances of - any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email address, - without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting - -### Enforcement Responsibilities - -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior that they deem inappropriate, threatening, offensive, -or harmful. - -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for moderation -decisions when appropriate. - -### Scope - -This Code of Conduct applies within all community spaces, and also applies when -an individual is officially representing the community in public spaces. -Examples of representing our community include using an official e-mail address, -posting via an official social media account, or acting as an appointed -representative at an online or offline event. - -### Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at the email -addresses listed above in the [Reporting](#reporting) and -[Escalation](#escalation) sections. All complaints will be reviewed and -investigated promptly and fairly. - -All community leaders are obligated to respect the privacy and security of the -reporter of any incident. - -### Enforcement Guidelines - -Community leaders will follow these Community Impact Guidelines in determining -the consequences for any action they deem in violation of this Code of Conduct: - -#### 1. Correction - -**Community Impact**: Use of inappropriate language or other behavior deemed -unprofessional or unwelcome in the community. - -**Consequence**: A private, written warning from community leaders, providing -clarity around the nature of the violation and an explanation of why the -behavior was inappropriate. A public apology may be requested. - -#### 2. Warning - -**Community Impact**: A violation through a single incident or series of -actions. - -**Consequence**: A warning with consequences for continued behavior. No -interaction with the people involved, including unsolicited interaction with -those enforcing the Code of Conduct, for a specified period of time. This -includes avoiding interactions in community spaces as well as external channels -like social media. Violating these terms may lead to a temporary or permanent -ban. - -#### 3. Temporary Ban - -**Community Impact**: A serious violation of community standards, including -sustained inappropriate behavior. - -**Consequence**: A temporary ban from any sort of interaction or public -communication with the community for a specified period of time. No public or -private interaction with the people involved, including unsolicited interaction -with those enforcing the Code of Conduct, is allowed during this period. -Violating these terms may lead to a permanent ban. - -#### 4. Permanent Ban - -**Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an -individual, or aggression toward or disparagement of classes of individuals. - -**Consequence**: A permanent ban from any sort of public interaction within the -project community. - -### Attribution - -This Code of Conduct is adapted from the [Contributor -Covenant](https://www.contributor-covenant.org), version 2.0, available at -[contributor-covenant.org/version/2/0/code_of_conduct](https://www.contributor-covenant.org/version/2/0/code_of_conduct). - -Community Impact Guidelines were inspired by [Mozilla's code of conduct -enforcement ladder](https://github.com/mozilla/diversity). - -For answers to common questions about this code of conduct, see the FAQ at -[contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). -Translations are available at -[contributor-covenant.org/translations](https://www.contributor-covenant.org/translations). +Please see Fastify's [organization-wide code of conduct +](https://github.com/fastify/.github/blob/main/CODE_OF_CONDUCT.md). \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2a8dd4552b6..6f1e68b702c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,41 +12,44 @@ details on contributing to this project. ### I want to be a collaborator! If you think you meet the above criteria and we have not invited you yet, we are -sorry! Feel free reach out to a [Lead +sorry! Feel free to reach out to a [Lead Maintainer](https://github.com/fastify/fastify#team) privately with a few links to your valuable contributions. Read the [GOVERNANCE](GOVERNANCE.md) to get more information. ## Rules -There are a few basic ground-rules for contributors: +There are a few basic ground rules for contributors: 1. **No `--force` pushes** on `main` or modifying the Git history in any way after a PR has been merged. 1. **Non-main branches** ought to be used for ongoing work. 1. **External API changes and significant modifications** ought to be subject to - an **internal pull-request** to solicit feedback from other contributors. -1. Internal pull-requests to solicit feedback are *encouraged* for any other - non-trivial contribution but left to the discretion of the contributor. -1. Contributors should attempt to adhere to the prevailing code-style. -1. At least two contributors, or one core member, must approve pull-requests - prior to merging. -1. All integrated CI services must be green before a pull-request can be merged. + an **internal pull request** to solicit feedback from other contributors. +1. Internal pull requests to solicit feedback are *encouraged* for any other + non-trivial contribution but are left to the discretion of the contributor. +1. Contributors should attempt to adhere to the prevailing code style. +1. At least two contributors, or one core member, must approve pull requests + before merging. +1. All integrated CI services must be green before a pull request can be merged. 1. A lead maintainer must merge SemVer-major changes in this repository. -1. In case it is not possible to reach consensus in a pull-request, the decision +1. If it is not possible to reach a consensus in a pull request, the decision is left to the lead maintainer's team. -### Fastify v1.x +### Fastify previous versions -Code for Fastify's **v1.x** is in [branch -1.x](https://github.com/fastify/fastify/tree/1.x), so all Fastify 1.x related -changes should be based on **`branch 1.x`**. +Every version of Fastify has its own branch. All Fastify related +changes should be based on the corresponding branch. -### Fastify v2.x +We have a [Long Term Support](./docs/Reference/LTS.md) policy that defines +the organization's efforts for each Fastify's version. -Code for Fastify's **v2.x** is in [branch -2.x](https://github.com/fastify/fastify/tree/2.x), so all Fastify 2.x related -changes should be based on **`branch 2.x`**. +|Version|Branch| +|-------|------| +**v1.x**|[branch 1.x](https://github.com/fastify/fastify/tree/1.x)| +**v2.x**|[branch 2.x](https://github.com/fastify/fastify/tree/2.x)| +**v3.x**|[branch 3.x](https://github.com/fastify/fastify/tree/3.x)| +**v4.x**|[branch 4.x](https://github.com/fastify/fastify/tree/4.x)| ## Releases @@ -55,23 +58,23 @@ not bump version numbers in pull requests. ## Plugins -The contributors to the Fastify's plugins must attend the same rules of the -Fastify repository with a few adjustments: +Contributors to Fastify plugins must follow the same rules as the main Fastify repository, + with a few adjustments: 1. Any member can publish a release. 1. The plugin version must follow the [semver](https://semver.org/) specification. -1. The Node.js compatibility must match with the Fastify's main branch. +1. The Node.js compatibility must match with Fastify's main branch. 1. The new release must have the changelog information stored in the GitHub - release. For this scope we suggest to adopt a tool like - [`releasify`](https://github.com/fastify/releasify) to archive this. + release. For this we suggest adopting a tool like + [`releasify`](https://github.com/fastify/releasify) to achieve this. 1. PR opened by bots (like Dependabot) can be merged if the CI is green and the - Node.js versions supported are the same of the plugin. + Node.js versions supported are the same as the plugin. ## Changes to this arrangement This is an experiment and feedback is welcome! This document may also be subject -to pull-requests or changes by contributors where you believe you have something +to pull requests or changes by contributors where you believe you have something valuable to add or change. # Fastify Organization Structure @@ -83,30 +86,38 @@ The Fastify structure is detailed in the [GOVERNANCE](GOVERNANCE.md) document. Welcome to the team! We are happy to have you. Before you start, please complete the following tasks: 1. Set up 2 factor authentication for GitHub and NPM - - [GitHub + - [GitHub 2FA](https://help.github.com/en/articles/securing-your-account-with-two-factor-authentication-2fa) - - [NPM 2FA](https://docs.npmjs.com/about-two-factor-authentication) + - [NPM 2FA](https://docs.npmjs.com/about-two-factor-authentication) 2. Choose which team to join *(more than one is ok!)* based on how you want to help. + - Core team: maintains core Fastify and its documentation + - Plugins team: maintains Fastify's plugins and its ecosystem 3. Open a pull request to [`fastify/fastify:HEAD`](https://github.com/fastify/fastify/pulls) that adds - your name, username, and email to the team you have choosen in the + your name, username, and email to the team you have chosen in the [README.md](./README.md) and [package.json](./package.json) *(if you are part - of the core team)* files. The members lists are sorted alphabetically; make - sure to add your name in the proper order. + of the core team)* files. The member lists are sorted alphabetically by last + name; make sure to add your name in the proper order. 4. Open a pull request to [`fastify/website:HEAD`](https://github.com/fastify/website/pulls) adding yourself to the - [team.yml](https://github.com/fastify/website/blob/HEAD/src/website/data/team.yml) + [team.yml](https://github.com/fastify/website/blob/HEAD/static/data/team.yml) file. This list is also sorted alphabetically so make sure to add your name in the proper order. Use your GitHub profile icon for the `picture:` field. -5. The person that does the onboarding must add you to the [npm - org](https://www.npmjs.com/org/fastify), so that you can help maintaining the - official plugins. +5. Read the [pinned announcements](https://github.com/orgs/fastify/discussions/categories/announcements) + to be updated with the organization’s news. +6. The person who does the onboarding must open a pull request to + [`fastify/org-admin`](https://github.com/fastify/org-admin?tab=readme-ov-file#org-admin) + so an admin can add the new member to the + [npm org](https://www.npmjs.com/org/fastify) and the GitHub Team, + so that the new joiner can help maintain the official plugins. +8. Optionally, the person can be added as an Open Collective member + by the lead team. ### Offboarding Collaborators -We are thankful to you and we are really glad to have worked with you. We'll be +We are thankful to you and we are really glad to have worked with you. We'd be really happy to see you here again if you want to come back, but for now the person that did the onboarding must: 1. Ask the collaborator if they want to stay or not. @@ -117,16 +128,20 @@ person that did the onboarding must: 2. Open a pull request to [`fastify/website:HEAD`](https://github.com/fastify/website/pulls) and move themselves to the *Past Collaborators* section in the - [team.yml](https://github.com/fastify/website/blob/HEAD/src/website/data/team.yml) + [team.yml](https://github.com/fastify/website/blob/HEAD/static/data/team.yml) file. The person that did the onboarding must: -1. If the collaborator doesn't reply to the ping in reasonable time, open the +1. If the collaborator does not reply to the ping in a reasonable time, open the pull requests described above. -2. Remove the collaborator from the Fastify teams on GitHub. -3. Remove the collaborator from the [npm +2. Open a pull request to [`fastify/org-admin`](https://github.com/fastify/org-admin?tab=readme-ov-file#org-admin) + so an admin will: + 1. Remove the collaborator from the Fastify teams on GitHub. + 2. Remove the collaborator from the [npm org](https://www.npmjs.com/org/fastify). -4. Remove the collaborator from the Azure team. + 3. Remove the collaborator from the Azure team. + 4. Remove the collaborator from the Open Collective members. + ----------------------------------------- diff --git a/EXPENSE_POLICY.md b/EXPENSE_POLICY.md new file mode 100644 index 00000000000..3baa6884267 --- /dev/null +++ b/EXPENSE_POLICY.md @@ -0,0 +1,105 @@ +# Expense Policy + +Fastify collaborators accept donations through the [Open Collective](https://opencollective.com/fastify/) +platform and [GitHub Sponsors](https://github.com/sponsors/fastify) +to enhance the project and support the community. + +This Collective is run by and for the benefit of the independent contributors to +the Fastify open source software project. +This Collective is not endorsed or administered by OpenJS Foundation, Inc. +(the “OpenJS Foundation”). The OpenJS Foundation does not receive or have +control over any funds contributed. The OpenJS Foundation does not direct or +otherwise supervise the actions of any contributor to the Fastify project, +and all donations made will be expended for the private benefit of or otherwise +to reimburse individuals that do not have an employer/employee, contractor, or +other agency relationship with the OpenJS Foundation. +The Fastify marks used herein are used under license from the OpenJS Foundation +for the benefit of the open source software community. + +The admins of the Fastify Collective are the [lead maintainers](./GOVERNANCE.md) +of the project. + +This document outlines the process for requesting reimbursement or an invoice +for expenses. + +## Reimbursement + +Reimbursement is applicable for expenses already paid, such as: + +- Stickers +- Gadgets +- Hosting + +**Before making any purchases**, initiate a [new discussion](https://github.com/orgs/fastify/discussions) +in the `fastify` organization with the following information: + +- What is needed +- Why it is needed +- Cost +- Deadline + +Once the discussion is approved by a lead maintainer and with no unresolved objections, +the purchase can proceed, and an expense can be submitted to the [Open Collective][submit]. +This process takes a minimum of 3 business days from the request to allow time for +discussion approval. + +The discussion helps prevent misunderstandings and ensures the expense is not rejected. +As a project under the OpenJS Foundation, Fastify benefits from the Foundation's +resources, including servers, domains, and [travel funds](https://github.com/openjs-foundation/community-fund/tree/main/programs/travel-fund). + +Always seek approval first. + +## Invoice + +Invoices are for services provided to the Fastify project, such as PR reviews, +documentation, etc. +A VAT number is not required to submit an invoice. +Refer to the [Open Collective documentation][openc_docs] for details. + +### Adding a bounty to an issue + +Issues become eligible for a bounty when the core team adds the `bounty` label, +with the amount determined by the core team based on `estimated hours * rate` +(suggested $50 per hour). + +> Example: If the estimated time to fix the issue is 2 hours, +> the bounty will be $100. + +To add a bounty: + +- Apply the `bounty` label to the issue +- Comment on the issue with the bounty amount +- Edit the first comment of the issue using this template: + +``` +## 💰 Bounty + +This issue has a bounty of [$AMOUNT](LINK TO THE BOUNTY COMMENT). +_Read more about [the bounty program](./EXPENSE_POLICY.md)_ +``` + +For discussions on bounties or determining amounts, open a [new discussion](https://github.com/orgs/fastify/discussions/new?category=bounty). + +### Outstanding contributions + +The lead team can decide to add a bounty to an issue or PR not labeled as `bounty` +if the contribution is outstanding. + +### Claiming a bounty + +To claim a bounty: + +- Submit a PR that fixes the issue +- If multiple submissions exist, a core member will choose the best solution +- Once merged, the PR author can claim the bounty by: + - Submitting an expense to the [Open Collective][submit] with the PR link + - Adding a comment on the PR with a link to their Open Collective expense to + ensure the claimant is the issue resolver +- The expense will be validated by a lead maintainer and then the payment will be + processed by Open Collective + +If the Open Collective budget is insufficient, the expense will be rejected. +Unclaimed bounties are available for other issues. + +[submit]: https://opencollective.com/fastify/expenses/new +[openc_docs]: https://docs.oscollective.org/how-it-works/basics/invoice-and-reimbursement-examples diff --git a/GOVERNANCE.md b/GOVERNANCE.md index c21c1bf15b2..c1d8fab6dbe 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -1,105 +1,4 @@ # Fastify Project Governance - - -* [Lead Maintainers](#lead-maintainers) -* [Collaborators](#collaborators) - * [Collaborator activities](#collaborator-activities) -* [Great Contributors](#great-contributors) -* [Collaborator nominations](#collaborator-maintainers-nominations) -* [Lead Maintainers nominations](#lead-maintainers-nominations) -* [Consensus seeking process](#consensus-seeking-process) - - - -## Lead Maintainers - -Fastify Lead Maintainers are the founder of the project and the organization -owners. They are the only members of the `@fastify/leads` team. The Lead -Maintainers are the curator of the Fastify project and their key responsibility -is to issue releases of Fastify and its dependencies. - -## Collaborators - -Fastify Collaborators maintain the projects of the Fastify organization. - -They are split into the following teams: - -| Team | Responsibility | Repository | -|---|---|---| -| `@fastify/leads` | Fastify Lead Maintainers | GitHub organization owners | -| `@fastify/core` | Fastify Core development | `fastify`, `fast-json-stringify`, `light-my-request`, `fastify-plugin`, `middie` | -| `@fastify/plugins` | Build, maintain and release Fastify plugins | All plugins repositories | -| `@fastify/benchmarks` | Build and maintain our benchmarks suite | `benchmarks` | -| `@fastify/docs-chinese` | Translate the Fastify documentation in Chinese | `docs-chinese` | - -Every member of the org is also part of `@fastify/fastify`. - -Collaborators have: - -* Commit access to the projects repository of the team they belong - * Grant to release new versions of the project - -Both Collaborators and non-Collaborators may propose changes to the source code -of the projects of the organization. The mechanism to propose such a change is a -GitHub pull request. Collaborators review and merge (_land_) pull requests -following the [CONTRIBUTING](CONTRIBUTING.md#rules) guidelines. - -### Collaborator activities - -* Helping users and novice contributors -* Contributing code and documentation changes that improve the project -* Reviewing and commenting on issues and pull requests -* Participation in working groups -* Merging pull requests -* Release plugins - -The Lead Maintainers can remove inactive Collaborators or provide them with -_Past Collaborators_ status. Past Collaborators may request that the Lead -Maintainers restore them to active status. - - -## Great Contributors - -Great contributors on a specific area in the Fastify ecosystem will be invited -to join this group by Lead Maintainers. This group has the same permissions of a -contributor. - -## Collaborator nominations - -Individuals making significant and valuable contributions to the project may be -a candidate to join the Fastify organization. - -A Collaborator needs to open a private team discussion on GitHub and list the -candidates they want to sponsor with a link to the user's contributions. For -example: - -* Activities in the Fastify organization - `[USERNAME](https://github.com/search?q=author:USERNAME+org:fastify)` - -Otherwise, a Contributor may self-apply if they believe they meet the above -criteria by reaching out to a Lead Maintainer privately with the links to their -valuable contributions. The Lead Maintainers will reply to the Contributor and -will decide if candidate it to be made a collaborator. - -The consensus to grant a new candidate Collaborator status is reached when: - -- at least one of the Lead Maintainers approve -- at least two of the Team Members approve - -After these conditions are satisfied, the [onboarding -process](CONTRIBUTING.md#onboarding-collaborators) may start. - - -## Lead Maintainers nominations - -A Team Member may be promoted to a Lead Maintainers only through nomination by a -Lead maintainer and with agreement from the rest of Lead Maintainers. - - -## Consensus seeking process - -The Fastify organization follows a [Consensus Seeking][] decision-making model. - -[Consensus Seeking]: - https://en.wikipedia.org/wiki/Consensus-seeking_decision-making +Please see Fastify's [organization-wide governance +](https://github.com/fastify/.github/blob/main/GOVERNANCE.md) document. \ No newline at end of file diff --git a/LICENSE b/LICENSE index a5b4e2a259f..266e719722c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,9 +1,6 @@ MIT License -Copyright (c) 2016-2022 The Fastify Team - -The Fastify team members are listed at https://github.com/fastify/fastify#team -and in the README file. +Copyright (c) 2016-present The Fastify team Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/PROJECT_CHARTER.md b/PROJECT_CHARTER.md index 79262c8d1d7..a841a165ba1 100644 --- a/PROJECT_CHARTER.md +++ b/PROJECT_CHARTER.md @@ -1,6 +1,6 @@ # Fastify Charter -The Fastify project aims to build a fast and low overhead web framework for +The Fastify project aims to build a fast and low-overhead web framework for Node.js. @@ -20,7 +20,7 @@ experience with the least overhead and a plugin architecture. ### 1.1: In-scope + Develop a web framework for Node.js with a focus on developer experience, - performance and extensibility. + performance, and extensibility. + Plugin Architecture + Support web protocols + Official plugins for common user requirements @@ -43,7 +43,7 @@ experience with the least overhead and a plugin architecture. + Support versions of Node.js at EOL (end of life) stage + Support serverless architecture -+ Contributions that violates the [Code of Conduct](CODE_OF_CONDUCT.md) ++ Contributions that violate the [Code of Conduct](CODE_OF_CONDUCT.md) ## Section 2: Relationship with OpenJS Foundation CPC. @@ -58,10 +58,10 @@ the Board of Directors (the "Board"). This Fastify Charter reflects a carefully constructed balanced role for the Collaborators and the CPC in the governance of the OpenJS Foundation. The charter amendment process is for the Fastify Collaborators to propose change -using simple majority of the full Fastify Organization, the proposed changes +using a majority of the full Fastify Organization, the proposed changes being subject to review and approval by the CPC. The CPC may additionally make amendments to the Collaborators charter at any time, though the CPC will not -interfere with day-to-day discussions, votes or meetings of the Fastify +interfere with day-to-day discussions, votes, or meetings of the Fastify Organization. @@ -92,7 +92,7 @@ Section Intentionally Left Blank Fastify's features can be discussed in GitHub issues and/or projects. Consensus on a discussion is reached when there is no objection by any collaborators. -Whenever there is not consensus, Lead Maintainers will have final say on the +When there is no consensus, Lead Maintainers will have the final say on the topic. **Voting, and/or Elections** @@ -112,7 +112,7 @@ Section Intentionally Left Blank Foundation Board. + *Collaborators*: contribute code and other artifacts, have the right to commit - to the code base and release plugins projects. Collaborators follow the + to the code base, and release plugin projects. Collaborators follow the [CONTRIBUTING](CONTRIBUTING.md) guidelines to manage the project. A Collaborator could be encumbered by other Fastify Collaborators and never by the CPC or OpenJS Foundation Board. diff --git a/README.md b/README.md index 90af4385471..403563ce97c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -
+
-[![CI](https://github.com/fastify/fastify/workflows/ci/badge.svg)](https://github.com/fastify/fastify/actions/workflows/ci.yml) +[![CI](https://github.com/fastify/fastify/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/fastify/fastify/actions/workflows/ci.yml) [![Package Manager -CI](https://github.com/fastify/fastify/workflows/package-manager-ci/badge.svg)](https://github.com/fastify/fastify/actions/workflows/package-manager-ci.yml) +CI](https://github.com/fastify/fastify/actions/workflows/package-manager-ci.yml/badge.svg?branch=main)](https://github.com/fastify/fastify/actions/workflows/package-manager-ci.yml) [![Web -SIte](https://github.com/fastify/fastify/workflows/website/badge.svg)](https://github.com/fastify/fastify/actions/workflows/website.yml) -[![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat)](https://standardjs.com/) +site](https://github.com/fastify/fastify/actions/workflows/website.yml/badge.svg?branch=main)](https://github.com/fastify/fastify/actions/workflows/website.yml) +[![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-brightgreen?style=flat)](https://github.com/neostandard/neostandard) +[![CII Best Practices](https://bestpractices.coreinfrastructure.org/projects/7585/badge)](https://bestpractices.coreinfrastructure.org/projects/7585)
@@ -27,21 +28,32 @@ downloads](https://img.shields.io/npm/dm/fastify.svg?style=flat)](https://www.np [![Security Responsible Disclosure](https://img.shields.io/badge/Security-Responsible%20Disclosure-yellow.svg)](https://github.com/fastify/fastify/blob/main/SECURITY.md) [![Discord](https://img.shields.io/discord/725613461949906985)](https://discord.gg/fastify) +[![Contribute with Gitpod](https://img.shields.io/badge/Contribute%20with-Gitpod-908a85?logo=gitpod&color=blue)](https://gitpod.io/#https://github.com/fastify/fastify) +[![Open Collective backers and sponsors](https://img.shields.io/opencollective/all/fastify)](https://github.com/sponsors/fastify#sponsors)

-An efficient server implies a lower cost of the infrastructure, a better -responsiveness under load and happy users. How can you efficiently handle the +An efficient server implies a lower cost of the infrastructure, better +responsiveness under load, and happy users. How can you efficiently handle the resources of your server, knowing that you are serving the highest number of -requests as possible, without sacrificing security validations and handy +requests possible, without sacrificing security validations and handy development? +Enter Fastify. Fastify is a web framework highly focused on providing the best +developer experience with the least overhead and a powerful plugin architecture. +It is inspired by Hapi and Express and as far as we know, it is one of the +fastest web frameworks in town. + +The `main` branch refers to the Fastify `v5` release. +Check out the [`4.x` branch](https://github.com/fastify/fastify/tree/4.x) for `v4`. + +### Table of Contents + - [Quick start](#quick-start) - [Install](#install) - [Example](#example) - - [Fastify v1.x and v2.x](#fastify-v1x-and-v2x) - [Core features](#core-features) - [Benchmarks](#benchmarks) - [Documentation](#documentation) @@ -51,13 +63,6 @@ development? - [Hosted by](#hosted-by) - [License](#license) -Enter Fastify. Fastify is a web framework highly focused on providing the best -developer experience with the least overhead and a powerful plugin architecture. -It is inspired by Hapi and Express and as far as we know, it is one of the -fastest web frameworks in town. - -This branch refers to the Fastify v4 release. Check out the -[v3.x](https://github.com/fastify/fastify/tree/v3.x) branch for v3. ### Quick start @@ -99,17 +104,11 @@ generate functionality of [Fastify CLI](https://github.com/fastify/fastify-cli). ### Install -If installing in an existing project, then Fastify can be installed into the -project as a dependency: +To install Fastify in an existing project as a dependency: -Install with npm: ```sh npm i fastify ``` -Install with yarn: -```sh -yarn add fastify -``` ### Example @@ -118,6 +117,7 @@ yarn add fastify // ESM import Fastify from 'fastify' + const fastify = Fastify({ logger: true }) @@ -138,11 +138,12 @@ fastify.listen({ port: 3000 }, (err, address) => { }) ``` -with async-await: +With async-await: ```js // ESM import Fastify from 'fastify' + const fastify = Fastify({ logger: true }) @@ -164,22 +165,14 @@ fastify.listen({ port: 3000 }, (err, address) => { Do you want to know more? Head to the
Getting Started. - - -### Fastify v1.x and v2.x - -Code for Fastify's **v1.x** is in [**`branch -1.x`**](https://github.com/fastify/fastify/tree/1.x), so all Fastify 1.x related -changes should be based on **`branch 1.x`**. In a similar way, all Fastify -**v2.x** related changes should be based on [**`branch -2.x`**](https://github.com/fastify/fastify/tree/2.x). +If you learn best by reading code, explore the official [demo](https://github.com/fastify/demo). > ## Note > `.listen` binds to the local host, `localhost`, interface by default > (`127.0.0.1` or `::1`, depending on the operating system configuration). If > you are running Fastify in a container (Docker, > [GCP](https://cloud.google.com/), etc.), you may need to bind to `0.0.0.0`. Be -> careful when deciding to listen on all interfaces; it comes with inherent +> careful when listening on all interfaces; it comes with inherent > [security > risks](https://web.archive.org/web/20170711105010/https://snyk.io/blog/mongodb-hack-and-secure-defaults/). > See [the documentation](./docs/Reference/Server.md#listen) for more @@ -190,23 +183,23 @@ changes should be based on **`branch 1.x`**. In a similar way, all Fastify - **Highly performant:** as far as we know, Fastify is one of the fastest web frameworks in town, depending on the code complexity we can serve up to 76+ thousand requests per second. -- **Extendible:** Fastify is fully extensible via its hooks, plugins and +- **Extensible:** Fastify is fully extensible via its hooks, plugins, and decorators. -- **Schema based:** even if it is not mandatory we recommend to use [JSON +- **Schema-based:** even if it is not mandatory we recommend using [JSON Schema](https://json-schema.org/) to validate your routes and serialize your - outputs, internally Fastify compiles the schema in a highly performant + outputs. Internally Fastify compiles the schema in a highly performant function. - **Logging:** logs are extremely important but are costly; we chose the best logger to almost remove this cost, [Pino](https://github.com/pinojs/pino)! - **Developer friendly:** the framework is built to be very expressive and help - the developer in their daily use, without sacrificing performance and + developers in their daily use without sacrificing performance and security. ### Benchmarks __Machine:__ EX41S-SSD, Intel Core i7, 4Ghz, 64GB RAM, 4C/8T, SSD. -__Method:__: `autocannon -c 100 -d 40 -p 10 localhost:3000` * 2, taking the +__Method__: `autocannon -c 100 -d 40 -p 10 localhost:3000` * 2, taking the second average | Framework | Version | Router? | Requests/sec | @@ -219,53 +212,44 @@ second average | - | | | | | `http.Server` | 16.14.2 | ✗ | 74,513 | -Benchmarks taken using https://github.com/fastify/benchmarks. This is a -synthetic, "hello world" benchmark that aims to evaluate the framework overhead. +These benchmarks taken using https://github.com/fastify/benchmarks. This is a +synthetic "hello world" benchmark that aims to evaluate the framework overhead. The overhead that each framework has on your application depends on your -application, you should __always__ benchmark if performance matters to you. +application. You should __always__ benchmark if performance matters to you. ## Documentation -* Getting - Started -* Guides -* Server -* Routes -* Encapsulation -* Logging -* Middleware -* Hooks -* Decorators -* Validation - and Serialization -* Fluent Schema -* Lifecycle -* Reply -* Request -* Errors -* Content Type - Parser -* Plugins -* Testing -* Benchmarking -* How to write a good - plugin -* Plugins Guide -* HTTP2 -* Long Term Support -* TypeScript and types - support -* Serverless -* Recommendations - -中文文档[地址](https://github.com/fastify/docs-chinese/blob/HEAD/README.md) +* [__`Getting Started`__](./docs/Guides/Getting-Started.md) +* [__`Guides`__](./docs/Guides/Index.md) +* [__`Server`__](./docs/Reference/Server.md) +* [__`Routes`__](./docs/Reference/Routes.md) +* [__`Encapsulation`__](./docs/Reference/Encapsulation.md) +* [__`Logging`__](./docs/Reference/Logging.md) +* [__`Middleware`__](./docs/Reference/Middleware.md) +* [__`Hooks`__](./docs/Reference/Hooks.md) +* [__`Decorators`__](./docs/Reference/Decorators.md) +* [__`Validation and Serialization`__](./docs/Reference/Validation-and-Serialization.md) +* [__`Fluent Schema`__](./docs/Guides/Fluent-Schema.md) +* [__`Lifecycle`__](./docs/Reference/Lifecycle.md) +* [__`Reply`__](./docs/Reference/Reply.md) +* [__`Request`__](./docs/Reference/Request.md) +* [__`Errors`__](./docs/Reference/Errors.md) +* [__`Content Type Parser`__](./docs/Reference/ContentTypeParser.md) +* [__`Plugins`__](./docs/Reference/Plugins.md) +* [__`Testing`__](./docs/Guides/Testing.md) +* [__`Benchmarking`__](./docs/Guides/Benchmarking.md) +* [__`How to write a good plugin`__](./docs/Guides/Write-Plugin.md) +* [__`Plugins Guide`__](./docs/Guides/Plugins-Guide.md) +* [__`HTTP2`__](./docs/Reference/HTTP2.md) +* [__`Long Term Support`__](./docs/Reference/LTS.md) +* [__`TypeScript and types support`__](./docs/Reference/TypeScript.md) +* [__`Serverless`__](./docs/Guides/Serverless.md) +* [__`Recommendations`__](./docs/Guides/Recommendations.md) ## Ecosystem - [Core](./docs/Guides/Ecosystem.md#core) - Core plugins maintained by the _Fastify_ [team](#team). -- [Community](./docs/Guides/Ecosystem.md#community) - Community supported +- [Community](./docs/Guides/Ecosystem.md#community) - Community-supported plugins. - [Live Examples](https://github.com/fastify/example) - Multirepo with a broad set of real working examples. @@ -276,6 +260,20 @@ application, you should __always__ benchmark if performance matters to you. Please visit [Fastify help](https://github.com/fastify/help) to view prior support issues and to ask new support questions. +Version 3 of Fastify and lower are EOL and will not receive any security or bug +fixes. + +Fastify's partner, HeroDevs, provides commercial security fixes for all +unsupported versions at [https://herodevs.com/support/fastify-nes][hd-link]. +Fastify's supported version matrix is available in the +[Long Term Support][lts-link] documentation. + +## Contributing + +Whether reporting bugs, discussing improvements and new ideas, or writing code, +we welcome contributions from anyone and everyone. Please read the [CONTRIBUTING](./CONTRIBUTING.md) +guidelines before submitting pull requests. + ## Team _Fastify_ is the result of the work of a great community. Team members are @@ -283,103 +281,133 @@ listed in alphabetical order. **Lead Maintainers:** * [__Matteo Collina__](https://github.com/mcollina), - , + , * [__Tomas Della Vedova__](https://github.com/delvedor), - , + , +* [__KaKa Ng__](https://github.com/climba03003), + +* [__Manuel Spigolon__](https://github.com/eomm), + , +* [__James Sumners__](https://github.com/jsumners), + , ### Fastify Core team -* [__Tommaso Allevi__](https://github.com/allevo), - , -* [__Ethan Arrowood__](https://github.com/Ethan-Arrowood/), - , +* [__Aras Abbasi__](https://github.com/uzlopak), + * [__Harry Brundage__](https://github.com/airhorns/), - , -* [__David Mark Clements__](https://github.com/davidmarkclements), - , - + , * [__Matteo Collina__](https://github.com/mcollina), - , + , +* [__Gürgün Dayıoğlu__](https://github.com/gurgunday), + * [__Tomas Della Vedova__](https://github.com/delvedor), - , -* [__Dustin Deus__](https://github.com/StarpTech), - , -* [__Ayoub El Khattabi__](https://github.com/AyoubElk), - , -* [__Denis Fäcke__](https://github.com/SerayaEryn), - , -* [__Rafael Gonzaga__](https://github.com/rafaelgss), - , + , +* [__Carlos Fuentes__](https://github.com/metcoder95), + , * [__Vincent Le Goff__](https://github.com/zekth) * [__Luciano Mammino__](https://github.com/lmammino), - , + , +* [__Jean Michelet__](https://github.com/jean-michelet), + +* [__KaKa Ng__](https://github.com/climba03003), + * [__Luis Orbaiceta__](https://github.com/luisorbaiceta), - , + , * [__Maksim Sinik__](https://github.com/fox1t), - , + , * [__Manuel Spigolon__](https://github.com/eomm), - , + , * [__James Sumners__](https://github.com/jsumners), - , + , ### Fastify Plugins team -* [__Matteo Collina__](https://github.com/mcollina), - , * [__Harry Brundage__](https://github.com/airhorns/), - , + , +* [__Simone Busoli__](https://github.com/simoneb), + , +* [__Dan Castillo__](https://github.com/dancastillo), + +* [__Matteo Collina__](https://github.com/mcollina), + , +* [__Gürgün Dayıoğlu__](https://github.com/gurgunday), + * [__Tomas Della Vedova__](https://github.com/delvedor), - , -* [__Ayoub El Khattabi__](https://github.com/AyoubElk), - , + , +* [__Carlos Fuentes__](https://github.com/metcoder95), + , * [__Vincent Le Goff__](https://github.com/zekth) -* [__Salman Mitha__](https://github.com/salmanm), - +* [__Jean Michelet__](https://github.com/jean-michelet), + +* [__KaKa Ng__](https://github.com/climba03003), + * [__Maksim Sinik__](https://github.com/fox1t), - , + , * [__Frazer Smith__](https://github.com/Fdawgs), * [__Manuel Spigolon__](https://github.com/eomm), - , -* [__Rafael Gonzaga__](https://github.com/rafaelgss), - , + , +* [__Antonio Tripodi__](https://github.com/Tony133), -### Great Contributors -Great contributors on a specific area in the Fastify ecosystem will be invited -to join this group by Lead Maintainers. +### Emeritus Contributors +Great contributors to a specific area of the Fastify ecosystem will be invited +to join this group by Lead Maintainers when they decide to step down from the +active contributor's group. -* [__dalisoft__](https://github.com/dalisoft), , - -* [__Luciano Mammino__](https://github.com/lmammino), - , -* [__Evan Shortiss__](https://github.com/evanshortiss), - , - -**Past Collaborators** +* [__Tommaso Allevi__](https://github.com/allevo), + , +* [__Ethan Arrowood__](https://github.com/Ethan-Arrowood/), + , * [__Çağatay Çalı__](https://github.com/cagataycali), - , + , +* [__David Mark Clements__](https://github.com/davidmarkclements), + , + +* [__dalisoft__](https://github.com/dalisoft), , + +* [__Dustin Deus__](https://github.com/StarpTech), + , +* [__Denis Fäcke__](https://github.com/SerayaEryn), + , +* [__Rafael Gonzaga__](https://github.com/rafaelgss), + , * [__Trivikram Kamat__](https://github.com/trivikr), - , + , +* [__Ayoub El Khattabi__](https://github.com/AyoubElk), + , * [__Cemre Mengu__](https://github.com/cemremengu), - , + , +* [__Salman Mitha__](https://github.com/salmanm), + * [__Nathan Woltman__](https://github.com/nwoltman), - , + , ## Hosted by [](https://openjsf.org/projects/#growth) +width="250px;"/>](https://openjsf.org/projects) -We are a [Growth -Project](https://github.com/openjs-foundation/cross-project-council/blob/HEAD/PROJECT_PROGRESSION.md#growth-stage) +We are an [At-Large +Project](https://github.com/openjs-foundation/cross-project-council/blob/HEAD/PROJECT_PROGRESSION.md#at-large-projects) in the [OpenJS Foundation](https://openjsf.org/). -## Acknowledgements +## Sponsors + +Support this project by becoming a [SPONSOR](./SPONSORS.md)! +Fastify has an [Open Collective](https://opencollective.com/fastify) +page where we accept and manage financial contributions. + +## Acknowledgments This project is kindly sponsored by: -- [nearForm](https://nearform.com) +- [NearForm](https://nearform.com) +- [Platformatic](https://platformatic.dev) Past Sponsors: - [LetzDoIt](https://www.letzdoitapp.com/) +This list includes all companies that support one or more team members +in maintaining this project. + ## License Licensed under [MIT](./LICENSE). @@ -390,3 +418,6 @@ dependencies: - ISC - BSD-3-Clause - BSD-2-Clause + +[hd-link]: https://www.herodevs.com/support/fastify-nes?utm_source=fastify&utm_medium=link&utm_campaign=github_readme +[lts-link]: https://fastify.dev/docs/latest/Reference/LTS/ diff --git a/SECURITY.md b/SECURITY.md index 23ea944aa5d..f4802b4e6c0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -3,11 +3,66 @@ This document describes the management of vulnerabilities for the Fastify project and its official plugins. +## Threat Model + +Fastify's threat model extends the +[Node.js security policy](https://github.com/nodejs/node/blob/main/SECURITY.md). + +**Trusted:** Application code (plugins, handlers, hooks, schemas), configuration, +and the runtime environment. + +**Untrusted:** All network input (HTTP headers, body, query strings, URL +parameters). + +Fastify assumes Node.js is running with `insecureHTTPParser: false` (the +secure default). Deployments that enable `insecureHTTPParser: true` are +outside Fastify's threat model. + +### Examples of Vulnerabilities + +- Parsing flaws that bypass validation or security controls +- DoS through malformed input to Fastify's core +- Bypasses of built-in protections (prototype poisoning, schema validation) + +### Examples of Non-Vulnerabilities + +The following are **not** considered vulnerabilities in Fastify: + +- **Application code vulnerabilities**: XSS, SQL injection, or other flaws in +user-written route handlers, hooks, or plugins +- **Malicious application code**: Issues caused by intentionally malicious +plugins or handlers (application code is trusted) +- **Validation schema issues**: Weak or incorrect schemas provided by developers +(schemas are trusted) +- **ReDoS in user patterns**: Regular expression DoS in user-provided regex +patterns for routes or validation +- **Missing security features**: Lack of rate limiting, authentication, or +authorization (these are application-level concerns) +- **Configuration mistakes**: Security issues arising from developer +misconfiguration (configuration is trusted) +- **`insecureHTTPParser: true` deployments**: Reports that rely on enabling +Node.js `insecureHTTPParser` are out of scope; Fastify assumes this flag is +`false` +- **Third-party dependencies**: Vulnerabilities in npm packages used by the +application (not Fastify core dependencies) +- **Resource exhaustion from handlers**: DoS caused by expensive operations in +user route handlers +- **Information disclosure by design**: Exposing error details or stack traces +explicitly enabled via configuration options + ## Reporting vulnerabilities Individuals who find potential vulnerabilities in Fastify are invited to -complete a vulnerability report via the dedicated HackerOne page: -[https://hackerone.com/fastify](https://hackerone.com/fastify). +complete a vulnerability report via the +[GitHub Security page](https://github.com/fastify/fastify/security/advisories/new). + +Do not assign or request a CVE directly. +CVE assignment is handled by the Fastify Security Team. +Fastify falls under the [OpenJS CNA](https://cna.openjsf.org/). +A CVE will be assigned as part of our responsible disclosure process. + +> ℹ️ Note: +> Fastify's [HackerOne](https://hackerone.com/fastify) program is now closed. ### Strict measures when reporting vulnerabilities @@ -15,23 +70,32 @@ It is of the utmost importance that you read carefully and follow these guidelines to ensure the ecosystem as a whole isn't disrupted due to improperly reported vulnerabilities: -* Avoid creating new "informative" reports on HackerOne. Only create new - HackerOne reports on a vulnerability if you are absolutely sure this should be +* Avoid creating new "informative" reports. Only create new + reports on a vulnerability if you are absolutely sure this should be tagged as an actual vulnerability. Third-party vendors and individuals are - tracking any new vulnerabilities reported in HackerOne and will flag them as - such for their customers (think about snyk, npm audit, ...). -* HackerOne reports should never be created and triaged by the same person. If - you are creating a HackerOne report for a vulnerability that you found, or on + tracking any new vulnerabilities reported on GitHub and will flag + them as such for their customers (think about snyk, npm audit, ...). +* Security reports should never be created and triaged by the same person. If + you are creating a report for a vulnerability that you found, or on behalf of someone else, there should always be a 2nd Security Team member who triages it. If in doubt, invite more Fastify Collaborators to help triage the validity of the report. In any case, the report should follow the same process as outlined below of inviting the maintainers to review and accept the vulnerability. +* ***Do not*** attempt to show CI/CD vulnerabilities by creating new pull + requests to any of the Fastify organization's repositories. Doing so will + result in a [content report][cr] to GitHub as an unsolicited exploit. + The proper way to provide such reports is by creating a new repository, + configured in the same manner as the repository you would like to submit + a report about, and with a pull request to your own repository showing + the proof of concept. + +[cr]: https://docs.github.com/en/communities/maintaining-your-safety-on-github/reporting-abuse-or-spam#reporting-an-issue-or-pull-request ### Vulnerabilities found outside this process -⚠ The Fastify project does not support any reporting outside the HackerOne -process. +⚠ The Fastify project does not support any reporting outside the process mentioned +in this document. ## Handling vulnerability reports @@ -45,18 +109,15 @@ Within 4 business days, a member of the security team provides a first answer to the individual who submitted the potential vulnerability. The possible responses can be: -* Acceptance: what was reported is considered as a new vulnerability -* Rejection: what was reported is not considered as a new vulnerability -* Need more information: the security team needs more information in order to +* **Acceptance**: what was reported is considered as a new vulnerability +* **Rejection**: what was reported is not considered as a new vulnerability +* **Need more information**: the security team needs more information in order to evaluate what was reported. Triaging should include updating issue fields: * Asset - set/create the module affected by the report * Severity - TBD, currently left empty -Reference: [HackerOne: Submitting -Reports](https://docs.hackerone.com/hackers/submitting-reports.html) - ### Correction follow-up **Delay:** 90 days @@ -84,25 +145,28 @@ The report's vulnerable versions upper limit should be set to: Within 90 days after the triage date, the vulnerability must be made public. **Severity**: Vulnerability severity is assessed using [CVSS -v.3](https://www.first.org/cvss/user-guide). More information can be found on -[HackerOne documentation](https://docs.hackerone.com/hackers/severity.html) +v.3](https://www.first.org/cvss/user-guide). If the package maintainer is actively developing a patch, an additional delay can be added with the approval of the security team and the individual who reported the vulnerability. -At this point, a CVE should be requested through the HackerOne platform through -the UI, which should include the Report ID and a summary. +### Secondary Contact -Within HackerOne, this is handled through a "public disclosure request". +If you do not receive an acknowledgment of your report within 6 business days, +or if you cannot find a private security contact for the project, you may +contact the OpenJS Foundation CNA at (or +`security@lists.openjsf.org`) for assistance. -Reference: [HackerOne: -Disclosure](https://docs.hackerone.com/hackers/disclosure.html) +The CNA can help ensure your report is properly acknowledged, assist with +coordinating disclosure timelines, and assign CVEs when necessary. This is a +support mechanism to ensure security reports are handled appropriately across +all OpenJS Foundation projects. ## The Fastify Security team -The core team is responsible for the management of HackerOne program and this -policy and process. +The core team is responsible for the management of the security program and +this policy and process. Members of this team are expected to keep all information that they have privileged access to by being on the team completely private to the team. This @@ -114,10 +178,34 @@ work as a member of the Fastify Core team. ### Members * [__Matteo Collina__](https://github.com/mcollina), - , + , * [__Tomas Della Vedova__](https://github.com/delvedor), - , + , * [__Vincent Le Goff__](https://github.com/zekth) * [__KaKa Ng__](https://github.com/climba03003) * [__James Sumners__](https://github.com/jsumners), - , + , + +## OpenSSF CII Best Practices + +[![CII Best Practices](https://bestpractices.coreinfrastructure.org/projects/7585/badge)](https://bestpractices.coreinfrastructure.org/projects/7585) + +There are three “tiers”: passing, silver, and gold. + +### Passing +We meet 100% of the “passing” criteria. + +### Silver +We meet 87% of the "silver" criteria. The gaps are as follows: + - we do not have a DCO or a CLA process for contributions. + - we do not currently document "the architecture (aka high-level design)" + for our project. + +### Gold +We meet 70% of the “gold” criteria. The gaps are as follows: + - we do not yet have the “silver” badge; see all the gaps above. + - We do not include a copyright or license statement in each source file. + Efforts are underway to change this archaic practice into a + suggestion instead of a hard requirement. + - There are a few unanswered questions around cryptography that are + waiting for clarification. diff --git a/SPONSORS.md b/SPONSORS.md new file mode 100644 index 00000000000..36346e172d3 --- /dev/null +++ b/SPONSORS.md @@ -0,0 +1,24 @@ +# Sponsors + +All active sponsors of Fastify are listed here, in order of contribution! +Our sponsors are the reason why we can work on some issues or features +that otherwise would be impossible to do. + +If you want to become a sponsor, please check out our [Open Collective page](https://opencollective.com/fastify) +or [GitHub Sponsors](https://github.com/sponsors/fastify)! + +## Tier 4 + +- [SerpApi](https://serpapi.com/?utm_source=fastify) + +## Tier 3 + +- [Mercedes-Benz Group](https://github.com/mercedes-benz) +- [Val Town, Inc.](https://opencollective.com/valtown) +- [Handsontable - JavaScript Data Grid](https://handsontable.com/docs/react-data-grid/?utm_source=Fastify_GH&utm_medium=sponsorship&utm_campaign=library_sponsorship_2024) +- [Lokalise - A Localization and Translation Software Tool](https://lokalise.com/?utm_source=Fastify_GH&utm_medium=sponsorship) +- [TestMu AI](https://www.testmu.ai/) + +## Tier 2 + +_Be the first!_ diff --git a/build/build-error-serializer.js b/build/build-error-serializer.js index d666bd38ea1..d7a0cd2e3c7 100644 --- a/build/build-error-serializer.js +++ b/build/build-error-serializer.js @@ -2,8 +2,8 @@ 'use strict' const FJS = require('fast-json-stringify') -const path = require('path') -const fs = require('fs') +const path = require('node:path') +const fs = require('node:fs') const code = FJS({ type: 'object', @@ -18,10 +18,12 @@ const code = FJS({ const file = path.join(__dirname, '..', 'lib', 'error-serializer.js') const moduleCode = `// This file is autogenerated by build/build-error-serializer.js, do not edit -/* istanbul ignore file */ +/* c8 ignore start */ ${code} +/* c8 ignore stop */ ` +/* c8 ignore start */ if (require.main === module) { fs.writeFileSync(file, moduleCode) console.log(`Saved ${file} file successfully`) @@ -30,3 +32,4 @@ if (require.main === module) { code: moduleCode } } +/* c8 ignore stop */ diff --git a/build/build-validation.js b/build/build-validation.js index 5439355ffe8..d20213a79bc 100644 --- a/build/build-validation.js +++ b/build/build-validation.js @@ -2,20 +2,21 @@ const AjvStandaloneCompiler = require('@fastify/ajv-compiler/standalone') const { _ } = require('ajv') -const fs = require('fs') -const path = require('path') +const fs = require('node:fs') +const path = require('node:path') const factory = AjvStandaloneCompiler({ readMode: false, storeFunction (routeOpts, schemaValidationCode) { - const moduleCode = `// This file is autogenerated by ${__filename.replace(__dirname, 'build')}, do not edit -/* istanbul ignore file */ + const moduleCode = `// This file is autogenerated by build/build-validation.js, do not edit +/* c8 ignore start */ ${schemaValidationCode} module.exports.defaultInitOptions = ${JSON.stringify(defaultInitOptions)} +/* c8 ignore stop */ ` - const file = path.join(__dirname, '..', 'lib', 'configValidator.js') + const file = path.join(__dirname, '..', 'lib', 'config-validator.js') fs.writeFileSync(file, moduleCode) console.log(`Saved ${file} file successfully`) } @@ -27,21 +28,30 @@ const defaultInitOptions = { forceCloseConnections: undefined, // keep-alive connections maxRequestsPerSocket: 0, // no limit requestTimeout: 0, // no limit + handlerTimeout: 0, // no timeout (disabled by default) bodyLimit: 1024 * 1024, // 1 MiB caseSensitive: true, allowUnsafeRegex: false, disableRequestLogging: false, - jsonShorthand: true, ignoreTrailingSlash: false, ignoreDuplicateSlashes: false, maxParamLength: 100, onProtoPoisoning: 'error', onConstructorPoisoning: 'error', pluginTimeout: 10000, - requestIdHeader: 'request-id', + requestIdHeader: false, requestIdLogLabel: 'reqId', http2SessionTimeout: 72000, // 72 seconds - exposeHeadRoutes: true + exposeHeadRoutes: true, + useSemicolonDelimiter: false, + allowErrorHandlerOverride: true, // TODO: set to false in v6 + routerOptions: { + ignoreTrailingSlash: false, + ignoreDuplicateSlashes: false, + maxParamLength: 100, + allowUnsafeRegex: false, + useSemicolonDelimiter: false + } } const schema = { @@ -63,6 +73,7 @@ const schema = { }, maxRequestsPerSocket: { type: 'integer', default: defaultInitOptions.maxRequestsPerSocket, nullable: true }, requestTimeout: { type: 'integer', default: defaultInitOptions.requestTimeout }, + handlerTimeout: { type: 'integer', default: defaultInitOptions.handlerTimeout }, bodyLimit: { type: 'integer', default: defaultInitOptions.bodyLimit }, caseSensitive: { type: 'boolean', default: defaultInitOptions.caseSensitive }, allowUnsafeRegex: { type: 'boolean', default: defaultInitOptions.allowUnsafeRegex }, @@ -89,26 +100,26 @@ const schema = { ignoreTrailingSlash: { type: 'boolean', default: defaultInitOptions.ignoreTrailingSlash }, ignoreDuplicateSlashes: { type: 'boolean', default: defaultInitOptions.ignoreDuplicateSlashes }, disableRequestLogging: { - type: 'boolean', default: false }, - jsonShorthand: { type: 'boolean', default: defaultInitOptions.jsonShorthand }, maxParamLength: { type: 'integer', default: defaultInitOptions.maxParamLength }, onProtoPoisoning: { type: 'string', default: defaultInitOptions.onProtoPoisoning }, onConstructorPoisoning: { type: 'string', default: defaultInitOptions.onConstructorPoisoning }, pluginTimeout: { type: 'integer', default: defaultInitOptions.pluginTimeout }, - requestIdHeader: { type: 'string', default: defaultInitOptions.requestIdHeader }, + requestIdHeader: { anyOf: [{ type: 'boolean' }, { type: 'string' }], default: defaultInitOptions.requestIdHeader }, requestIdLogLabel: { type: 'string', default: defaultInitOptions.requestIdLogLabel }, http2SessionTimeout: { type: 'integer', default: defaultInitOptions.http2SessionTimeout }, exposeHeadRoutes: { type: 'boolean', default: defaultInitOptions.exposeHeadRoutes }, - // deprecated style of passing the versioning constraint - versioning: { + useSemicolonDelimiter: { type: 'boolean', default: defaultInitOptions.useSemicolonDelimiter }, + routerOptions: { type: 'object', additionalProperties: true, - required: ['storage', 'deriveVersion'], properties: { - storage: { }, - deriveVersion: { } + ignoreTrailingSlash: { type: 'boolean', default: defaultInitOptions.routerOptions.ignoreTrailingSlash }, + ignoreDuplicateSlashes: { type: 'boolean', default: defaultInitOptions.routerOptions.ignoreDuplicateSlashes }, + maxParamLength: { type: 'integer', default: defaultInitOptions.routerOptions.maxParamLength }, + allowUnsafeRegex: { type: 'boolean', default: defaultInitOptions.routerOptions.allowUnsafeRegex }, + useSemicolonDelimiter: { type: 'boolean', default: defaultInitOptions.routerOptions.useSemicolonDelimiter } } }, constraints: { @@ -119,9 +130,9 @@ const schema = { additionalProperties: true, properties: { name: { type: 'string' }, - storage: { }, - validate: { }, - deriveConstraint: { } + storage: {}, + validate: {}, + deriveConstraint: {} } } } diff --git a/build/sync-version.js b/build/sync-version.js index 5d00f216ed9..b26cced3834 100644 --- a/build/sync-version.js +++ b/build/sync-version.js @@ -1,7 +1,7 @@ 'use strict' -const fs = require('fs') -const path = require('path') +const fs = require('node:fs') +const path = require('node:path') // package.json:version -> fastify.js:VERSION const { version } = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json')).toString('utf8')) diff --git a/docs/Guides/Benchmarking.md b/docs/Guides/Benchmarking.md index 5e2e990ba95..2aeac0a2736 100644 --- a/docs/Guides/Benchmarking.md +++ b/docs/Guides/Benchmarking.md @@ -1,18 +1,18 @@

Fastify

## Benchmarking -Benchmarking is important if you want to measure how a change can affect the -performance of your application. We provide a simple way to benchmark your +Benchmarking is important if you want to measure how a change can affect your +application's performance. We provide a simple way to benchmark your application from the point of view of a user and contributor. The setup allows you to automate benchmarks in different branches and on different Node.js versions. The modules we will use: -- [Autocannon](https://github.com/mcollina/autocannon): A HTTP/1.1 benchmarking +- [Autocannon](https://github.com/mcollina/autocannon): An HTTP/1.1 benchmarking tool written in node. - [Branch-comparer](https://github.com/StarpTech/branch-comparer): Checkout - multiple git branches, execute scripts and log the results. -- [Concurrently](https://github.com/kimmobrunfeldt/concurrently): Run commands + multiple git branches, execute scripts, and log the results. +- [Concurrently](https://github.com/open-cli-tools/concurrently): Run commands concurrently. - [Npx](https://github.com/npm/npx): NPM package runner used to run scripts against different Node.js Versions and execute local binaries. Shipped with diff --git a/docs/Guides/Contributing.md b/docs/Guides/Contributing.md index 3cd8be41d0e..d2df29e935d 100644 --- a/docs/Guides/Contributing.md +++ b/docs/Guides/Contributing.md @@ -6,9 +6,10 @@ receive your support and knowledge. This guide is our attempt to help you help us. > ## Note -> This is an informal guide. Please review the formal [CONTRIBUTING -> document](https://github.com/fastify/fastify/blob/main/CONTRIBUTING.md) for -> full details and our [Developer Certificate of +> This is an informal guide. For full details, please review the formal +> [CONTRIBUTING +> document](https://github.com/fastify/fastify/blob/main/CONTRIBUTING.md) +> our [Developer Certificate of > Origin](https://en.wikipedia.org/wiki/Developer_Certificate_of_Origin). ## Table Of Contents @@ -50,7 +51,7 @@ should expect from others): * We have a [Code of Conduct](https://github.com/fastify/fastify/blob/main/CODE_OF_CONDUCT.md). You must adhere to it to participate in this project. -* If you open a pull request, please ensure that your contribution passes all +* If you open a pull request, please ensure your contribution passes all tests. If there are test failures, you will need to address them before we can merge your contribution. @@ -79,9 +80,11 @@ https://github.com/github/opensource.guide/blob/2868efbf0c14aec821909c19e210c360 Please adhere to the project's code and documentation style. Some popular tools that automatically "correct" code and documentation do not follow a style that -conforms to the styles this project uses. Notably, this project uses +conforms to this project's styles. Notably, this project uses [StandardJS](https://standardjs.com) for code formatting. +[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/fastify/fastify) + ### Using Visual Studio Code @@ -114,7 +117,7 @@ mkdir -p /Applications/VSCodeFastify/code-portable-data/{user-data,extensions} Before continuing, we need to add the `code` command to your terminal's `PATH`. To do so, we will [manually add VSCode to the -`PATH`](https://code.visualstudio.com/docs/setup/mac#_launching-from-the-command-line). +`PATH`](https://code.visualstudio.com/docs/setup/mac#_launch-vs-code-from-the-command-line). As outlined in that document, the instructions vary depending on your default shell, so you should follow the instructions in that guide as relates to your preferred shell. However, we will tweak them slightly by defining an alias @@ -162,9 +165,11 @@ the left sidebar. But wait! We are not quite done yet. There are a few more baseline settings that should be set before VSCode is ready. Press `cmd+shift+p` to bring up the VSCode command input prompt. Type `open -settings (json)` and then choose the same item from the filtered menu. This will -open a document that is the settings for the editor. Paste the following JSON -into this document, overwriting any text already present, and save it: +settings (json)`. Three [VSCode Setting](https://code.visualstudio.com/docs/getstarted/settings) +options will appear in the dropdown: Workspace, Default, +and User settings. We recommend selecting Default. This will open a document +that is the settings for the editor. Paste the following JSON into this +document, overwriting any text already present, and save it: ```json { diff --git a/docs/Guides/Database.md b/docs/Guides/Database.md index d9d9b09754d..f63b7bdfb84 100644 --- a/docs/Guides/Database.md +++ b/docs/Guides/Database.md @@ -2,17 +2,17 @@ ## Database -Fastify's ecosystem provides a handful of -plugins for connecting to various database engines. -This guide covers engines that have Fastify +Fastify's ecosystem provides a handful of +plugins for connecting to various database engines. +This guide covers engines that have Fastify plugins maintained within the Fastify organization. -> If a plugin for your database of choice does not exist -> you can still use the database as Fastify is database agnostic. -> By following the examples of the database plugins listed in this guide, -> a plugin can be written for the missing database engine. +> If a plugin for your database of choice does not exist +> you can still use the database as Fastify is database agnostic. +> By following the examples of the database plugins listed in this guide, +> a plugin can be written for the missing database engine. -> If you would like to write your own Fastify plugin +> If you would like to write your own Fastify plugin > please take a look at the [plugins guide](./Plugins-Guide.md) ### [MySQL](https://github.com/fastify/fastify-mysql) @@ -104,8 +104,8 @@ fastify.listen({ port: 3000 }, err => { }) ``` -By default `@fastify/redis` doesn't close -the client connection when Fastify server shuts down. +By default `@fastify/redis` doesn't close +the client connection when Fastify server shuts down. To opt-in to this behavior, register the client like so: ```javascript @@ -126,23 +126,22 @@ fastify.register(require('@fastify/mongodb'), { // force to close the mongodb connection when app stopped // the default value is false forceClose: true, - + url: 'mongodb://mongo/mydb' }) -fastify.get('/user/:id', function (req, reply) { +fastify.get('/user/:id', async function (req, reply) { // Or this.mongo.client.db('mydb').collection('users') const users = this.mongo.db.collection('users') // if the id is an ObjectId format, you need to create a new ObjectId const id = this.mongo.ObjectId(req.params.id) - users.findOne({ id }, (err, user) => { - if (err) { - reply.send(err) - return - } - reply.send(user) - }) + try { + const user = await users.findOne({ id }) + return user + } catch (err) { + return err + } }) fastify.listen({ port: 3000 }, err => { @@ -179,8 +178,8 @@ fastify.listen({ port: 3000 }, err => { ``` ### Writing plugin for a database library -We could write a plugin for a database -library too (e.g. Knex, Prisma, or TypeORM). +We could write a plugin for a database +library too (e.g. Knex, Prisma, or TypeORM). We will use [Knex](https://knexjs.org/) in our example. ```javascript @@ -238,15 +237,15 @@ development. Migrations provide a repeatable and testable way to modify a database's schema and prevent data loss. As stated at the beginning of the guide, Fastify is database agnostic and any -NodeJS database migration tool can be used with it. We will give an example of +Node.js database migration tool can be used with it. We will give an example of using [Postgrator](https://www.npmjs.com/package/postgrator) which has support for Postgres, MySQL, SQL Server and SQLite. For MongoDB migrations, please check [migrate-mongo](https://www.npmjs.com/package/migrate-mongo). #### [Postgrator](https://www.npmjs.com/package/postgrator) -Postgrator is NodeJS SQL migration tool that uses a directory of SQL scripts to -alter the database schema. Each file an migrations folder need to follow the +Postgrator is Node.js SQL migration tool that uses a directory of SQL scripts to +alter the database schema. Each file in a migrations folder needs to follow the pattern: ` [version].[action].[optional-description].sql`. **version:** must be an incrementing number (e.g. `001` or a timestamp). @@ -276,18 +275,20 @@ CREATE TABLE IF NOT EXISTS users ( ```javascript const pg = require('pg') const Postgrator = require('postgrator') -const path = require('path') +const path = require('node:path') async function migrate() { const client = new pg.Client({ host: 'localhost', port: 5432, - database: 'example', + database: 'example', user: 'example', password: 'example', }); try { + await client.connect(); + const postgrator = new Postgrator({ migrationPattern: path.join(__dirname, '/migrations/*'), driver: 'pg', @@ -309,10 +310,10 @@ async function migrate() { process.exitCode = 0 } catch(err) { - console.error(error) + console.error(err) process.exitCode = 1 } - + await client.end() } diff --git a/docs/Guides/Delay-Accepting-Requests.md b/docs/Guides/Delay-Accepting-Requests.md index 9a45ec26db5..acb2fdaa952 100644 --- a/docs/Guides/Delay-Accepting-Requests.md +++ b/docs/Guides/Delay-Accepting-Requests.md @@ -49,7 +49,7 @@ That will be achieved by wrapping into a custom plugin two main features: 1. the mechanism for authenticating with the provider [decorating](../Reference/Decorators.md) the `fastify` object with the -authentication key (`magicKey` from here onwards) +authentication key (`magicKey` from here onward) 1. the mechanism for denying requests that would, otherwise, fail ### Hands-on @@ -77,8 +77,8 @@ server.get('/ping', function (request, reply) { }) server.post('/webhook', function (request, reply) { - // It's good practice to validate webhook requests really come from - // whoever you expect. This is skipped in this sample for the sake + // It's good practice to validate webhook requests come from + // who you expect. This is skipped in this sample for the sake // of simplicity const { magicKey } = request.body @@ -103,7 +103,7 @@ server.get('/v1*', async function (request, reply) { } }) -server.decorate('magicKey', null) +server.decorate('magicKey') server.listen({ port: '1234' }, () => { provider.thirdPartyMagicKeyGenerator(USUAL_WAIT_TIME_MS) @@ -135,7 +135,7 @@ follows: ```js const { fetch } = require('undici') -const { setTimeout } = require('timers/promises') +const { setTimeout } = require('node:timers/promises') const MAGIC_KEY = '12345' @@ -249,7 +249,7 @@ server.listen({ port: '1234' }) ```js const { fetch } = require('undici') -const { setTimeout } = require('timers/promises') +const { setTimeout } = require('node:timers/promises') const MAGIC_KEY = '12345' @@ -303,7 +303,7 @@ async function setup(fastify) { fastify.server.on('listening', doMagic) // Set up the placeholder for the magicKey - fastify.decorate('magicKey', null) + fastify.decorate('magicKey') // Our magic -- important to make sure errors are handled. Beware of async // functions outside `try/catch` blocks @@ -406,7 +406,7 @@ https://nodejs.org/api/net.html#event-listening). We use that to reach out to our provider as soon as possible, with the `doMagic` function. ```js - fastify.decorate('magicKey', null) + fastify.decorate('magicKey') ``` The `magicKey` decoration is also part of the plugin now. We initialize it with @@ -448,10 +448,10 @@ have the possibility of giving the customer meaningful information, like how long they should wait before retrying the request. Going even further, by issuing a [`503` status code](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/503) we're -signaling to our infrastructure components (namely load balancers) we're still -not ready to take incoming requests and they should redirect traffic to other -instances, if available, besides in how long we estimate that will be solved. -All of that in a few simple lines! +signaling to our infrastructure components (namely load balancers) that we're +still not ready to take incoming requests and they should redirect traffic to +other instances, if available. Additionally, we are providing a `Retry-After` +header with the time in milliseconds the client should wait before retrying. It's noteworthy that we didn't use the `fastify-plugin` wrapper in the `delay` factory. That's because we wanted the `onRequest` hook to only be set within @@ -524,14 +524,17 @@ Retry-After: 5000 } ``` -Then we attempt a new request (`req-2`), which was a `GET /ping`. As expected, +Then we attempted a new request (`req-2`), which was a `GET /ping`. As expected, since that was not one of the requests we asked our plugin to filter, it -succeeded. That could also be used as means of informing an interested party -whether or not we were ready to serve requests (although `/ping` is more -commonly associated with *liveness* checks and that would be the responsibility -of a *readiness* check -- the curious reader can get more info on these terms -[here](https://cloud.google.com/blog/products/containers-kubernetes/kubernetes-best-practices-setting-up-health-checks-with-readiness-and-liveness-probes)) -with the `ready` field. Below is the response for that request: +succeeded. That could also be used as a means of informing an interested party +whether or not we were ready to serve requests with the `ready` field. Although +`/ping` is more commonly associated with *liveness* checks and that would be +the responsibility of a *readiness* check. The curious reader can get more info +on these terms in the article +["Kubernetes best practices: Setting up health checks with readiness and liveness probes"]( +https://cloud.google.com/blog/products/containers-kubernetes/kubernetes-best-practices-setting-up-health-checks-with-readiness-and-liveness-probes). + +Below is the response to that request: ```sh HTTP/1.1 200 OK @@ -547,7 +550,7 @@ Keep-Alive: timeout=5 } ``` -After that there were more interesting log messages: +After that, there were more interesting log messages: ```sh diff --git a/docs/Guides/Detecting-When-Clients-Abort.md b/docs/Guides/Detecting-When-Clients-Abort.md new file mode 100644 index 00000000000..b2dee213636 --- /dev/null +++ b/docs/Guides/Detecting-When-Clients-Abort.md @@ -0,0 +1,172 @@ +

Fastify

+ +# Detecting When Clients Abort + +## Introduction + +Fastify provides request events to trigger at certain points in a request's +lifecycle. However, there isn't a built-in mechanism to +detect unintentional client disconnection scenarios such as when the client's +internet connection is interrupted. This guide covers methods to detect if +and when a client intentionally aborts a request. + +Keep in mind, Fastify's `clientErrorHandler` is not designed to detect when a +client aborts a request. This works in the same way as the standard Node HTTP +module, which triggers the `clientError` event when there is a bad request or +exceedingly large header data. When a client aborts a request, there is no +error on the socket and the `clientErrorHandler` will not be triggered. + +## Solution + +### Overview + +The proposed solution is a possible way of detecting when a client +intentionally aborts a request, such as when a browser is closed or the HTTP +request is aborted from your client application. If there is an error in your +application code that results in the server crashing, you may require +additional logic to avoid a false abort detection. + +The goal here is to detect when a client intentionally aborts a connection +so your application logic can proceed accordingly. This can be useful for +logging purposes or halting business logic. + +### Hands-on + +Say we have the following base server set up: + +```js +import Fastify from 'fastify'; + +const sleep = async (time) => { + return await new Promise(resolve => setTimeout(resolve, time || 1000)); +} + +const app = Fastify({ + logger: { + transport: { + target: 'pino-pretty', + options: { + translateTime: 'HH:MM:ss Z', + ignore: 'pid,hostname', + }, + }, + }, +}) + +app.addHook('onRequest', async (request, reply) => { + request.raw.on('close', () => { + if (request.raw.aborted) { + app.log.info('request closed') + } + }) +}) + +app.get('/', async (request, reply) => { + await sleep(3000) + reply.code(200).send({ ok: true }) +}) + +const start = async () => { + try { + await app.listen({ port: 3000 }) + } catch (err) { + app.log.error(err) + process.exit(1) + } +} + +start() +``` + +Our code is setting up a Fastify server which includes the following +functionality: + +- Accepting requests at `http://localhost:3000`, with a 3 second delayed response +of `{ ok: true }`. +- An onRequest hook that triggers when every request is received. +- Logic that triggers in the hook when the request is closed. +- Logging that occurs when the closed request property `aborted` is true. + +Whilst the `aborted` property has been deprecated, `destroyed` is not a +suitable replacement as the +[Node.js documentation suggests](https://nodejs.org/api/http.html#requestaborted). +A request can be `destroyed` for various reasons, such as when the server closes +the connection. The `aborted` property is still the most reliable way to detect +when a client intentionally aborts a request. + +You can also perform this logic outside of a hook, directly in a specific route. + +```js +app.get('/', async (request, reply) => { + request.raw.on('close', () => { + if (request.raw.aborted) { + app.log.info('request closed') + } + }) + await sleep(3000) + reply.code(200).send({ ok: true }) +}) +``` + +At any point in your business logic, you can check if the request has been +aborted and perform alternative actions. + +```js +app.get('/', async (request, reply) => { + await sleep(3000) + if (request.raw.aborted) { + // do something here + } + await sleep(3000) + reply.code(200).send({ ok: true }) +}) +``` + +A benefit to adding this in your application code is that you can log Fastify +details such as the reqId, which may be unavailable in lower-level code that +only has access to the raw request information. + +### Testing + +To test this functionality you can use an app like Postman and cancel your +request within 3 seconds. Alternatively, you can use Node to send an HTTP +request with logic to abort the request before 3 seconds. Example: + +```js +const controller = new AbortController(); +const signal = controller.signal; + +(async () => { + try { + const response = await fetch('http://localhost:3000', { signal }); + const body = await response.text(); + console.log(body); + } catch (error) { + console.error(error); + } +})(); + +setTimeout(() => { + controller.abort() +}, 1000); +``` + +With either approach, you should see the Fastify log appear at the moment the +request is aborted. + +## Conclusion + +Specifics of the implementation will vary from one problem to another, but the +main goal of this guide was to show a very specific use case of an issue that +could be solved within Fastify's ecosystem. + +You can listen to the request close event and determine if the request was +aborted or if it was successfully delivered. You can implement this solution +in an onRequest hook or directly in an individual route. + +This approach will not trigger in the event of internet disruption, and such +detection would require additional business logic. If you have flawed backend +application logic that results in a server crash, then you could trigger a +false detection. The `clientErrorHandler`, either by default or with custom +logic, is not intended to handle this scenario and will not trigger when the +client aborts a request. diff --git a/docs/Guides/Ecosystem.md b/docs/Guides/Ecosystem.md index 44760038680..15dd405140f 100644 --- a/docs/Guides/Ecosystem.md +++ b/docs/Guides/Ecosystem.md @@ -11,9 +11,7 @@ section. - [`@fastify/accepts`](https://github.com/fastify/fastify-accepts) to have [accepts](https://www.npmjs.com/package/accepts) in your request object. - [`@fastify/accepts-serializer`](https://github.com/fastify/fastify-accepts-serializer) - to serialize to output according to `Accept` header. -- [`@fastify/any-schema`](https://github.com/fastify/any-schema-you-like) Save - multiple schemas and decide which one to use to serialize the payload + to serialize to output according to the `Accept` header. - [`@fastify/auth`](https://github.com/fastify/fastify-auth) Run multiple auth functions in Fastify. - [`@fastify/autoload`](https://github.com/fastify/fastify-autoload) Require all @@ -42,14 +40,14 @@ section. plugin for adding [CSRF](https://en.wikipedia.org/wiki/Cross-site_request_forgery) protection to Fastify. -- [`@fastify/diagnostics-channel`](https://github.com/fastify/fastify-diagnostics-channel) - Plugin to deal with `diagnostics_channel` on Fastify - [`@fastify/elasticsearch`](https://github.com/fastify/fastify-elasticsearch) Plugin to share the same ES client. - [`@fastify/env`](https://github.com/fastify/fastify-env) Load and check configuration. - [`@fastify/etag`](https://github.com/fastify/fastify-etag) Automatically generate ETags for HTTP responses. +- [`@fastify/express`](https://github.com/fastify/fastify-express) Express + compatibility layer for Fastify. - [`@fastify/flash`](https://github.com/fastify/fastify-flash) Set and get flash messages using the session. - [`@fastify/formbody`](https://github.com/fastify/fastify-formbody) Plugin to @@ -66,6 +64,8 @@ section. your HTTP requests to another server, with hooks. - [`@fastify/jwt`](https://github.com/fastify/fastify-jwt) JWT utils for Fastify, internally uses [fast-jwt](https://github.com/nearform/fast-jwt). +- [`@fastify/kafka`](https://github.com/fastify/fastify-kafka) Plugin to interact + with Apache Kafka. - [`@fastify/leveldb`](https://github.com/fastify/fastify-leveldb) Plugin to share a common LevelDB connection across Fastify. - [`@fastify/middie`](https://github.com/fastify/middie) Middleware engine for @@ -75,13 +75,29 @@ section. connection pool across every part of your server. - [`@fastify/multipart`](https://github.com/fastify/fastify-multipart) Multipart support for Fastify. +- [`@fastify/mysql`](https://github.com/fastify/fastify-mysql) Fastify MySQL + connection plugin. +- [`@fastify/nextjs`](https://github.com/fastify/fastify-nextjs) React + server-side rendering support for Fastify with + [Next](https://github.com/vercel/next.js/). - [`@fastify/oauth2`](https://github.com/fastify/fastify-oauth2) Wrap around [`simple-oauth2`](https://github.com/lelylan/simple-oauth2). +- [`@fastify/one-line-logger`](https://github.com/fastify/one-line-logger) Formats + Fastify's logs into a nice one-line message. +- [`@fastify/otel`](https://github.com/fastify/otel) OpenTelemetry + instrumentation library. +- [`@fastify/passport`](https://github.com/fastify/fastify-passport) Use Passport + strategies to authenticate requests and protect route. - [`@fastify/postgres`](https://github.com/fastify/fastify-postgres) Fastify PostgreSQL connection plugin, with this you can share the same PostgreSQL connection pool in every part of your server. - [`@fastify/rate-limit`](https://github.com/fastify/fastify-rate-limit) A low overhead rate limiter for your routes. +- [`@fastify/redis`](https://github.com/fastify/fastify-redis) Fastify Redis + connection plugin, with which you can share the same Redis connection across + every part of your server. +- [`@fastify/reply-from`](https://github.com/fastify/fastify-reply-from) Plugin + to forward the current HTTP request to another server. - [`@fastify/request-context`](https://github.com/fastify/fastify-request-context) Request-scoped storage, based on [AsyncLocalStorage](https://nodejs.org/api/async_hooks.html#async_hooks_class_asynclocalstorage) @@ -89,29 +105,39 @@ section. providing functionality similar to thread-local storages. - [`@fastify/response-validation`](https://github.com/fastify/fastify-response-validation) A simple plugin that enables response validation for Fastify. -- [`@fastify/nextjs`](https://github.com/fastify/fastify-nextjs) React - server-side rendering support for Fastify with - [Next](https://github.com/zeit/next.js/). -- [`@fastify/redis`](https://github.com/fastify/fastify-redis) Fastify Redis - connection plugin, with which you can share the same Redis connection across - every part of your server. -- [`@fastify/reply-from`](https://github.com/fastify/fastify-reply-from) Plugin - to forward the current HTTP request to another server. - [`@fastify/routes`](https://github.com/fastify/fastify-routes) Plugin that provides a `Map` of routes. +- [`@fastify/routes-stats`](https://github.com/fastify/fastify-routes-stats) + Provide stats for routes using `node:perf_hooks`. - [`@fastify/schedule`](https://github.com/fastify/fastify-schedule) Plugin for scheduling periodic jobs, based on [toad-scheduler](https://github.com/kibertoad/toad-scheduler). +- [`@fastify/secure-session`](https://github.com/fastify/fastify-secure-session) + Create a secure stateless cookie session for Fastify. - [`@fastify/sensible`](https://github.com/fastify/fastify-sensible) Defaults for Fastify that everyone can agree on. It adds some useful decorators such as HTTP errors and assertions, but also more request and reply methods. - [`@fastify/session`](https://github.com/fastify/session) a session plugin for Fastify. +- [`@fastify/sse`](https://github.com/fastify/sse) Plugin for Server-Sent Events + (SSE) support in Fastify. - [`@fastify/static`](https://github.com/fastify/fastify-static) Plugin for serving static files as fast as possible. - [`@fastify/swagger`](https://github.com/fastify/fastify-swagger) Plugin for serving Swagger/OpenAPI documentation for Fastify, supporting dynamic generation. +- [`@fastify/swagger-ui`](https://github.com/fastify/fastify-swagger-ui) Plugin + for serving Swagger UI. +- [`@fastify/throttle`](https://github.com/fastify/fastify-throttle) Plugin for + throttling the download speed of a request. +- [`@fastify/type-provider-json-schema-to-ts`](https://github.com/fastify/fastify-type-provider-json-schema-to-ts) + Fastify + [type provider](https://fastify.dev/docs/latest/Reference/Type-Providers/) + for [json-schema-to-ts](https://github.com/ThomasAribart/json-schema-to-ts). +- [`@fastify/type-provider-typebox`](https://github.com/fastify/fastify-type-provider-typebox) + Fastify + [type provider](https://fastify.dev/docs/latest/Reference/Type-Providers/) + for [Typebox](https://github.com/sinclairzx81/typebox). - [`@fastify/under-pressure`](https://github.com/fastify/under-pressure) Measure process load with automatic handling of _"Service Unavailable"_ plugin for Fastify. @@ -119,13 +145,35 @@ section. the `Request` object with a method to access raw URL components. - [`@fastify/view`](https://github.com/fastify/point-of-view) Templates rendering (_ejs, pug, handlebars, marko_) plugin support for Fastify. +- [`@fastify/vite`](https://github.com/fastify/fastify-vite) Integration with + [Vite](https://vitejs.dev/), allows for serving SPA/MPA/SSR Vite applications. - [`@fastify/websocket`](https://github.com/fastify/fastify-websocket) WebSocket support for Fastify. Built upon [ws](https://github.com/websockets/ws). +- [`@fastify/zipkin`](https://github.com/fastify/fastify-zipkin) Plugin + for Zipkin distributed tracing system. #### [Community](#community) +> ℹ️ Note: +> Fastify community plugins are part of the broader community efforts, +> and we are thankful for these contributions. However, they are not +> maintained by the Fastify team. +> Use them at your own discretion. +> If you find malicious code, please +> [open an issue](https://github.com/fastify/fastify/issues/new/choose) or +> submit a PR to remove the plugin from the list. + +- [`@aaroncadillac/crudify-mongo`](https://github.com/aaroncadillac/crudify-mongo) + A simple way to add a crud in your fastify project. - [`@applicazza/fastify-nextjs`](https://github.com/applicazza/fastify-nextjs) Alternate Fastify and Next.js integration. +- [`@attaryz/fastify-devtools`](https://github.com/attaryz/fastify-devtools) + Development tools plugin for Fastify with live request dashboard, replay + capabilities, and metrics tracking. +- [`@blastorg/fastify-aws-dynamodb-cache`](https://github.com/blastorg/fastify-aws-dynamodb-cache) + A plugin to help with caching API responses using AWS DynamoDB. +- [`@clerk/fastify`](https://github.com/clerk/javascript/tree/main/packages/fastify) + Add authentication and user management to your Fastify application with Clerk. - [`@coobaha/typed-fastify`](https://github.com/Coobaha/typed-fastify) Strongly typed routes with a runtime validation using JSON schema generated from types. - [`@dnlup/fastify-doc`](https://github.com/dnlup/fastify-doc) A plugin for @@ -134,19 +182,37 @@ section. close the server gracefully on `SIGINT` and `SIGTERM` signals. - [`@eropple/fastify-openapi3`](https://github.com/eropple/fastify-openapi3) Provides easy, developer-friendly OpenAPI 3.1 specs + doc explorer based on your routes. + + +- [`@exortek/fastify-mongo-sanitize`](https://github.com/ExorTek/fastify-mongo-sanitize) + A Fastify plugin that protects against No(n)SQL injection by sanitizing data. +- [`@exortek/remix-fastify`](https://github.com/ExorTek/remix-fastify) + Fastify plugin for Remix. +- [`@glidemq/fastify`](https://github.com/avifenesh/glidemq-fastify) + Queue management plugin for glide-mq with REST API endpoints, SSE events, + and in-memory testing mode. Powered by Valkey/Redis Streams. - [`@gquittet/graceful-server`](https://github.com/gquittet/graceful-server) - Tiny (~5k), Fast, KISS, and dependency-free Node.JS library to make your + Tiny (~5k), Fast, KISS, and dependency-free Node.js library to make your Fastify API graceful. - [`@h4ad/serverless-adapter`](https://github.com/H4ad/serverless-adapter) Run REST APIs and other web applications using your existing Node.js application framework (Express, Koa, Hapi and Fastify), on top of AWS Lambda, Huawei and many other clouds. +- [`@hey-api/openapi-ts`](https://heyapi.dev/openapi-ts/plugins/fastify) + The OpenAPI to TypeScript codegen. Generate clients, SDKs, validators, and more. - [`@immobiliarelabs/fastify-metrics`](https://github.com/immobiliare/fastify-metrics) Minimalistic and opinionated plugin that collects usage/process metrics and dispatches to [statsd](https://github.com/statsd/statsd). -- [`@immobiliarelabs/fastify-sentry`](https://github.com/immobiliare/fastify-sentry) - Sentry errors handler that just works! Install, add your DSN and you're good - to go! +- [`@inaiat/fastify-papr`](https://github.com/inaiat/fastify-papr) + A plugin to integrate [Papr](https://github.com/plexinc/papr), + the MongoDB ORM for TypeScript & MongoDB, with Fastify. +- [`@jerome1337/fastify-enforce-routes-pattern`](https://github.com/Jerome1337/fastify-enforce-routes-pattern) + A Fastify plugin that enforces naming pattern for routes path. +- [`@joggr/fastify-prisma`](https://github.com/joggrdocs/fastify-prisma) + A plugin for accessing an instantiated PrismaClient on your server. +- [`@matths/fastify-svelte-view`](https://github.com/matths/fastify-svelte-view) + A Fastify plugin for rendering Svelte components with support for SSR + (Server-Side Rendering), CSR (Client-Side Rendering), and SSR with hydration. - [`@mgcrea/fastify-graceful-exit`](https://github.com/mgcrea/fastify-graceful-exit) A plugin to close the server gracefully - [`@mgcrea/fastify-request-logger`](https://github.com/mgcrea/fastify-request-logger) @@ -159,18 +225,33 @@ section. Fast sodium-based crypto for @mgcrea/fastify-session - [`@mgcrea/pino-pretty-compact`](https://github.com/mgcrea/pino-pretty-compact) A custom compact pino-base prettifier +- [`@pybot/fastify-autoload`](https://github.com/kunal097/fastify-autoload) + Plugin to generate routes automatically with valid json content +- [`@scalar/fastify-api-reference`](https://github.com/scalar/scalar/tree/main/integrations/fastify) + Beautiful OpenAPI/Swagger API references for Fastify - [`@trubavuong/fastify-seaweedfs`](https://github.com/trubavuong/fastify-seaweedfs) SeaweedFS for Fastify -- [`apollo-server-fastify`](https://github.com/apollographql/apollo-server/tree/master/packages/apollo-server-fastify) - Run an [Apollo Server](https://github.com/apollographql/apollo-server) to - serve GraphQL with Fastify. -- [`arecibo`](https://github.com/nucleode/arecibo) Fastify ping responder for +- [`@yeliex/fastify-problem-details`](https://github.com/yeliex/fastify-problem-details) + RFC 9457 Problem Details implementation for Fastify, with typed HTTP errors. +- [`apitally`](https://github.com/apitally/apitally-js) Fastify plugin to + integrate with [Apitally](https://apitally.io/fastify), an API analytics, + logging and monitoring tool. +- [`arecibo`](https://github.com/ducktors/arecibo) Fastify ping responder for Kubernetes Liveness and Readiness Probes. +- [`aws-xray-sdk-fastify`](https://github.com/aws/aws-xray-sdk-node/tree/master/sdk_contrib/fastify) + A Fastify plugin to log requests and subsegments through AWSXray. - [`cls-rtracer`](https://github.com/puzpuzpuz/cls-rtracer) Fastify middleware for CLS-based request ID generation. An out-of-the-box solution for adding request IDs into your logs. +- [`electron-server`](https://github.com/anonrig/electron-server) A plugin for + using Fastify without the need of consuming a port on Electron apps. +- [`elements-fastify`](https://github.com/rohitsoni007/elements-fastify) Fastify + Plugin for Stoplight Elements API Documentation using + openapi swagger json yml. - [`fast-water`](https://github.com/tswayne/fast-water) A Fastify plugin for waterline. Decorates Fastify with waterline models. +- [`fastify-204`](https://github.com/Shiva127/fastify-204) Fastify plugin that + return 204 status on empty response. - [`fastify-405`](https://github.com/Eomm/fastify-405) Fastify plugin that adds 405 HTTP status to your routes - [`fastify-allow`](https://github.com/mattbishop/fastify-allow) Fastify plugin @@ -178,7 +259,7 @@ section. 405 responses for routes that have a handler but not for the request's method. - [`fastify-amqp`](https://github.com/RafaelGSS/fastify-amqp) Fastify AMQP connection plugin, to use with RabbitMQ or another connector. Just a wrapper - to [`amqplib`](https://github.com/squaremo/amqp.node). + to [`amqplib`](https://github.com/amqp-node/amqplib). - [`fastify-amqp-async`](https://github.com/kffl/fastify-amqp-async) Fastify AMQP plugin with a Promise-based API provided by [`amqplib-as-promised`](https://github.com/twawszczak/amqplib-as-promised). @@ -187,24 +268,37 @@ section. [`@angular/platform-server`](https://github.com/angular/angular/tree/master/packages/platform-server) for Fastify - [`fastify-api-key`](https://github.com/arkerone/fastify-api-key) Fastify - plugin to authenticate HTTP requests based on api key and signature -- [`fastify-appwrite`](https://github.com/Dev-Manny/fastify-appwrite) Fastify + plugin to authenticate HTTP requests based on API key and signature +- [`fastify-appwrite`](https://github.com/maniecodes/fastify-appwrite) Fastify Plugin for interacting with Appwrite server. +- [`fastify-asyncforge`](https://github.com/mcollina/fastify-asyncforge) Plugin + to access Fastify instance, logger, request and reply from Node.js [Async + Local Storage](https://nodejs.org/api/async_context.html#class-asynclocalstorage). +- [`fastify-at-mysql`](https://github.com/mateonunez/fastify-at-mysql) Fastify + MySQL plugin with auto SQL injection attack prevention. +- [`fastify-at-postgres`](https://github.com/mateonunez/fastify-at-postgres) Fastify + Postgres plugin with auto SQL injection attack prevention. - [`fastify-auth0-verify`](https://github.com/nearform/fastify-auth0-verify): Auth0 verification plugin for Fastify, internally uses [fastify-jwt](https://npm.im/fastify-jwt) and [jsonwebtoken](https://npm.im/jsonwebtoken). -- [`fastify-autocrud`](https://github.com/paranoiasystem/fastify-autocrud) - Plugin to auto-generate CRUD routes as fast as possible. - [`fastify-autoroutes`](https://github.com/GiovanniCardamone/fastify-autoroutes) Plugin to scan and load routes based on filesystem path from a custom directory. +- [`fastify-aws-sns`](https://github.com/gzileni/fastify-aws-sns) Fastify plugin + for AWS Simple Notification Service (AWS SNS) that coordinates and manages + the delivery or sending of messages to subscribing endpoints or clients. +- [`fastify-aws-timestream`](https://github.com/gzileni/fastify-aws-timestream) + Fastify plugin for managing databases, tables, and querying and creating + scheduled queries with AWS Timestream. - [`fastify-axios`](https://github.com/davidedantonio/fastify-axios) Plugin to send HTTP requests via [axios](https://github.com/axios/axios). - [`fastify-babel`](https://github.com/cfware/fastify-babel) Fastify plugin for development servers that require Babel transformations of JavaScript sources. -- [`fastify-bcrypt`](https://github.com/heply/fastify-bcrypt) A Bcrypt hash +- [`fastify-bcrypt`](https://github.com/beliven-it/fastify-bcrypt) A Bcrypt hash generator & checker. +- [`fastify-better-sqlite3`](https://github.com/punkish/fastify-better-sqlite3) + Plugin for better-sqlite3. - [`fastify-blipp`](https://github.com/PavelPolyakov/fastify-blipp) Prints your routes to the console, so you definitely know which endpoints are available. - [`fastify-bookshelf`](https://github.com/butlerx/fastify-bookshelfjs) Fastify @@ -213,6 +307,11 @@ section. to add [boom](https://github.com/hapijs/boom) support. - [`fastify-bree`](https://github.com/climba03003/fastify-bree) Fastify plugin to add [bree](https://github.com/breejs/bree) support. +- [`fastify-bugsnag`](https://github.com/ZigaStrgar/fastify-bugsnag) Fastify plugin + to add support for [Bugsnag](https://www.bugsnag.com/) error reporting. +- [`fastify-cacheman`](https://gitlab.com/aalfiann/fastify-cacheman) + Small and efficient cache provider for Node.js with In-memory, File, Redis + and MongoDB engines for Fastify - [`fastify-casbin`](https://github.com/nearform/fastify-casbin) Casbin support for Fastify. - [`fastify-casbin-rest`](https://github.com/nearform/fastify-casbin-rest) @@ -224,18 +323,27 @@ section. - [`fastify-cloudevents`](https://github.com/smartiniOnGitHub/fastify-cloudevents) Fastify plugin to generate and forward Fastify events in the Cloudevents format. +- [`fastify-cloudflare-turnstile`](https://github.com/112RG/fastify-cloudflare-turnstile) + Fastify plugin for CloudFlare Turnstile. +- [`fastify-cloudinary`](https://github.com/Vanilla-IceCream/fastify-cloudinary) + Plugin to share a common Cloudinary connection across Fastify. - [`fastify-cockroachdb`](https://github.com/alex-ppg/fastify-cockroachdb) Fastify plugin to connect to a CockroachDB PostgreSQL instance via the Sequelize ORM. +- [`fastify-constraints`](https://github.com/nearform/fastify-constraints) + Fastify plugin to add constraints to multiple routes - [`fastify-couchdb`](https://github.com/nigelhanlon/fastify-couchdb) Fastify plugin to add CouchDB support via [nano](https://github.com/apache/nano). -- [`fastify-crud-generator`](https://github.com/heply/fastify-crud-generator) A - plugin to rapidly generate CRUD routes for any entity. +- [`fastify-crud-generator`](https://github.com/beliven-it/fastify-crud-generator) + A plugin to rapidly generate CRUD routes for any entity. - [`fastify-custom-healthcheck`](https://github.com/gkampitakis/fastify-custom-healthcheck) Fastify plugin to add health route in your server that asserts custom functions. - [`fastify-decorators`](https://github.com/L2jLiga/fastify-decorators) Fastify plugin that provides the set of TypeScript decorators. +- [`fastify-delay-request`](https://github.com/climba03003/fastify-delay-request) + Fastify plugin that allows requests to be delayed whilst a task the response is + dependent on is run, such as a resource intensive process. - [`fastify-disablecache`](https://github.com/Fdawgs/fastify-disablecache) Fastify plugin to disable client-side caching, inspired by [nocache](https://github.com/helmetjs/nocache). @@ -245,9 +353,6 @@ section. object. - [`fastify-dynareg`](https://github.com/greguz/fastify-dynareg) Dynamic plugin register for Fastify. -- [`fastify-early-hints`](https://github.com/zekth/fastify-early-hints) Plugin - to add HTTP 103 feature based on [RFC - 8297](https://httpwg.org/specs/rfc8297.html) - [`fastify-envalid`](https://github.com/alemagio/fastify-envalid) Fastify plugin to integrate [envalid](https://github.com/af/envalid) in your Fastify project. @@ -256,6 +361,11 @@ section. - [`fastify-esso`](https://github.com/patrickpissurno/fastify-esso) The easiest authentication plugin for Fastify, with built-in support for Single sign-on (and great documentation). +- [`fastify-event-bus`](https://github.com/Shiva127/fastify-event-bus) Event bus + support for Fastify. Built upon [js-event-bus](https://github.com/bcerati/js-event-bus). +- [`fastify-evervault`](https://github.com/Briscoooe/fastify-evervault/) Fastify + plugin for instantiating and encapsulating the + [Evervault](https://evervault.com/) client. - [`fastify-explorer`](https://github.com/Eomm/fastify-explorer) Get control of your decorators across all the encapsulated contexts. - [`fastify-favicon`](https://github.com/smartiniOnGitHub/fastify-favicon) @@ -263,18 +373,11 @@ section. - [`fastify-feature-flags`](https://gitlab.com/m03geek/fastify-feature-flags) Fastify feature flags plugin with multiple providers support (e.g. env, [config](https://lorenwest.github.io/node-config/), - [unleash](https://unleash.github.io/)). + [unleash](https://github.com/Unleash/unleash)). +- [`fastify-file-router`](https://github.com/bhouston/fastify-file-router) + A typesafe TanStack Start / Next.JS-style router with JSON + Zod schema support. - [`fastify-file-routes`](https://github.com/spa5k/fastify-file-routes) Get Next.js based file system routing into fastify. -- [`fastify-file-upload`](https://github.com/huangang/fastify-file-upload) - Fastify plugin for uploading files. -- [`fastify-firebase`](https://github.com/now-ims/fastify-firebase) Fastify - plugin for [Firebase Admin SDK](https://firebase.google.com/docs/admin/setup) - to Fastify so you can easily use Firebase Auth, Firestore, Cloud Storage, - Cloud Messaging, and more. -- [`fastify-firebase-auth`](https://github.com/oxsav/fastify-firebase-auth) - Firebase Authentication for Fastify supporting all of the methods relating to - the authentication API. - [`fastify-formidable`](https://github.com/climba03003/fastify-formidable) Handy plugin to provide multipart support and fastify-swagger integration. - [`fastify-gcloud-trace`](https://github.com/mkinoshi/fastify-gcloud-trace) @@ -296,6 +399,10 @@ section. Providers. - [`fastify-guard`](https://github.com/hsynlms/fastify-guard) A Fastify plugin that protects endpoints by checking authenticated user roles and/or scopes. +- [`fastify-hana`](https://github.com/yoav0gal/fastify-hana) connects your + application to [`SAP-HANA`](https://help.sap.com/docs/SAP_HANA_CLIENT). +- [`fastify-hashids`](https://github.com/andersonjoseph/fastify-hashids) A Fastify + plugin to encode/decode IDs using [hashids](https://github.com/niieani/hashids.js). - [`fastify-hasura`](https://github.com/ManUtopiK/fastify-hasura) A Fastify plugin to have fun with [Hasura](https://github.com/hasura/graphql-engine). - [`fastify-healthcheck`](https://github.com/smartiniOnGitHub/fastify-healthcheck) @@ -303,41 +410,63 @@ section. - [`fastify-hemera`](https://github.com/hemerajs/fastify-hemera) Fastify Hemera plugin, for writing reliable & fault-tolerant microservices with [nats.io](https://nats.io/). -- [`fastify-http-client`](https://github.com/kenuyx/fastify-http-client) Plugin - to send HTTP(s) requests. Built upon [urllib](https://github.com/node-modules/urllib). +- [`fastify-hl7`](https://github.com/Bugs5382/fastify-hl7) A Fastify Plugin to + create a server, build, and send HL7 formatted Hl7 messages. Using + [node-hl7-client](https://github.com/Bugs5382/node-hl7-client) and + [node-hl7-server](https://github.com/Bugs5382/node-hl7-server) as the + underlining technology to do this. - [`fastify-http-context`](https://github.com/thorough-developer/fastify-http-context) Fastify plugin for "simulating" a thread of execution to allow for true HTTP context to take place per API call within the Fastify lifecycle of calls. - [`fastify-http-errors-enhanced`](https://github.com/ShogunPanda/fastify-http-errors-enhanced) An error handling plugin for Fastify that uses enhanced HTTP errors. +- [`fastify-http-exceptions`](https://github.com/bhouston/fastify-http-exceptions) + Typed HTTP status exceptions which are automatically converted into Fastify responses. - [`fastify-http2https`](https://github.com/lolo32/fastify-http2https) Redirect HTTP requests to HTTPS, both using the same port number, or different response on HTTP and HTTPS. +- [`fastify-https-always`](https://github.com/mattbishop/fastify-https-always) + Lightweight, proxy-aware redirect plugin from HTTP to HTTPS. - [`fastify-https-redirect`](https://github.com/tomsvogel/fastify-https-redirect) Fastify plugin for auto-redirect from HTTP to HTTPS. +- [`fastify-i18n`](https://github.com/Vanilla-IceCream/fastify-i18n) + Internationalization plugin for Fastify. Built upon node-polyglot. - [`fastify-impressions`](https://github.com/manju4ever/fastify-impressions) Fastify plugin to track impressions of all the routes. - [`fastify-influxdb`](https://github.com/alex-ppg/fastify-influxdb) Fastify InfluxDB plugin connecting to an InfluxDB instance via the Influx default package. +- [`fastify-ip`](https://github.com/metcoder95/fastify-ip) A plugin + for Fastify that allows you to infer a request ID by a + given set of custom Request headers. +- [`fastify-json-to-xml`](https://github.com/Fdawgs/fastify-json-to-xml) Fastify + plugin to serialize JSON responses into XML. - [`fastify-jwt-authz`](https://github.com/Ethan-Arrowood/fastify-jwt-authz) JWT user scope verifier. - [`fastify-jwt-webapp`](https://github.com/charlesread/fastify-jwt-webapp) JWT authentication for Fastify-based web apps. - [`fastify-kafkajs`](https://github.com/kffl/fastify-kafkajs) Fastify plugin that adds support for KafkaJS - a modern Apache Kafka client library. -- [`fastify-knexjs`](https://github.com/chapuletta/fastify-knexjs) Fastify - plugin for support KnexJS Query Builder. -- [`fastify-knexjs-mock`](https://github.com/chapuletta/fastify-knexjs-mock) - Fastify Mock KnexJS for testing support. +- [`fastify-keycloak-adapter`](https://github.com/yubinTW/fastify-keycloak-adapter) + A keycloak adapter for a Fastify app. +- [`fastify-koa`](https://github.com/rozzilla/fastify-koa) Convert Koa +middlewares into Fastify plugins - [`fastify-kubernetes`](https://github.com/greguz/fastify-kubernetes) Fastify Kubernetes client plugin. +- [`fastify-kysely`](https://github.com/alenap93/fastify-kysely) Fastify + plugin for supporting Kysely type-safe query builder. - [`fastify-language-parser`](https://github.com/lependu/fastify-language-parser) Fastify plugin to parse request language. - [`fastify-lcache`](https://github.com/denbon05/fastify-lcache) Lightweight cache plugin +- [`fastify-list-routes`](https://github.com/chuongtrh/fastify-list-routes) + A simple plugin for Fastify to list all available routes. +- [`fastify-lm`](https://github.com/galiprandi/fastify-lm#readme) + Use OpenAI, Claude, Google, Deepseek, and others LMs with one Fastify plugin. - [`fastify-loader`](https://github.com/TheNoim/fastify-loader) Load routes from a directory and inject the Fastify instance in each file. +- [`fastify-log-controller`](https://github.com/Eomm/fastify-log-controller/) + changes the log level of your Fastify server at runtime. - [`fastify-lured`](https://github.com/lependu/fastify-lured) Plugin to load lua scripts with [fastify-redis](https://github.com/fastify/fastify-redis) and [lured](https://github.com/enobufs/lured). @@ -353,8 +482,6 @@ section. exporting [Prometheus](https://prometheus.io) metrics. - [`fastify-minify`](https://github.com/Jelenkee/fastify-minify) Plugin for minification and transformation of responses. -- [`fastify-mongo-memory`](https://github.com/chapuletta/fastify-mongo-memory) - Fastify MongoDB in Memory Plugin for testing support. - [`fastify-mongodb-sanitizer`](https://github.com/KlemenKozelj/fastify-mongodb-sanitizer) Fastify plugin that sanitizes client input to prevent potential MongoDB query injection attacks. @@ -367,33 +494,31 @@ section. [mqtt](https://www.npmjs.com/package/mqtt) client across Fastify. - [`fastify-msgpack`](https://github.com/kenriortega/fastify-msgpack) Fastify and MessagePack, together at last. Uses @msgpack/msgpack by default. +- [`fastify-msgraph-webhook`](https://github.com/flower-of-the-bridges/fastify-msgraph-change-notifications-webhook) + to manage + [MS Graph Change Notifications webhooks](https://learn.microsoft.com/it-it/graph/change-notifications-delivery-webhooks?tabs=http). - [`fastify-multer`](https://github.com/fox1t/fastify-multer) Multer is a plugin for handling multipart/form-data, which is primarily used for uploading files. -- [`fastify-nats`](https://github.com/mahmed8003/fastify-nats) Plugin to share - [NATS](https://nats.io) client across Fastify. +- [`fastify-multilingual`](https://github.com/gbrugger/fastify-multilingual) Unobtrusively + decorates fastify request with Polyglot.js for i18n. - [`fastify-next-auth`](https://github.com/wobsoriano/fastify-next-auth) NextAuth.js plugin for Fastify. - [`fastify-no-additional-properties`](https://github.com/greguz/fastify-no-additional-properties) Add `additionalProperties: false` by default to your JSON Schemas. - [`fastify-no-icon`](https://github.com/jsumners/fastify-no-icon) Plugin to eliminate thrown errors for `/favicon.ico` requests. -- [`fastify-nodemailer`](https://github.com/lependu/fastify-nodemailer) Plugin - to share [nodemailer](https://nodemailer.com) transporter across Fastify. -- [`fastify-normalize-request-reply`](https://github.com/ericrglass/fastify-normalize-request-reply) - Plugin to normalize the request and reply to the Express version 4.x request - and response, which allows use of middleware, like swagger-stats, that was - originally written for Express. + - [`fastify-now`](https://github.com/yonathan06/fastify-now) Structure your endpoints in a folder and load them dynamically with Fastify. - [`fastify-nuxtjs`](https://github.com/gomah/fastify-nuxtjs) Vue server-side rendering support for Fastify with Nuxt.js Framework. - [`fastify-oas`](https://gitlab.com/m03geek/fastify-oas) Generates OpenAPI 3.0+ documentation from routes schemas for Fastify. -- [`fastify-objectionjs`](https://github.com/jarcodallo/fastify-objectionjs) - Plugin for the Fastify framework that provides integration with objectionjs - ORM. - [`fastify-objectionjs-classes`](https://github.com/kamikazechaser/fastify-objectionjs-classes) Plugin to cherry-pick classes from objectionjs ORM. +- [`fastify-opaque-apake`](https://github.com/squirrelchat/fastify-opaque-apake) + A Fastify plugin to implement the OPAQUE aPAKE protocol. Uses + [@squirrelchat/opaque-wasm-server](https://github.com/squirrelchat/opaque-wasm). - [`fastify-openapi-docs`](https://github.com/ShogunPanda/fastify-openapi-docs) A Fastify plugin that generates OpenAPI spec automatically. - [`fastify-openapi-glue`](https://github.com/seriousme/fastify-openapi-glue) @@ -406,19 +531,28 @@ section. - [`fastify-oracle`](https://github.com/cemremengu/fastify-oracle) Attaches an [`oracledb`](https://github.com/oracle/node-oracledb) connection pool to a Fastify server instance. -- [`fastify-orientdb`](https://github.com/mahmed8003/fastify-orientdb) Fastify - OrientDB connection plugin, with which you can share the OrientDB connection - across every part of your server. +- [`fastify-orama`](https://github.com/mateonunez/fastify-orama) +- [`fastify-osm`](https://github.com/gzileni/fastify-osm) Fastify + OSM plugin to run overpass queries by OpenStreetMap. +- [`fastify-override`](https://github.com/matthyk/fastify-override) + Fastify plugin to override decorators, plugins and hooks for testing purposes +- [`fastify-passkit-webservice`](https://github.com/alexandercerutti/fastify-passkit-webservice) + A set of Fastify plugins to integrate Apple Wallet Web Service specification - [`fastify-peekaboo`](https://github.com/simone-sanfratello/fastify-peekaboo) Fastify plugin for memoize responses by expressive settings. +- [`fastify-permissions`](https://github.com/pckrishnadas88/fastify-permissions) + Route-level permission middleware for Fastify supports + custom permission checks. - [`fastify-piscina`](https://github.com/piscinajs/fastify-piscina) A worker thread pool plugin using [Piscina](https://github.com/piscinajs/piscina). -- [`fastify-polyglot`](https://github.com/heply/fastify-polyglot) A plugin to - handle i18n using +- [`fastify-polyglot`](https://github.com/beliven-it/fastify-polyglot) A plugin + to handle i18n using [node-polyglot](https://www.npmjs.com/package/node-polyglot). - [`fastify-postgraphile`](https://github.com/alemagio/fastify-postgraphile) Plugin to integrate [PostGraphile](https://www.graphile.org/postgraphile/) in a Fastify project. +- [`fastify-postgres-dot-js`](https://github.com/kylerush/fastify-postgresjs) Fastify + PostgreSQL connection plugin that uses [Postgres.js](https://github.com/porsager/postgres). - [`fastify-prettier`](https://github.com/hsynlms/fastify-prettier) A Fastify plugin that uses [prettier](https://github.com/prettier/prettier) under the hood to beautify outgoing responses and/or other things in the Fastify server. @@ -428,20 +562,29 @@ section. Fastify and protobufjs, together at last. Uses protobufjs by default. - [`fastify-qrcode`](https://github.com/chonla/fastify-qrcode) This plugin utilizes [qrcode](https://github.com/soldair/node-qrcode) to generate QR Code. -- [`fastify-qs`](https://github.com/webdevium/fastify-qs) A plugin for Fastify +- [`fastify-qs`](https://github.com/vanodevium/fastify-qs) A plugin for Fastify that adds support for parsing URL query parameters with [qs](https://github.com/ljharb/qs). +- [`fastify-rabbitmq`](https://github.com/Bugs5382/fastify-rabbitmq) Fastify + RabbitMQ plugin that uses + [node-rabbitmq-client](https://github.com/cody-greene/node-rabbitmq-client) + plugin as a wrapper. - [`fastify-racing`](https://github.com/metcoder95/fastify-racing) Fastify's plugin that adds support to handle an aborted request asynchronous. +- [`fastify-ravendb`](https://github.com/nearform/fastify-ravendb) RavenDB + connection plugin. It exposes the same `DocumentStore` (or multiple ones) + across the whole Fastify application. - [`fastify-raw-body`](https://github.com/Eomm/fastify-raw-body) Add the `request.rawBody` field. - [`fastify-rbac`](https://gitlab.com/m03geek/fastify-rbac) Fastify role-based access control plugin. - [`fastify-recaptcha`](https://github.com/qwertyforce/fastify-recaptcha) - Fastify plugin for recaptcha verification. + Fastify plugin for reCAPTCHA verification. - [`fastify-redis-channels`](https://github.com/hearit-io/fastify-redis-channels) A plugin for fast, reliable, and scalable channels implementation based on Redis streams. +- [`fastify-redis-session`](https://github.com/mohammadraufzahed/fastify-redis-session) + Redis Session plugin for fastify. - [`fastify-register-routes`](https://github.com/israeleriston/fastify-register-routes) Plugin to automatically load routes from a specified path and optionally limit loaded file names by a regular expression. @@ -458,35 +601,40 @@ section. - [`fastify-rob-config`](https://github.com/jeromemacias/fastify-rob-config) Fastify Rob-Config integration. - [`fastify-route-group`](https://github.com/TakNePoidet/fastify-route-group) - Convenient grouping and inheritance of routes + Convenient grouping and inheritance of routes. +- [`fastify-route-preset`](https://github.com/inyourtime/fastify-route-preset) + A Fastify plugin that enables you to create route configurations that can be + applied to multiple routes. +- [`fastify-s3-buckets`](https://github.com/kibertoad/fastify-s3-buckets) + Ensure the existence of defined S3 buckets on the application startup. - [`fastify-schema-constraint`](https://github.com/Eomm/fastify-schema-constraint) Choose the JSON schema to use based on request parameters. - [`fastify-schema-to-typescript`](https://github.com/thomasthiebaud/fastify-schema-to-typescript) Generate typescript types based on your JSON/YAML validation schemas so they are always in sync. -- [`fastify-secure-session`](https://github.com/mcollina/fastify-secure-session) - Create a secure stateless cookie session for Fastify. - [`fastify-sentry`](https://github.com/alex-ppg/fastify-sentry) Fastify plugin to add the Sentry SDK error handler to requests. - [`fastify-sequelize`](https://github.com/lyquocnam/fastify-sequelize) Fastify - plugin work with Sequelize (adapter for NodeJS -> Sqlite, Mysql, Mssql, + plugin work with Sequelize (adapter for Node.js -> Sqlite, Mysql, Mssql, Postgres). - [`fastify-server-session`](https://github.com/jsumners/fastify-server-session) A session plugin with support for arbitrary backing caches via `fastify-caching`. -- [`fastify-slonik`](https://github.com/Unbuttun/fastify-slonik) Fastify Slonik - plugin, with this you can use slonik in every part of your server. -- [`fastify-soap-client`](https://github.com/fastify/fastify-soap-client) a SOAP - client plugin for Fastify. -- [`fastify-socket.io`](https://github.com/alemagio/fastify-socket.io) a - Socket.io plugin for Fastify. +- [`fastify-ses-mailer`](https://github.com/KaranHotwani/fastify-ses-mailer) A + Fastify plugin for sending emails via AWS SES using AWS SDK v3. +- [`fastify-shared-schema`](https://github.com/Adibla/fastify-shared-schema) Plugin + for sharing schemas between different routes. +- [`fastify-slow-down`](https://github.com/nearform/fastify-slow-down) A plugin + to delay the response from the server. - [`fastify-split-validator`](https://github.com/MetCoder95/fastify-split-validator) Small plugin to allow you use multiple validators in one route based on each HTTP part of the request. +- [`fastify-sqlite`](https://github.com/Eomm/fastify-sqlite) connects your + application to a sqlite3 database. +- [`fastify-sqlite-typed`](https://github.com/yoav0gal/fastify-sqlite-typed) connects + your application to a SQLite database with full Typescript support. - [`fastify-sse`](https://github.com/lolo32/fastify-sse) to provide Server-Sent Events with `reply.sse( … )` to Fastify. -- [`fastify-sse-v2`](https://github.com/nodefactoryio/fastify-sse-v2) to provide - Server-Sent Events using Async Iterators (supports newer versions of Fastify). - [`fastify-ssr-vite`](https://github.com/nineohnine/fastify-ssr-vite) A simple plugin for setting up server side rendering with vite. - [`fastify-stripe`](https://github.com/coopflow/fastify-stripe) Plugin to @@ -498,27 +646,33 @@ section. - [`fastify-tls-keygen`](https://gitlab.com/sebdeckers/fastify-tls-keygen) Automatically generate a browser-compatible, trusted, self-signed, localhost-only, TLS certificate. -- [`fastify-tokenize`](https://github.com/Bowser65/fastify-tokenize) - [Tokenize](https://github.com/Bowser65/Tokenize) plugin for Fastify that - removes the pain of managing authentication tokens, with built-in integration - for `fastify-auth`. -- [`fastify-totp`](https://github.com/heply/fastify-totp) A plugin to handle +- [`fastify-totp`](https://github.com/beliven-it/fastify-totp) A plugin to handle TOTP (e.g. for 2FA). -- [`fastify-twitch-ebs-tools`](https://github.com/lukemnet/fastify-twitch-ebs-tools) - Useful functions for Twitch Extension Backend Services (EBS). +- [`fastify-type-provider-effect-schema`](https://github.com/daotl/fastify-type-provider-effect-schema) + Fastify + [type provider](https://fastify.dev/docs/latest/Reference/Type-Providers/) + for [@effect/schema](https://github.com/Effect-TS/effect). +- [`fastify-type-provider-zod`](https://github.com/turkerdev/fastify-type-provider-zod) + Fastify + [type provider](https://fastify.dev/docs/latest/Reference/Type-Providers/) + for [zod](https://github.com/colinhacks/zod). - [`fastify-typeorm-plugin`](https://github.com/inthepocket/fastify-typeorm-plugin) Fastify plugin to work with TypeORM. +- [`fastify-user-agent`](https://github.com/Eomm/fastify-user-agent) parses your + request's `user-agent` header. +- [`fastify-uws`](https://github.com/geut/fastify-uws) A Fastify plugin to + use the web server [uWebSockets.js](https://github.com/uNetworking/uWebSockets.js). - [`fastify-vhost`](https://github.com/patrickpissurno/fastify-vhost) Proxy subdomain HTTP requests to another server (useful if you want to point multiple subdomains to the same IP address, while running different servers on the same machine). -- [`fastify-vite`](https://github.com/galvez/fastify-vite) - [Vite](https://vitejs.dev/) plugin for Fastify with SSR data support. - [`fastify-vue-plugin`](https://github.com/TheNoim/fastify-vue) [Nuxt.js](https://nuxtjs.org) plugin for Fastify. Control the routes nuxt should use. - [`fastify-wamp-router`](https://github.com/lependu/fastify-wamp-router) Web Application Messaging Protocol router for Fastify. +- [`fastify-web-response`](https://github.com/erfanium/fastify-web-response) + Enables returning web streams objects `Response` and `ReadableStream` in routes. - [`fastify-webpack-hmr`](https://github.com/lependu/fastify-webpack-hmr) Webpack hot module reloading plugin for Fastify. - [`fastify-webpack-hot`](https://github.com/gajus/fastify-webpack-hot) Webpack @@ -529,8 +683,9 @@ section. [uws](https://github.com/uNetworking/uWebSockets). - [`fastify-xml-body-parser`](https://github.com/NaturalIntelligence/fastify-xml-body-parser) Parse XML payload / request body into JS / JSON object. -- [`fastify-xray`](https://github.com/jeromemacias/fastify-xray) Fastify plugin - for AWS XRay recording. +- [`http-wizard`](https://github.com/flodlc/http-wizard) + Exports a typescript API client for your Fastify API and ensures fullstack type + safety for your project. - [`i18next-http-middleware`](https://github.com/i18next/i18next-http-middleware#fastify-usage) An [i18next](https://www.i18next.com) based i18n (internationalization) middleware to be used with Node.js web frameworks like Express or Fastify and @@ -549,13 +704,23 @@ section. - [`openapi-validator-middleware`](https://github.com/PayU/openapi-validator-middleware#fastify) Swagger and OpenAPI 3.0 spec-based request validation middleware that supports Fastify. +- [`pubsub-http-handler`](https://github.com/simenandre/pubsub-http-handler) A Fastify + plugin to easily create Google Cloud PubSub endpoints. - [`sequelize-fastify`](https://github.com/hsynlms/sequelize-fastify) A simple and lightweight Sequelize plugin for Fastify. -- [`typeorm-fastify-plugin`](https://github.com/jclemens24/fastify-typeorm) A simple - and updated Typeorm plugin for use with Fastify. #### [Community Tools](#community-tools) + - [`fast-maker`](https://github.com/imjuni/fast-maker) route configuration generator by directory structure. +- [`fastify-flux`](https://github.com/Jnig/fastify-flux) Tool for building + Fastify APIs using decorators and convert Typescript interface to JSON Schema. +- [`jeasx`](https://www.jeasx.dev) + A flexible server-rendering framework built on Fastify + that leverages asynchronous JSX to simplify web development. - [`simple-tjscli`](https://github.com/imjuni/simple-tjscli) CLI tool to generate JSON Schema from TypeScript interfaces. +- [`vite-plugin-fastify`](https://github.com/Vanilla-IceCream/vite-plugin-fastify) + Fastify plugin for Vite with Hot-module Replacement. +- [`vite-plugin-fastify-routes`](https://github.com/Vanilla-IceCream/vite-plugin-fastify-routes) + File-based routing for Fastify applications using Vite. diff --git a/docs/Guides/Fluent-Schema.md b/docs/Guides/Fluent-Schema.md index b3463bac2c6..a1f10cdfe6c 100644 --- a/docs/Guides/Fluent-Schema.md +++ b/docs/Guides/Fluent-Schema.md @@ -55,7 +55,7 @@ fastify.post('/the/url', { schema }, handler) ### Reuse -With `fluent-json-schema` you can manipulate your schemas more easily and +With `fluent-json-schema`, you can manipulate your schemas more easily and programmatically and then reuse them thanks to the `addSchema()` method. You can refer to the schema in two different manners that are detailed in the [Validation and @@ -122,5 +122,6 @@ const schema = { body: bodyJsonSchema } fastify.post('/the/url', { schema }, handler) ``` -NB You can mix up the `$ref-way` and the `replace-way` when using -`fastify.addSchema`. +> ℹ️ Note: +> You can mix up the `$ref-way` and the `replace-way` +> when using `fastify.addSchema`. diff --git a/docs/Guides/Getting-Started.md b/docs/Guides/Getting-Started.md index 8f89026628d..68a559004fd 100644 --- a/docs/Guides/Getting-Started.md +++ b/docs/Guides/Getting-Started.md @@ -14,11 +14,12 @@ Let's start! Install with npm: -``` +```sh npm i fastify ``` + Install with yarn: -``` +```sh yarn add fastify ``` @@ -31,6 +32,7 @@ Let's write our first server: // ESM import Fastify from 'fastify' + const fastify = Fastify({ logger: true }) @@ -54,11 +56,20 @@ fastify.listen({ port: 3000 }, function (err, address) { }) ``` +> If you are using ECMAScript Modules (ESM) in your project, be sure to +> include "type": "module" in your package.json. +>```js +>{ +> "type": "module" +>} +>``` + Do you prefer to use `async/await`? Fastify supports it out-of-the-box. ```js // ESM import Fastify from 'fastify' + const fastify = Fastify({ logger: true }) @@ -95,7 +106,7 @@ of your code. Fastify offers an easy platform that helps to solve all of the problems outlined above, and more! -> ## Note +> **Note** > The above examples, and subsequent examples in this document, default to > listening *only* on the localhost `127.0.0.1` interface. To listen on all > available IPv4 interfaces the example should be modified to listen on @@ -117,6 +128,9 @@ above, and more! > > When deploying to a Docker (or another type of) container using `0.0.0.0` or > `::` would be the easiest method for exposing the application. +> +> Note that when using `0.0.0.0`, the address provided in the callback argument +> above will be the first address the wildcard refers to. ### Your first plugin @@ -132,7 +146,7 @@ declaration](../Reference/Routes.md) docs). ```js // ESM import Fastify from 'fastify' -import firstRoute from './our-first-route' +import firstRoute from './our-first-route.js' /** * @type {import('fastify').FastifyInstance} Instance of Fastify */ @@ -171,13 +185,14 @@ fastify.listen({ port: 3000 }, function (err, address) { }) ``` + ```js // our-first-route.js /** * Encapsulates the routes * @param {FastifyInstance} fastify Encapsulated Fastify Instance - * @param {Object} options plugin options, refer to https://www.fastify.io/docs/latest/Reference/Plugins/#plugin-options + * @param {Object} options plugin options, refer to https://fastify.dev/docs/latest/Reference/Plugins/#plugin-options */ async function routes (fastify, options) { fastify.get('/', async (request, reply) => { @@ -185,6 +200,10 @@ async function routes (fastify, options) { }) } +//ESM +export default routes; + +// CommonJs module.exports = routes ``` In this example, we used the `register` API, which is the core of the Fastify @@ -208,7 +227,7 @@ Let's rewrite the above example with a database connection. First, install `fastify-plugin` and `@fastify/mongodb`: -``` +```sh npm i fastify-plugin @fastify/mongodb ``` @@ -216,8 +235,8 @@ npm i fastify-plugin @fastify/mongodb ```js // ESM import Fastify from 'fastify' -import dbConnector from './our-db-connector' -import firstRoute from './our-first-route' +import dbConnector from './our-db-connector.js' +import firstRoute from './our-first-route.js' /** * @type {import('fastify').FastifyInstance} Instance of Fastify @@ -277,7 +296,7 @@ async function dbConnector (fastify, options) { // Wrapping a plugin function with fastify-plugin exposes the decorators // and hooks, declared inside the plugin to the parent scope. -module.exports = fastifyPlugin(dbConnector) +export default fastifyPlugin(dbConnector) ``` @@ -292,7 +311,7 @@ const fastifyPlugin = require('fastify-plugin') /** * Connects to a MongoDB database * @param {FastifyInstance} fastify Encapsulated Fastify Instance - * @param {Object} options plugin options, refer to https://www.fastify.io/docs/latest/Reference/Plugins/#plugin-options + * @param {Object} options plugin options, refer to https://fastify.dev/docs/latest/Reference/Plugins/#plugin-options */ async function dbConnector (fastify, options) { fastify.register(require('@fastify/mongodb'), { @@ -311,7 +330,7 @@ module.exports = fastifyPlugin(dbConnector) /** * A plugin that provide encapsulated routes * @param {FastifyInstance} fastify encapsulated fastify instance - * @param {Object} options plugin options, refer to https://www.fastify.io/docs/latest/Reference/Plugins/#plugin-options + * @param {Object} options plugin options, refer to https://fastify.dev/docs/latest/Reference/Plugins/#plugin-options */ async function routes (fastify, options) { const collection = fastify.mongo.db.collection('test_collection') @@ -401,7 +420,7 @@ In this way, you will always have access to all of the properties declared in the current scope. As discussed previously, Fastify offers a solid encapsulation model, to help you -build your application as single and independent services. If you want to +build your application as independent services. If you want to register a plugin only for a subset of routes, you just have to replicate the above structure. ``` @@ -434,8 +453,6 @@ Data validation is extremely important and a core concept of the framework. To validate incoming requests, Fastify uses [JSON Schema](https://json-schema.org/). -(JTD schemas are loosely supported, but `jsonShorthand` must be disabled first) - Let's look at an example demonstrating validation for routes: ```js /** @@ -545,12 +562,13 @@ Read the [testing](./Testing.md) documentation to learn more! ### Run your server from CLI -Fastify also has CLI integration thanks to -[fastify-cli](https://github.com/fastify/fastify-cli). +Fastify also has CLI integration via +[fastify-cli](https://github.com/fastify/fastify-cli), +a separate tool for scaffolding and managing Fastify projects. First, install `fastify-cli`: -``` +```sh npm i fastify-cli ``` diff --git a/docs/Guides/Index.md b/docs/Guides/Index.md index fa8b4635984..1220b0c0f4a 100644 --- a/docs/Guides/Index.md +++ b/docs/Guides/Index.md @@ -15,9 +15,11 @@ This table of contents is in alphabetical order. met in your application. This guide focuses on solving the problem using [`Hooks`](../Reference/Hooks.md), [`Decorators`](../Reference/Decorators.md), and [`Plugins`](../Reference/Plugins.md). ++ [Detecting When Clients Abort](./Detecting-When-Clients-Abort.md): A + practical guide on detecting if and when a client aborts a request. + [Ecosystem](./Ecosystem.md): Lists all core plugins and many known community plugins. -+ [Fluent Schema](./Fluent-Schema.md): Shows how writing JSON Schema can be ++ [Fluent Schema](./Fluent-Schema.md): Shows how JSON Schema can be written with a fluent API and used in Fastify. + [Getting Started](./Getting-Started.md): Introduction tutorial for Fastify. This is where beginners should start. diff --git a/docs/Guides/Migration-Guide-V4.md b/docs/Guides/Migration-Guide-V4.md index 9968d49709f..7fc50a74ff8 100644 --- a/docs/Guides/Migration-Guide-V4.md +++ b/docs/Guides/Migration-Guide-V4.md @@ -6,16 +6,38 @@ Before migrating to v4, please ensure that you have fixed all deprecation warnings from v3. All v3 deprecations have been removed and they will no longer work after upgrading. +## Codemods +### Fastify v4 Codemods + +To help with the upgrade, we’ve worked with the team at +[Codemod](https://github.com/codemod-com/codemod) to +publish codemods that will automatically update your code to many of +the new APIs and patterns in Fastify v4. + + +```bash +npx codemod@latest fastify/4/migration-recipe +``` +This applies the following codemods: + +- fastify/4/remove-app-use +- fastify/4/reply-raw-access +- fastify/4/wrap-routes-plugin +- fastify/4/await-register-calls + +For information on the migration recipe, see +https://app.codemod.com/registry/fastify/4/migration-recipe. + + ## Breaking Changes ### Error handling composition ([#3261](https://github.com/fastify/fastify/pull/3261)) -When an error is thrown in a async error handler function, -the upper-level error handler is executed if set. -If there is not a upper-level error handler, the default will -be executed as it was previously. +When an error is thrown in an async error handler function, the upper-level +error handler is executed if set. If there is no upper-level error handler, +the default will be executed as it was previously: -``` +```js import Fastify from 'fastify' const fastify = Fastify() @@ -25,14 +47,14 @@ fastify.register(async fastify => { console.log(err.message) // 'kaboom' throw new Error('caught') }) - + fastify.get('/encapsulated', async () => { throw new Error('kaboom') }) }) fastify.setErrorHandler(async err => { - console.log(err.message) // 'caught' + console.log(err.message) // 'caught' throw new Error('wrapped') }) @@ -40,94 +62,199 @@ const res = await fastify.inject('/encapsulated') console.log(res.json().message) // 'wrapped' ``` -### Deprecation of `app.use()` ([#3506](https://github.com/fastify/fastify/pull/3506)) +>The root error handler is Fastify’s generic error handler. +>This error handler will use the headers and status code in the Error object, +>if they exist. **The headers and status code will not be automatically set if +>a custom error handler is provided**. + +### Removed `app.use()` ([#3506](https://github.com/fastify/fastify/pull/3506)) -Starting this version of Fastify, we have deprecated the use of `app.use()`. We -have decided not to support the use of middlewares. Both -[`@fastify/middie`](https://github.com/fastify/middie) and -[`@fastify/express`](https://github.com/fastify/fastify-express) will still be -there and maintained. Use Fastify's [hooks](../Reference/Hooks.md) instead. +With v4 of Fastify, `app.use()` has been removed and the use of middleware is +no longer supported. + +If you need to use middleware, use +[`@fastify/middie`](https://github.com/fastify/middie) or +[`@fastify/express`](https://github.com/fastify/fastify-express), which will +continue to be maintained. +However, it is strongly recommended that you migrate to Fastify's [hooks](../Reference/Hooks.md). + +> ℹ️ Note: +> Codemod remove `app.use()` with: +> ```bash +> npx codemod@latest fastify/4/remove-app-use +> ``` ### `reply.res` moved to `reply.raw` If you previously used the `reply.res` attribute to access the underlying Request -object you'll instead need to depend on `reply.raw`. +object you will now need to use `reply.raw`. + +> ℹ️ Note: +> Codemod `reply.res` to `reply.raw` with: +> ```bash +> npx codemod@latest fastify/4/reply-raw-access +> ``` ### Need to `return reply` to signal a "fork" of the promise chain -In some situations, like when a response is sent asynchronously or when you're -just not explicitly returning a response, you'll need to return the `reply` +In some situations, like when a response is sent asynchronously or when you are +not explicitly returning a response, you will now need to return the `reply` argument from your router handler. ### `exposeHeadRoutes` true by default -Starting from v4, all the `GET` routes will create a sibling `HEAD` route. -You can revert this behaviour by setting the server's option `exposeHeadRoutes` -to `false`. +Starting with v4, every `GET` route will create a sibling `HEAD` route. +You can revert this behavior by setting `exposeHeadRoutes: false` in the server +options. -### Synchronous route definitions +### Synchronous route definitions ([#2954](https://github.com/fastify/fastify/pull/2954)) -The route registration has been made synchronous from v4. -This change was done to provide better error reporting for route definition. -As a result if you specify an `onRoute` hook in a plugin you should either: +To improve error reporting in route definitions, route registration is now synchronous. +As a result, if you specify an `onRoute` hook in a plugin you should now either: * wrap your routes in a plugin (recommended) + + For example, refactor this: + ```js + fastify.register((instance, opts, done) => { + instance.addHook('onRoute', (routeOptions) => { + const { path, method } = routeOptions; + console.log({ path, method }); + done(); + }); + }); + + fastify.get('/', (request, reply) => { reply.send('hello') }); + ``` + + Into this: + ```js + fastify.register((instance, opts, done) => { + instance.addHook('onRoute', (routeOptions) => { + const { path, method } = routeOptions; + console.log({ path, method }); + done(); + }); + }); + + fastify.register((instance, opts, done) => { + instance.get('/', (request, reply) => { reply.send('hello') }); + done(); + }); + ``` + +> ℹ️ Note: +> Codemod synchronous route definitions with: +> ```bash +> npx codemod@latest fastify/4/wrap-routes-plugin +> ``` + * use `await register(...)` -For example refactor this: -``` -fastify.register((instance, opts, done) => { - instance.addHook('onRoute', (routeOptions) => { - const { path, method } = routeOptions; - console.log({ path, method }); + For example, refactor this: + ```js + fastify.register((instance, opts, done) => { + instance.addHook('onRoute', (routeOptions) => { + const { path, method } = routeOptions; + console.log({ path, method }); + }); + done(); + }); + ``` + + Into this: + ```js + await fastify.register((instance, opts, done) => { + instance.addHook('onRoute', (routeOptions) => { + const { path, method } = routeOptions; + console.log({ path, method }); + }); + done(); }); - done(); + ``` + +> ℹ️ Note: +> Codemod 'await register(...)' with: +> ```bash +> npx codemod@latest fastify/4/await-register-calls +> ``` + + +### Optional URL parameters + +If you've already used any implicitly optional parameters, you'll get a 404 +error when trying to access the route. You will now need to declare the +optional parameters explicitly. + +For example, if you have the same route for listing and showing a post, +refactor this: +```js +fastify.get('/posts/:id', (request, reply) => { + const { id } = request.params; }); ``` + Into this: -``` -await fastify.register((instance, opts, done) => { - instance.addHook('onRoute', (routeOptions) => { - const { path, method } = routeOptions; - console.log({ path, method }); - }); - done(); +```js +fastify.get('/posts/:id?', (request, reply) => { + const { id } = request.params; }); ``` -## Non Breaking Changes +## Non-Breaking Changes -### Change of schema for multiple types +### Deprecation of variadic `.listen()` signature +The [variadic signature](https://en.wikipedia.org/wiki/Variadic_function) of the +`fastify.listen()` method is now deprecated. -Since Fastify v4 has upgraded to Ajv v8. The "type" keywords with multiple types -(other than with "null") are prohibited. Read more -['here'](https://ajv.js.org/strict-mode.html#strict-types) +Before this release, the following invocations of this method were valid: -You may encounter a console warning such as + - `fastify.listen(8000)` + - `fastify.listen(8000, ‘127.0.0.1’)` + - `fastify.listen(8000, ‘127.0.0.1’, 511)` + - `fastify.listen(8000, (err) => { if (err) throw err })` + - `fastify.listen({ port: 8000 }, (err) => { if (err) throw err })` -``` +With Fastify v4, only the following invocations are valid: + + - `fastify.listen()` + - `fastify.listen({ port: 8000 })` + - `fastify.listen({ port: 8000 }, (err) => { if (err) throw err })` + +### Change of schema for multiple types + +Ajv has been upgraded to v8 in Fastify v4, meaning "type" keywords with multiple +types other than "null" +[are now prohibited](https://ajv.js.org/strict-mode.html#strict-types). + +You may encounter a console warning such as: +```sh strict mode: use allowUnionTypes to allow union type keyword at "#/properties/image" (strictTypes) ``` -So schemas like below will need to be changed from -``` -type: 'object', -properties: { - api_key: { type: 'string' }, - image: { type: ['object', 'array'] } + +As such, schemas like below will need to be changed from: +```js +{ + type: 'object', + properties: { + api_key: { type: 'string' }, + image: { type: ['object', 'array'] } } } ``` -to -``` -type: 'object', -properties: { - api_key: { type: 'string' }, - image: { - anyOf: [ - { type: 'array' }, - { type: 'object' } - ] +Into: +```js +{ + type: 'object', + properties: { + api_key: { type: 'string' }, + image: { + anyOf: [ + { type: 'array' }, + { type: 'object' } + ] + } } } ``` diff --git a/docs/Guides/Migration-Guide-V5.md b/docs/Guides/Migration-Guide-V5.md new file mode 100644 index 00000000000..9c8ad875a3c --- /dev/null +++ b/docs/Guides/Migration-Guide-V5.md @@ -0,0 +1,727 @@ +# V5 Migration Guide + +This guide is intended to help with migration from Fastify v4 to v5. + +Before migrating to v5, please ensure that you have fixed all deprecation +warnings from v4. All v4 deprecations have been removed and will no longer +work after upgrading. + +## Long Term Support Cycle + +Fastify v5 will only support Node.js v20+. If you are using an older version of +Node.js, you will need to upgrade to a newer version to use Fastify v5. + +Fastify v4 is still supported until June 30, 2025. If you are unable to upgrade, +you should consider buying an end-of-life support plan from HeroDevs. + +### Why Node.js v20? + +Fastify v5 will only support Node.js v20+ because it has significant differences +compared to v18, such as +better support for `node:test`. This allows us to provide a better developer +experience and streamline maintenance. + +Node.js v18 will exit Long Term Support on April 30, 2025, so you should be planning +to upgrade to v20 anyway. + +## Breaking Changes + +### Full JSON Schema is now required for `querystring`, `params` and `body` and response schemas + +Starting with v5, Fastify will require a full JSON schema for the `querystring`, +`params` and `body` schema. Note that the `jsonShortHand` option has been +removed as well. + +If the default JSON Schema validator is used, you will need +to provide a full JSON schema for the +`querystring`, `params`, `body`, and `response` schemas, +including the `type` property. + +```js +// v4 +fastify.get('/route', { + schema: { + querystring: { + name: { type: 'string' } + } + } +}, (req, reply) => { + reply.send({ hello: req.query.name }); +}); +``` + +```js +// v5 +fastify.get('/route', { + schema: { + querystring: { + type: 'object', + properties: { + name: { type: 'string' } + }, + required: ['name'] + } + } +}, (req, reply) => { + reply.send({ hello: req.query.name }); +}); +``` + +See [#5586](https://github.com/fastify/fastify/pull/5586) for more details + +Note that it's still possible to override the JSON Schema validator to +use a different format, such as Zod. This change simplifies that as well. + +This change helps with integration of other tools, such as +[`@fastify/swagger`](https://github.com/fastify/fastify-swagger). + +### New logger constructor signature + +In Fastify v4, Fastify accepted the options to build a pino +logger in the `logger` option, as well as a custom logger instance. +This was the source of significant confusion. + +As a result, the `logger` option will not accept a custom logger anymore in v5. +To use a custom logger, you should use the `loggerInstance` option instead: + +```js +// v4 +const logger = require('pino')(); +const fastify = require('fastify')({ + logger +}); +``` + +```js +// v5 +const loggerInstance = require('pino')(); +const fastify = require('fastify')({ + loggerInstance +}); +``` + +### `useSemicolonDelimiter` false by default + +Starting with v5, Fastify instances will no longer default to supporting the use +of semicolon delimiters in the query string as they did in v4. +This is due to it being non-standard +behavior and not adhering to [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986#section-3.4). + +If you still wish to use semicolons as delimiters, you can do so by +setting `useSemicolonDelimiter: true` in the server configuration. + +```js +const fastify = require('fastify')({ + useSemicolonDelimiter: true +}); +``` + +### The parameters object no longer has a prototype + +In v4, the `parameters` object had a prototype. This is no longer the case in v5. +This means that you can no longer access properties inherited from `Object` on +the `parameters` object, such as `toString` or `hasOwnProperty`. + +```js +// v4 +fastify.get('/route/:name', (req, reply) => { + console.log(req.params.hasOwnProperty('name')); // true + return { hello: req.params.name }; +}); +``` + +```js +// v5 +fastify.get('/route/:name', (req, reply) => { + console.log(Object.hasOwn(req.params, 'name')); // true + return { hello: req.params.name }; +}); +``` + +This increases the security of the application by hardening against prototype +pollution attacks. + +### Type Providers now differentiate between validator and serializer schemas + +In v4, the type providers had the same types for both validation and serialization. +In v5, the type providers have been split into two separate types: `ValidatorSchema` +and `SerializerSchema`. + +[`@fastify/type-provider-json-schema-to-ts`](https://github.com/fastify/fastify-type-provider-json-schema-to-ts) +and +[`@fastify/type-provider-typebox`](https://github.com/fastify/fastify-type-provider-typebox) +have already been updated: upgrade to the latest version to get the new types. +If you are using a custom type provider, you will need to modify it like +the following: + +``` +--- a/index.ts ++++ b/index.ts +@@ -11,7 +11,8 @@ import { + import { FromSchema, FromSchemaDefaultOptions, FromSchemaOptions, JSONSchema } from 'json-schema-to-ts' + + export interface JsonSchemaToTsProvider< + Options extends FromSchemaOptions = FromSchemaDefaultOptions + > extends FastifyTypeProvider { +- output: this['input'] extends JSONSchema ? FromSchema : unknown; ++ validator: this['schema'] extends JSONSchema ? FromSchema : unknown; ++ serializer: this['schema'] extends JSONSchema ? FromSchema : unknown; + } + ``` + +### Changes to the .listen() method + +The variadic argument signature of the `.listen()` method has been removed. +This means that you can no longer call `.listen()` with a variable number of arguments. + +```js +// v4 +fastify.listen(8000) +``` + +Will become: + +```js +// v5 +fastify.listen({ port: 8000 }) +``` + +This was already deprecated in v4 as `FSTDEP011`, so you should have already updated +your code to use the new signature. + +### Direct return of trailers has been removed + +In v4, you could directly return trailers from a handler. +This is no longer possible in v5. + +```js +// v4 +fastify.get('/route', (req, reply) => { + reply.trailer('ETag', function (reply, payload) { + return 'custom-etag' + }) + reply.send('') +}); +``` + +```js +// v5 +fastify.get('/route', (req, reply) => { + reply.trailer('ETag', async function (reply, payload) { + return 'custom-etag' + }) + reply.send('') +}); +``` + +A callback could also be used. +This was already deprecated in v4 as `FSTDEP013`, +so you should have already updated your code to use the new signature. + +### Streamlined access to route definition + +All deprecated properties relating to accessing the route definition have been removed +and are now accessed via `request.routeOptions`. + +| Code | Description | How to solve | Discussion | +| ---- | ----------- | ------------ | ---------- | +| FSTDEP012 | You are trying to access the deprecated `request.context` property. | Use `request.routeOptions.config` or `request.routeOptions.schema`. | [#4216](https://github.com/fastify/fastify/pull/4216) [#5084](https://github.com/fastify/fastify/pull/5084) | +| FSTDEP015 | You are accessing the deprecated `request.routeSchema` property. | Use `request.routeOptions.schema`. | [#4470](https://github.com/fastify/fastify/pull/4470) | +| FSTDEP016 | You are accessing the deprecated `request.routeConfig` property. | Use `request.routeOptions.config`. | [#4470](https://github.com/fastify/fastify/pull/4470) | +| FSTDEP017 | You are accessing the deprecated `request.routerPath` property. | Use `request.routeOptions.url`. | [#4470](https://github.com/fastify/fastify/pull/4470) | +| FSTDEP018 | You are accessing the deprecated `request.routerMethod` property. | Use `request.routeOptions.method`. | [#4470](https://github.com/fastify/fastify/pull/4470) | +| FSTDEP019 | You are accessing the deprecated `reply.context` property. | Use `reply.routeOptions.config` or `reply.routeOptions.schema`. | [#5032](https://github.com/fastify/fastify/pull/5032) [#5084](https://github.com/fastify/fastify/pull/5084) | + +See [#5616](https://github.com/fastify/fastify/pull/5616) for more information. + +### `reply.redirect()` has a new signature + +The `reply.redirect()` method has a new signature: +`reply.redirect(url: string, code?: number)`. + +```js +// v4 +reply.redirect(301, '/new-route') +``` + +Change it to: + +```js +// v5 +reply.redirect('/new-route', 301) +``` + +This was already deprecated in v4 as `FSTDEP021`, so you should have already +updated your code to use the new signature. + + +### Modifying `reply.sent` is now forbidden + +In v4, you could modify the `reply.sent` property to prevent the response from +being sent. +This is no longer possible in v5, use `reply.hijack()` instead. + +```js +// v4 +fastify.get('/route', (req, reply) => { + reply.sent = true; + reply.raw.end('hello'); +}); +``` + +Change it to: + +```js +// v5 +fastify.get('/route', (req, reply) => { + reply.hijack(); + reply.raw.end('hello'); +}); +``` + +This was already deprecated in v4 as `FSTDEP010`, so you should have already +updated your code to use the new signature. + +### Constraints for route versioning signature changes + +We changed the signature for route versioning constraints. +The `version` and `versioning` options have been removed and you should +use the `constraints` option instead. + +| Code | Description | How to solve | Discussion | +| ---- | ----------- | ------------ | ---------- | +| FSTDEP008 | You are using route constraints via the route `{version: "..."}` option. | Use `{constraints: {version: "..."}}` option. | [#2682](https://github.com/fastify/fastify/pull/2682) | +| FSTDEP009 | You are using a custom route versioning strategy via the server `{versioning: "..."}` option. | Use `{constraints: {version: "..."}}` option. | [#2682](https://github.com/fastify/fastify/pull/2682) | + +### `HEAD` routes requires to register before `GET` when `exposeHeadRoutes: true` + +We have a more strict requirement for custom `HEAD` route when +`exposeHeadRoutes: true`. + +When you provides a custom `HEAD` route, you must either explicitly +set `exposeHeadRoutes` to `false` + +```js +// v4 +fastify.get('/route', { + +}, (req, reply) => { + reply.send({ hello: 'world' }); +}); + +fastify.head('/route', (req, reply) => { + // ... +}); +``` + +```js +// v5 +fastify.get('/route', { + exposeHeadRoutes: false +}, (req, reply) => { + reply.send({ hello: 'world' }); +}); + +fastify.head('/route', (req, reply) => { + // ... +}); +``` + +or place the `HEAD` route before `GET`. + +```js +// v5 +fastify.head('/route', (req, reply) => { + // ... +}); + +fastify.get('/route', { + +}, (req, reply) => { + reply.send({ hello: 'world' }); +}); +``` + +This was changed in [#2700](https://github.com/fastify/fastify/pull/2700), +and the old behavior was deprecated in v4 as `FSTDEP007`. + +### Removed `request.connection` + +The `request.connection` property has been removed in v5. +You should use `request.socket` instead. + +```js +// v4 +fastify.get('/route', (req, reply) => { + console.log(req.connection.remoteAddress); + return { hello: 'world' }; +}); +``` + +```js +// v5 +fastify.get('/route', (req, reply) => { + console.log(req.socket.remoteAddress); + return { hello: 'world' }; +}); +``` + +This was already deprecated in v4 as `FSTDEP05`, so you should +have already updated your code to use the new signature. + +### `reply.getResponseTime()` has been removed, use `reply.elapsedTime` instead + +The `reply.getResponseTime()` method has been removed in v5. +You should use `reply.elapsedTime` instead. + +```js +// v4 +fastify.get('/route', (req, reply) => { + console.log(reply.getResponseTime()); + return { hello: 'world' }; +}); +``` + +```js +// v5 +fastify.get('/route', (req, reply) => { + console.log(reply.elapsedTime); + return { hello: 'world' }; +}); +``` + +This was already deprecated in v4 as `FSTDEP20`, so you should have already +updated your code to use the new signature. + +### `fastify.hasRoute()` now matches the behavior of `find-my-way` + +The `fastify.hasRoute()` method now matches the behavior of `find-my-way` +and requires the route definition to be passed as it is defined in the route. + +```js +// v4 +fastify.get('/example/:file(^\\d+).png', function (request, reply) { }) + +console.log(fastify.hasRoute({ + method: 'GET', + url: '/example/12345.png' +)); // true +``` + +```js +// v5 + +fastify.get('/example/:file(^\\d+).png', function (request, reply) { }) + +console.log(fastify.hasRoute({ + method: 'GET', + url: '/example/:file(^\\d+).png' +)); // true +``` + +### Removal of some non-standard HTTP methods + +We have removed the following HTTP methods from Fastify: +- `PROPFIND` +- `PROPPATCH` +- `MKCOL` +- `COPY` +- `MOVE` +- `LOCK` +- `UNLOCK` +- `TRACE` +- `SEARCH` + +It's now possible to add them back using the `addHttpMethod` method. + +```js +const fastify = Fastify() + +// add a new http method on top of the default ones: +fastify.addHttpMethod('REBIND') + +// add a new HTTP method that accepts a body: +fastify.addHttpMethod('REBIND', { hasBody: true }) + +// reads the HTTP methods list: +fastify.supportedMethods // returns a string array +``` + +See [#5567](https://github.com/fastify/fastify/pull/5567) for more +information. + +### Removed support from reference types in decorators + +Decorating Request/Reply with a reference type (`Array`, `Object`) +is now prohibited as this reference is shared amongst all requests. + +```js +// v4 +fastify.decorateRequest('myObject', { hello: 'world' }); +``` + +```js +// v5 +fastify.decorateRequest('myObject'); +fastify.addHook('onRequest', async (req, reply) => { + req.myObject = { hello: 'world' }; +}); +``` + +or turn it into a function + +```js +// v5 +fastify.decorateRequest('myObject', () => ({ hello: 'world' })); +``` + +or as a getter + +```js +// v5 +fastify.decorateRequest('myObject', { + getter () { + return { hello: 'world' } + } +}); +``` + +See [#5462](https://github.com/fastify/fastify/pull/5462) for more information. + +### Remove support for DELETE with a `Content-Type: application/json` header and an empty body + +In v4, Fastify allowed `DELETE` requests with a `Content-Type: application/json` +header and an empty body was accepted. +This is no longer allowed in v5. + +See [#5419](https://github.com/fastify/fastify/pull/5419) for more information. + +### Plugins cannot mix callback/promise API anymore + +In v4, plugins could mix the callback and promise API, leading to unexpected behavior. +This is no longer allowed in v5. + +```js +// v4 +fastify.register(async function (instance, opts, done) { + done(); +}); +``` + +```js +// v5 +fastify.register(async function (instance, opts) { + return; +}); +``` + +or + +```js +// v5 +fastify.register(function (instance, opts, done) { + done(); +}); +``` + +### Requests now have `host`, `hostname`, and `port`, and `hostname` no longer includes the port number + +In Fastify v4, `req.hostname` would include both the hostname and the +server’s port, so locally it might have the value `localhost:1234`. +With v5, we aligned to the Node.js URL object and now include `host`, `hostname`, +and `port` properties. `req.host` has the same value as `req.hostname` did in v4, +while `req.hostname` includes the hostname _without_ a port if a port is present, +and `req.port` contains just the port number. +See [#4766](https://github.com/fastify/fastify/pull/4766) +and [#4682](https://github.com/fastify/fastify/issues/4682) for more information. + +### Removes `getDefaultRoute` and `setDefaultRoute` methods + +The `getDefaultRoute` and `setDefaultRoute` methods have been removed in v5. + +See [#4485](https://github.com/fastify/fastify/pull/4485) +and [#4480](https://github.com/fastify/fastify/pull/4485) +for more information. +This was already deprecated in v4 as `FSTDEP014`, +so you should have already updated your code. + +### `time` and `date-time` formats enforce timezone + +The updated AJV compiler updates `ajv-formats` which now +enforce the use of timezone in `time` and `date-time` format. +A workaround is to use `iso-time` and `iso-date-time` formats +which support an optional timezone for backwards compatibility. +See the +[full discussion](https://github.com/fastify/fluent-json-schema/issues/267). + +## New Features + +### Diagnostic Channel support + +Fastify v5 now supports the [Diagnostics Channel](https://nodejs.org/api/diagnostics_channel.html) +API natively +and provides a way to trace the lifecycle of a request. + +```js +'use strict' + +const diagnostics = require('node:diagnostics_channel') +const Fastify = require('fastify') + +diagnostics.subscribe('tracing:fastify.request.handler:start', (msg) => { + console.log(msg.route.url) // '/:id' + console.log(msg.route.method) // 'GET' +}) + +diagnostics.subscribe('tracing:fastify.request.handler:end', (msg) => { + // msg is the same as the one emitted by the 'tracing:fastify.request.handler:start' channel + console.log(msg) +}) + +diagnostics.subscribe('tracing:fastify.request.handler:error', (msg) => { + // in case of error +}) + +const fastify = Fastify() +fastify.route({ + method: 'GET', + url: '/:id', + handler: function (req, reply) { + return { hello: 'world' } + } +}) + +fastify.listen({ port: 0 }, async function () { + const result = await fetch(fastify.listeningOrigin + '/7') + + t.assert.ok(result.ok) + t.assert.strictEqual(response.status, 200) + t.assert.deepStrictEqual(await result.json(), { hello: 'world' }) +}) +``` + +See the [documentation](https://github.com/fastify/fastify/blob/main/docs/Reference/Hooks.md#diagnostics-channel-hooks) +and [#5252](https://github.com/fastify/fastify/pull/5252) for additional details. + +## Contributors + +The complete list of contributors, across all of the core +Fastify packages, is provided below. Please consider +contributing to those that are capable of accepting sponsorships. + +| Contributor | Sponsor Link | Packages | +| --- | --- | --- | +| 10xLaCroixDrinker | [❤️ sponsor](https://github.com/sponsors/10xLaCroixDrinker) | fastify-cli | +| Bram-dc | | fastify; fastify-swagger | +| BrianValente | | fastify | +| BryanAbate | | fastify-cli | +| Cadienvan | [❤️ sponsor](https://github.com/sponsors/Cadienvan) | fastify | +| Cangit | | fastify | +| Cyberlane | | fastify-elasticsearch | +| Eomm | [❤️ sponsor](https://github.com/sponsors/Eomm) | ajv-compiler; fastify; fastify-awilix; fastify-diagnostics-channel; fastify-elasticsearch; fastify-hotwire; fastify-mongodb; fastify-nextjs; fastify-swagger-ui; under-pressure | +| EstebanDalelR | [❤️ sponsor](https://github.com/sponsors/EstebanDalelR) | fastify-cli | +| Fdawgs | [❤️ sponsor](https://github.com/sponsors/Fdawgs) | aws-lambda-fastify; csrf-protection; env-schema; fastify; fastify-accepts; fastify-accepts-serializer; fastify-auth; fastify-awilix; fastify-basic-auth; fastify-bearer-auth; fastify-caching; fastify-circuit-breaker; fastify-cli; fastify-cookie; fastify-cors; fastify-diagnostics-channel; fastify-elasticsearch; fastify-env; fastify-error; fastify-etag; fastify-express; fastify-flash; fastify-formbody; fastify-funky; fastify-helmet; fastify-hotwire; fastify-http-proxy; fastify-jwt; fastify-kafka; fastify-leveldb; fastify-mongodb; fastify-multipart; fastify-mysql; fastify-nextjs; fastify-oauth2; fastify-passport; fastify-plugin; fastify-postgres; fastify-rate-limit; fastify-redis; fastify-reply-from; fastify-request-context; fastify-response-validation; fastify-routes; fastify-routes-stats; fastify-schedule; fastify-secure-session; fastify-sensible; fastify-swagger-ui; fastify-url-data; fastify-websocket; fastify-zipkin; fluent-json-schema; forwarded; middie; point-of-view; process-warning; proxy-addr; safe-regex2; secure-json-parse; under-pressure | +| Gehbt | | fastify-secure-session | +| Gesma94 | | fastify-routes-stats | +| H4ad | [❤️ sponsor](https://github.com/sponsors/H4ad) | aws-lambda-fastify | +| JohanManders | | fastify-secure-session | +| LiviaMedeiros | | fastify | +| Momy93 | | fastify-secure-session | +| MunifTanjim | | fastify-swagger-ui | +| Nanosync | | fastify-secure-session | +| RafaelGSS | [❤️ sponsor](https://github.com/sponsors/RafaelGSS) | fastify; under-pressure | +| Rantoledo | | fastify | +| SMNBLMRR | | fastify | +| SimoneDevkt | | fastify-cli | +| Tony133 | | fastify | +| Uzlopak | [❤️ sponsor](https://github.com/sponsors/Uzlopak) | fastify; fastify-autoload; fastify-diagnostics-channel; fastify-hotwire; fastify-nextjs; fastify-passport; fastify-plugin; fastify-rate-limit; fastify-routes; fastify-static; fastify-swagger-ui; point-of-view; under-pressure | +| Zamiell | | fastify-secure-session | +| aadito123 | | fastify | +| aaroncadillac | [❤️ sponsor](https://github.com/sponsors/aaroncadillac) | fastify | +| aarontravass | | fastify | +| acro5piano | [❤️ sponsor](https://github.com/sponsors/acro5piano) | fastify-secure-session | +| adamward459 | | fastify-cli | +| adrai | [❤️ sponsor](https://github.com/sponsors/adrai) | aws-lambda-fastify | +| alenap93 | | fastify | +| alexandrucancescu | | fastify-nextjs | +| anthonyringoet | | aws-lambda-fastify | +| arshcodemod | | fastify | +| autopulated | | point-of-view | +| barbieri | | fastify | +| beyazit | | fastify | +| big-kahuna-burger | [❤️ sponsor](https://github.com/sponsors/big-kahuna-burger) | fastify-cli; fastify-compress; fastify-helmet | +| bilalshareef | | fastify-routes | +| blue86321 | | fastify-swagger-ui | +| bodinsamuel | | fastify-rate-limit | +| busybox11 | [❤️ sponsor](https://github.com/sponsors/busybox11) | fastify | +| climba03003 | | csrf-protection; fastify; fastify-accepts; fastify-accepts-serializer; fastify-auth; fastify-basic-auth; fastify-bearer-auth; fastify-caching; fastify-circuit-breaker; fastify-compress; fastify-cors; fastify-env; fastify-etag; fastify-flash; fastify-formbody; fastify-http-proxy; fastify-mongodb; fastify-swagger-ui; fastify-url-data; fastify-websocket; middie | +| dancastillo | [❤️ sponsor](https://github.com/sponsors/dancastillo) | fastify; fastify-basic-auth; fastify-caching; fastify-circuit-breaker; fastify-cors; fastify-helmet; fastify-passport; fastify-response-validation; fastify-routes; fastify-schedule | +| danny-andrews | | fastify-kafka | +| davidcralph | [❤️ sponsor](https://github.com/sponsors/davidcralph) | csrf-protection | +| davideroffo | | under-pressure | +| dhensby | | fastify-cli | +| dmkng | | fastify | +| domdomegg | | fastify | +| faustman | | fastify-cli | +| floridemai | | fluent-json-schema | +| fox1t | | fastify-autoload | +| giuliowaitforitdavide | | fastify | +| gunters63 | | fastify-reply-from | +| gurgunday | | fastify; fastify-circuit-breaker; fastify-cookie; fastify-multipart; fastify-mysql; fastify-rate-limit; fastify-response-validation; fastify-sensible; fastify-swagger-ui; fluent-json-schema; middie; proxy-addr; safe-regex2; secure-json-parse | +| ildella | | under-pressure | +| james-kaguru | | fastify | +| jcbain | | fastify-http-proxy | +| jdhollander | | fastify-swagger-ui | +| jean-michelet | | fastify; fastify-autoload; fastify-cli; fastify-mysql; fastify-sensible | +| johaven | | fastify-multipart | +| jordanebelanger | | fastify-plugin | +| jscheffner | | fastify | +| jsprw | | fastify-secure-session | +| jsumners | [❤️ sponsor](https://github.com/sponsors/jsumners) | ajv-compiler; avvio; csrf-protection; env-schema; fast-json-stringify; fastify; fastify-accepts; fastify-accepts-serializer; fastify-auth; fastify-autoload; fastify-awilix; fastify-basic-auth; fastify-bearer-auth; fastify-caching; fastify-circuit-breaker; fastify-compress; fastify-cookie; fastify-cors; fastify-env; fastify-error; fastify-etag; fastify-express; fastify-flash; fastify-formbody; fastify-funky; fastify-helmet; fastify-http-proxy; fastify-jwt; fastify-kafka; fastify-leveldb; fastify-multipart; fastify-mysql; fastify-oauth2; fastify-plugin; fastify-postgres; fastify-redis; fastify-reply-from; fastify-request-context; fastify-response-validation; fastify-routes; fastify-routes-stats; fastify-schedule; fastify-secure-session; fastify-sensible; fastify-static; fastify-swagger; fastify-swagger-ui; fastify-url-data; fastify-websocket; fastify-zipkin; fluent-json-schema; forwarded; light-my-request; middie; process-warning; proxy-addr; safe-regex2; secure-json-parse; under-pressure | +| karankraina | | under-pressure | +| kerolloz | [❤️ sponsor](https://github.com/sponsors/kerolloz) | fastify-jwt | +| kibertoad | | fastify-rate-limit | +| kukidon-dev | | fastify-passport | +| kunal097 | | fastify | +| lamweili | | fastify-sensible | +| lemonclown | | fastify-mongodb | +| liuhanqu | | fastify | +| matthyk | | fastify-plugin | +| mch-dsk | | fastify | +| mcollina | [❤️ sponsor](https://github.com/sponsors/mcollina) | ajv-compiler; avvio; csrf-protection; fastify; fastify-accepts; fastify-accepts-serializer; fastify-auth; fastify-autoload; fastify-awilix; fastify-basic-auth; fastify-bearer-auth; fastify-caching; fastify-circuit-breaker; fastify-cli; fastify-compress; fastify-cookie; fastify-cors; fastify-diagnostics-channel; fastify-elasticsearch; fastify-env; fastify-etag; fastify-express; fastify-flash; fastify-formbody; fastify-funky; fastify-helmet; fastify-http-proxy; fastify-jwt; fastify-kafka; fastify-leveldb; fastify-multipart; fastify-mysql; fastify-oauth2; fastify-passport; fastify-plugin; fastify-postgres; fastify-rate-limit; fastify-redis; fastify-reply-from; fastify-request-context; fastify-response-validation; fastify-routes; fastify-routes-stats; fastify-schedule; fastify-secure-session; fastify-static; fastify-swagger; fastify-swagger-ui; fastify-url-data; fastify-websocket; fastify-zipkin; fluent-json-schema; light-my-request; middie; point-of-view; proxy-addr; secure-json-parse; under-pressure | +| melroy89 | [❤️ sponsor](https://github.com/sponsors/melroy89) | under-pressure | +| metcoder95 | [❤️ sponsor](https://github.com/sponsors/metcoder95) | fastify-elasticsearch | +| mhamann | | fastify-cli | +| mihaur | | fastify-elasticsearch | +| mikesamm | | fastify | +| mikhael-abdallah | | secure-json-parse | +| miquelfire | [❤️ sponsor](https://github.com/sponsors/miquelfire) | fastify-routes | +| miraries | | fastify-swagger-ui | +| mohab-sameh | | fastify | +| monish001 | | fastify | +| moradebianchetti81 | | fastify | +| mouhannad-sh | | aws-lambda-fastify | +| multivoltage | | point-of-view | +| muya | [❤️ sponsor](https://github.com/sponsors/muya) | under-pressure | +| mweberxyz | | point-of-view | +| nflaig | | fastify | +| nickfla1 | | avvio | +| o-az | | process-warning | +| ojeytonwilliams | | csrf-protection | +| onosendi | | fastify-formbody | +| philippviereck | | fastify | +| pip77 | | fastify-mongodb | +| puskin94 | | fastify | +| remidewitte | | fastify | +| rozzilla | | fastify | +| samialdury | | fastify-cli | +| sknetl | | fastify-cors | +| sourcecodeit | | fastify | +| synapse | | env-schema | +| timursaurus | | secure-json-parse | +| tlhunter | | fastify | +| tlund101 | | fastify-rate-limit | +| ttshivers | | fastify-http-proxy | +| voxpelli | [❤️ sponsor](https://github.com/sponsors/voxpelli) | fastify | +| weixinwu | | fastify-cli | +| zetaraku | | fastify-cli | diff --git a/docs/Guides/Plugins-Guide.md b/docs/Guides/Plugins-Guide.md index 6f1d543525b..6edb74fa82e 100644 --- a/docs/Guides/Plugins-Guide.md +++ b/docs/Guides/Plugins-Guide.md @@ -71,8 +71,8 @@ order of plugins. *How?* Glad you asked, check out [`avvio`](https://github.com/mcollina/avvio)! Fastify starts loading the plugin __after__ `.listen()`, `.inject()` or `.ready()` are called. -Inside a plugin you can do whatever you want, register routes, utilities (we -will see this in a moment) and do nested registers, just remember to call `done` +Inside a plugin you can do whatever you want, register routes and utilities (we +will see this in a moment), and do nested registers, just remember to call `done` when everything is set up! ```js module.exports = function (fastify, options, done) { @@ -117,7 +117,7 @@ Now you can access your utility just by calling `fastify.util` whenever you need it - even inside your test. And here starts the magic; do you remember how just now we were talking about -encapsulation? Well, using `register` and `decorate` in conjunction enable +encapsulation? Well, using `register` and `decorate` in conjunction enables exactly that, let me show you an example to clarify this: ```js fastify.register((instance, opts, done) => { @@ -137,7 +137,7 @@ Inside the second register call `instance.util` will throw an error because `util` exists only inside the first register context. Let's step back for a moment and dig deeper into this: every time you use the -`register` API, a new context is created which avoids the negative situations +`register` API, a new context is created that avoids the negative situations mentioned above. Do note that encapsulation applies to the ancestors and siblings, but not the @@ -147,7 +147,7 @@ fastify.register((instance, opts, done) => { instance.decorate('util', (a, b) => a + b) console.log(instance.util('that is ', 'awesome')) - fastify.register((instance, opts, done) => { + instance.register((instance, opts, done) => { console.log(instance.util('that is ', 'awesome')) // This will not throw an error done() }) @@ -202,14 +202,14 @@ a utility that also needs access to the `request` and `reply` instance, a function that is defined using the `function` keyword is needed instead of an *arrow function expression*. -In the same way you can do this for the `request` object: +You can do the same for the `request` object: ```js -fastify.decorate('getHeader', (req, header) => { - return req.headers[header] +fastify.decorate('getBoolHeader', (req, name) => { + return req.headers[name] ?? false // We return `false` if header is missing }) fastify.addHook('preHandler', (request, reply, done) => { - request.isHappy = fastify.getHeader(request.raw, 'happy') + request.isHappy = fastify.getBoolHeader(request, 'happy') done() }) @@ -219,14 +219,14 @@ fastify.get('/happiness', (request, reply) => { ``` Again, it works, but it can be much better! ```js -fastify.decorateRequest('setHeader', function (header) { - this.isHappy = this.headers[header] +fastify.decorateRequest('setBoolHeader', function (name) { + this.isHappy = this.headers[name] ?? false }) fastify.decorateRequest('isHappy', false) // This will be added to the Request object prototype, yay speed! fastify.addHook('preHandler', (request, reply, done) => { - request.setHeader('happy') + request.setBoolHeader('happy') done() }) @@ -308,8 +308,52 @@ fastify.get('/plugin2', (request, reply) => { ``` Now your hook will run just for the first route! +An alternative approach is to make use of the [onRoute hook](../Reference/Hooks.md#onroute) +to customize application routes dynamically from inside the plugin. Every time +a new route is registered, you can read and modify the route options. For example, +based on a [route config option](../Reference/Routes.md#routes-options): + +```js +fastify.register((instance, opts, done) => { + instance.decorate('util', (request, key, value) => { request[key] = value }) + + function handler(request, reply, done) { + instance.util(request, 'timestamp', new Date()) + done() + } + + instance.addHook('onRoute', (routeOptions) => { + if (routeOptions.config && routeOptions.config.useUtil === true) { + // set or add our handler to the route preHandler hook + if (!routeOptions.preHandler) { + routeOptions.preHandler = [handler] + return + } + if (Array.isArray(routeOptions.preHandler)) { + routeOptions.preHandler.push(handler) + return + } + routeOptions.preHandler = [routeOptions.preHandler, handler] + } + }) + + instance.get('/plugin1', {config: {useUtil: true}}, (request, reply) => { + reply.send(request) + }) + + instance.get('/plugin2', (request, reply) => { + reply.send(request) + }) + + done() +}) +``` + +This variant becomes extremely useful if you plan to distribute your plugin, as +described in the next section. + As you probably noticed by now, `request` and `reply` are not the standard -Nodejs *request* and *response* objects, but Fastify's objects. +Node.js *request* and *response* objects, but Fastify's objects. ## How to handle encapsulation and distribution @@ -351,7 +395,7 @@ As we mentioned earlier, Fastify starts loading its plugins __after__ have been declared. This means that, even though the plugin may inject variables to the external Fastify instance via [`decorate`](../Reference/Decorators.md), the decorated variables will not be accessible before calling `.listen()`, -`.inject()` or `.ready()`. +`.inject()`, or `.ready()`. In case you rely on a variable injected by a preceding plugin and want to pass that in the `options` argument of `register`, you can do so by using a function @@ -368,7 +412,7 @@ function dbPlugin (fastify, opts, done) { }) } -fastify.register(fp(dbPlugin), { url: 'https://example.com' }) +fastify.register(fp(dbPlugin), { url: 'https://fastify.example' }) fastify.register(require('your-plugin'), parent => { return { connection: parent.db, otherOption: 'foo-bar' } }) @@ -383,7 +427,7 @@ variables that were injected by preceding plugins in the order of declaration. ESM is supported as well from [Node.js `v13.3.0`](https://nodejs.org/api/esm.html) and above! Just export your plugin -as ESM module and you are good to go! +as an ESM module and you are good to go! ```js // plugin.mjs @@ -395,24 +439,6 @@ async function plugin (fastify, opts) { export default plugin ``` -__Note__: Fastify does not support named imports within an ESM context. Instead, -the `default` export is available. - -```js -// server.mjs -import Fastify from 'fastify' - -const fastify = Fastify() - -///... - -fastify.listen({ port: 3000 }, (err, address) => { - if (err) { - fastify.log.error(err) - process.exit(1) - } -}) -``` ## Handle errors @@ -467,8 +493,8 @@ use case, you can use the ```js const warning = require('process-warning')() -warning.create('FastifyDeprecation', 'FST_ERROR_CODE', 'message') -warning.emit('FST_ERROR_CODE') +warning.create('MyPluginWarning', 'MP_ERROR_CODE', 'message') +warning.emit('MP_ERROR_CODE') ``` ## Let's start! diff --git a/docs/Guides/Prototype-Poisoning.md b/docs/Guides/Prototype-Poisoning.md index 8d29cf6e7b6..0d01aa90477 100644 --- a/docs/Guides/Prototype-Poisoning.md +++ b/docs/Guides/Prototype-Poisoning.md @@ -4,29 +4,24 @@ > but otherwise remains the same. The original HTML can be retrieved from the > above permission link. -## A Tale of (prototype) Poisoning +## History behind prototype poisoning -This story is a behind-the-scenes look at the process and drama created by a -particularity interesting web security issue. It is also a perfect illustration -of the efforts required to maintain popular pieces of open source software and -the limitations of existing communication channels. - -But first, if you use a JavaScript framework to process incoming JSON data, take -a moment to read up on [Prototype -Poisoning](https://medium.com/intrinsic/javascript-prototype-poisoning-vulnerabilities-in-the-wild-7bc15347c96) -in general, and the specific [technical -details](https://github.com/hapijs/hapi/issues/3916) of this issue. I'll explain -it all in a bit, but since this could be a critical issue, you might want to -verify your own code first. While this story is focused on a specific framework, -any solution that uses `JSON.parse()` to process external data is potentially at -risk. +Based on the article by Eran Hammer,the issue is created by a web security bug. +It is also a perfect illustration of the efforts required to maintain +open-source software and the limitations of existing communication channels. + +But first, if we use a JavaScript framework to process incoming JSON data, take +a moment to read up on [Prototype Poisoning](https://medium.com/intrinsic/javascript-prototype-poisoning-vulnerabilities-in-the-wild-7bc15347c96) +in general, and the specific +[technical details](https://github.com/hapijs/hapi/issues/3916) of this issue. +This could be a critical issue so, we might need to verify your own code first. +It focuses on specific framework however, any solution that uses `JSON.parse()` +to process external data is potentially at risk. ### BOOM -Our story begins with a bang. - The engineering team at Lob (long time generous supporters of my work!) reported a critical security vulnerability they identified in our data validation module — [joi](https://github.com/hapijs/joi). They provided some technical @@ -34,21 +29,22 @@ details and a proposed solution. The main purpose of a data validation library is to ensure the output fully complies with the rules defined. If it doesn't, validation fails. If it passes, -your can blindly trust that the data you are working with is safe. In fact, most +we can blindly trust that the data you are working with is safe. In fact, most developers treat validated input as completely safe from a system integrity -perspective. This is crucial. +perspective which is crucial! -In our case, the Lob team provided an example where some data was able to sneak +In our case, the Lob team provided an example where some data was able to escape by the validation logic and pass through undetected. This is the worst possible defect a validation library can have. ### Prototype in a nutshell -To understand this story, you need to understand how JavaScript works a bit. +To understand this, we need to understand how JavaScript works a bit. Every object in JavaScript can have a prototype. It is a set of methods and -properties it "inherits" from another object. I put inherits in quotes because -JavaScript isn't really an object oriented language. +properties it "inherits" from another object. I have put inherits in quotes +because JavaScript isn't really an object-oriented language. It is a prototype- +based object-oriented language. A long time ago, for a bunch of irrelevant reasons, someone decided that it would be a good idea to use the special property name `__proto__` to access (and @@ -68,22 +64,21 @@ To demonstrate: { b: 5 } ``` -As you can see, the object doesn't have a `c` property, but its prototype does. +The object doesn't have a `c` property, but its prototype does. When validating the object, the validation library ignores the prototype and only validates the object's own properties. This allows `c` to sneak in via the prototype. -Another important part of this story is the way `JSON.parse()` — a utility -provided by the language to convert JSON formatted text into objects  —  handles -this magic `__proto__` property name. +Another important part is the way `JSON.parse()` — a utility +provided by the language to convert JSON formatted text into +objects  —  handles this magic `__proto__` property name. ``` -> const text = '{ "b": 5, "__proto__": { "c": 6 } }'; +> const text = '{"b": 5, "__proto__": { "c": 6 }}'; > const a = JSON.parse(text); > a; -{ b: 5, __proto__: { c: 6 } } +{b: 5, __proto__: { c: 6 }} ``` - Notice how `a` has a `__proto__` property. This is not a prototype reference. It is a simple object property key, just like `b`. As we've seen from the first example, we can't actually create this key through assignment as that invokes @@ -111,17 +106,17 @@ level properties of `a` into the provided empty `{}` object), the magic Surprise! -Put together, if you get some external text input, parse it with `JSON.parse()` -then perform some simple manipulation of that object (say, shallow clone and add -an `id` ), and then pass it to our validation library, anything passed through -via `__proto__` would sneak in undetected. +If you get some external text input and parse it with `JSON.parse()` +then perform some simple manipulation of that object (e.g shallow clone and add +an `id` ), and pass it to our validation library, it would sneak in undetected +via `__proto__`. ### Oh joi! The first question is, of course, why does the validation module **joi** ignore the prototype and let potentially harmful data through? We asked ourselves the -same question and our instant thought was "it was an oversight". A bug. A really +same question and our instant thought was "it was an oversight". A bug - a really big mistake. The joi module should not have allowed this to happen. But… While joi is used primarily for validating web input data, it also has a @@ -166,7 +161,6 @@ will share with the world how to exploit this vulnerability while also making it more time consuming for systems to upgrade (breaking changes never get applied automatically by build tools). -Lose — Lose. ### A detour @@ -386,6 +380,4 @@ plan](https://web.archive.org/web/20190201220503/https://hueniverse.com/on-hapi- coming in March. You can read more about it [here](https://web.archive.org/web/20190201220503/https://hueniverse.com/on-hapi-licensing-a-preview-f982662ee898). -Of all the time consuming things, security is at the very top. I hope this story -successfully conveyed not just the technical details, but also the human drama and -what it takes to keep the web secure. + diff --git a/docs/Guides/Recommendations.md b/docs/Guides/Recommendations.md index f0ee7b19859..3747a2dc10c 100644 --- a/docs/Guides/Recommendations.md +++ b/docs/Guides/Recommendations.md @@ -7,7 +7,10 @@ This document contains a set of recommendations when using Fastify. - [Use A Reverse Proxy](#use-a-reverse-proxy) - [HAProxy](#haproxy) - [Nginx](#nginx) +- [Common Causes Of Performance Degradation](#common-causes-of-performance-degradation) - [Kubernetes](#kubernetes) +- [Capacity Planning For Production](#capacity) +- [Running Multiple Instances](#multiple) ## Use A Reverse Proxy @@ -110,7 +113,7 @@ frontend proxy-ssl # Here we define rule pairs to handle static resources. Any incoming request # that has a path starting with `/static`, e.g. - # `https://one.example.com/static/foo.jpeg`, will be redirected to the + # `https://one.fastify.example/static/foo.jpeg`, will be redirected to the # static resources server. acl is_static path -i -m beg /static use_backend static-backend if is_static @@ -120,10 +123,10 @@ frontend proxy-ssl # the incoming hostname and define a boolean indicating if it is a match. # The `use_backend` line is used to direct the traffic if the boolean is # true. - acl example1 hdr_sub(Host) one.example.com + acl example1 hdr_sub(Host) one.fastify.example use_backend example1-backend if example1 - acl example2 hdr_sub(Host) two.example.com + acl example2 hdr_sub(Host) two.fastify.example use_backend example2-backend if example2 # Finally, we have a fallback redirect if none of the requested hosts @@ -142,14 +145,14 @@ backend default-server # requests over TLS, but that is outside the scope of this example. server server1 10.10.10.2:80 -# This backend configuration will serve requests for `https://one.example.com` +# This backend configuration will serve requests for `https://one.fastify.example` # by proxying requests to three backend servers in a round-robin manner. backend example1-backend server example1-1 10.10.11.2:80 server example1-2 10.10.11.2:80 server example2-2 10.10.11.3:80 -# This one serves requests for `https://two.example.com` +# This one serves requests for `https://two.fastify.example` backend example2-backend server example2-1 10.10.12.2:80 server example2-2 10.10.12.2:80 @@ -210,17 +213,19 @@ server { # server group via port 3000. server { # This listen directive asks NGINX to accept requests - # coming to any address, port 443, with SSL, and HTTP/2 - # if possible. - listen 443 ssl http2 default_server; - listen [::]:443 ssl http2 default_server; + # coming to any address, port 443, with SSL. + listen 443 ssl default_server; + listen [::]:443 ssl default_server; # With a server_name directive you can also ask NGINX to # use this server block only with matching server name(s) - # listen 443 ssl http2; - # listen [::]:443 ssl http2; + # listen 443 ssl; + # listen [::]:443 ssl; # server_name example.tld; + # Enable HTTP/2 support + http2 on; + # Your SSL/TLS certificate (chain) and secret key in the PEM format ssl_certificate /path/to/fullchain.pem; ssl_certificate_key /path/to/private.pem; @@ -278,10 +283,34 @@ server { [nginx]: https://nginx.org/ +## Common Causes Of Performance Degradation + +These patterns can increase latency or reduce throughput in production: + +- Prefer static or simple parametric routes on hot paths. RegExp routes are + expensive, and routes with many parameters can also hurt router performance. + See [Routes - Url building](../Reference/Routes.md#url-building). +- Use route constraints carefully. Version constraints can degrade router + performance, and asynchronous custom constraints should be treated as a last + resort. See [Routes - Constraints](../Reference/Routes.md#constraints). +- Prefer Fastify plugins/hooks over generic middleware when possible. Fastify's + middleware adapters work, but native integrations are typically better for + performance-sensitive paths. See [Middleware](../Reference/Middleware.md). +- Define response schemas to speed up JSON serialization. See + [Getting Started - Serialize your data](./Getting-Started.md#serialize-data). +- Keep Ajv `allErrors` disabled by default. Enable it only when detailed + validation feedback is needed (for example, form-heavy APIs), and avoid it + on latency-sensitive endpoints. When `allErrors: true` is enabled, validation + can do more work per request and make denial-of-service attacks easier on + untrusted inputs. + See also: + - [Validation and Serialization - Validator Compiler](../Reference/Validation-and-Serialization.md#schema-validator) + - [Ajv Security Risks of Trusted Schemas](https://ajv.js.org/security.html#security-risks-of-trusted-schemas). + ## Kubernetes -The `readinessProbe` uses [(by +The `readinessProbe` uses ([by default](https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/#configure-probes)) the pod IP as the hostname. Fastify listens on `127.0.0.1` by default. The probe will not be able to reach the application in this case. To make it work, @@ -298,3 +327,52 @@ readinessProbe: timeoutSeconds: 3 successThreshold: 1 failureThreshold: 5 +``` + +## Capacity Planning For Production + + +In order to rightsize the production environment for your Fastify application, +it is highly recommended that you perform your own measurements against +different configurations of the environment, which may +use real CPU cores, virtual CPU cores (vCPU), or even fractional +vCPU cores. We will use the term vCPU throughout this +recommendation to represent any CPU type. + +Tools such as [k6](https://github.com/grafana/k6) +or [autocannon](https://github.com/mcollina/autocannon) can be used for +conducting the necessary performance tests. + +That said, you may also consider the following as a rule of thumb: + +* To have the lowest possible latency, 2 vCPU are recommended per app +instance (e.g., a k8s pod). The second vCPU will mostly be used by the +garbage collector (GC) and libuv threadpool. This will minimize the latency +for your users, as well as the memory usage, as the GC will be run more +frequently. Also, the main thread won't have to stop to let the GC run. + +* To optimize for throughput (handling the largest possible amount of +requests per second per vCPU available), consider using a smaller amount of vCPUs +per app instance. It is totally fine to run Node.js applications with 1 vCPU. + +* You may experiment with an even smaller amount of vCPU, which may provide +even better throughput in certain use-cases. There are reports of API gateway +solutions working well with 100m-200m vCPU in Kubernetes. + +See [Node's Event Loop From the Inside Out ](https://www.youtube.com/watch?v=P9csgxBgaZ8) +to understand the workings of Node.js in greater detail and make a +better determination about what your specific application needs. + +## Running Multiple Instances + + +There are several use-cases where running multiple Fastify +apps on the same server might be considered. A common example +would be exposing metrics endpoints on a separate port, +to prevent public access, when using a reverse proxy or an ingress +firewall is not an option. + +It is perfectly fine to spin up several Fastify instances within the same +Node.js process and run them concurrently, even in high load systems. +Each Fastify instance only generates as much load as the traffic it receives, +plus the memory used for that Fastify instance. diff --git a/docs/Guides/Serverless.md b/docs/Guides/Serverless.md index 1c7b7863161..f6e3428cf65 100644 --- a/docs/Guides/Serverless.md +++ b/docs/Guides/Serverless.md @@ -1,20 +1,20 @@

Serverless

Run serverless applications and REST APIs using your existing Fastify -application. By default, Fastify will not work on your serverless platform of -choice, you will need to make some small changes to fix this. This document -contains a small guide for the most popular serverless providers and how to use +application. You may need to make code changes to work on your +serverless platform of choice. This document contains a small guide +for the most popular serverless providers and how to use Fastify with them. #### Should you use Fastify in a serverless platform? -That is up to you! Keep in mind that functions as a service should always use +That is up to you! Keep in mind, functions as a service should always use small and focused functions, but you can also run an entire web application with them. It is important to remember that the bigger the application the slower the initial boot will be. The best way to run Fastify applications in serverless -environments is to use platforms like Google Cloud Run, AWS Fargate, and Azure -Container Instances, where the server can handle multiple requests at the same -time and make full use of Fastify's features. +environments is to use platforms like Google Cloud Run, AWS Fargate, Azure +Container Instances, and Vercel where the server can handle multiple requests +at the same time and make full use of Fastify's features. One of the best features of using Fastify in serverless applications is the ease of development. In your local environment, you will always run the Fastify @@ -25,7 +25,9 @@ snippet of code. ### Contents - [AWS](#aws) +- [Genezio](#genezio) - [Google Cloud Functions](#google-cloud-functions) +- [Google Firebase Functions](#google-firebase-functions) - [Google Cloud Run](#google-cloud-run) - [Netlify Lambda](#netlify-lambda) - [Vercel](#vercel) @@ -34,10 +36,10 @@ snippet of code. To integrate with AWS, you have two choices of library: -- Using [@fastify/aws-lambda](https://github.com/fastify/aws-lambda-fastify) +- Using [@fastify/aws-lambda](https://github.com/fastify/aws-lambda-fastify) which only adds API Gateway support but has heavy optimizations for fastify. -- Using [@h4ad/serverless-adapter](https://github.com/H4ad/serverless-adapter) - which is a little slower as it creates an HTTP request for each AWS event but +- Using [@h4ad/serverless-adapter](https://github.com/H4ad/serverless-adapter) + which is a little slower as it creates an HTTP request for each AWS event but has support for more AWS services such as: AWS SQS, AWS SNS and others. So you can decide which option is best for you, but you can test both libraries. @@ -127,6 +129,13 @@ If you need to integrate with more AWS services, take a look at [@h4ad/serverless-adapter](https://viniciusl.com.br/serverless-adapter/docs/main/frameworks/fastify) on Fastify to find out how to integrate. +## Genezio + +[Genezio](https://genezio.com/) is a platform designed to simplify the deployment +of serverless applications to the cloud. + +[Genezio has a dedicated guide for deploying a Fastify application.](https://genezio.com/docs/frameworks/fastify/) + ## Google Cloud Functions ### Creation of Fastify instance @@ -202,7 +211,7 @@ const fastifyFunction = async (request, reply) => { fastify.server.emit('request', request, reply) } -export.fastifyFunction = fastifyFunction; +exports.fastifyFunction = fastifyFunction; ``` ### Local test @@ -228,14 +237,13 @@ npx @google-cloud/functions-framework --target=fastifyFunction Or add this command to your `package.json` scripts: ```json "scripts": { -... -"dev": "npx @google-cloud/functions-framework --target=fastifyFunction" -... + ... + "dev": "npx @google-cloud/functions-framework --target=fastifyFunction" + ... } ``` and run it with `npm run dev`. - ### Deploy ```bash gcloud functions deploy fastifyFunction \ @@ -259,6 +267,132 @@ curl -X POST https://$GOOGLE_REGION-$GOOGLE_PROJECT.cloudfunctions.net/me \ - [Google Cloud Functions - Node.js Quickstart ](https://cloud.google.com/functions/docs/quickstart-nodejs) +## Google Firebase Functions + +Follow this guide if you want to use Fastify as the HTTP framework for +Firebase Functions instead of the vanilla JavaScript router provided with +`onRequest(async (req, res) => {}`. + +### The onRequest() handler + +We use the `onRequest` function to wrap our Fastify application instance. + +As such, we'll begin with importing it to the code: + +```js +const { onRequest } = require("firebase-functions/v2/https") +``` + +### Creation of Fastify instance + +Create the Fastify instance and encapsulate the returned application instance +in a function that will register routes, await the server's processing of +plugins, hooks, and other settings. As follows: + +```js +const fastify = require("fastify")({ + logger: true, +}) + +const fastifyApp = async (request, reply) => { + await registerRoutes(fastify) + await fastify.ready() + fastify.server.emit("request", request, reply) +} +``` + +### Add Custom `contentTypeParser` to Fastify instance and define endpoints + +Firebase Function's HTTP layer already parses the request and makes a JSON +payload available through the property `payload.body` below. It also provides +access to the raw body, unparsed, which is useful for calculating request +signatures to validate HTTP webhooks. + +Add as follows to the `registerRoutes()` function: + +```js +async function registerRoutes (fastify) { + fastify.addContentTypeParser("application/json", {}, (req, payload, done) => { + // useful to include the request's raw body on the `req` object that will + // later be available in your other routes so you can calculate the HMAC + // if needed + req.rawBody = payload.rawBody + + // payload.body is already the parsed JSON so we just fire the done callback + // with it + done(null, payload.body) + }) + + // define your endpoints here... + fastify.post("/some-route-here", async (request, reply) => {}) + + fastify.get('/', async (request, reply) => { + reply.send({message: 'Hello World!'}) + }) +} +``` + +**Failing to add this `ContentTypeParser` may lead to the Fastify process +remaining stuck and not processing any other requests after receiving one with +the Content-Type `application/json`.** + +When using Typescript, since the type of `payload` is a native `IncomingMessage` +that gets modified by Firebase, it won't be able to find the property +`payload.body`. + +In order to suppress the error, you can use the following signature: + +```ts +declare module 'http' { + interface IncomingMessage { + body?: unknown; + } +} +``` + +### Export the function using Firebase onRequest + +Final step is to export the Fastify app instance to Firebase's own +`onRequest()` function so it can pass the request and reply objects to it: + +```js +exports.app = onRequest(fastifyApp) +``` + +### Local test + +Install the Firebase tools functions so you can use the CLI: + +```bash +npm i -g firebase-tools +``` + +Then you can run your function locally with: + +```bash +firebase emulators:start --only functions +``` + +### Deploy + +Deploy your Firebase Functions with: + +```bash +firebase deploy --only functions +``` + +#### Read logs + +Use the Firebase tools CLI: + +```bash +firebase functions:log +``` + +### References +- [Fastify on Firebase Functions](https://github.com/lirantal/lemon-squeezy-firebase-webhook-fastify/blob/main/package.json) +- [An article about HTTP webhooks on Firebase Functions and Fastify: A Practical Case Study with Lemon Squeezy](https://lirantal.com/blog/http-webhooks-firebase-functions-fastify-practical-case-study-lemon-squeezy) + ## Google Cloud Run Unlike AWS Lambda or Google Cloud Functions, Google Cloud Run is a serverless @@ -273,7 +407,7 @@ familiar with gcloud or just follow their ### Adjust Fastify server -In order for Fastify to properly listen for requests within the container, be +For Fastify to properly listen for requests within the container, be sure to set the correct port and address: ```js @@ -344,7 +478,7 @@ CMD [ "npm", "start" ] To keep build artifacts out of your container (which keeps it small and improves build times) add a `.dockerignore` file like the one below: -```.dockerignore +```dockerignore Dockerfile README.md node_modules @@ -371,12 +505,11 @@ gcloud beta run deploy --image gcr.io/PROJECT-ID/APP-NAME --platform managed Your app will be accessible from the URL GCP provides. - ## netlify-lambda First, please perform all preparation steps related to **AWS Lambda**. -Create a folder called `functions`, then create `server.js` (and your endpoint +Create a folder called `functions`, then create `server.js` (and your endpoint path will be `server.js`) inside the `functions` folder. ### functions/server.js @@ -445,54 +578,27 @@ Add this command to your `package.json` *scripts* ```json "scripts": { -... -"build:functions": "netlify-lambda build functions --config ./webpack.config.netlify.js" -... + ... + "build:functions": "netlify-lambda build functions --config ./webpack.config.netlify.js" + ... } ``` -Then it should work fine - +Then it should work fine. ## Vercel -[Vercel](https://vercel.com) provides zero-configuration deployment for Node.js -applications. To use it now, it is as simple as configuring your `vercel.json` -file like the following: - -```json -{ - "rewrites": [ - { - "source": "/(.*)", - "destination": "/api/serverless.js" - } - ] -} -``` - -Then, write `api/serverless.js` like so: - -```js -"use strict"; - -// Read the .env file. -import * as dotenv from "dotenv"; -dotenv.config(); - -// Require the framework -import Fastify from "fastify"; +[Vercel](https://vercel.com) fully supports deploying Fastify applications. +Additionally, with Vercel's +[Fluid compute](https://vercel.com/docs/functions/fluid-compute), you can combine +server-like concurrency with the autoscaling properties of traditional +serverless functions. -// Instantiate Fastify with some config -const app = Fastify({ - logger: true, -}); - -// Register your application as a normal plugin. -app.register(import("../src/app")); +Get started with the +[Fastify template on Vercel]( +https://vercel.com/templates/backend/fastify-on-vercel). -export default async (req, res) => { - await app.ready(); - app.server.emit('request', req, res); -} -``` +[Fluid compute](https://vercel.com/docs/functions/fluid-compute) currently +requires an explicit opt-in. Learn more about enabling Fluid compute +[here]( +https://vercel.com/docs/fluid-compute#enabling-fluid-compute). \ No newline at end of file diff --git a/docs/Guides/Style-Guide.md b/docs/Guides/Style-Guide.md index 16f2c73087f..68fc7639033 100644 --- a/docs/Guides/Style-Guide.md +++ b/docs/Guides/Style-Guide.md @@ -13,7 +13,7 @@ This guide is for anyone who loves to build with Fastify or wants to contribute to our documentation. You do not need to be an expert in writing technical documentation. This guide is here to help you. -Visit the [contribute](https://www.fastify.io/contribute) page on our website or +Visit the [contribute](https://fastify.dev/contribute) page on our website or read the [CONTRIBUTING.md](https://github.com/fastify/fastify/blob/main/CONTRIBUTING.md) file on GitHub to join our Open Source folks. @@ -70,12 +70,12 @@ markdown. **Example** ``` -To learn more about hooks, see [Fastify hooks](https://www.fastify.io/docs/latest/Reference/Hooks/). +To learn more about hooks, see [Fastify hooks](https://fastify.dev/docs/latest/Reference/Hooks/). ``` Result: >To learn more about hooks, see [Fastify ->hooks](https://www.fastify.io/docs/latest/Reference/Hooks/). +>hooks](https://fastify.dev/docs/latest/Reference/Hooks/). @@ -83,7 +83,7 @@ Result: Make sure you avoid copying other people's work. Keep it as original as possible. You can learn from what they have done and reference where it is from -if you used a particular quote from their work. +if you use a particular quote from their work. ## Word Choice @@ -217,25 +217,25 @@ Styles](https://medium.com/better-programming/string-case-styles-camel-pascal-sn ### Hyperlinks -Hyperlinks should have a clear title of what it references. Here is how your +Hyperlinks should have a clear title of what they reference. Here is how your hyperlink should look: ```MD // Add clear & brief description -[Fastify Plugins] (https://www.fastify.io/docs/latest/Plugins/) +[Fastify Plugins] (https://fastify.dev/docs/latest/Plugins/) // incomplete description -[Fastify] (https://www.fastify.io/docs/latest/Plugins/) +[Fastify] (https://fastify.dev/docs/latest/Plugins/) // Adding title in link brackets -[](https://www.fastify.io/docs/latest/Plugins/ "fastify plugin") +[](https://fastify.dev/docs/latest/Plugins/ "fastify plugin") // Empty title -[](https://www.fastify.io/docs/latest/Plugins/) +[](https://fastify.dev/docs/latest/Plugins/) // Adding links localhost URLs instead of using code strings (``) [http://localhost:3000/](http://localhost:3000/) diff --git a/docs/Guides/Testing.md b/docs/Guides/Testing.md index 039cbb8ea6e..4901191e5a6 100644 --- a/docs/Guides/Testing.md +++ b/docs/Guides/Testing.md @@ -1,16 +1,19 @@ -

Fastify

+

Fastify

-## Testing +# Testing + Testing is one of the most important parts of developing an application. Fastify is very flexible when it comes to testing and is compatible with most testing -frameworks (such as [Tap](https://www.npmjs.com/package/tap), which is used in -the examples below). +frameworks (such as [Node Test Runner](https://nodejs.org/api/test.html), +which is used in the examples below). + +## Application Let's `cd` into a fresh directory called 'testing-example' and type `npm init -y` in our terminal. -Run `npm i fastify && npm i tap pino-pretty -D` +Run `npm i fastify && npm i pino-pretty -D` ### Separating concerns makes testing easy @@ -110,24 +113,25 @@ Now we can replace our `console.log` calls with actual tests! In your `package.json` change the "test" script to: -`"test": "tap --reporter=list --watch"` +`"test": "node --test --watch"` **app.test.js**: ```js 'use strict' -const { test } = require('tap') +const { test } = require('node:test') const build = require('./app') test('requests the "/" route', async t => { + t.plan(1) const app = build() const response = await app.inject({ method: 'GET', url: '/' }) - t.equal(response.statusCode, 200, 'returns a status code of 200') + t.assert.strictEqual(response.statusCode, 200, 'returns a status code of 200') }) ``` @@ -211,26 +215,26 @@ module.exports = buildFastify **test.js** ```js -const tap = require('tap') +const { test } = require('node:test') const buildFastify = require('./app') -tap.test('GET `/` route', t => { +test('GET `/` route', t => { t.plan(4) const fastify = buildFastify() // At the end of your tests it is highly recommended to call `.close()` // to ensure that all connections to external services get closed. - t.teardown(() => fastify.close()) + t.after(() => fastify.close()) fastify.inject({ method: 'GET', url: '/' }, (err, response) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-type'], 'application/json; charset=utf-8') - t.same(response.json(), { hello: 'world' }) + t.assert.ifError(err) + t.assert.strictEqual(response.statusCode, 200) + t.assert.strictEqual(response.headers['content-type'], 'application/json; charset=utf-8') + t.assert.deepStrictEqual(response.json(), { hello: 'world' }) }) }) ``` @@ -243,47 +247,81 @@ after initializing routes and plugins with `fastify.ready()`. Uses **app.js** from the previous example. -**test-listen.js** (testing with -[`Request`](https://www.npmjs.com/package/request)) +**test-listen.js** (testing with [`undici`](https://www.npmjs.com/package/undici)) ```js -const tap = require('tap') -const request = require('request') +const { test } = require('node:test') +const { Client } = require('undici') const buildFastify = require('./app') -tap.test('GET `/` route', t => { - t.plan(5) +test('should work with undici', async t => { + t.plan(2) const fastify = buildFastify() - t.teardown(() => fastify.close()) + await fastify.listen() - fastify.listen({ port: 0 }, (err) => { - t.error(err) + const client = new Client( + 'http://localhost:' + fastify.server.address().port, { + keepAliveTimeout: 10, + keepAliveMaxTimeout: 10 + } + ) - request({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-type'], 'application/json; charset=utf-8') - t.same(JSON.parse(body), { hello: 'world' }) - }) + t.after(() => { + fastify.close() + client.close() }) + + const response = await client.request({ method: 'GET', path: '/' }) + + t.assert.strictEqual(await response.body.text(), '{"hello":"world"}') + t.assert.strictEqual(response.statusCode, 200) +}) +``` + +Alternatively, starting with Node.js 18, +[`fetch`](https://nodejs.org/docs/latest-v18.x/api/globals.html#fetch) +may be used without requiring any extra dependencies: + +**test-listen.js** +```js +const { test } = require('node:test') +const buildFastify = require('./app') + +test('should work with fetch', async t => { + t.plan(3) + + const fastify = buildFastify() + + t.after(() => fastify.close()) + + await fastify.listen() + + const response = await fetch( + 'http://localhost:' + fastify.server.address().port + ) + + t.assert.strictEqual(response.status, 200) + t.assert.strictEqual( + response.headers.get('content-type'), + 'application/json; charset=utf-8' + ) + const jsonResult = await response.json() + t.assert.strictEqual(jsonResult.hello, 'world') }) ``` **test-ready.js** (testing with [`SuperTest`](https://www.npmjs.com/package/supertest)) ```js -const tap = require('tap') +const { test } = require('node:test') const supertest = require('supertest') const buildFastify = require('./app') -tap.test('GET `/` route', async (t) => { +test('GET `/` route', async (t) => { const fastify = buildFastify() - t.teardown(() => fastify.close()) + t.after(() => fastify.close()) await fastify.ready() @@ -291,24 +329,153 @@ tap.test('GET `/` route', async (t) => { .get('/') .expect(200) .expect('Content-Type', 'application/json; charset=utf-8') - t.same(response.body, { hello: 'world' }) + t.assert.deepStrictEqual(response.body, { hello: 'world' }) }) ``` -### How to inspect tap tests +### How to inspect node tests 1. Isolate your test by passing the `{only: true}` option ```javascript test('should ...', {only: true}, t => ...) ``` -2. Run `tap` using `npx` +2. Run `node --test` ```bash -> npx tap -O -T --node-arg=--inspect-brk test/ +> node --test --test-only --inspect-brk test/ ``` -- `-O` specifies to run tests with the `only` option enabled -- `-T` specifies not to timeout (while you're debugging) -- `--node-arg=--inspect-brk` will launch the node debugger +- `--test-only` specifies to run tests with the `only` option enabled +- `--inspect-brk` will launch the node debugger 3. In VS Code, create and launch a `Node.js: Attach` debug configuration. No modification should be necessary. Now you should be able to step through your test file (and the rest of `Fastify`) in your code editor. + + + +## Plugins +Let's `cd` into a fresh directory called 'testing-plugin-example' and type +`npm init -y` in our terminal. + +Run `npm i fastify fastify-plugin` + +**plugin/myFirstPlugin.js**: + +```js +const fP = require("fastify-plugin") + +async function myPlugin(fastify, options) { + fastify.decorateRequest("helloRequest", "Hello World") + fastify.decorate("helloInstance", "Hello Fastify Instance") +} + +module.exports = fP(myPlugin) +``` + +A basic example of a Plugin. See [Plugin Guide](./Plugins-Guide.md) + +**test/myFirstPlugin.test.js**: + +```js +const Fastify = require("fastify"); +const { test } = require("node:test"); +const myPlugin = require("../plugin/myFirstPlugin"); + +test("Test the Plugin Route", async t => { + // Create a mock fastify application to test the plugin + const fastify = Fastify() + + fastify.register(myPlugin) + + // Add an endpoint of your choice + fastify.get("/", async (request, reply) => { + return ({ message: request.helloRequest }) + }) + + // Use fastify.inject to fake a HTTP Request + const fastifyResponse = await fastify.inject({ + method: "GET", + url: "/" + }) + + console.log('status code: ', fastifyResponse.statusCode) + console.log('body: ', fastifyResponse.body) +}) +``` +Learn more about [```fastify.inject()```](#benefits-of-using-fastifyinject). +Run the test file in your terminal `node test/myFirstPlugin.test.js` + +```sh +status code: 200 +body: {"message":"Hello World"} +``` + +Now we can replace our `console.log` calls with actual tests! + +In your `package.json` change the "test" script to: + +`"test": "node --test --watch"` + +Create the test for the endpoint. + +**test/myFirstPlugin.test.js**: + +```js +const Fastify = require("fastify"); +const { test } = require("node:test"); +const myPlugin = require("../plugin/myFirstPlugin"); + +test("Test the Plugin Route", async t => { + // Specifies the number of test + t.plan(2) + + const fastify = Fastify() + + fastify.register(myPlugin) + + fastify.get("/", async (request, reply) => { + return ({ message: request.helloRequest }) + }) + + const fastifyResponse = await fastify.inject({ + method: "GET", + url: "/" + }) + + t.assert.strictEqual(fastifyResponse.statusCode, 200) + t.assert.deepStrictEqual(JSON.parse(fastifyResponse.body), { message: "Hello World" }) +}) +``` + +Finally, run `npm test` in the terminal and see your test results! + +Test the ```.decorate()``` and ```.decorateRequest()```. + +**test/myFirstPlugin.test.js**: + +```js +const Fastify = require("fastify"); +const { test }= require("node:test"); +const myPlugin = require("../plugin/myFirstPlugin"); + +test("Test the Plugin Route", async t => { + t.plan(5) + const fastify = Fastify() + + fastify.register(myPlugin) + + fastify.get("/", async (request, reply) => { + // Testing the fastify decorators + t.assert.ifError(request.helloRequest) + t.assert.ok(request.helloRequest, "Hello World") + t.assert.ok(fastify.helloInstance, "Hello Fastify Instance") + return ({ message: request.helloRequest }) + }) + + const fastifyResponse = await fastify.inject({ + method: "GET", + url: "/" + }) + t.assert.strictEqual(fastifyResponse.statusCode, 200) + t.assert.deepStrictEqual(JSON.parse(fastifyResponse.body), { message: "Hello World" }) +}) +``` diff --git a/docs/Guides/Write-Plugin.md b/docs/Guides/Write-Plugin.md index 5d161b478fa..78e1225844b 100644 --- a/docs/Guides/Write-Plugin.md +++ b/docs/Guides/Write-Plugin.md @@ -1,4 +1,4 @@ -

Fastify

+

Fastify

# How to write a good plugin First, thank you for deciding to write a plugin for Fastify. Fastify is a @@ -14,7 +14,7 @@ suggestion"](https://github.com/fastify/fastify/issues?q=is%3Aissue+is%3Aopen+la in our issue tracker!* ## Code -Fastify uses different techniques to optimize its code, many of them are +Fastify uses different techniques to optimize its code, many of which are documented in our Guides. We highly recommend you read [the hitchhiker's guide to plugins](./Plugins-Guide.md) to discover all the APIs you can use to build your plugin and learn how to use them. @@ -53,16 +53,17 @@ Always put an example file in your repository. Examples are very helpful for users and give a very fast way to test your plugin. Your users will be grateful. ## Test -It is extremely important that a plugin is thoroughly tested to verify that is -working properly. +A plugin **must** be thoroughly tested to verify that is working properly. A plugin without tests will not be accepted to the ecosystem list. A lack of tests does not inspire trust nor guarantee that the code will continue to work among different versions of its dependencies. -We do not enforce any testing library. We use [`tap`](https://www.node-tap.org/) +We do not enforce any testing library. We use [`node:test`](https://nodejs.org/api/test.html) since it offers out-of-the-box parallel testing and code coverage, but it is up to you to choose your library of preference. +We highly recommend you read the [Plugin Testing](./Testing.md#plugins) to +learn about how to test your plugins. ## Code Linter It is not mandatory, but we highly recommend you use a code linter in your @@ -80,7 +81,7 @@ to show that the plugin works as intended. Both Actions](https://github.com/features/actions) are free for open source projects and easy to set up. -In addition, you can enable services like [Dependabot](https://dependabot.com/), +In addition, you can enable services like [Dependabot](https://github.com/dependabot), which will help you keep your dependencies up to date and discover if a new release of Fastify has some issues with your plugin. diff --git a/docs/Guides/Write-Type-Provider.md b/docs/Guides/Write-Type-Provider.md new file mode 100644 index 00000000000..0f0d750fdf9 --- /dev/null +++ b/docs/Guides/Write-Type-Provider.md @@ -0,0 +1,34 @@ +

Fastify

+ +## How to write your own type provider + +Things to keep in mind when implementing a custom [type provider](../Reference/Type-Providers.md): + +### Type Contravariance + +Whereas exhaustive type narrowing checks normally rely on `never` to represent +an unreachable state, reduction in type provider interfaces should only be done +up to `unknown`. + +The reasoning is that certain methods of `FastifyInstance` are +contravariant on `TypeProvider`, which can lead to TypeScript surfacing +assignability issues unless the custom type provider interface is +substitutable with `FastifyTypeProviderDefault`. + +For example, `FastifyTypeProviderDefault` will not be assignable to the following: +```ts +export interface NotSubstitutableTypeProvider extends FastifyTypeProvider { + // bad, nothing is assignable to `never` (except for itself) + validator: this['schema'] extends /** custom check here**/ ? /** narrowed type here **/ : never; + serializer: this['schema'] extends /** custom check here**/ ? /** narrowed type here **/ : never; +} +``` + +Unless changed to: +```ts +export interface SubstitutableTypeProvider extends FastifyTypeProvider { + // good, anything can be assigned to `unknown` + validator: this['schema'] extends /** custom check here**/ ? /** narrowed type here **/ : unknown; + serializer: this['schema'] extends /** custom check here**/ ? /** narrowed type here **/ : unknown; +} +``` diff --git a/docs/Reference/ContentTypeParser.md b/docs/Reference/ContentTypeParser.md index 133853b88c0..8f92873da43 100644 --- a/docs/Reference/ContentTypeParser.md +++ b/docs/Reference/ContentTypeParser.md @@ -1,31 +1,41 @@

Fastify

## `Content-Type` Parser -Natively, Fastify only supports `'application/json'` and `'text/plain'` content -types. If the content type is not one of these, an -`FST_ERR_CTP_INVALID_MEDIA_TYPE` error will be thrown. +Fastify natively supports `'application/json'` and `'text/plain'` content types +with a default charset of `utf-8`. These default parsers can be changed or +removed. -The default charset is `utf-8`. If you need to support different content types, -you can use the `addContentTypeParser` API. *The default JSON and/or plain text -parser can be changed or removed.* +Unsupported content types will throw an `FST_ERR_CTP_INVALID_MEDIA_TYPE` error. -*Note: If you decide to specify your own content type with the `Content-Type` -header, UTF-8 will not be the default. Be sure to include UTF-8 like this -`text/html; charset=utf-8`.* +To support other content types, use the `addContentTypeParser` API or an +existing [plugin](https://fastify.dev/ecosystem/). -As with the other APIs, `addContentTypeParser` is encapsulated in the scope in -which it is declared. This means that if you declare it in the root scope it -will be available everywhere, while if you declare it inside a plugin it will be -available only in that scope and its children. +As with other APIs, `addContentTypeParser` is encapsulated in the scope in which +it is declared. If declared in the root scope, it is available everywhere; if +declared in a plugin, it is available only in that scope and its children. Fastify automatically adds the parsed request payload to the [Fastify -request](./Request.md) object which you can access with `request.body`. - -Note that for `GET` and `HEAD` requests the payload is never parsed. For -`OPTIONS` and `DELETE` requests the payload is only parsed if the content type -is given in the content-type header. If it is not given, the -[catch-all](#catch-all) parser is not executed as with `POST`, `PUT` and -`PATCH`, but the payload is simply not parsed. +request](./Request.md) object, accessible via `request.body`. + +> **Important:** When using a body schema with the +> [`content`](./Validation-and-Serialization.md#body-content-type-validation) +> property to validate per content type, only content types listed in the schema +> will be validated. If you add a custom content type parser but do not include +> its content type in the body schema's `content` property, the incoming data +> will be parsed but **not validated**. + +Note that for `GET` and `HEAD` requests, the payload is never parsed. For +`OPTIONS` and `DELETE` requests, the payload is parsed only if a valid +`content-type` header is provided. Unlike `POST`, `PUT`, and `PATCH`, the +[catch-all](#catch-all) parser is not executed, and the payload is simply not +parsed. + +> ⚠ Warning: +> When using regular expressions to detect `Content-Type`, it is important to +> ensure proper detection. For example, to match `application/*`, use +> `/^application\/([\w-]+);?/` to match the +> [essence MIME type](https://mimesniff.spec.whatwg.org/#mime-type-miscellaneous) +> only. ### Usage ```js @@ -44,13 +54,13 @@ fastify.addContentTypeParser(['text/xml', 'application/xml'], function (request, // Async is also supported in Node versions >= 8.0.0 fastify.addContentTypeParser('application/jsoff', async function (request, payload) { - var res = await jsoffParserAsync(payload) + const res = await jsoffParserAsync(payload) return res }) // Handle all content types that matches RegExp -fastify.addContentTypeParser(/^image\/.*/, function (request, payload, done) { +fastify.addContentTypeParser(/^image\/([\w-]+);?/, function (request, payload, done) { imageParser(payload, function (err, body) { done(err, body) }) @@ -61,11 +71,10 @@ fastify.addContentTypeParser('text/json', { parseAs: 'string' }, fastify.getDefa ``` Fastify first tries to match a content-type parser with a `string` value before -trying to find a matching `RegExp`. If you provide overlapping content types, -Fastify tries to find a matching content type by starting with the last one -passed and ending with the first one. So if you want to specify a general -content type more precisely, first specify the general content type and then the -more specific one, like in the example below. +trying to find a matching `RegExp`. For overlapping content types, it starts +with the last one configured and ends with the first (last in, first out). +To specify a general content type more precisely, first specify the general +type, then the specific one, as shown below. ```js // Here only the second content type parser is called because its value also matches the first one @@ -78,14 +87,34 @@ fastify.addContentTypeParser('application/vnd.custom', (request, body, done) => fastify.addContentTypeParser('application/vnd.custom+xml', (request, body, done) => {} ) ``` -Besides the `addContentTypeParser` API there are further APIs that can be used. -These are `hasContentTypeParser`, `removeContentTypeParser` and -`removeAllContentTypeParsers`. +### Using addContentTypeParser with fastify.register +When using `addContentTypeParser` with `fastify.register`, avoid `await` +when registering routes. Using `await` makes route registration asynchronous, +potentially registering routes before `addContentTypeParser` is set. + +#### Correct Usage +```js +const fastify = require('fastify')(); + + +fastify.register((fastify, opts) => { + fastify.addContentTypeParser('application/json', function (request, payload, done) { + jsonParser(payload, function (err, body) { + done(err, body) + }) + }) + + fastify.get('/hello', async (req, res) => {}); +}); +``` + +In addition to `addContentTypeParser`, the `hasContentTypeParser`, +`removeContentTypeParser`, and `removeAllContentTypeParsers` APIs are available. #### hasContentTypeParser -You can use the `hasContentTypeParser` API to find if a specific content type -parser already exists. +Use the `hasContentTypeParser` API to check if a specific content type parser +exists. ```js if (!fastify.hasContentTypeParser('application/jsoff')){ @@ -97,11 +126,10 @@ if (!fastify.hasContentTypeParser('application/jsoff')){ } ``` -======= #### removeContentTypeParser -With `removeContentTypeParser` a single or an array of content types can be -removed. The method supports `string` and `RegExp` content types. +`removeContentTypeParser` can remove a single content type or an array of +content types, supporting both `string` and `RegExp`. ```js fastify.addContentTypeParser('text/xml', function (request, payload, done) { @@ -115,16 +143,11 @@ fastify.removeContentTypeParser(['application/json', 'text/plain']) ``` #### removeAllContentTypeParsers - -In the example from just above, it is noticeable that we need to specify each -content type that we want to remove. To solve this problem Fastify provides the -`removeAllContentTypeParsers` API. This can be used to remove all currently -existing content type parsers. In the example below we achieve the same as in -the example above except that we do not need to specify each content type to -delete. Just like `removeContentTypeParser`, this API supports encapsulation. -The API is especially useful if you want to register a [catch-all content type -parser](#catch-all) that should be executed for every content type and the -built-in parsers should be ignored as well. +The `removeAllContentTypeParsers` API removes all existing content type parsers +eliminating the need to specify each one individually. This API supports +encapsulation and is useful for registering a +[catch-all content type parser](#catch-all) that should be executed for every +content type, ignoring built-in parsers. ```js fastify.removeAllContentTypeParsers() @@ -136,22 +159,21 @@ fastify.addContentTypeParser('text/xml', function (request, payload, done) { }) ``` -**Notice**: The old syntaxes `function(req, done)` and `async function(req)` for -the parser are still supported but they are deprecated. +> ℹ️ Note: +> `function(req, done)` and `async function(req)` are +> still supported but deprecated. #### Body Parser -You can parse the body of a request in two ways. The first one is shown above: -you add a custom content type parser and handle the request stream. In the -second one, you should pass a `parseAs` option to the `addContentTypeParser` -API, where you declare how you want to get the body. It could be of type -`'string'` or `'buffer'`. If you use the `parseAs` option, Fastify will -internally handle the stream and perform some checks, such as the [maximum -size](./Server.md#factory-body-limit) of the body and the content length. If the -limit is exceeded the custom parser will not be invoked. +The request body can be parsed in two ways. First, add a custom content type +parser and handle the request stream. Or second, use the `parseAs` option in the +`addContentTypeParser` API, specifying `'string'` or `'buffer'`. Fastify will +handle the stream, check the [maximum size](./Server.md#factory-body-limit) of +the body, and the content length. If the limit is exceeded, the custom parser +will not be invoked. ```js fastify.addContentTypeParser('application/json', { parseAs: 'string' }, function (req, body, done) { try { - var json = JSON.parse(body) + const json = JSON.parse(body) done(null, json) } catch (err) { err.statusCode = 400 @@ -165,30 +187,27 @@ See for an example. ##### Custom Parser Options -+ `parseAs` (string): Either `'string'` or `'buffer'` to designate how the - incoming data should be collected. Default: `'buffer'`. ++ `parseAs` (string): `'string'` or `'buffer'` to designate how the incoming + data should be collected. Default: `'buffer'`. + `bodyLimit` (number): The maximum payload size, in bytes, that the custom parser will accept. Defaults to the global body limit passed to the [`Fastify factory function`](./Server.md#bodylimit). #### Catch-All -There are some cases where you need to catch all requests regardless of their -content type. With Fastify, you can just use the `'*'` content type. +To catch all requests regardless of content type, use the `'*'` content type: ```js fastify.addContentTypeParser('*', function (request, payload, done) { - var data = '' + let data = '' payload.on('data', chunk => { data += chunk }) payload.on('end', () => { done(null, data) }) }) ``` +All requests without a corresponding content type parser will be handled by +this function. -Using this, all requests that do not have a corresponding content type parser -will be handled by the specified function. - -This is also useful for piping the request stream. You can define a content -parser like: +This is also useful for piping the request stream. Define a content parser like: ```js fastify.addContentTypeParser('*', function (request, payload, done) { @@ -196,7 +215,7 @@ fastify.addContentTypeParser('*', function (request, payload, done) { }) ``` -and then access the core HTTP request directly for piping it where you want: +And then access the core HTTP request directly for piping: ```js app.post('/hello', (request, reply) => { @@ -224,19 +243,18 @@ fastify.route({ }) ``` -For piping file uploads you may want to check out [this -plugin](https://github.com/fastify/fastify-multipart). +For piping file uploads, check out +[`@fastify/multipart`](https://github.com/fastify/fastify-multipart). -If you want the content type parser to be executed on all content types and not -only on those that don't have a specific one, you should call the -`removeAllContentTypeParsers` method first. +To execute the content type parser on all content types, call +`removeAllContentTypeParsers` first. ```js // Without this call, the request body with the content type application/json would be processed by the built-in JSON parser fastify.removeAllContentTypeParsers() fastify.addContentTypeParser('*', function (request, payload, done) { - var data = '' + const data = '' payload.on('data', chunk => { data += chunk }) payload.on('end', () => { done(null, data) diff --git a/docs/Reference/Decorators.md b/docs/Reference/Decorators.md index 9a051e6305b..5d57691c066 100644 --- a/docs/Reference/Decorators.md +++ b/docs/Reference/Decorators.md @@ -2,16 +2,15 @@ ## Decorators -The decorators API allows customization of the core Fastify objects, such as the -server instance itself and any request and reply objects used during the HTTP -request lifecycle. The decorators API can be used to attach any type of property -to the core objects, e.g. functions, plain objects, or native types. +The decorators API customizes core Fastify objects, such as the server instance +and any request and reply objects used during the HTTP request lifecycle. It +can attach any type of property to core objects, e.g., functions, plain +objects, or native types. -This API is *synchronous*. Attempting to define a decoration asynchronously -could result in the Fastify instance booting before the decoration completes its -initialization. To avoid this issue, and register an asynchronous decoration, -the `register` API, in combination with `fastify-plugin`, must be used instead. -To learn more, see the [Plugins](./Plugins.md) documentation. +This API is *synchronous*. Defining a decoration asynchronously could result in +the Fastify instance booting before the decoration completes. To register an +asynchronous decoration, use the `register` API with `fastify-plugin`. See the +[Plugins](./Plugins.md) documentation for more details. Decorating core objects with this API allows the underlying JavaScript engine to optimize the handling of server, request, and reply objects. This is @@ -35,9 +34,9 @@ fastify.get('/', function (req, reply) { }) ``` -Since the above example mutates the request object after it has already been -instantiated, the JavaScript engine must deoptimize access to the request -object. By using the decoration API this deoptimization is avoided: +The above example mutates the request object after instantiation, causing the +JavaScript engine to deoptimize access. Using the decoration API avoids this +deoptimization: ```js // Decorate request with a 'user' property @@ -54,17 +53,13 @@ fastify.get('/', (req, reply) => { }) ``` -Note that it is important to keep the initial shape of a decorated field as -close as possible to the value intended to be set dynamically in the future. -Initialize a decorator as a `''` if the intended value is a string, and as -`null` if it will be an object or a function. - -Remember this example works only with value types as reference types will be -shared amongst all requests. See [decorateRequest](#decorate-request). - -See [JavaScript engine fundamentals: Shapes and Inline -Caches](https://mathiasbynens.be/notes/shapes-ics) for more information on this -topic. +Keep the initial shape of a decorated field close to its future dynamic value. +Initialize a decorator as `''` for strings and `null` for objects or functions. +This works only with value types; reference types will throw an error during +Fastify startup. See [decorateRequest](#decorate-request) and +[JavaScript engine fundamentals: Shapes +and Inline Caches](https://mathiasbynens.be/notes/shapes-ics) +for more information. ### Usage @@ -72,8 +67,7 @@ topic. #### `decorate(name, value, [dependencies])` -This method is used to customize the Fastify [server](./Server.md) -instance. +This method customizes the Fastify [server](./Server.md) instance. For example, to attach a new method to the server instance: @@ -83,7 +77,7 @@ fastify.decorate('utility', function () { }) ``` -As mentioned above, non-function values can be attached: +Non-function values can also be attached to the server instance: ```js fastify.decorate('conf', { @@ -101,37 +95,71 @@ console.log(fastify.conf.db) ``` The decorated [Fastify server](./Server.md) is bound to `this` in -route [route](./Routes.md) handlers: +[route](./Routes.md) handlers: ```js fastify.decorate('db', new DbConnection()) fastify.get('/', async function (request, reply) { - reply({hello: await this.db.query('world')}) + // using return + return { hello: await this.db.query('world') } + + // or + // using reply.send() + reply.send({ hello: await this.db.query('world') }) + await reply }) ``` The `dependencies` parameter is an optional list of decorators that the -decorator being defined relies upon. This list is simply a list of string names -of other decorators. In the following example, the "utility" decorator depends -upon "greet" and "log" decorators: +decorator being defined relies upon. This list contains the names of other +decorators. In the following example, the "utility" decorator depends on the +"greet" and "hi" decorators: ```js -fastify.decorate('utility', fn, ['greet', 'log']) +async function greetDecorator (fastify, opts) { + fastify.decorate('greet', () => { + return 'greet message' + }) +} + +async function hiDecorator (fastify, opts) { + fastify.decorate('hi', () => { + return 'hi message' + }) +} + +async function utilityDecorator (fastify, opts) { + fastify.decorate('utility', () => { + return `${fastify.greet()} | ${fastify.hi()}` + }) +} + +fastify.register(fastifyPlugin(greetDecorator, { name: 'greet' })) +fastify.register(fastifyPlugin(hiDecorator, { name: 'hi' })) +fastify.register(fastifyPlugin(utilityDecorator, { dependencies: ['greet', 'hi'] })) + +fastify.get('/', function (req, reply) { + // Response: {"hello":"greet message | hi message"} + reply.send({ hello: fastify.utility() }) +}) + +fastify.listen({ port: 3000 }, (err, address) => { + if (err) throw err +}) ``` -Note: using an arrow function will break the binding of `this` to the -`FastifyInstance`. +Using an arrow function breaks the binding of `this` to +the `FastifyInstance`. -If a dependency is not satisfied, the `decorate` method will throw an exception. -The dependency check is performed before the server instance is booted. Thus, it -cannot occur during runtime. +If a dependency is not satisfied, the `decorate` method throws an exception. +The dependency check occurs before the server instance boots, not during +runtime. #### `decorateReply(name, value, [dependencies])` -As the name suggests, this API is used to add new methods/properties to the core -`Reply` object: +This API adds new methods/properties to the core `Reply` object: ```js fastify.decorateReply('utility', function () { @@ -139,28 +167,29 @@ fastify.decorateReply('utility', function () { }) ``` -Note: using an arrow function will break the binding of `this` to the Fastify +Using an arrow function will break the binding of `this` to the Fastify `Reply` instance. -Note: using `decorateReply` will emit a warning if used with a reference type: +Using `decorateReply` will throw and error if used with a reference type: ```js // Don't do this fastify.decorateReply('foo', { bar: 'fizz'}) ``` -In this example, the reference of the object is shared with all the requests: +In this example, the object reference would be shared with all requests, and **any mutation will impact all requests, potentially creating security -vulnerabilities or memory leaks**. To achieve proper encapsulation across -requests configure a new value for each incoming request in the [`'onRequest'` -hook](./Hooks.md#onrequest). Example: +vulnerabilities or memory leaks**. Fastify blocks this. + +To achieve proper encapsulation across requests configure a new value for each +incoming request in the [`'onRequest'` hook](./Hooks.md#onrequest). ```js const fp = require('fastify-plugin') async function myPlugin (app) { - app.decorateRequest('foo', null) + app.decorateReply('foo') app.addHook('onRequest', async (req, reply) => { - req.foo = { bar: 42 } + reply.foo = { bar: 42 } }) } @@ -172,8 +201,8 @@ See [`decorate`](#decorate) for information about the `dependencies` parameter. #### `decorateRequest(name, value, [dependencies])` -As above with [`decorateReply`](#decorate-reply), this API is used add new -methods/properties to the core `Request` object: +As with [`decorateReply`](#decorate-reply), this API adds new methods/properties +to the core `Request` object: ```js fastify.decorateRequest('utility', function () { @@ -181,27 +210,29 @@ fastify.decorateRequest('utility', function () { }) ``` -Note: using an arrow function will break the binding of `this` to the Fastify +Using an arrow function will break the binding of `this` to the Fastify `Request` instance. -Note: using `decorateRequest` will emit a warning if used with a reference type: +Using `decorateRequest` will emit an error if used with a reference type: ```js // Don't do this fastify.decorateRequest('foo', { bar: 'fizz'}) ``` -In this example, the reference of the object is shared with all the requests: +In this example, the object reference would be shared with all requests, and **any mutation will impact all requests, potentially creating security -vulnerabilities or memory leaks**. +vulnerabilities or memory leaks**. Fastify blocks this. To achieve proper encapsulation across requests configure a new value for each -incoming request in the [`'onRequest'` hook](./Hooks.md#onrequest). Example: +incoming request in the [`'onRequest'` hook](./Hooks.md#onrequest). + +Example: ```js const fp = require('fastify-plugin') async function myPlugin (app) { - app.decorateRequest('foo', null) + app.decorateRequest('foo') app.addHook('onRequest', async (req, reply) => { req.foo = { bar: 42 } }) @@ -210,6 +241,28 @@ async function myPlugin (app) { module.exports = fp(myPlugin) ``` +The hook solution is more flexible and allows for more complex initialization +because more logic can be added to the `onRequest` hook. + +Another approach is to use the getter/setter pattern, but it requires 2 decorators: + +```js +fastify.decorateRequest('my_decorator_holder') // define the holder +fastify.decorateRequest('user', { + getter () { + this.my_decorator_holder ??= {} // initialize the holder + return this.my_decorator_holder + } +}) + +fastify.get('/', async function (req, reply) { + req.user.access = 'granted' + // other code +}) +``` + +This ensures that the `user` property is always unique for each request. + See [`decorate`](#decorate) for information about the `dependencies` parameter. #### `hasDecorator(name)` @@ -244,9 +297,7 @@ fastify.hasReplyDecorator('utility') Defining a decorator (using `decorate`, `decorateRequest`, or `decorateReply`) with the same name more than once in the same **encapsulated** context will -throw an exception. - -As an example, the following will throw: +throw an exception. For example, the following will throw: ```js const server = require('fastify')() @@ -297,9 +348,9 @@ server.listen({ port: 3000 }) ### Getters and Setters -Decorators accept special "getter/setter" objects. These objects have functions -named `getter` and `setter` (though the `setter` function is optional). This -allows defining properties via decorators, for example: +Decorators accept special "getter/setter" objects with `getter` and optional +`setter` functions. This allows defining properties via decorators, +for example: ```js fastify.decorate('foo', { @@ -314,3 +365,72 @@ Will define the `foo` property on the Fastify instance: ```js console.log(fastify.foo) // 'a getter' ``` + +#### `getDecorator(name)` + + +Used to retrieve an existing decorator from the Fastify instance, `Request`, +or `Reply`. +If the decorator is not defined, an `FST_ERR_DEC_UNDECLARED` error is thrown. + +```js +// Get a decorator from the Fastify instance +const utility = fastify.getDecorator('utility') + +// Get a decorator from the request object +const user = request.getDecorator('user') + +// Get a decorator from the reply object +const helper = reply.getDecorator('helper') +``` + +The `getDecorator` method is useful for dependency validation - it can be used to +check for required decorators at registration time. If any are missing, it fails +at boot, ensuring dependencies are available during the request lifecycle. + +```js +fastify.register(async function (fastify) { + // Verify the decorator exists before using it + const usersRepository = fastify.getDecorator('usersRepository') + + fastify.get('/users', async function (request, reply) { + return usersRepository.findAll() + }) +}) +``` + +> ℹ️ Note: +> For TypeScript users, `getDecorator` supports generic type parameters. +> See the [TypeScript documentation](/docs/Reference/TypeScript.md) for +> advanced typing examples. + +#### `setDecorator(name, value)` + + +Used to safely update the value of a `Request` decorator. +If the decorator does not exist, a `FST_ERR_DEC_UNDECLARED` error is thrown. + +```js +fastify.decorateRequest('user', null) + +fastify.addHook('preHandler', async (req, reply) => { + // Safely set the decorator value + req.setDecorator('user', 'Bob Dylan') +}) +``` + +The `setDecorator` method provides runtime safety by ensuring the decorator exists +before setting its value, preventing errors from typos in decorator names. + +```js +fastify.decorateRequest('account', null) +fastify.addHook('preHandler', async (req, reply) => { + // This will throw FST_ERR_DEC_UNDECLARED due to typo in decorator name + req.setDecorator('acount', { id: 123 }) +}) +``` + +> ℹ️ Note: +> For TypeScript users, see the +> [TypeScript documentation](/docs/Reference/TypeScript.md) for advanced +> typing examples using `setDecorator`. diff --git a/docs/Reference/Encapsulation.md b/docs/Reference/Encapsulation.md index aa26ef170aa..cb02ffb0126 100644 --- a/docs/Reference/Encapsulation.md +++ b/docs/Reference/Encapsulation.md @@ -3,21 +3,20 @@ ## Encapsulation -A fundamental feature of Fastify is the "encapsulation context." The -encapsulation context governs which [decorators](./Decorators.md), registered -[hooks](./Hooks.md), and [plugins](./Plugins.md) are available to -[routes](./Routes.md). A visual representation of the encapsulation context -is shown in the following figure: +A fundamental feature of Fastify is the "encapsulation context." It governs +which [decorators](./Decorators.md), registered [hooks](./Hooks.md), and +[plugins](./Plugins.md) are available to [routes](./Routes.md). A visual +representation of the encapsulation context is shown in the following figure: ![Figure 1](../resources/encapsulation_context.svg) -In the above figure, there are several entities: +In the figure above, there are several entities: 1. The _root context_ 2. Three _root plugins_ -3. Two _child contexts_ where each _child context_ has +3. Two _child contexts_, each with: * Two _child plugins_ - * One _grandchild context_ where each _grandchild context_ has + * One _grandchild context_, each with: - Three _child plugins_ Every _child context_ and _grandchild context_ has access to the _root plugins_. @@ -26,15 +25,18 @@ _child plugins_ registered within the containing _child context_, but the containing _child context_ **does not** have access to the _child plugins_ registered within its _grandchild context_. -Given that everything in Fastify is a [plugin](./Plugins.md), except for the +Given that everything in Fastify is a [plugin](./Plugins.md) except for the _root context_, every "context" and "plugin" in this example is a plugin -that can consist of decorators, hooks, plugins, and routes. Thus, to put -this example into concrete terms, consider a basic scenario of a REST API -server that has three routes: the first route (`/one`) requires authentication, -the second route (`/two`) does not, and the third route (`/three`) has -access to the same context as the second route. Using -[@fastify/bearer-auth][bearer] to provide the authentication, the code for this -example is as follows: +that can consist of decorators, hooks, plugins, and routes. As plugins, they +must still signal completion either by returning a Promise (e.g., using `async` +functions) or by calling the `done` function if using the callback style. + +To put this +example into concrete terms, consider a basic scenario of a REST API server +with three routes: the first route (`/one`) requires authentication, the +second route (`/two`) does not, and the third route (`/three`) has access to +the same context as the second route. Using [@fastify/bearer-auth][bearer] to +provide authentication, the code for this example is as follows: ```js 'use strict' @@ -52,9 +54,9 @@ fastify.register(async function authenticatedContext (childServer) { handler (request, response) { response.send({ answer: request.answer, - // request.foo will be undefined as it's only defined in publicContext + // request.foo will be undefined as it is only defined in publicContext foo: request.foo, - // request.bar will be undefined as it's only defined in grandchildContext + // request.bar will be undefined as it is only defined in grandchildContext bar: request.bar }) } @@ -71,7 +73,7 @@ fastify.register(async function publicContext (childServer) { response.send({ answer: request.answer, foo: request.foo, - // request.bar will be undefined as it's only defined in grandchildContext + // request.bar will be undefined as it is only defined in grandchildContext bar: request.bar }) } @@ -97,16 +99,16 @@ fastify.register(async function publicContext (childServer) { fastify.listen({ port: 8000 }) ``` -The above server example shows all of the encapsulation concepts outlined in the +The server example above demonstrates the encapsulation concepts from the original diagram: 1. Each _child context_ (`authenticatedContext`, `publicContext`, and -`grandchildContext`) has access to the `answer` request decorator defined in -the _root context_. + `grandchildContext`) has access to the `answer` request decorator defined in + the _root context_. 2. Only the `authenticatedContext` has access to the `@fastify/bearer-auth` -plugin. + plugin. 3. Both the `publicContext` and `grandchildContext` have access to the `foo` -request decorator. + request decorator. 4. Only the `grandchildContext` has access to the `bar` request decorator. To see this, start the server and issue requests: @@ -125,16 +127,13 @@ To see this, start the server and issue requests: ## Sharing Between Contexts -Notice that each context in the prior example inherits _only_ from the parent -contexts. Parent contexts cannot access any entities within their descendent -contexts. This default is occasionally not desired. In such cases, the -encapsulation context can be broken through the usage of -[fastify-plugin][fastify-plugin] such that anything registered in a descendent -context is available to the containing parent context. +Each context in the prior example inherits _only_ from its parent contexts. Parent +contexts cannot access entities within their descendant contexts. If needed, +encapsulation can be broken using [fastify-plugin][fastify-plugin], making +anything registered in a descendant context available to the parent context. -Assuming the `publicContext` needs access to the `bar` decorator defined -within the `grandchildContext` in the previous example, the code can be -rewritten as: +To allow `publicContext` access to the `bar` decorator in `grandchildContext`, +rewrite the code as follows: ```js 'use strict' diff --git a/docs/Reference/Errors.md b/docs/Reference/Errors.md index ec969cd63c8..f1e66374752 100644 --- a/docs/Reference/Errors.md +++ b/docs/Reference/Errors.md @@ -3,12 +3,112 @@ ## Errors +**Table of contents** +- [Errors](#errors) + - [Error Handling In Node.js](#error-handling-in-nodejs) + - [Uncaught Errors](#uncaught-errors) + - [Catching Errors In Promises](#catching-errors-in-promises) + - [Errors In Fastify](#errors-in-fastify) + - [Errors In Input Data](#errors-in-input-data) + - [Catching Uncaught Errors In Fastify](#catching-uncaught-errors-in-fastify) + - [Errors In Fastify Lifecycle Hooks And A Custom Error Handler](#errors-in-fastify-lifecycle-hooks-and-a-custom-error-handler) + - [Fastify Error Codes](#fastify-error-codes) + - [FST_ERR_NOT_FOUND](#fst_err_not_found) + - [FST_ERR_OPTIONS_NOT_OBJ](#fst_err_options_not_obj) + - [FST_ERR_QSP_NOT_FN](#fst_err_qsp_not_fn) + - [FST_ERR_SCHEMA_CONTROLLER_BUCKET_OPT_NOT_FN](#fst_err_schema_controller_bucket_opt_not_fn) + - [FST_ERR_SCHEMA_ERROR_FORMATTER_NOT_FN](#fst_err_schema_error_formatter_not_fn) + - [FST_ERR_AJV_CUSTOM_OPTIONS_OPT_NOT_OBJ](#fst_err_ajv_custom_options_opt_not_obj) + - [FST_ERR_AJV_CUSTOM_OPTIONS_OPT_NOT_ARR](#fst_err_ajv_custom_options_opt_not_arr) + - [FST_ERR_CTP_ALREADY_PRESENT](#fst_err_ctp_already_present) + - [FST_ERR_CTP_INVALID_TYPE](#fst_err_ctp_invalid_type) + - [FST_ERR_CTP_EMPTY_TYPE](#fst_err_ctp_empty_type) + - [FST_ERR_CTP_INVALID_HANDLER](#fst_err_ctp_invalid_handler) + - [FST_ERR_CTP_INVALID_PARSE_TYPE](#fst_err_ctp_invalid_parse_type) + - [FST_ERR_CTP_BODY_TOO_LARGE](#fst_err_ctp_body_too_large) + - [FST_ERR_CTP_INVALID_MEDIA_TYPE](#fst_err_ctp_invalid_media_type) + - [FST_ERR_CTP_INVALID_CONTENT_LENGTH](#fst_err_ctp_invalid_content_length) + - [FST_ERR_CTP_EMPTY_JSON_BODY](#fst_err_ctp_empty_json_body) + - [FST_ERR_CTP_INVALID_JSON_BODY](#fst_err_ctp_invalid_json_body) + - [FST_ERR_CTP_INSTANCE_ALREADY_STARTED](#fst_err_ctp_instance_already_started) + - [FST_ERR_INSTANCE_ALREADY_LISTENING](#fst_err_instance_already_listening) + - [FST_ERR_DEC_ALREADY_PRESENT](#fst_err_dec_already_present) + - [FST_ERR_DEC_DEPENDENCY_INVALID_TYPE](#fst_err_dec_dependency_invalid_type) + - [FST_ERR_DEC_MISSING_DEPENDENCY](#fst_err_dec_missing_dependency) + - [FST_ERR_DEC_AFTER_START](#fst_err_dec_after_start) + - [FST_ERR_DEC_REFERENCE_TYPE](#fst_err_dec_reference_type) + - [FST_ERR_DEC_UNDECLARED](#fst_err_dec_undeclared) + - [FST_ERR_HOOK_INVALID_TYPE](#fst_err_hook_invalid_type) + - [FST_ERR_HOOK_INVALID_HANDLER](#fst_err_hook_invalid_handler) + - [FST_ERR_HOOK_INVALID_ASYNC_HANDLER](#fst_err_hook_invalid_async_handler) + - [FST_ERR_HOOK_NOT_SUPPORTED](#fst_err_hook_not_supported) + - [FST_ERR_MISSING_MIDDLEWARE](#fst_err_missing_middleware) + - [FST_ERR_HOOK_TIMEOUT](#fst_err_hook_timeout) + - [FST_ERR_LOG_INVALID_DESTINATION](#fst_err_log_invalid_destination) + - [FST_ERR_LOG_INVALID_LOGGER](#fst_err_log_invalid_logger) + - [FST_ERR_LOG_INVALID_LOGGER_INSTANCE](#fst_err_log_invalid_logger_instance) + - [FST_ERR_LOG_INVALID_LOGGER_CONFIG](#fst_err_log_invalid_logger_config) + - [FST_ERR_LOG_LOGGER_AND_LOGGER_INSTANCE_PROVIDED](#fst_err_log_logger_and_logger_instance_provided) + - [FST_ERR_REP_INVALID_PAYLOAD_TYPE](#fst_err_rep_invalid_payload_type) + - [FST_ERR_REP_RESPONSE_BODY_CONSUMED](#fst_err_rep_response_body_consumed) + - [FST_ERR_REP_READABLE_STREAM_LOCKED](#fst_err_rep_readable_stream_locked) + - [FST_ERR_REP_ALREADY_SENT](#fst_err_rep_already_sent) + - [FST_ERR_REP_SENT_VALUE](#fst_err_rep_sent_value) + - [FST_ERR_SEND_INSIDE_ONERR](#fst_err_send_inside_onerr) + - [FST_ERR_SEND_UNDEFINED_ERR](#fst_err_send_undefined_err) + - [FST_ERR_BAD_STATUS_CODE](#fst_err_bad_status_code) + - [FST_ERR_BAD_TRAILER_NAME](#fst_err_bad_trailer_name) + - [FST_ERR_BAD_TRAILER_VALUE](#fst_err_bad_trailer_value) + - [FST_ERR_FAILED_ERROR_SERIALIZATION](#fst_err_failed_error_serialization) + - [FST_ERR_MISSING_SERIALIZATION_FN](#fst_err_missing_serialization_fn) + - [FST_ERR_MISSING_CONTENTTYPE_SERIALIZATION_FN](#fst_err_missing_contenttype_serialization_fn) + - [FST_ERR_REQ_INVALID_VALIDATION_INVOCATION](#fst_err_req_invalid_validation_invocation) + - [FST_ERR_SCH_MISSING_ID](#fst_err_sch_missing_id) + - [FST_ERR_SCH_ALREADY_PRESENT](#fst_err_sch_already_present) + - [FST_ERR_SCH_CONTENT_MISSING_SCHEMA](#fst_err_sch_content_missing_schema) + - [FST_ERR_SCH_DUPLICATE](#fst_err_sch_duplicate) + - [FST_ERR_SCH_VALIDATION_BUILD](#fst_err_sch_validation_build) + - [FST_ERR_SCH_SERIALIZATION_BUILD](#fst_err_sch_serialization_build) + - [FST_ERR_SCH_RESPONSE_SCHEMA_NOT_NESTED_2XX](#fst_err_sch_response_schema_not_nested_2xx) + - [FST_ERR_INIT_OPTS_INVALID](#fst_err_init_opts_invalid) + - [FST_ERR_FORCE_CLOSE_CONNECTIONS_IDLE_NOT_AVAILABLE](#fst_err_force_close_connections_idle_not_available) + - [FST_ERR_DUPLICATED_ROUTE](#fst_err_duplicated_route) + - [FST_ERR_BAD_URL](#fst_err_bad_url) + - [FST_ERR_ASYNC_CONSTRAINT](#fst_err_async_constraint) + - [FST_ERR_INVALID_URL](#fst_err_invalid_url) + - [FST_ERR_ROUTE_OPTIONS_NOT_OBJ](#fst_err_route_options_not_obj) + - [FST_ERR_ROUTE_DUPLICATED_HANDLER](#fst_err_route_duplicated_handler) + - [FST_ERR_ROUTE_HANDLER_NOT_FN](#fst_err_route_handler_not_fn) + - [FST_ERR_ROUTE_MISSING_HANDLER](#fst_err_route_missing_handler) + - [FST_ERR_ROUTE_METHOD_INVALID](#fst_err_route_method_invalid) + - [FST_ERR_ROUTE_METHOD_NOT_SUPPORTED](#fst_err_route_method_not_supported) + - [FST_ERR_ROUTE_BODY_VALIDATION_SCHEMA_NOT_SUPPORTED](#fst_err_route_body_validation_schema_not_supported) + - [FST_ERR_ROUTE_BODY_LIMIT_OPTION_NOT_INT](#fst_err_route_body_limit_option_not_int) + - [FST_ERR_HANDLER_TIMEOUT](#fst_err_handler_timeout) + + - [FST_ERR_ROUTE_HANDLER_TIMEOUT_OPTION_NOT_INT](#fst_err_route_handler_timeout_option_not_int) + - [FST_ERR_ROUTE_REWRITE_NOT_STR](#fst_err_route_rewrite_not_str) + - [FST_ERR_REOPENED_CLOSE_SERVER](#fst_err_reopened_close_server) + - [FST_ERR_REOPENED_SERVER](#fst_err_reopened_server) + - [FST_ERR_PLUGIN_VERSION_MISMATCH](#fst_err_plugin_version_mismatch) + - [FST_ERR_PLUGIN_CALLBACK_NOT_FN](#fst_err_plugin_callback_not_fn) + - [FST_ERR_PLUGIN_NOT_VALID](#fst_err_plugin_not_valid) + - [FST_ERR_ROOT_PLG_BOOTED](#fst_err_root_plg_booted) + - [FST_ERR_PARENT_PLUGIN_BOOTED](#fst_err_parent_plugin_booted) + - [FST_ERR_PLUGIN_TIMEOUT](#fst_err_plugin_timeout) + - [FST_ERR_PLUGIN_NOT_PRESENT_IN_INSTANCE](#fst_err_plugin_not_present_in_instance) + - [FST_ERR_PLUGIN_INVALID_ASYNC_HANDLER](#fst_err_plugin_invalid_async_handler) + - [FST_ERR_VALIDATION](#fst_err_validation) + - [FST_ERR_LISTEN_OPTIONS_INVALID](#fst_err_listen_options_invalid) + - [FST_ERR_ERROR_HANDLER_NOT_FN](#fst_err_error_handler_not_fn) + - [FST_ERR_ERROR_HANDLER_ALREADY_SET](#fst_err_error_handler_already_set) + ### Error Handling In Node.js #### Uncaught Errors -In Node.js, uncaught errors are likely to cause memory leaks, file descriptor -leaks, and other major production issues. +In Node.js, uncaught errors can cause memory leaks, file descriptor leaks, and +other major production issues. [Domains](https://nodejs.org/en/docs/guides/domain-postmortem/) were a failed attempt to fix this. @@ -17,29 +117,28 @@ way to deal with them is to [crash](https://nodejs.org/api/process.html#process_warning_using_uncaughtexception_correctly). #### Catching Errors In Promises -If you are using promises, you should attach a `.catch()` handler synchronously. +When using promises, attach a `.catch()` handler synchronously. ### Errors In Fastify -Fastify follows an all-or-nothing approach and aims to be lean and optimal as -much as possible. The developer is responsible for making sure that the errors -are handled properly. +Fastify follows an all-or-nothing approach and aims to be lean and optimal. The +developer is responsible for ensuring errors are handled properly. #### Errors In Input Data -Most errors are a result of unexpected input data, so we recommend [validating -your input data against a JSON schema](./Validation-and-Serialization.md). +Most errors result from unexpected input data, so it is recommended to +[validate input data against a JSON schema](./Validation-and-Serialization.md). #### Catching Uncaught Errors In Fastify -Fastify tries to catch as many uncaught errors as it can without hindering +Fastify tries to catch as many uncaught errors as possible without hindering performance. This includes: 1. synchronous routes, e.g. `app.get('/', () => { throw new Error('kaboom') })` 2. `async` routes, e.g. `app.get('/', async () => { throw new Error('kaboom') })` -The error in both cases will be caught safely and routed to Fastify's default -error handler for a generic `500 Internal Server Error` response. +In both cases, the error will be caught safely and routed to Fastify's default +error handler, resulting in a generic `500 Internal Server Error` response. -To customize this behavior you should use +To customize this behavior, use [`setErrorHandler`](./Server.md#seterrorhandler). ### Errors In Fastify Lifecycle Hooks And A Custom Error Handler @@ -49,192 +148,230 @@ From the [Hooks documentation](./Hooks.md#manage-errors-from-a-hook): > `done()` and Fastify will automatically close the request and send the > appropriate error code to the user. -When a custom error handler has been defined through -[`setErrorHandler`](./Server.md#seterrorhandler), the custom error handler will -receive the error passed to the `done()` callback (or through other supported -automatic error handling mechanisms). If `setErrorHandler` has been used -multiple times to define multiple handlers, the error will be routed to the most -precedent handler defined within the error [encapsulation -context](./Encapsulation.md). Error handlers are fully encapsulated, so a -`setErrorHandler` call within a plugin will limit the error handler to that -plugin's context. +When a custom error handler is defined through +[`setErrorHandler`](./Server.md#seterrorhandler), it will receive the error +passed to the `done()` callback or through other supported automatic error +handling mechanisms. If `setErrorHandler` is used multiple times, the error will +be routed to the most precedent handler within the error +[encapsulation context](./Encapsulation.md). Error handlers are fully +encapsulated, so a `setErrorHandler` call within a plugin will limit the error +handler to that plugin's context. The root error handler is Fastify's generic error handler. This error handler will use the headers and status code in the `Error` object, if they exist. The headers and status code will not be automatically set if a custom error handler is provided. -Some things to consider in your custom error handler: +The following should be considered when using a custom error handler: -- you can `reply.send(data)`, which will behave as it would in [regular route - handlers](./Reply.md#senddata) +- `reply.send(data)` behaves as in [regular route handlers](./Reply.md#senddata) - objects are serialized, triggering the `preSerialization` lifecycle hook if - you have one defined - - strings, buffers, and streams are sent to the client, with appropriate - headers (no serialization) - -- You can throw a new error in your custom error handler - errors (new error or - the received error parameter re-thrown) - will call the parent `errorHandler`. - - `onError` hook will be triggered once only for the first error being thrown. - - an error will not be triggered twice from a lifecycle hook - Fastify - internally monitors the error invocation to avoid infinite loops for errors - thrown in the reply phases of the lifecycle. (those after the route handler) + defined + - strings, buffers, and streams are sent to the client with appropriate headers + (no serialization) + +- Throwing a new error in a custom error handler will call the parent + `errorHandler`. + - The `onError` hook will be triggered once for the first error thrown + - An error will not be triggered twice from a lifecycle hook. Fastify + internally monitors error invocation to avoid infinite loops for errors + thrown in the reply phases of the lifecycle (those after the route handler) + +When using Fastify's custom error handling through +[`setErrorHandler`](./Server.md#seterrorhandler), be aware of how errors are +propagated between custom and default error handlers. + +If a plugin's error handler re-throws an error that is not an instance of +[Error](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error), +it will not propagate to the parent context error handler. Instead, it will be +caught by the default error handler. This can be seen in the `/bad` route of the +example below. + +To ensure consistent error handling, throw instances of `Error`. For example, +replace `throw 'foo'` with `throw new Error('foo')` in the `/bad` route to +ensure errors propagate through the custom error handling chain as intended. +This practice helps avoid potential pitfalls when working with custom error +handling in Fastify. + +For example: +```js +const Fastify = require('fastify') + +// Instantiate the framework +const fastify = Fastify({ + logger: true +}) + +// Register parent error handler +fastify.setErrorHandler((error, request, reply) => { + reply.status(500).send({ ok: false }) +}) + +fastify.register((app, options, next) => { + // Register child error handler + fastify.setErrorHandler((error, request, reply) => { + throw error + }) + + fastify.get('/bad', async () => { + // Throws a non-Error type, 'bar' + throw 'foo' + }) + + fastify.get('/good', async () => { + // Throws an Error instance, 'bar' + throw new Error('bar') + }) + + next() +}) + +// Run the server +fastify.listen({ port: 3000 }, function (err, address) { + if (err) { + fastify.log.error(err) + process.exit(1) + } + // Server is listening at ${address} +}) +``` ### Fastify Error Codes -#### FST_ERR_BAD_URL - - -The router received an invalid url. - - -#### FST_ERR_DUPLICATED_ROUTE - -The HTTP method already has a registered controller for that URL - - -#### FST_ERR_CTP_ALREADY_PRESENT - - -The parser for this content type was already registered. - -#### FST_ERR_CTP_BODY_TOO_LARGE - - -The request body is larger than the provided limit. - -This setting can be defined in the Fastify server instance: -[`bodyLimit`](./Server.md#bodylimit) - -#### FST_ERR_CTP_EMPTY_TYPE - - -The content type cannot be an empty string. - -#### FST_ERR_CTP_INVALID_CONTENT_LENGTH - - -Request body size did not match Content-Length. - -#### FST_ERR_CTP_INVALID_HANDLER - - -An invalid handler was passed for the content type. - -#### FST_ERR_CTP_INVALID_MEDIA_TYPE - - -The received media type is not supported (i.e. there is no suitable -`Content-Type` parser for it). - -#### FST_ERR_CTP_INVALID_PARSE_TYPE - - -The provided parse type is not supported. Accepted values are `string` or -`buffer`. - -#### FST_ERR_CTP_INVALID_TYPE - - -The `Content-Type` should be a string. - -#### FST_ERR_DEC_ALREADY_PRESENT - - -A decorator with the same name is already registered. - -#### FST_ERR_DEC_MISSING_DEPENDENCY - - -The decorator cannot be registered due to a missing dependency. - -#### FST_ERR_HOOK_INVALID_HANDLER - - -The hook callback must be a function. - -#### FST_ERR_HOOK_INVALID_TYPE - - -The hook name must be a string. - -#### FST_ERR_LOG_INVALID_DESTINATION - - -The logger accepts either a `'stream'` or a `'file'` as the destination. - -#### FST_ERR_PROMISE_NOT_FULFILLED - - -A promise may not be fulfilled with 'undefined' when statusCode is not 204. - -#### FST_ERR_REP_ALREADY_SENT - - -A response was already sent. - -#### FST_ERR_REP_INVALID_PAYLOAD_TYPE - - -Reply payload can be either a `string` or a `Buffer`. - -#### FST_ERR_SCH_ALREADY_PRESENT - - -A schema with the same `$id` already exists. - -#### FST_ERR_SCH_MISSING_ID - - -The schema provided does not have `$id` property. - -#### FST_ERR_SCH_SERIALIZATION_BUILD - - -The JSON schema provided for serialization of a route response is not valid. - -#### FST_ERR_SCH_VALIDATION_BUILD - - -The JSON schema provided for validation to a route is not valid. - -#### FST_ERR_SEND_INSIDE_ONERR - - -You cannot use `send` inside the `onError` hook. - -#### FST_ERR_SEND_UNDEFINED_ERR - - -Undefined error has occurred. - - -#### FST_ERR_PLUGIN_NOT_VALID - -Plugin must be a function or a promise. - - -#### FST_ERR_PLUGIN_TIMEOUT - -Plugin did not start in time. Default timeout (in millis): `10000` - - -#### FST_ERR_HOOK_TIMEOUT - -A callback for a hook timed out - - -#### FST_ERR_ROOT_PLG_BOOTED - -Root plugin has already booted (mapped directly from `avvio`) - - -#### FST_ERR_PARENT_PLUGIN_BOOTED - -Impossible to load plugin because the parent (mapped directly from `avvio`) - - -#### FST_ERR_PLUGIN_CALLBACK_NOT_FN - -Callback for a hook is not a function (mapped directly from `avvio`) +You can access `errorCodes` for mapping: +```js +// ESM +import { errorCodes } from 'fastify' + +// CommonJS +const errorCodes = require('fastify').errorCodes +``` + +For example: +```js +const Fastify = require('fastify') + +// Instantiate the framework +const fastify = Fastify({ + logger: true +}) + +// Declare a route +fastify.get('/', function (request, reply) { + reply.code('bad status code').send({ hello: 'world' }) +}) + +fastify.setErrorHandler(function (error, request, reply) { + if (error instanceof Fastify.errorCodes.FST_ERR_BAD_STATUS_CODE) { + // Log error + this.log.error(error) + // Send error response + reply.status(500).send({ ok: false }) + } else { + // Fastify will use parent error handler to handle this + reply.send(error) + } +}) + +// Run the server! +fastify.listen({ port: 3000 }, function (err, address) { + if (err) { + fastify.log.error(err) + process.exit(1) + } + // Server is now listening on ${address} +}) +``` + +Below is a table with all the error codes used by Fastify. + +| Code | Description | How to solve | Discussion | +|------|-------------|--------------|------------| +| FST_ERR_NOT_FOUND | 404 Not Found | - | [#1168](https://github.com/fastify/fastify/pull/1168) | +| FST_ERR_OPTIONS_NOT_OBJ | Fastify options wrongly specified. | Fastify options should be an object. | [#4554](https://github.com/fastify/fastify/pull/4554) | +| FST_ERR_QSP_NOT_FN | QueryStringParser wrongly specified. | QueryStringParser option should be a function. | [#4554](https://github.com/fastify/fastify/pull/4554) | +| FST_ERR_SCHEMA_CONTROLLER_BUCKET_OPT_NOT_FN | SchemaController.bucket wrongly specified. | SchemaController.bucket option should be a function. | [#4554](https://github.com/fastify/fastify/pull/4554) | +| FST_ERR_SCHEMA_ERROR_FORMATTER_NOT_FN | SchemaErrorFormatter option wrongly specified. | SchemaErrorFormatter option should be a non async function. | [#4554](https://github.com/fastify/fastify/pull/4554) | +| FST_ERR_AJV_CUSTOM_OPTIONS_OPT_NOT_OBJ | ajv.customOptions wrongly specified. | ajv.customOptions option should be an object. | [#4554](https://github.com/fastify/fastify/pull/4554) | +| FST_ERR_AJV_CUSTOM_OPTIONS_OPT_NOT_ARR | ajv.plugins option wrongly specified. | ajv.plugins option should be an array. | [#4554](https://github.com/fastify/fastify/pull/4554) | +| FST_ERR_CTP_ALREADY_PRESENT | The parser for this content type was already registered. | Use a different content type or delete the already registered parser. | [#1168](https://github.com/fastify/fastify/pull/1168) | +| FST_ERR_CTP_INVALID_TYPE | `Content-Type` wrongly specified | The `Content-Type` should be a string. | [#1168](https://github.com/fastify/fastify/pull/1168) | +| FST_ERR_CTP_EMPTY_TYPE | `Content-Type` is an empty string. | `Content-Type` cannot be an empty string. | [#1168](https://github.com/fastify/fastify/pull/1168) | +| FST_ERR_CTP_INVALID_HANDLER | Invalid handler for the content type. | Use a different handler. | [#1168](https://github.com/fastify/fastify/pull/1168) | +| FST_ERR_CTP_INVALID_PARSE_TYPE | The provided parse type is not supported. | Accepted values are string or buffer. | [#1168](https://github.com/fastify/fastify/pull/1168) | +| FST_ERR_CTP_BODY_TOO_LARGE | The request body is larger than the provided limit. | Increase the limit in the Fastify server instance setting: [bodyLimit](./Server.md#bodylimit) | [#1168](https://github.com/fastify/fastify/pull/1168) | +| FST_ERR_CTP_INVALID_MEDIA_TYPE | The received media type is not supported (i.e. there is no suitable `Content-Type` parser for it). | Use a different content type. | [#1168](https://github.com/fastify/fastify/pull/1168) | +| FST_ERR_CTP_INVALID_CONTENT_LENGTH | Request body size did not match Content-Length. | Check the request body size and the Content-Length header. | [#1168](https://github.com/fastify/fastify/pull/1168) | +| FST_ERR_CTP_EMPTY_JSON_BODY | Body is not valid JSON but content-type is set to application/json. | Check if the request body is valid JSON. | [#5925](https://github.com/fastify/fastify/pull/5925) | +| FST_ERR_CTP_INVALID_JSON_BODY | Body cannot be empty when content-type is set to application/json. | Check the request body. | [#1253](https://github.com/fastify/fastify/pull/1253) | +| FST_ERR_CTP_INSTANCE_ALREADY_STARTED | Fastify is already started. | - | [#4554](https://github.com/fastify/fastify/pull/4554) | +| FST_ERR_INSTANCE_ALREADY_LISTENING | Fastify instance is already listening. | - | [#4554](https://github.com/fastify/fastify/pull/4554) | +| FST_ERR_DEC_ALREADY_PRESENT | A decorator with the same name is already registered. | Use a different decorator name. | [#1168](https://github.com/fastify/fastify/pull/1168) | +| FST_ERR_DEC_DEPENDENCY_INVALID_TYPE | The dependencies of decorator must be of type `Array`. | Use an array for the dependencies. | [#3090](https://github.com/fastify/fastify/pull/3090) | +| FST_ERR_DEC_MISSING_DEPENDENCY | The decorator cannot be registered due to a missing dependency. | Register the missing dependency. | [#1168](https://github.com/fastify/fastify/pull/1168) | +| FST_ERR_DEC_AFTER_START | The decorator cannot be added after start. | Add the decorator before starting the server. | [#2128](https://github.com/fastify/fastify/pull/2128) | +| FST_ERR_DEC_REFERENCE_TYPE | The decorator cannot be a reference type. | Define the decorator with a getter/setter interface or an empty decorator with a hook. | [#5462](https://github.com/fastify/fastify/pull/5462) | +| FST_ERR_DEC_UNDECLARED | An attempt was made to access a decorator that has not been declared. | Declare the decorator before using it. | [#](https://github.com/fastify/fastify/pull/) +| FST_ERR_HOOK_INVALID_TYPE | The hook name must be a string. | Use a string for the hook name. | [#1168](https://github.com/fastify/fastify/pull/1168) | +| FST_ERR_HOOK_INVALID_HANDLER | The hook callback must be a function. | Use a function for the hook callback. | [#1168](https://github.com/fastify/fastify/pull/1168) | +| FST_ERR_HOOK_INVALID_ASYNC_HANDLER | Async function has too many arguments. Async hooks should not use the `done` argument. | Remove the `done` argument from the async hook. | [#4367](https://github.com/fastify/fastify/pull/4367) | +| FST_ERR_HOOK_NOT_SUPPORTED | The hook is not supported. | Use a supported hook. | [#4554](https://github.com/fastify/fastify/pull/4554) | +| FST_ERR_MISSING_MIDDLEWARE | You must register a plugin for handling middlewares, visit [`Middleware`](./Middleware.md) for more info. | Register a plugin for handling middlewares. | [#2014](https://github.com/fastify/fastify/pull/2014) | +| FST_ERR_HOOK_TIMEOUT | A callback for a hook timed out. | Increase the timeout for the hook. | [#3106](https://github.com/fastify/fastify/pull/3106) | +| FST_ERR_LOG_INVALID_DESTINATION | The logger does not accept the specified destination. | Use a `'stream'` or a `'file'` as the destination. | [#1168](https://github.com/fastify/fastify/pull/1168) | +| FST_ERR_LOG_INVALID_LOGGER | The logger should have all these methods: `'info'`, `'error'`, `'debug'`, `'fatal'`, `'warn'`, `'trace'`, `'child'`. | Use a logger with all the required methods. | [#4520](https://github.com/fastify/fastify/pull/4520) | +| FST_ERR_LOG_INVALID_LOGGER_INSTANCE | The `loggerInstance` only accepts a logger instance, not a configuration object. | To pass a configuration object, use `'logger'` instead. | [#5020](https://github.com/fastify/fastify/pull/5020) | +| FST_ERR_LOG_INVALID_LOGGER_CONFIG | The logger option only accepts a configuration object, not a logger instance. | To pass an instance, use `'loggerInstance'` instead. | [#5020](https://github.com/fastify/fastify/pull/5020) | +| FST_ERR_LOG_LOGGER_AND_LOGGER_INSTANCE_PROVIDED | You cannot provide both `'logger'` and `'loggerInstance'`. | Please provide only one option. | [#5020](https://github.com/fastify/fastify/pull/5020) | +| FST_ERR_REP_INVALID_PAYLOAD_TYPE | Reply payload can be either a `string` or a `Buffer`. | Use a `string` or a `Buffer` for the payload. | [#1168](https://github.com/fastify/fastify/pull/1168) | +| FST_ERR_REP_RESPONSE_BODY_CONSUMED | Using `Response` as reply payload, but the body is being consumed. | Make sure you don't consume the `Response.body` | [#5286](https://github.com/fastify/fastify/pull/5286) | +| FST_ERR_REP_READABLE_STREAM_LOCKED | Using `ReadableStream` as reply payload, but locked with another reader. | Make sure you don't call the `Readable.getReader` before sending or release lock with `reader.releaseLock()` before sending. | [#5920](https://github.com/fastify/fastify/pull/5920) | +| FST_ERR_REP_ALREADY_SENT | A response was already sent. | - | [#1336](https://github.com/fastify/fastify/pull/1336) | +| FST_ERR_REP_SENT_VALUE | The only possible value for `reply.sent` is `true`. | - | [#1336](https://github.com/fastify/fastify/pull/1336) | +| FST_ERR_SEND_INSIDE_ONERR | You cannot use `send` inside the `onError` hook. | - | [#1348](https://github.com/fastify/fastify/pull/1348) | +| FST_ERR_SEND_UNDEFINED_ERR | Undefined error has occurred. | - | [#2074](https://github.com/fastify/fastify/pull/2074) | +| FST_ERR_BAD_STATUS_CODE | The status code is not valid. | Use a valid status code. | [#2082](https://github.com/fastify/fastify/pull/2082) | +| FST_ERR_BAD_TRAILER_NAME | Called `reply.trailer` with an invalid header name. | Use a valid header name. | [#3794](https://github.com/fastify/fastify/pull/3794) | +| FST_ERR_BAD_TRAILER_VALUE | Called `reply.trailer` with an invalid type. Expected a function. | Use a function. | [#3794](https://github.com/fastify/fastify/pull/3794) | +| FST_ERR_FAILED_ERROR_SERIALIZATION | Failed to serialize an error. | - | [#4601](https://github.com/fastify/fastify/pull/4601) | +| FST_ERR_MISSING_SERIALIZATION_FN | Missing serialization function. | Add a serialization function. | [#3970](https://github.com/fastify/fastify/pull/3970) | +| FST_ERR_MISSING_CONTENTTYPE_SERIALIZATION_FN | Missing `Content-Type` serialization function. | Add a serialization function. | [#4264](https://github.com/fastify/fastify/pull/4264) | +| FST_ERR_REQ_INVALID_VALIDATION_INVOCATION | Invalid validation invocation. Missing validation function for HTTP part nor schema provided. | Add a validation function. | [#3970](https://github.com/fastify/fastify/pull/3970) | +| FST_ERR_SCH_MISSING_ID | The schema provided does not have `$id` property. | Add a `$id` property. | [#1168](https://github.com/fastify/fastify/pull/1168) | +| FST_ERR_SCH_ALREADY_PRESENT | A schema with the same `$id` already exists. | Use a different `$id`. | [#1168](https://github.com/fastify/fastify/pull/1168) | +| FST_ERR_SCH_CONTENT_MISSING_SCHEMA | A schema is missing for the corresponding content type. | Add a schema. | [#4264](https://github.com/fastify/fastify/pull/4264) | +| FST_ERR_SCH_DUPLICATE | Schema with the same attribute already present! | Use a different attribute. | [#1954](https://github.com/fastify/fastify/pull/1954) | +| FST_ERR_SCH_VALIDATION_BUILD | The JSON schema provided for validation to a route is not valid. | Fix the JSON schema. | [#2023](https://github.com/fastify/fastify/pull/2023) | +| FST_ERR_SCH_SERIALIZATION_BUILD | The JSON schema provided for serialization of a route response is not valid. | Fix the JSON schema. | [#2023](https://github.com/fastify/fastify/pull/2023) | +| FST_ERR_SCH_RESPONSE_SCHEMA_NOT_NESTED_2XX | Response schemas should be nested under a valid status code (2XX). | Use a valid status code. | [#4554](https://github.com/fastify/fastify/pull/4554) | +| FST_ERR_INIT_OPTS_INVALID | Invalid initialization options. | Use valid initialization options. | [#1471](https://github.com/fastify/fastify/pull/1471) | +| FST_ERR_FORCE_CLOSE_CONNECTIONS_IDLE_NOT_AVAILABLE | Cannot set forceCloseConnections to `idle` as your HTTP server does not support `closeIdleConnections` method. | Use a different value for `forceCloseConnections`. | [#3925](https://github.com/fastify/fastify/pull/3925) | +| FST_ERR_DUPLICATED_ROUTE | The HTTP method already has a registered controller for that URL. | Use a different URL or register the controller for another HTTP method. | [#2954](https://github.com/fastify/fastify/pull/2954) | +| FST_ERR_BAD_URL | The router received an invalid URL. | Use a valid URL. | [#2106](https://github.com/fastify/fastify/pull/2106) | +| FST_ERR_ASYNC_CONSTRAINT | The router received an error when using asynchronous constraints. | - | [#4323](https://github.com/fastify/fastify/pull/4323) | +| FST_ERR_INVALID_URL | URL must be a string. | Use a string for the URL. | [#3653](https://github.com/fastify/fastify/pull/3653) | +| FST_ERR_ROUTE_OPTIONS_NOT_OBJ | Options for the route must be an object. | Use an object for the route options. | [#4554](https://github.com/fastify/fastify/pull/4554) | +| FST_ERR_ROUTE_DUPLICATED_HANDLER | Duplicate handler for the route is not allowed. | Use a different handler. | [#4554](https://github.com/fastify/fastify/pull/4554) | +| FST_ERR_ROUTE_HANDLER_NOT_FN | Handler for the route must be a function. | Use a function for the handler. | [#4554](https://github.com/fastify/fastify/pull/4554) | +| FST_ERR_ROUTE_MISSING_HANDLER | Missing handler function for the route. | Add a handler function. | [#4554](https://github.com/fastify/fastify/pull/4554) | +| FST_ERR_ROUTE_METHOD_INVALID | Method is not a valid value. | Use a valid value for the method. | [#4750](https://github.com/fastify/fastify/pull/4750) | +| FST_ERR_ROUTE_METHOD_NOT_SUPPORTED | Method is not supported for the route. | Use a supported method. | [#4554](https://github.com/fastify/fastify/pull/4554) | +| FST_ERR_ROUTE_BODY_VALIDATION_SCHEMA_NOT_SUPPORTED | Body validation schema route is not supported. | Use a different different method for the route. | [#4554](https://github.com/fastify/fastify/pull/4554) | +| FST_ERR_ROUTE_BODY_LIMIT_OPTION_NOT_INT | `bodyLimit` option must be an integer. | Use an integer for the `bodyLimit` option. | [#4554](https://github.com/fastify/fastify/pull/4554) | +| FST_ERR_HANDLER_TIMEOUT | Request timed out. | Increase the `handlerTimeout` option or optimize the handler. | - | +| FST_ERR_ROUTE_HANDLER_TIMEOUT_OPTION_NOT_INT | `handlerTimeout` option must be a positive integer. | Use a positive integer for the `handlerTimeout` option. | - | +| FST_ERR_ROUTE_REWRITE_NOT_STR | `rewriteUrl` needs to be of type `string`. | Use a string for the `rewriteUrl`. | [#4554](https://github.com/fastify/fastify/pull/4554) | +| FST_ERR_REOPENED_CLOSE_SERVER | Fastify has already been closed and cannot be reopened. | - | [#2415](https://github.com/fastify/fastify/pull/2415) | +| FST_ERR_REOPENED_SERVER | Fastify is already listening. | - | [#2415](https://github.com/fastify/fastify/pull/2415) | +| FST_ERR_PLUGIN_VERSION_MISMATCH | Installed Fastify plugin mismatched expected version. | Use a compatible version of the plugin. | [#2549](https://github.com/fastify/fastify/pull/2549) | +| FST_ERR_PLUGIN_CALLBACK_NOT_FN | Callback for a hook is not a function. | Use a function for the callback. | [#3106](https://github.com/fastify/fastify/pull/3106) | +| FST_ERR_PLUGIN_NOT_VALID | Plugin must be a function or a promise. | Use a function or a promise for the plugin. | [#3106](https://github.com/fastify/fastify/pull/3106) | +| FST_ERR_ROOT_PLG_BOOTED | Root plugin has already booted. | - | [#3106](https://github.com/fastify/fastify/pull/3106) | +| FST_ERR_PARENT_PLUGIN_BOOTED | Impossible to load plugin because the parent (mapped directly from `avvio`) | - | [#3106](https://github.com/fastify/fastify/pull/3106) | +| FST_ERR_PLUGIN_TIMEOUT | Plugin did not start in time. | Increase the timeout for the plugin. | [#3106](https://github.com/fastify/fastify/pull/3106) | +| FST_ERR_PLUGIN_NOT_PRESENT_IN_INSTANCE | The decorator is not present in the instance. | - | [#4554](https://github.com/fastify/fastify/pull/4554) | +| FST_ERR_PLUGIN_INVALID_ASYNC_HANDLER | The plugin being registered mixes async and callback styles. | - | [#5141](https://github.com/fastify/fastify/pull/5141) | +| FST_ERR_VALIDATION | The Request failed the payload validation. | Check the request payload. | [#4824](https://github.com/fastify/fastify/pull/4824) | +| FST_ERR_LISTEN_OPTIONS_INVALID | Invalid listen options. | Check the listen options. | [#4886](https://github.com/fastify/fastify/pull/4886) | +| FST_ERR_ERROR_HANDLER_NOT_FN | Error Handler must be a function | Provide a function to `setErrorHandler`. | [#5317](https://github.com/fastify/fastify/pull/5317) | FST_ERR_ERROR_HANDLER_ALREADY_SET | Error Handler already set in this scope. Set `allowErrorHandlerOverride: true` to allow overriding. | By default, `setErrorHandler` can only be called once per encapsulation context. | [#6097](https://github.com/fastify/fastify/pull/6098) | diff --git a/docs/Reference/HTTP2.md b/docs/Reference/HTTP2.md index e868502763e..d6e7a2c46fd 100644 --- a/docs/Reference/HTTP2.md +++ b/docs/Reference/HTTP2.md @@ -2,13 +2,11 @@ ## HTTP2 -_Fastify_ offers **experimental support** for HTTP2 starting from Node 8 LTS, -which includes HTTP2 without a flag; HTTP2 is supported over either HTTPS or -plaintext. +_Fastify_ supports HTTP2 over HTTPS (h2) or plaintext (h2c). Currently, none of the HTTP2-specific APIs are available through _Fastify_, but -Node's `req` and `res` can be accessed through our `Request` and `Reply` -interface. PRs are welcome. +Node's `req` and `res` can be accessed through the `Request` and `Reply` +interfaces. PRs are welcome. ### Secure (HTTPS) @@ -17,8 +15,8 @@ HTTP2 is supported in all modern browsers __only over a secure connection__: ```js 'use strict' -const fs = require('fs') -const path = require('path') +const fs = require('node:fs') +const path = require('node:path') const fastify = require('fastify')({ http2: true, https: { @@ -34,7 +32,8 @@ fastify.get('/', function (request, reply) { fastify.listen({ port: 3000 }) ``` -ALPN negotiation allows support for both HTTPS and HTTP/2 over the same socket. +[ALPN negotiation](https://datatracker.ietf.org/doc/html/rfc7301) allows +support for both HTTPS and HTTP/2 over the same socket. Node core `req` and `res` objects can be either [HTTP/1](https://nodejs.org/api/http.html) or [HTTP/2](https://nodejs.org/api/http2.html). _Fastify_ supports this out of the @@ -43,8 +42,8 @@ box: ```js 'use strict' -const fs = require('fs') -const path = require('path') +const fs = require('node:fs') +const path = require('node:path') const fastify = require('fastify')({ http2: true, https: { @@ -62,7 +61,7 @@ fastify.get('/', function (request, reply) { fastify.listen({ port: 3000 }) ``` -You can test your new server with: +Test the new server with: ``` $ npx h2url https://localhost:3000 @@ -70,8 +69,8 @@ $ npx h2url https://localhost:3000 ### Plain or insecure -If you are building microservices, you can connect to HTTP2 in plain text, -however, this is not supported by browsers. +For microservices, HTTP2 can connect in plain text, but this is not +supported by browsers. ```js 'use strict' @@ -87,7 +86,7 @@ fastify.get('/', function (request, reply) { fastify.listen({ port: 3000 }) ``` -You can test your new server with: +Test the new server with: ``` $ npx h2url http://localhost:3000 diff --git a/docs/Reference/Hooks.md b/docs/Reference/Hooks.md index 3fd8f75245f..400b50d7ae8 100644 --- a/docs/Reference/Hooks.md +++ b/docs/Reference/Hooks.md @@ -19,11 +19,14 @@ are Request/Reply hooks and application hooks: - [onSend](#onsend) - [onResponse](#onresponse) - [onTimeout](#ontimeout) + - [onRequestAbort](#onrequestabort) - [Manage Errors from a hook](#manage-errors-from-a-hook) - [Respond to a request from a hook](#respond-to-a-request-from-a-hook) - [Application Hooks](#application-hooks) - [onReady](#onready) + - [onListen](#onlisten) - [onClose](#onclose) + - [preClose](#preclose) - [onRoute](#onroute) - [onRegister](#onregister) - [Scope](#scope) @@ -31,9 +34,10 @@ are Request/Reply hooks and application hooks: - [Using Hooks to Inject Custom Properties](#using-hooks-to-inject-custom-properties) - [Diagnostics Channel Hooks](#diagnostics-channel-hooks) -**Notice:** the `done` callback is not available when using `async`/`await` or -returning a `Promise`. If you do invoke a `done` callback in this situation -unexpected behavior may occur, e.g. duplicate invocation of handlers. +> ℹ️ Note: +> The `done` callback is not available when using `async`/`await` or +> returning a `Promise`. If you do invoke a `done` callback in this situation +> unexpected behavior may occur, e.g. duplicate invocation of handlers. ## Request/Reply Hooks @@ -65,9 +69,10 @@ fastify.addHook('onRequest', async (request, reply) => { }) ``` -**Notice:** in the [onRequest](#onrequest) hook, `request.body` will always be -`undefined`, because the body parsing happens before the -[preValidation](#prevalidation) hook. +> ℹ️ Note: +> In the [onRequest](#onrequest) hook, `request.body` will always be +> `undefined`, because the body parsing happens before the +> [preValidation](#prevalidation) hook. ### preParsing @@ -78,7 +83,7 @@ hooks, and a stream with the current request payload. If it returns a value (via `return` or via the callback function), it must return a stream. -For instance, you can uncompress the request body: +For instance, you can decompress the request body: ```js fastify.addHook('preParsing', (request, reply, payload, done) => { @@ -95,14 +100,20 @@ fastify.addHook('preParsing', async (request, reply, payload) => { }) ``` -**Notice:** in the [preParsing](#preparsing) hook, `request.body` will always be -`undefined`, because the body parsing happens before the -[preValidation](#prevalidation) hook. +> ℹ️ Note: +> In the [preParsing](#preparsing) hook, `request.body` will always be +> `undefined`, because the body parsing happens before the +> [preValidation](#prevalidation) hook. -**Notice:** you should also add a `receivedEncodedLength` property to the -returned stream. This property is used to correctly match the request payload -with the `Content-Length` header value. Ideally, this property should be updated -on each received chunk. +> ℹ️ Note: +> You should also add a `receivedEncodedLength` property to the +> returned stream. This property is used to correctly match the request payload +> with the `Content-Length` header value. Ideally, this property should be updated +> on each received chunk. + +> ℹ️ Note: +> The size of the returned stream is checked to not exceed the limit +> set in [`bodyLimit`](./Server.md#bodylimit) option. ### preValidation @@ -124,6 +135,10 @@ fastify.addHook('preValidation', async (request, reply) => { ``` ### preHandler + +The `preHandler` hook allows you to specify a function that is executed before +a routes's handler. + ```js fastify.addHook('preHandler', (request, reply, done) => { // some code @@ -156,8 +171,9 @@ fastify.addHook('preSerialization', async (request, reply, payload) => { }) ``` -Note: the hook is NOT called if the payload is a `string`, a `Buffer`, a -`stream`, or `null`. +> ℹ️ Note: +> The hook is NOT called if the payload is a `string`, a `Buffer`, a +> `stream`, or `null`. ### onError ```js @@ -179,13 +195,12 @@ specific header in case of error. It is not intended for changing the error, and calling `reply.send` will throw an exception. -This hook will be executed only after the `customErrorHandler` has been -executed, and only if the `customErrorHandler` sends an error back to the user -*(Note that the default `customErrorHandler` always sends the error back to the -user)*. +This hook will be executed before +the [Custom Error Handler set by `setErrorHandler`](./Server.md#seterrorhandler). -**Notice:** unlike the other hooks, passing an error to the `done` function is not -supported. +> ℹ️ Note: +> Unlike the other hooks, passing an error to the `done` function is not +> supported. ### onSend If you are using the `onSend` hook, you can change the payload. For example: @@ -221,8 +236,9 @@ fastify.addHook('onSend', (request, reply, payload, done) => { > to `0`, whereas the `Content-Length` header will not be set if the payload is > `null`. -Note: If you change the payload, you may only change it to a `string`, a -`Buffer`, a `stream`, or `null`. +> ℹ️ Note: +> If you change the payload, you may only change it to a `string`, a +> `Buffer`, a `stream`, a `ReadableStream`, a `Response`, or `null`. ### onResponse @@ -244,6 +260,10 @@ The `onResponse` hook is executed when a response has been sent, so you will not be able to send more data to the client. It can however be useful for sending data to external services, for example, to gather statistics. +> ℹ️ Note: +> Setting `disableRequestLogging` to `true` will disable any error log +> inside the `onResponse` hook. In this case use `try - catch` to log errors. + ### onTimeout ```js @@ -262,8 +282,36 @@ fastify.addHook('onTimeout', async (request, reply) => { `onTimeout` is useful if you need to monitor the request timed out in your service (if the `connectionTimeout` property is set on the Fastify instance). The `onTimeout` hook is executed when a request is timed out and the HTTP socket -has been hanged up. Therefore, you will not be able to send data to the client. +has been hung up. Therefore, you will not be able to send data to the client. + +> ℹ️ Note: +> The `onTimeout` hook is triggered by socket-level timeouts set via +> `connectionTimeout`. For application-level per-route timeouts, see the +> [`handlerTimeout`](./Server.md#factory-handler-timeout) option which uses +> `request.signal` for cooperative cancellation. +### onRequestAbort + +```js +fastify.addHook('onRequestAbort', (request, done) => { + // Some code + done() +}) +``` +Or `async/await`: +```js +fastify.addHook('onRequestAbort', async (request) => { + // Some code + await asyncMethod() +}) +``` +The `onRequestAbort` hook is executed when a client closes the connection before +the entire request has been processed. Therefore, you will not be able to send +data to the client. + +> ℹ️ Note: +> Client abort detection is not completely reliable. +> See: [`Detecting-When-Clients-Abort.md`](../Guides/Detecting-When-Clients-Abort.md) ### Manage Errors from a hook If you get an error during the execution of your hook, just pass it to `done()` @@ -287,7 +335,7 @@ fastify.addHook('preHandler', (request, reply, done) => { Or if you're using `async/await` you can just throw an error: ```js -fastify.addHook('onResponse', async (request, reply) => { +fastify.addHook('onRequest', async (request, reply) => { throw new Error('Some error') }) ``` @@ -317,9 +365,11 @@ fastify.addHook('onRequest', (request, reply, done) => { // Works with async functions too fastify.addHook('preHandler', async (request, reply) => { - await something() - reply.send({ hello: 'world' }) + setTimeout(() => { + reply.send({ hello: 'from prehandler' }) + }) return reply // mandatory, so the request is not executed further +// Commenting the line above will allow the hooks to continue and fail with FST_ERR_REP_ALREADY_SENT }) ``` @@ -360,7 +410,9 @@ fastify.addHook('preHandler', async (request, reply) => { You can hook into the application-lifecycle as well. - [onReady](#onready) +- [onListen](#onlisten) - [onClose](#onclose) +- [preClose](#preclose) - [onRoute](#onroute) - [onRegister](#onregister) @@ -387,14 +439,46 @@ fastify.addHook('onReady', async function () { }) ``` +### onListen + +Triggered when the server starts listening for requests. The hooks run one +after another. If a hook function causes an error, it is logged and +ignored, allowing the queue of hooks to continue. Hook functions accept one +argument: a callback, `done`, to be invoked after the hook function is +complete. Hook functions are invoked with `this` bound to the associated +Fastify instance. + +This is an alternative to `fastify.server.on('listening', () => {})`. + +```js +// callback style +fastify.addHook('onListen', function (done) { + // Some code + const err = null; + done(err) +}) + +// or async/await style +fastify.addHook('onListen', async function () { + // Some async code +}) +``` + +> ℹ️ Note: +> This hook will not run when the server is started using +> `fastify.inject()` or `fastify.ready()`. + ### onClose -Triggered when `fastify.close()` is invoked to stop the server. It is useful -when [plugins](./Plugins.md) need a "shutdown" event, for example, to close an -open connection to a database. +Triggered when `fastify.close()` is invoked to stop the server. By the time +`onClose` hooks execute, the HTTP server has already stopped listening, all +in-flight HTTP requests have been completed, and connections have been drained. +This makes `onClose` the safe place for [plugins](./Plugins.md) to release +resources such as database connection pools, as no new requests will +arrive. -The hook function takes the Fastify instance as a first argument, +The hook function takes the Fastify instance as a first argument, and a `done` callback for synchronous hook functions. ```js // callback style @@ -410,10 +494,74 @@ fastify.addHook('onClose', async (instance) => { }) ``` +#### Execution order + +When multiple `onClose` hooks are registered across plugins, child-plugin hooks +execute before parent-plugin hooks. This means a database plugin's `onClose` +hook will run before the root-level `onClose` hooks: + +```js +fastify.register(function dbPlugin (instance, opts, done) { + instance.addHook('onClose', async (instance) => { + // Runs first — close the database pool + await instance.db.close() + }) + done() +}) + +fastify.addHook('onClose', async (instance) => { + // Runs second — after child plugins have cleaned up +}) +``` + +See [`close`](./Server.md#close) for the full shutdown lifecycle. + +### preClose + + +Triggered when `fastify.close()` is invoked to stop the server. At this +point the server is already rejecting new requests with `503` (when +[`return503OnClosing`](./Server.md#factory-return-503-on-closing) is `true`), +but the HTTP server has not yet stopped listening and in-flight requests are +still being processed. + +It is useful when [plugins](./Plugins.md) have set up state attached to the HTTP +server that would prevent the server from closing, such as open WebSocket +connections or Server-Sent Events streams that must be explicitly terminated for +`server.close()` to complete. +_It is unlikely you will need to use this hook_, +use the [`onClose`](#onclose) for the most common case. + +```js +// callback style +fastify.addHook('preClose', (done) => { + // Some code + done() +}) + +// or async/await style +fastify.addHook('preClose', async () => { + // Some async code + await removeSomeServerState() +}) +``` + +For example, closing WebSocket connections during shutdown: + +```js +fastify.addHook('preClose', async () => { + // Close all WebSocket connections so that server.close() can complete. + // Without this, open connections would keep the server alive. + for (const ws of activeWebSockets) { + ws.close(1001, 'Server shutting down') + } +}) +``` + ### onRoute -Triggered when a new route is registered. Listeners are passed a `routeOptions` +Triggered when a new route is registered. Listeners are passed a [`routeOptions`](./Routes.md#routes-options) object as the sole parameter. The interface is synchronous, and, as such, the listeners are not passed a callback. This hook is encapsulated. @@ -446,6 +594,33 @@ fastify.addHook('onRoute', (routeOptions) => { }) ``` +To add more routes within an onRoute hook, the routes must +be tagged correctly. The hook will run into an infinite loop if +not tagged. The recommended approach is shown below. + +```js +const kRouteAlreadyProcessed = Symbol('route-already-processed') + +fastify.addHook('onRoute', function (routeOptions) { + const { url, method } = routeOptions + + const isAlreadyProcessed = (routeOptions.custom && routeOptions.custom[kRouteAlreadyProcessed]) || false + + if (!isAlreadyProcessed) { + this.route({ + url, + method, + custom: { + [kRouteAlreadyProcessed]: true + }, + handler: () => {} + }) + } +}) +``` + +For more details, see this [issue](https://github.com/fastify/fastify/issues/4319). + ### onRegister @@ -456,8 +631,9 @@ This hook can be useful if you are developing a plugin that needs to know when a plugin context is formed, and you want to operate in that specific context, thus this hook is encapsulated. -**Note:** This hook will not be called if a plugin is wrapped inside -[`fastify-plugin`](https://github.com/fastify/fastify-plugin). +> ℹ️ Note: +> This hook will not be called if a plugin is wrapped inside +> [`fastify-plugin`](https://github.com/fastify/fastify-plugin). ```js fastify.decorate('data', []) @@ -604,6 +780,12 @@ fastify.route({ // This hook will always be executed after the shared `onRequest` hooks done() }, + // // Example with an async hook. All hooks support this syntax + // + // onRequest: async function (request, reply) { + // // This hook will always be executed after the shared `onRequest` hooks + // await ... + // } onResponse: function (request, reply, done) { // this hook will always be executed after the shared `onResponse` hooks done() @@ -648,7 +830,8 @@ fastify.route({ }) ``` -**Note**: both options also accept an array of functions. +> ℹ️ Note: +> Both options also accept an array of functions. ## Using Hooks to Inject Custom Properties @@ -677,7 +860,7 @@ fastify.get('/me/is-admin', async function (req, reply) { ``` Note that `.authenticatedUser` could actually be any property name -choosen by yourself. Using your own custom property prevents you +chosen by yourself. Using your own custom property prevents you from mutating existing properties, which would be a dangerous and destructive operation. So be careful and make sure your property is entirely new, also using this approach @@ -703,19 +886,11 @@ consider creating a custom [Plugin](./Plugins.md) instead. ## Diagnostics Channel Hooks -> **Note:** The `diagnostics_channel` is currently experimental on Node.js, so -> its API is subject to change even in semver-patch releases of Node.js. For -> versions of Node.js supported by Fastify where `diagnostics_channel` is -> unavailable, the hook will use the -> [polyfill](https://www.npmjs.com/package/diagnostics_channel) if it is -> available. Otherwise, this feature will not be present. - -Currently, one -[`diagnostics_channel`](https://nodejs.org/api/diagnostics_channel.html) publish -event, `'fastify.initialization'`, happens at initialization time. The Fastify -instance is passed into the hook as a property of the object passed in. At this -point, the instance can be interacted with to add hooks, plugins, routes, or any -other sort of modification. +One [`diagnostics_channel`](https://nodejs.org/api/diagnostics_channel.html) +publish event, `'fastify.initialization'`, happens at initialization time. The +Fastify instance is passed into the hook as a property of the object passed in. +At this point, the instance can be interacted with to add hooks, plugins, +routes, or any other sort of modification. For example, a tracing package might do something like the following (which is, of course, a simplification). This would be in a file loaded in the @@ -723,14 +898,14 @@ initialization of the tracking package, in the typical "require instrumentation tools first" fashion. ```js -const tracer = /* retrieved from elsehwere in the package */ -const dc = require('diagnostics_channel') +const tracer = /* retrieved from elsewhere in the package */ +const dc = require('node:diagnostics_channel') const channel = dc.channel('fastify.initialization') const spans = new WeakMap() channel.subscribe(function ({ fastify }) { fastify.addHook('onRequest', (request, reply, done) => { - const span = tracer.startSpan('fastify.request') + const span = tracer.startSpan('fastify.request.handler') spans.set(request, span) done() }) @@ -742,3 +917,42 @@ channel.subscribe(function ({ fastify }) { }) }) ``` + +> ℹ️ Note: +> The TracingChannel class API is currently experimental and may undergo +> breaking changes even in semver-patch releases of Node.js. + +Five other events are published on a per-request basis following the +[Tracing Channel](https://nodejs.org/api/diagnostics_channel.html#class-tracingchannel) +nomenclature. The list of the channel names and the event they receive is: + +- `tracing:fastify.request.handler:start`: Always fires + - `{ request: Request, reply: Reply, route: { url, method } }` +- `tracing:fastify.request.handler:end`: Always fires + - `{ request: Request, reply: Reply, route: { url, method }, async: Bool }` +- `tracing:fastify.request.handler:asyncStart`: Fires for promise/async handlers + - `{ request: Request, reply: Reply, route: { url, method } }` +- `tracing:fastify.request.handler:asyncEnd`: Fires for promise/async handlers + - `{ request: Request, reply: Reply, route: { url, method } }` +- `tracing:fastify.request.handler:error`: Fires when an error occurs + - `{ request: Request, reply: Reply, route: { url, method }, error: Error }` + +The object instance remains the same for all events associated with a given +request. All payloads include a `request` and `reply` property which are an +instance of Fastify's `Request` and `Reply` instances. They also include a +`route` property which is an object with the matched `url` pattern (e.g. +`/collection/:id`) and the `method` HTTP method (e.g. `GET`). The `:start` and +`:end` events always fire for requests. If a request handler is an `async` +function or one that returns a `Promise` then the `:asyncStart` and `:asyncEnd` +events also fire. Finally, the `:error` event contains an `error` property +associated with the request's failure. + +These events can be received like so: + +```js +const dc = require('node:diagnostics_channel') +const channel = dc.channel('tracing:fastify.request.handler:start') +channel.subscribe((msg) => { + console.log(msg.request, msg.reply) +}) +``` diff --git a/docs/Reference/Index.md b/docs/Reference/Index.md index e72a380f66a..ef44aed2ec1 100644 --- a/docs/Reference/Index.md +++ b/docs/Reference/Index.md @@ -69,3 +69,5 @@ This table of contents is in alphabetical order. + [Validation and Serialization](./Validation-and-Serialization.md): Details Fastify's support for validating incoming data and how Fastify serializes data for responses. ++ [Warnings](./Warnings.md): Details the warnings Fastify emits and how to + solve them. diff --git a/docs/Reference/LTS.md b/docs/Reference/LTS.md index 8ed9431a94d..2e59c16726d 100644 --- a/docs/Reference/LTS.md +++ b/docs/Reference/LTS.md @@ -10,18 +10,25 @@ in this document: versions, are supported for a minimum period of six months from their release date. The release date of any specific version can be found at [https://github.com/fastify/fastify/releases](https://github.com/fastify/fastify/releases). - 2. Major releases will receive security updates for an additional six months from the release of the next major release. After this period we will still review and release security fixes as long as they are provided by the community and they do not violate other constraints, e.g. minimum supported Node.js version. - 3. Major releases will be tested and verified against all Node.js release lines that are supported by the [Node.js LTS policy](https://github.com/nodejs/Release) within the LTS period of that given Fastify release line. This implies that only the latest Node.js release of a given line is supported. +4. In addition to Node.js runtime, major releases of Fastify will also be tested + and verified against alternative runtimes that are compatible with Node.js. + The maintenance teams of these alternative runtimes are responsible for ensuring + and guaranteeing these tests work properly. + 1. [N|Solid](https://docs.nodesource.com/docs/product_suite) tests and + verifies each Fastify major release against current N|Solid LTS versions. + NodeSource ensures Fastify compatibility with N|Solid, aligning with the + support scope of N|Solid LTS versions at the time of the Fastify release. + This guarantees N|Solid users can confidently use Fastify. A "month" is defined as 30 consecutive days. @@ -36,35 +43,44 @@ A "month" is defined as 30 consecutive days. > use the tilde (`~`) range qualifier. For example, to get patches for the 3.15 > release, and avoid automatically updating to the 3.16 release, specify the > dependency as `"fastify": "~3.15.x"`. This will leave your application -> vulnerable, so please use with caution. +> vulnerable, so please use it with caution. -[semver]: https://semver.org/ +### Security Support Beyond LTS + +Fastify's partner, HeroDevs, provides commercial security support through the +OpenJS Ecosystem Sustainability Program for versions of Fastify that are EOL. +For more information, see their [Never Ending Support][hd-link] service. ### Schedule -| Version | Release Date | End Of LTS Date | Node.js | -| :------ | :----------- | :-------------- | :------------------- | -| 1.0.0 | 2018-03-06 | 2019-09-01 | 6, 8, 9, 10, 11 | -| 2.0.0 | 2019-02-25 | 2021-01-31 | 6, 8, 10, 12, 14 | -| 3.0.0 | 2020-07-07 | 2023-06-30 | 10, 12, 14, 16, 18 | -| 4.0.0 | 2022-06-08 | TBD | 14, 16, 18 | +| Version | Release Date | End Of LTS Date | Node.js | Nsolid(Node) | +| :------ | :----------- | :-------------- | :----------------- | :------------- | +| 1.0.0 | 2018-03-06 | 2019-09-01 | 6, 8, 9, 10, 11 | | +| 2.0.0 | 2019-02-25 | 2021-01-31 | 6, 8, 10, 12, 14 | | +| 3.0.0 | 2020-07-07 | 2023-06-30 | 10, 12, 14, 16, 18 | v5(18) | +| 4.0.0 | 2022-06-08 | 2025-06-30 | 14, 16, 18, 20, 22 | v5(18), v5(20) | +| 5.0.0 | 2024-09-17 | TBD | 20, 22 | v5(20) | ### CI tested operating systems -Fastify uses GitHub Actions for CI testing, please refer to [GitHub's +Fastify uses GitHub Actions for CI testing, please refer to [GitHub's documentation regarding workflow -runners](https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources) +runners](https://docs.github.com/en/actions/reference/runners/github-hosted-runners#supported-runners-and-hardware-resources) for further details on what the latest virtual environment is in relation to the YAML workflow labels below: -| OS | YAML Workflow Label | Package Manager | Node.js | -|---------|------------------------|---------------------------|--------------| -| Linux | `ubuntu-latest` | npm | 14,16,18 | -| Linux | `ubuntu-18.04` | yarn,pnpm | 14,16,18 | -| Windows | `windows-latest` | npm | 14,16,18 | -| MacOS | `macos-latest` | npm | 14,16,18 | +| OS | YAML Workflow Label | Package Manager | Node.js | Nsolid(Node) | +| ------- | ------------------- | --------------- | ----------- | ------------- | +| Linux | `ubuntu-latest` | npm | 20 | v5(20) | +| Linux | `ubuntu-latest` | yarn,pnpm | 20 | v5(20) | +| Windows | `windows-latest` | npm | 20 | v5(20) | +| MacOS | `macos-latest` | npm | 20 | v5(20) | Using [yarn](https://yarnpkg.com/) might require passing the `--ignore-engines` flag. + +[semver]: https://semver.org/ + +[hd-link]: https://www.herodevs.com/support/fastify-nes?utm_source=fastify&utm_medium=link&utm_campaign=eol_support_fastify diff --git a/docs/Reference/Lifecycle.md b/docs/Reference/Lifecycle.md index b12a1f22f62..1202759f46c 100644 --- a/docs/Reference/Lifecycle.md +++ b/docs/Reference/Lifecycle.md @@ -1,12 +1,13 @@

Fastify

## Lifecycle -Following the schema of the internal lifecycle of Fastify. + -On the right branch of every section there is the next phase of the lifecycle, -on the left branch there is the corresponding error code that will be generated -if the parent throws an error *(note that all the errors are automatically -handled by Fastify)*. +This schema shows the internal lifecycle of Fastify. + +The right branch of each section shows the next phase of the lifecycle. The left +branch shows the corresponding error code generated if the parent throws an +error. All errors are automatically handled by Fastify. ``` Incoming Request @@ -40,25 +41,28 @@ Incoming Request └─▶ onResponse Hook ``` -At any point before or during the `User Handler`, `reply.hijack()` can be called -to prevent Fastify from: -- Running all the following hooks and user handler -- Sending the response automatically +When `handlerTimeout` is configured, a timer starts after routing. If the +response is not sent within the allowed time, `request.signal` is aborted and +a 503 error is sent. The timer is cleared when the response finishes or when +`reply.hijack()` is called. See [`handlerTimeout`](./Server.md#factory-handler-timeout). + +Before or during the `User Handler`, `reply.hijack()` can be called to: +- Prevent Fastify from running subsequent hooks and the user handler +- Prevent Fastify from sending the response automatically -NB (*): If `reply.raw` is used to send a response back to the user, `onResponse` -hooks will still be executed +If `reply.raw` is used to send a response, `onResponse` hooks will still +be executed. ## Reply Lifecycle + -Whenever the user handles the request, the result may be: +When the user handles the request, the result may be: -- in async handler: it returns a payload -- in async handler: it throws an `Error` -- in sync handler: it sends a payload -- in sync handler: it sends an `Error` instance +- In an async handler: it returns a payload or throws an `Error` +- In a sync handler: it sends a payload or an `Error` instance -If the reply was hijacked, we skip all the below steps. Otherwise, when it is -being submitted, the data flow performed is the following: +If the reply was hijacked, all subsequent steps are skipped. Otherwise, when +submitted, the data flow is as follows: ``` ★ schema validation Error @@ -71,16 +75,25 @@ being submitted, the data flow performed is the following: ★ send or return │ │ │ │ │ │ ▼ │ - reply sent ◀── JSON ─┴─ Error instance ──▶ setErrorHandler ◀─────┘ + reply sent ◀── JSON ─┴─ Error instance ──▶ onError Hook ◀───────┘ │ - reply sent ◀── JSON ─┴─ Error instance ──▶ onError Hook + reply sent ◀── JSON ─┴─ Error instance ──▶ setErrorHandler │ └─▶ reply sent ``` -Note: `reply sent` means that the JSON payload will be serialized by: +`reply sent` means the JSON payload will be serialized by one of the following: +- The [reply serializer](./Server.md#setreplyserializer) if set +- The [serializer compiler](./Server.md#setserializercompiler) if a JSON schema + is set for the HTTP status code +- The default `JSON.stringify` function + +## Shutdown Lifecycle + -- the [reply serialized](./Server.md#setreplyserializer) if set -- or by the [serializer compiler](./Server.md#setserializercompiler) when a JSON - schema has been set for the returning HTTP status code -- or by the default `JSON.stringify` function +When [`fastify.close()`](./Server.md#close) is called, the server goes through a +graceful shutdown sequence involving +[`preClose`](./Hooks.md#pre-close) hooks, connection draining, and +[`onClose`](./Hooks.md#on-close) hooks. See the +[`close`](./Server.md#close) method documentation for the full step-by-step +lifecycle. \ No newline at end of file diff --git a/docs/Reference/Logging.md b/docs/Reference/Logging.md index 960c8725b2f..94b7722bb74 100644 --- a/docs/Reference/Logging.md +++ b/docs/Reference/Logging.md @@ -2,17 +2,18 @@ ## Logging -### Enable logging -Logging is disabled by default, and you can enable it by passing `{ logger: true -}` or `{ logger: { level: 'info' } }` when you create a Fastify instance. Note -that if the logger is disabled, it is impossible to enable it at runtime. We use -[abstract-logging](https://www.npmjs.com/package/abstract-logging) for this -purpose. +### Enable Logging +Logging is disabled by default. Enable it by passing `{ logger: true }` or +`{ logger: { level: 'info' } }` when creating a Fastify instance. Note that if +the logger is disabled, it cannot be enabled at runtime. +[abstract-logging](https://www.npmjs.com/package/abstract-logging) is used for +this purpose. As Fastify is focused on performance, it uses [pino](https://github.com/pinojs/pino) as its logger, with the default log -level, when enabled, set to `'info'`. +level set to `'info'` when enabled. +#### Basic logging setup Enabling the production JSON logger: ```js @@ -21,29 +22,33 @@ const fastify = require('fastify')({ }) ``` -Enabling the logger with appropriate configuration for both local development -and production environment requires bit more configuration: +#### Environment-Specific Configuration +Enabling the logger with appropriate configuration for local development, +production, and test environments requires more configuration: + ```js +const envToLogger = { + development: { + transport: { + target: 'pino-pretty', + options: { + translateTime: 'HH:MM:ss Z', + ignore: 'pid,hostname', + }, + }, + }, + production: true, + test: false, +} const fastify = require('fastify')({ - logger: { - transport: - environment === 'development' - ? { - target: 'pino-pretty', - options: { - translateTime: 'HH:MM:ss Z', - ignore: 'pid,hostname' - } - } - : undefined - } + logger: envToLogger[environment] ?? true // defaults to true if no entry matches in the map }) ``` -⚠️ `pino-pretty` needs to be installed as a dev dependency, it is not included +⚠️ `pino-pretty` needs to be installed as a dev dependency. It is not included by default for performance reasons. ### Usage -You can use the logger like this in your route handlers: +The logger can be used in route handlers as follows: ```js fastify.get('/', options, function (request, reply) { @@ -52,16 +57,16 @@ fastify.get('/', options, function (request, reply) { }) ``` -You can trigger new logs outside route handlers by using the Pino instance from -the Fastify instance: +Trigger new logs outside route handlers using the Pino instance from the Fastify +instance: ```js fastify.log.info('Something important happened!'); ``` -If you want to pass some options to the logger, just pass them to Fastify. You -can find all available options in the [Pino -documentation](https://github.com/pinojs/pino/blob/master/docs/api.md#pinooptions-stream). -If you want to specify a file destination, use: +#### Passing Logger Options +To pass options to the logger, provide them to Fastify. See the +[Pino documentation](https://github.com/pinojs/pino/blob/main/docs/api.md#options) +for available options. To specify a file destination, use: ```js const fastify = require('fastify')({ @@ -77,8 +82,8 @@ fastify.get('/', options, function (request, reply) { }) ``` -If you want to pass a custom stream to the Pino instance, just add a stream -field to the logger object. +To pass a custom stream to the Pino instance, add a `stream` field to the logger +object: ```js const split = require('split2') @@ -92,19 +97,27 @@ const fastify = require('fastify')({ }) ``` - +### Advanced Logger Configuration + +#### Request ID Tracking By default, Fastify adds an ID to every request for easier tracking. If the -"request-id" header is present its value is used, otherwise a new incremental ID -is generated. See Fastify Factory +`requestIdHeader` option is set and the corresponding header is present, its +value is used; otherwise, a new incremental ID is generated. See Fastify Factory [`requestIdHeader`](./Server.md#factory-request-id-header) and Fastify Factory [`genReqId`](./Server.md#genreqid) for customization options. -The default logger is configured with a set of standard serializers that -serialize objects with `req`, `res`, and `err` properties. The object received -by `req` is the Fastify [`Request`](./Request.md) object, while the object -received by `res` is the Fastify [`Reply`](./Reply.md) object. This behaviour -can be customized by specifying custom serializers. +> ⚠ Warning: +> Enabling `requestIdHeader` allows any callers to set `reqId` to a +> value of their choosing. +> No validation is performed on `requestIdHeader`. + +#### Serializers +The default logger uses standard serializers for objects with `req`, `res`, and +`err` properties. The `req` object is the Fastify [`Request`](./Request.md) +object, and the `res` object is the Fastify [`Reply`](./Reply.md) object. This +behavior can be customized with custom serializers. + ```js const fastify = require('fastify')({ logger: { @@ -117,7 +130,7 @@ const fastify = require('fastify')({ }) ``` For example, the response payload and headers could be logged using the approach -below (even if it is *not recommended*): +below (not recommended): ```js const fastify = require('fastify')({ @@ -136,12 +149,11 @@ const fastify = require('fastify')({ return { method: request.method, url: request.url, - path: request.routerPath, + path: request.routeOptions.url, parameters: request.params, - // Including the headers in the log could be in violation - // of privacy laws, e.g. GDPR. You should use the "redact" option to - // remove sensitive fields. It could also leak authentication data in - // the logs. + // Including headers in the log could violate privacy laws, + // e.g., GDPR. Use the "redact" option to remove sensitive + // fields. It could also leak authentication data in the logs. headers: request.headers }; } @@ -149,11 +161,41 @@ const fastify = require('fastify')({ } }); ``` -**Note**: The body cannot be serialized inside a `req` method because the -request is serialized when we create the child logger. At that time, the body is -not yet parsed. -See an approach to log `req.body` +> ℹ️ Note: +> In some cases, the [`Reply`](./Reply.md) object passed to the `res` +> serializer cannot be fully constructed. When writing a custom `res` +> serializer, check for the existence of any properties on `reply` aside from +> `statusCode`, which is always present. For example, verify the existence of +> `getHeaders` before calling it: + +```js +const fastify = require('fastify')({ + logger: { + transport: { + target: 'pino-pretty' + }, + serializers: { + res (reply) { + // The default + return { + statusCode: reply.statusCode, + headers: typeof reply.getHeaders === 'function' + ? reply.getHeaders() + : {} + } + }, + } + } +}); +``` + +> ℹ️ Note: +> The body cannot be serialized inside a `req` method because the +> request is serialized when the child logger is created. At that time, the body +> is not yet parsed. + +See the following approach to log `req.body`: ```js app.addHook('preHandler', function (req, reply, done) { @@ -164,19 +206,25 @@ app.addHook('preHandler', function (req, reply, done) { }) ``` +> ℹ️ Note: +> Ensure serializers never throw errors, as this can cause the Node +> process to exit. See the +> [Pino documentation](https://getpino.io/#/docs/api?id=opt-serializers) for more +> information. *Any logger other than Pino will ignore this option.* -You can also supply your own logger instance. Instead of passing configuration -options, pass the instance. The logger you supply must conform to the Pino -interface; that is, it must have the following methods: `info`, `error`, -`debug`, `fatal`, `warn`, `trace`, `child`. +### Using Custom Loggers +A custom logger instance can be supplied by passing it as `loggerInstance`. The +logger must conform to the Pino interface, with methods: `info`, `error`, +`debug`, `fatal`, `warn`, `trace`, `silent`, `child`, and a string property +`level`. Example: ```js const log = require('pino')({ level: 'info' }) -const fastify = require('fastify')({ logger: log }) +const fastify = require('fastify')({ loggerInstance: log }) log.info('does not have request information') @@ -189,11 +237,11 @@ fastify.get('/', function (request, reply) { *The logger instance for the current request is available in every part of the [lifecycle](./Lifecycle.md).* -## Log Redaction +### Log Redaction [Pino](https://getpino.io) supports low-overhead log redaction for obscuring -values of specific properties in recorded logs. As an example, we might want to -log all the HTTP headers minus the `Authorization` header for security concerns: +values of specific properties in recorded logs. For example, log all HTTP +headers except the `Authorization` header for security: ```js const fastify = Fastify({ @@ -207,7 +255,7 @@ const fastify = Fastify({ method: request.method, url: request.url, headers: request.headers, - hostname: request.hostname, + host: request.host, remoteAddress: request.ip, remotePort: request.socket.remotePort } diff --git a/docs/Reference/Middleware.md b/docs/Reference/Middleware.md index 51d77a97214..e92a0c58c48 100644 --- a/docs/Reference/Middleware.md +++ b/docs/Reference/Middleware.md @@ -22,39 +22,40 @@ fastify.use(require('ienoopen')()) fastify.use(require('x-xss-protection')()) ``` -You can also use [`@fastify/middie`](https://github.com/fastify/middie), which provides -support for simple Express-style middleware but with improved performance: +[`@fastify/middie`](https://github.com/fastify/middie) can also be used, +which provides support for simple Express-style middleware with improved +performance: ```js await fastify.register(require('@fastify/middie')) fastify.use(require('cors')()) ``` -Remember that middleware can be encapsulated; this means that you can decide -where your middleware should run by using `register` as explained in the -[plugins guide](../Guides/Plugins-Guide.md). +Middleware can be encapsulated, allowing control over where it runs using +`register` as explained in the [plugins guide](../Guides/Plugins-Guide.md). -Fastify middleware does not expose the `send` method or other methods specific to -the Fastify [Reply](./Reply.md#reply) instance. This is because Fastify wraps +Fastify middleware does not expose the `send` method or other methods specific +to the Fastify [Reply](./Reply.md#reply) instance. This is because Fastify wraps the incoming `req` and `res` Node instances using the [Request](./Request.md#request) and [Reply](./Reply.md#reply) objects -internally, but this is done after the middleware phase. If you need to create -middleware, you have to use the Node `req` and `res` instances. Otherwise, you -can use the `preHandler` hook that already has the -[Request](./Request.md#request) and [Reply](./Reply.md#reply) Fastify instances. -For more information, see [Hooks](./Hooks.md#hooks). +internally, but this is done after the middleware phase. To create middleware, +use the Node `req` and `res` instances. Alternatively, use the `preHandler` hook +that already has the Fastify [Request](./Request.md#request) and +[Reply](./Reply.md#reply) instances. For more information, see +[Hooks](./Hooks.md#hooks). #### Restrict middleware execution to certain paths -If you need to only run middleware under certain paths, just pass the path as -the first parameter to `use` and you are done! +To run middleware under certain paths, pass the path as the first parameter to +`use`. -*Note that this does not support routes with parameters, (e.g. -`/user/:id/comments`) and wildcards are not supported in multiple paths.* +> ℹ️ Note: +> This does not support routes with parameters +> (e.g. `/user/:id/comments`) and wildcards are not supported in multiple paths. ```js -const path = require('path') +const path = require('node:path') const serveStatic = require('serve-static') // Single path @@ -69,8 +70,8 @@ fastify.use(['/css', '/js'], serveStatic(path.join(__dirname, '/assets'))) ### Alternatives -Fastify offers some alternatives to the most commonly used middleware, such as -[`@fastify/helmet`](https://github.com/fastify/fastify-helmet) in case of +Fastify offers alternatives to commonly used middleware, such as +[`@fastify/helmet`](https://github.com/fastify/fastify-helmet) for [`helmet`](https://github.com/helmetjs/helmet), [`@fastify/cors`](https://github.com/fastify/fastify-cors) for [`cors`](https://github.com/expressjs/cors), and diff --git a/docs/Reference/Plugins.md b/docs/Reference/Plugins.md index 1e6a18f52b5..944553d76c3 100644 --- a/docs/Reference/Plugins.md +++ b/docs/Reference/Plugins.md @@ -1,21 +1,18 @@

Fastify

## Plugins -Fastify allows the user to extend its functionalities with plugins. A plugin can -be a set of routes, a server [decorator](./Decorators.md), or whatever. The API -that you will need to use one or more plugins, is `register`. - -By default, `register` creates a *new scope*, this means that if you make some -changes to the Fastify instance (via `decorate`), this change will not be -reflected by the current context ancestors, but only by its descendants. This -feature allows us to achieve plugin *encapsulation* and *inheritance*, in this -way we create a *directed acyclic graph* (DAG) and we will not have issues -caused by cross dependencies. - -You may have already seen in the [Getting -Started](../Guides/Getting-Started.md#your-first-plugin) guide how easy it is -to use this API: -``` +Fastify can be extended with plugins, which can be a set of routes, a server +[decorator](./Decorators.md), or other functionality. Use the `register` API to +add one or more plugins. + +By default, `register` creates a *new scope*, meaning changes to the Fastify +instance (via `decorate`) will not affect the current context ancestors, only +its descendants. This feature enables plugin *encapsulation* and *inheritance*, +creating a *directed acyclic graph* (DAG) and avoiding cross-dependency issues. + +The [Getting Started](../Guides/Getting-Started.md#your-first-plugin) guide +includes an example of using this API: +```js fastify.register(plugin, [options]) ``` @@ -33,10 +30,9 @@ Fastify specific options is: + [`logSerializers`](./Routes.md#custom-log-serializer) + [`prefix`](#route-prefixing-option) -**Note: Those options will be ignored when used with fastify-plugin** +These options will be ignored when used with fastify-plugin. -It is possible that Fastify will directly support other options in the future. -Thus, to avoid collisions, a plugin should consider namespacing its options. For +To avoid collisions, a plugin should consider namespacing its options. For example, a plugin `foo` might be registered like so: ```js @@ -49,8 +45,7 @@ fastify.register(require('fastify-foo'), { }) ``` -If collisions are not a concern, the plugin may simply accept the options object -as-is: +If collisions are not a concern, the plugin may accept the options object as-is: ```js fastify.register(require('fastify-foo'), { @@ -60,9 +55,8 @@ fastify.register(require('fastify-foo'), { }) ``` -The `options` parameter can also be a `Function` that will be evaluated at the -time the plugin is registered while giving access to the Fastify instance via -the first positional argument: +The `options` parameter can also be a `Function` evaluated at plugin registration, +providing access to the Fastify instance via the first argument: ```js const fp = require('fastify-plugin') @@ -77,40 +71,38 @@ fastify.register(fp((fastify, opts, done) => { fastify.register(require('fastify-foo'), parent => parent.foo_bar) ``` -The Fastify instance passed on to the function is the latest state of the -**external Fastify instance** the plugin was declared on, allowing access to -variables injected via [`decorate`](./Decorators.md) by preceding plugins -according to the **order of registration**. This is useful in case a plugin -depends on changes made to the Fastify instance by a preceding plugin i.e. -utilizing an existing database connection to wrap around it. +The Fastify instance passed to the function is the latest state of the **external +Fastify instance** the plugin was declared on, allowing access to variables +injected via [`decorate`](./Decorators.md) by preceding plugins according to the +**order of registration**. This is useful if a plugin depends on changes made to +the Fastify instance by a preceding plugin, such as utilizing an existing database +connection. -Keep in mind that the Fastify instance passed on to the function is the same as -the one that will be passed into the plugin, a copy of the external Fastify -instance rather than a reference. Any usage of the instance will behave the same -as it would if called within the plugins function i.e. if `decorate` is called, -the decorated variables will be available within the plugins function unless it -was wrapped with [`fastify-plugin`](https://github.com/fastify/fastify-plugin). +Keep in mind that the Fastify instance passed to the function is the same as the +one passed into the plugin, a copy of the external Fastify instance rather than +a reference. Any usage of the instance will behave the same as it would if called +within the plugin's function. For example, if `decorate` is called, the decorated +variables will be available within the plugin's function unless it was wrapped +with [`fastify-plugin`](https://github.com/fastify/fastify-plugin). #### Route Prefixing option -If you pass an option with the key `prefix` with a `string` value, Fastify will -use it to prefix all the routes inside the register, for more info check +If an option with the key `prefix` and a `string` value is passed, Fastify will +use it to prefix all the routes inside the register. For more info, check [here](./Routes.md#route-prefixing). -Be aware that if you wrap your routes with +Be aware that if routes are wrapped with [`fastify-plugin`](https://github.com/fastify/fastify-plugin), this option will -not work (there is a [workaround](./Routes.md#fastify-plugin) available). +not work (see the [workaround](./Routes.md#fastify-plugin)). #### Error handling -The error handling is done by -[avvio](https://github.com/mcollina/avvio#error-handling). +Error handling is done by [avvio](https://github.com/mcollina/avvio#error-handling). -As a general rule, it is highly recommended that you handle your errors in the -next `after` or `ready` block, otherwise you will get them inside the `listen` -callback. +As a general rule, handle errors in the next `after` or `ready` block, otherwise +they will be caught inside the `listen` callback. ```js fastify.register(require('my-plugin')) @@ -134,7 +126,7 @@ fastify.listen({ port: 3000 }, (err, address) => { *async/await* is supported by `after`, `ready`, and `listen`, as well as -`fastify` being a [Thenable](https://promisesaplus.com/). +`fastify` being a Thenable. ```js await fastify.register(require('my-plugin')) @@ -145,12 +137,15 @@ await fastify.ready() await fastify.listen({ port: 3000 }) ``` +Using `await` when registering a plugin loads the plugin and its dependencies, +"finalizing" the encapsulation process. Any mutations to the plugin after it and +its dependencies have been loaded will not be reflected in the parent instance. #### ESM support -ESM is supported as well from [Node.js -`v13.3.0`](https://nodejs.org/api/esm.html) and above! +ESM is supported from [Node.js `v13.3.0`](https://nodejs.org/api/esm.html) +and above. ```js // main.mjs @@ -175,21 +170,29 @@ export default plugin ### Create a plugin -Creating a plugin is very easy, you just need to create a function that takes -three parameters, the `fastify` instance, an `options` object, and the `done` -callback. +Creating a plugin is easy. Create a function that takes three parameters: the +`fastify` instance, an `options` object, and the `done` callback. Alternatively, +use an `async` function and omit the `done` callback. Example: ```js -module.exports = function (fastify, opts, done) { +module.exports = function callbackPlugin (fastify, opts, done) { fastify.decorate('utility', function () {}) fastify.get('/', handler) done() } + +// Or using async +module.exports = async function asyncPlugin (fastify, opts) { + fastify.decorate('utility', function () {}) + + fastify.get('/', handler) +} ``` -You can also use `register` inside another `register`: + +`register` can also be used inside another `register`: ```js module.exports = function (fastify, opts, done) { fastify.decorate('utility', function () {}) @@ -201,28 +204,23 @@ module.exports = function (fastify, opts, done) { done() } ``` -Sometimes, you will need to know when the server is about to close, for example, -because you must close a connection to a database. To know when this is going to -happen, you can use the [`'onClose'`](./Hooks.md#on-close) hook. -Do not forget that `register` will always create a new Fastify scope, if you do -not need that, read the following section. +Remember, `register` always creates a new Fastify scope. If this is not needed, +read the following section. ### Handle the scope -If you are using `register` only for extending the functionality of the server -with [`decorate`](./Decorators.md), it is your responsibility to tell Fastify -not to create a new scope. Otherwise, your changes will not be accessible by the -user in the upper scope. +If `register` is used only to extend server functionality with +[`decorate`](./Decorators.md), tell Fastify not to create a new scope. Otherwise, +changes will not be accessible in the upper scope. -You have two ways to tell Fastify to avoid the creation of a new context: +There are two ways to avoid creating a new context: - Use the [`fastify-plugin`](https://github.com/fastify/fastify-plugin) module - Use the `'skip-override'` hidden property -We recommend using the `fastify-plugin` module, because it solves this problem -for you, and you can pass a version range of Fastify as a parameter that your -plugin will support. +Using the `fastify-plugin` module is recommended, as it solves this problem and +allows passing a version range of Fastify that the plugin will support: ```js const fp = require('fastify-plugin') @@ -234,10 +232,9 @@ module.exports = fp(function (fastify, opts, done) { Check the [`fastify-plugin`](https://github.com/fastify/fastify-plugin) documentation to learn more about how to use this module. -If you do not use the `fastify-plugin` module, you can use the `'skip-override'` -hidden property, but we do not recommend it. If in the future the Fastify API -changes it will be your responsibility to update the module, while if you use -`fastify-plugin`, you can be sure about backward compatibility. +If not using `fastify-plugin`, the `'skip-override'` hidden property can be used, +but it is not recommended. Future Fastify API changes will be your responsibility +to update, whilst `fastify-plugin` ensures backward compatibility. ```js function yourPlugin (fastify, opts, done) { fastify.decorate('utility', function () {}) diff --git a/docs/Reference/Principles.md b/docs/Reference/Principles.md new file mode 100644 index 00000000000..fc5694cbbc8 --- /dev/null +++ b/docs/Reference/Principles.md @@ -0,0 +1,73 @@ +# Technical Principles + +Every decision in the Fastify framework and its official plugins is guided by +the following technical principles: + +1. “Zero” overhead in production +2. “Good” developer experience +3. Works great for small & big projects alike +4. Easy to migrate to microservices (or even serverless) and back +5. Security & data validation +6. If something could be a plugin, it likely should be +7. Easily testable +8. Do not monkeypatch core +9. Semantic versioning & Long Term Support +10. Specification adherence + +## "Zero" Overhead in Production + +Fastify aims to implement features with minimal overhead. This is achieved by +using fast algorithms, data structures, and JavaScript-specific features. + +Since JavaScript does not offer zero-overhead data structures, this principle +can conflict with providing a great developer experience and additional features, +as these usually incur some overhead. + +## "Good" Developer Experience + +Fastify aims to provide the best developer experience at its performance point. +It offers a great out-of-the-box experience that is flexible enough to adapt to +various situations. + +For example, binary addons are forbidden because most JavaScript developers do +not have access to a compiler. + +## Works great for small and big projects alike + +Most applications start small and become more complex over time. Fastify aims to +grow with this complexity, providing advanced features to structure codebases. + +## Easy to migrate to microservices (or even serverless) and back + +Route deployment should not matter. The framework should "just work". + +## Security and Data Validation + +A web framework is the first point of contact with untrusted data and must act +as the first line of defense for the system. + +## If something could be a plugin, it likely should + +Recognizing the infinite use cases for an HTTP framework, catering to all in a +single module would make the codebase unmaintainable. Therefore, hooks and +options are provided to customize the framework as needed. + +## Easily testable + +Testing Fastify applications should be a first-class concern. + +## Do not monkeypatch core + +Monkeypatching Node.js APIs or installing globals that alter the runtime makes +building modular applications harder and limits Fastify's use cases. Other +frameworks do this; Fastify does not. + +## Semantic Versioning and Long Term Support + +A clear [Long Term Support strategy is provided](./LTS.md) to inform developers +when to upgrade. + +## Specification adherence + +In doubt, we chose the strict behavior as defined by the relevant +Specifications. diff --git a/docs/Reference/Reply.md b/docs/Reference/Reply.md index bc0b9bf30db..e950ee8a509 100644 --- a/docs/Reference/Reply.md +++ b/docs/Reference/Reply.md @@ -4,22 +4,25 @@ - [Reply](#reply) - [Introduction](#introduction) - [.code(statusCode)](#codestatuscode) + - [.elapsedTime](#elapsedtime) - [.statusCode](#statuscode) - [.server](#server) - [.header(key, value)](#headerkey-value) - - [set-cookie](#set-cookie) - [.headers(object)](#headersobject) - [.getHeader(key)](#getheaderkey) - [.getHeaders()](#getheaders) - [.removeHeader(key)](#removeheaderkey) - [.hasHeader(key)](#hasheaderkey) + - [.writeEarlyHints(hints, callback)](#writeearlyhintshints-callback) - [.trailer(key, function)](#trailerkey-function) - [.hasTrailer(key)](#hastrailerkey) - [.removeTrailer(key)](#removetrailerkey) - - [.redirect([code,] dest)](#redirectcode--dest) + - [.redirect(dest, [code ,])](#redirectdest--code) - [.callNotFound()](#callnotfound) - - [.getResponseTime()](#getresponsetime) - [.type(contentType)](#typecontenttype) + - [.getSerializationFunction(schema | httpStatus, [contentType])](#getserializationfunctionschema--httpstatus) + - [.compileSerializationSchema(schema, [httpStatus], [contentType])](#compileserializationschemaschema-httpstatus) + - [.serializeInput(data, [schema | httpStatus], [httpStatus], [contentType])](#serializeinputdata-schema--httpstatus-httpstatus) - [.serializer(func)](#serializerfunc) - [.raw](#raw) - [.sent](#sent) @@ -29,6 +32,9 @@ - [Strings](#strings) - [Streams](#streams) - [Buffers](#buffers) + - [TypedArrays](#typedarrays) + - [ReadableStream](#readablestream) + - [Response](#response) - [Errors](#errors) - [Type of the final payload](#type-of-the-final-payload) - [Async-Await and Promises](#async-await-and-promises) @@ -43,6 +49,8 @@ object that exposes the following functions and properties: - `.code(statusCode)` - Sets the status code. - `.status(statusCode)` - An alias for `.code(statusCode)`. - `.statusCode` - Read and set the HTTP status code. +- `.elapsedTime` - Returns the amount of time passed +since the request was received by Fastify. - `.server` - A reference to the fastify instance object. - `.header(name, value)` - Sets a response header. - `.headers(object)` - Sets all the keys of the object as response headers. @@ -50,16 +58,30 @@ object that exposes the following functions and properties: - `.getHeaders()` - Gets a shallow copy of all current response headers. - `.removeHeader(key)` - Remove the value of a previously set header. - `.hasHeader(name)` - Determine if a header has been set. +- `.writeEarlyHints(hints, callback)` - Sends early hints to the user + while the response is being prepared. - `.trailer(key, function)` - Sets a response trailer. - `.hasTrailer(key)` - Determine if a trailer has been set. - `.removeTrailer(key)` - Remove the value of a previously set trailer. - `.type(value)` - Sets the header `Content-Type`. -- `.redirect([code,] dest)` - Redirect to the specified url, the status code is - optional (default to `302`). +- `.redirect(dest, [code,])` - Redirect to the specified URL, the status code is + optional (defaults to `302`). - `.callNotFound()` - Invokes the custom not found handler. - `.serialize(payload)` - Serializes the specified payload using the default JSON serializer or using the custom serializer (if one is set) and returns the serialized payload. +- `.getSerializationFunction(schema | httpStatus, [contentType])` - Returns the + serialization function for the specified schema or http status, if any of + either are set. +- `.compileSerializationSchema(schema, [httpStatus], [contentType])` - Compiles + the specified schema and returns a serialization function using the default + (or customized) `SerializerCompiler`. The optional `httpStatus` is forwarded + to the `SerializerCompiler` if provided, default to `undefined`. +- `.serializeInput(data, schema, [,httpStatus], [contentType])` - Serializes + the specified data using the specified schema and returns the serialized payload. + If the optional `httpStatus`, and `contentType` are provided, the function + will use the serializer function given for that specific content type and + HTTP Status Code. Default to `undefined`. - `.serializer(function)` - Sets a custom serializer for the payload. - `.send(payload)` - Sends the payload to the user, could be a plain text, a buffer, JSON, stream, or an Error object. @@ -67,11 +89,10 @@ object that exposes the following functions and properties: already been called. - `.hijack()` - interrupt the normal request lifecycle. - `.raw` - The - [`http.ServerResponse`](https://nodejs.org/dist/latest-v14.x/docs/api/http.html#http_class_http_serverresponse) + [`http.ServerResponse`](https://nodejs.org/dist/latest-v20.x/docs/api/http.html#http_class_http_serverresponse) from Node core. - `.log` - The logger instance of the incoming request. - `.request` - The incoming request. -- `.context` - Access the [Request's context](./Request.md) property. ```js fastify.get('/', options, function (request, reply) { @@ -83,19 +104,21 @@ fastify.get('/', options, function (request, reply) { }) ``` -Additionally, `Reply` provides access to the context of the request: - -```js -fastify.get('/', {config: {foo: 'bar'}}, function (request, reply) { - reply.send('handler config.foo = ' + reply.context.config.foo) -}) -``` - ### .code(statusCode) If not set via `reply.code`, the resulting `statusCode` will be `200`. +### .elapsedTime + + +Invokes the custom response time getter to calculate the amount of time passed +since the request was received by Fastify. + +```js +const milliseconds = reply.elapsedTime +``` + ### .statusCode @@ -129,14 +152,15 @@ fastify.get('/', async function (req, rep) { Sets a response header. If the value is omitted or undefined, it is coerced to `''`. -> Note: the header's value must be properly encoded using +> ℹ️ Note: +> The header's value must be properly encoded using > [`encodeURI`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURI) > or similar modules such as > [`encodeurl`](https://www.npmjs.com/package/encodeurl). Invalid characters > will result in a 500 `TypeError` response. For more information, see -[`http.ServerResponse#setHeader`](https://nodejs.org/dist/latest-v14.x/docs/api/http.html#http_response_setheader_name_value). +[`http.ServerResponse#setHeader`](https://nodejs.org/dist/latest-v20.x/docs/api/http.html#http_response_setheader_name_value). - ### set-cookie @@ -209,6 +233,27 @@ reply.getHeader('x-foo') // undefined Returns a boolean indicating if the specified header has been set. +### .writeEarlyHints(hints, callback) + + +Sends early hints to the client. Early hints allow the client to +start processing resources before the final response is sent. +This can improve performance by allowing the client to preload +or preconnect to resources while the server is still generating the response. + +The hints parameter is an object containing the early hint key-value pairs. + +Example: + +```js +reply.writeEarlyHints({ + Link: '; rel=preload; as=style' +}); +``` + +The optional callback parameter is a function that will be called +once the hint is sent or if an error occurs. + ### .trailer(key, function) @@ -217,25 +262,35 @@ requires heavy resources to be sent after the `data`, for example, `Server-Timing` and `Etag`. It can ensure the client receives the response data as soon as possible. -*Note: The header `Transfer-Encoding: chunked` will be added once you use the -trailer. It is a hard requirement for using trailer in Node.js.* +> ℹ️ Note: +> The header `Transfer-Encoding: chunked` will be added once you use +> the trailer. It is a hard requirement for using trailer in Node.js. -*Note: Currently, the computation function only supports synchronous function. -That means `async-await` and `promise` are not supported.* +> ℹ️ Note: +> Any error passed to `done` callback will be ignored. If you are interested +> in the error, you can turn on `debug` level logging. ```js reply.trailer('server-timing', function() { return 'db;dur=53, app;dur=47.2' }) -const { createHash } = require('crypto') -// trailer function also recieve two argument +const { createHash } = require('node:crypto') +// trailer function also receive two argument // @param {object} reply fastify reply // @param {string|Buffer|null} payload payload that already sent, note that it will be null when stream is sent -reply.trailer('content-md5', function(reply, payload) { +// @param {function} done callback to set trailer value +reply.trailer('content-md5', function(reply, payload, done) { + const hash = createHash('md5') + hash.update(payload) + done(null, hash.digest('hex')) +}) + +// when you prefer async-await +reply.trailer('content-md5', async function(reply, payload) { const hash = createHash('md5') hash.update(payload) - return hash.disgest('hex') + return hash.digest('hex') }) ``` @@ -257,13 +312,14 @@ reply.getTrailer('server-timing') // undefined ``` -### .redirect([code ,] dest) +### .redirect(dest, [code ,]) Redirects a request to the specified URL, the status code is optional, default to `302` (if status code is not already set by calling `code`). -> Note: the input URL must be properly encoded using +> ℹ️ Note: +> The input URL must be properly encoded using > [`encodeURI`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURI) > or similar modules such as > [`encodeurl`](https://www.npmjs.com/package/encodeurl). Invalid URLs will @@ -278,7 +334,7 @@ reply.redirect('/home') Example (no `reply.code()` call) sets status code to `303` and redirects to `/home` ```js -reply.redirect(303, '/home') +reply.redirect('/home', 303) ``` Example (`reply.code()` call) sets status code to `303` and redirects to `/home` @@ -288,7 +344,7 @@ reply.code(303).redirect('/home') Example (`reply.code()` call) sets status code to `302` and redirects to `/home` ```js -reply.code(303).redirect(302, '/home') +reply.code(303).redirect('/home', 302) ``` ### .callNotFound() @@ -301,19 +357,6 @@ hook specified in [`setNotFoundHandler`](./Server.md#set-not-found-handler). reply.callNotFound() ``` -### .getResponseTime() - - -Invokes the custom response time getter to calculate the amount of time passed -since the request was started. - -Note that unless this function is called in the [`onResponse` -hook](./Hooks.md#onresponse) it will always return `0`. - -```js -const milliseconds = reply.getResponseTime() -``` - ### .type(contentType) @@ -324,7 +367,199 @@ Sets the content type for the response. This is a shortcut for reply.type('text/html') ``` If the `Content-Type` has a JSON subtype, and the charset parameter is not set, -`utf-8` will be used as the charset by default. +`utf-8` will be used as the charset by default. For other content types, the +charset must be set explicitly. + +### .getSerializationFunction(schema | httpStatus, [contentType]) + + +By calling this function using a provided `schema` or `httpStatus`, +and the optional `contentType`, it will return a `serialization` function +that can be used to serialize diverse inputs. It returns `undefined` if no +serialization function was found using either of the provided inputs. + +This heavily depends of the `schema#responses` attached to the route, or +the serialization functions compiled by using `compileSerializationSchema`. + +```js +const serialize = reply + .getSerializationFunction({ + type: 'object', + properties: { + foo: { + type: 'string' + } + } + }) +serialize({ foo: 'bar' }) // '{"foo":"bar"}' + +// or + +const serialize = reply + .getSerializationFunction(200) +serialize({ foo: 'bar' }) // '{"foo":"bar"}' + +// or + +const serialize = reply + .getSerializationFunction(200, 'application/json') +serialize({ foo: 'bar' }) // '{"foo":"bar"}' +``` + +See [.compileSerializationSchema(schema, [httpStatus], [contentType])](#compileserializationschema) +for more information on how to compile serialization schemas. + +### .compileSerializationSchema(schema, [httpStatus], [contentType]) + + +This function will compile a serialization schema and +return a function that can be used to serialize data. +The function returned (a.k.a. _serialization function_) returned is compiled +by using the provided `SerializerCompiler`. Also this is cached by using +a `WeakMap` for reducing compilation calls. + +The optional parameters `httpStatus` and `contentType`, if provided, +are forwarded directly to the `SerializerCompiler`, so it can be used +to compile the serialization function if a custom `SerializerCompiler` is used. + +This heavily depends of the `schema#responses` attached to the route, or +the serialization functions compiled by using `compileSerializationSchema`. + +```js +const serialize = reply + .compileSerializationSchema({ + type: 'object', + properties: { + foo: { + type: 'string' + } + } + }) +serialize({ foo: 'bar' }) // '{"foo":"bar"}' + +// or + +const serialize = reply + .compileSerializationSchema({ + type: 'object', + properties: { + foo: { + type: 'string' + } + } + }, 200) +serialize({ foo: 'bar' }) // '{"foo":"bar"}' + +// or + +const serialize = reply + .compileSerializationSchema({ + '3xx': { + content: { + 'application/json': { + schema: { + name: { type: 'string' }, + phone: { type: 'number' } + } + } + } + } + }, '3xx', 'application/json') +serialize({ name: 'Jone', phone: 201090909090 }) // '{"name":"Jone", "phone":201090909090}' +``` + +Note that you should be careful when using this function, as it will cache +the compiled serialization functions based on the schema provided. If the +schemas provided is mutated or changed, the serialization functions will not +detect that the schema has been altered and for instance it will reuse the +previously compiled serialization function based on the reference of the schema +previously provided. + +If there's a need to change the properties of a schema, always opt to create +a totally new object, otherwise the implementation won't benefit from the cache +mechanism. + +:Using the following schema as example: +```js +const schema1 = { + type: 'object', + properties: { + foo: { + type: 'string' + } + } +} +``` + +*Not* +```js +const serialize = reply.compileSerializationSchema(schema1) + +// Later on... +schema1.properties.foo.type. = 'integer' +const newSerialize = reply.compileSerializationSchema(schema1) + +console.log(newSerialize === serialize) // true +``` + +*Instead* +```js +const serialize = reply.compileSerializationSchema(schema1) + +// Later on... +const newSchema = Object.assign({}, schema1) +newSchema.properties.foo.type = 'integer' + +const newSerialize = reply.compileSerializationSchema(newSchema) + +console.log(newSerialize === serialize) // false +``` + +### .serializeInput(data, [schema | httpStatus], [httpStatus], [contentType]) + + +This function will serialize the input data based on the provided schema +or HTTP status code. If both are provided the `httpStatus` will take precedence. + +If there is not a serialization function for a given `schema` a new serialization +function will be compiled, forwarding the `httpStatus` and `contentType` if provided. + +```js +reply + .serializeInput({ foo: 'bar'}, { + type: 'object', + properties: { + foo: { + type: 'string' + } + } + }) // '{"foo":"bar"}' + +// or + +reply + .serializeInput({ foo: 'bar'}, { + type: 'object', + properties: { + foo: { + type: 'string' + } + } + }, 200) // '{"foo":"bar"}' + +// or + +reply + .serializeInput({ foo: 'bar'}, 200) // '{"foo":"bar"}' + +// or + +reply + .serializeInput({ name: 'Jone', age: 18 }, '200', 'application/vnd.v1+json') // '{"name": "Jone", "age": 18}' +``` + +See [.compileSerializationSchema(schema, [httpStatus], [contentType])](#compileserializationschema) +for more information on how to compile serialization schemas. ### .serializer(func) @@ -358,7 +593,7 @@ values. This is the -[`http.ServerResponse`](https://nodejs.org/dist/latest-v14.x/docs/api/http.html#http_class_http_serverresponse) +[`http.ServerResponse`](https://nodejs.org/dist/latest-v20.x/docs/api/http.html#http_class_http_serverresponse) from Node core. Whilst you are using the Fastify `Reply` object, the use of `Reply.raw` functions is at your own risk as you are skipping all the Fastify logic of handling the HTTP response. e.g.: @@ -392,8 +627,9 @@ low-level request and response. Moreover, hooks will not be invoked. *Modifying the `.sent` property directly is deprecated. Please use the aforementioned `.hijack()` method to achieve the same effect.* - ### .hijack() + + Sometimes you might need to halt the execution of the normal request lifecycle and handle sending the response manually. @@ -439,9 +675,21 @@ fastify.get('/json', options, function (request, reply) { If you pass a string to `send` without a `Content-Type`, it will be sent as `text/plain; charset=utf-8`. If you set the `Content-Type` header and pass a string to `send`, it will be serialized with the custom serializer if one is -set, otherwise, it will be sent unmodified (unless the `Content-Type` header is -set to `application/json; charset=utf-8`, in which case it will be -JSON-serialized like an object — see the section above). +set, otherwise, it will be sent unmodified. + +> ℹ️ Note: +> Even when the `Content-Type` header is set to `application/json`, +> strings are sent unmodified by default. To serialize a string as JSON, you +> must set a custom serializer: + +```js +fastify.get('/json-string', async function (request, reply) { + reply + .type('application/json; charset=utf-8') + .serializer(JSON.stringify) + .send('Hello') // Returns "Hello" (JSON-encoded string) +}) +``` ```js fastify.get('/json', options, function (request, reply) { reply.send('plain string') @@ -451,24 +699,47 @@ fastify.get('/json', options, function (request, reply) { #### Streams -*send* can also handle streams out of the box. If you are sending a stream and -you have not set a `'Content-Type'` header, *send* will set it at -`'application/octet-stream'`. +If you are sending a stream and you have not set a `'Content-Type'` header, +*send* will set it to `'application/octet-stream'`. + +As noted above, streams are considered to be pre-serialized, so they will be +sent unmodified without response validation. + +See special note about error handling for streams in +[`setErrorHandler`](./Server.md#seterrorhandler). + ```js +const fs = require('node:fs') + fastify.get('/streams', function (request, reply) { - const fs = require('fs') const stream = fs.createReadStream('some-file', 'utf8') + reply.header('Content-Type', 'application/octet-stream') reply.send(stream) }) ``` +When using async-await you will need to return or await the reply object: +```js +const fs = require('node:fs') + +fastify.get('/streams', async function (request, reply) { + const stream = fs.createReadStream('some-file', 'utf8') + reply.header('Content-Type', 'application/octet-stream') + return reply.send(stream) +}) +``` #### Buffers If you are sending a buffer and you have not set a `'Content-Type'` header, *send* will set it to `'application/octet-stream'`. + +As noted above, Buffers are considered to be pre-serialized, so they will be +sent unmodified without response validation. + ```js -const fs = require('fs') +const fs = require('node:fs') + fastify.get('/streams', function (request, reply) { fs.readFile('some-file', (err, fileBuffer) => { reply.send(err || fileBuffer) @@ -476,6 +747,84 @@ fastify.get('/streams', function (request, reply) { }) ``` +When using async-await you will need to return or await the reply object: +```js +const fs = require('node:fs') + +fastify.get('/streams', async function (request, reply) { + fs.readFile('some-file', (err, fileBuffer) => { + reply.send(err || fileBuffer) + }) + return reply +}) +``` + +#### TypedArrays + + +`send` manages TypedArray like a Buffer, and sets the `'Content-Type'` +header to `'application/octet-stream'` if not already set. + +As noted above, TypedArray/Buffers are considered to be pre-serialized, so they +will be sent unmodified without response validation. + +```js +const fs = require('node:fs') + +fastify.get('/streams', function (request, reply) { + const typedArray = new Uint16Array(10) + reply.send(typedArray) +}) +``` + +#### ReadableStream + + +`ReadableStream` will be treated as a node stream mentioned above, +the content is considered to be pre-serialized, so they will be +sent unmodified without response validation. + +```js +const fs = require('node:fs') +const { ReadableStream } = require('node:stream/web') + +fastify.get('/streams', function (request, reply) { + const stream = fs.createReadStream('some-file') + reply.header('Content-Type', 'application/octet-stream') + reply.send(ReadableStream.from(stream)) +}) +``` + +#### Response + + +`Response` allows to manage the reply payload, status code and +headers in one place. The payload provided inside `Response` is +considered to be pre-serialized, so they will be sent unmodified +without response validation. + +Please be aware when using `Response`, the status code and headers +will not directly reflect to `reply.statusCode` and `reply.getHeaders()`. +Such behavior is based on `Response` only allow `readonly` status +code and headers. The data is not allow to be bi-direction editing, +and may confuse when checking the `payload` in `onSend` hooks. + +```js +const fs = require('node:fs') +const { ReadableStream } = require('node:stream/web') + +fastify.get('/streams', function (request, reply) { + const stream = fs.createReadStream('some-file') + const readableStream = ReadableStream.from(stream) + const response = new Response(readableStream, { + status: 200, + headers: { 'content-type': 'application/octet-stream' } + }) + reply.send(response) +}) +``` + + #### Errors @@ -494,8 +843,9 @@ automatically create an error structured as the following: You can add custom properties to the Error object, such as `headers`, that will be used to enhance the HTTP response. -*Note: If you are passing an error to `send` and the statusCode is less than -400, Fastify will automatically set it at 500.* +> ℹ️ Note: +> If you are passing an error to `send` and the statusCode is less than +> 400, Fastify will automatically set it at 500. Tip: you can simplify errors by using the [`http-errors`](https://npm.im/http-errors) module or @@ -514,7 +864,7 @@ To customize the JSON error output you can do it by: - add the additional properties to the `Error` instance Notice that if the returned status code is not in the response schema list, the -default behaviour will be applied. +default behavior will be applied. ```js fastify.get('/', { @@ -542,14 +892,15 @@ fastify.get('/', { If you want to customize error handling, check out [`setErrorHandler`](./Server.md#seterrorhandler) API. -*Note: you are responsible for logging when customizing the error handler* +> ℹ️ Note: +> You are responsible for logging when customizing the error handler. API: ```js fastify.setErrorHandler(function (error, request, reply) { request.log.warn(error) - var statusCode = error.statusCode >= 400 ? error.statusCode : 500 + const statusCode = error.statusCode >= 400 ? error.statusCode : 500 reply .code(statusCode) .type('text/plain') @@ -557,6 +908,11 @@ fastify.setErrorHandler(function (error, request, reply) { }) ``` +Beware that calling `reply.send(error)` in your custom error handler will send +the error to the default error handler. +Check out the [Reply Lifecycle](./Lifecycle.md#reply-lifecycle) +for more information. + The not found errors generated by the router will use the [`setNotFoundHandler`](./Server.md#setnotfoundhandler) @@ -591,6 +947,7 @@ Fastify natively handles promises and supports async-await. *Note that in the following examples we are not using reply.send.* ```js +const { promisify } = require('node:util') const delay = promisify(setTimeout) fastify.get('/promises', options, function (request, reply) { @@ -640,6 +997,5 @@ For more details, see: - https://github.com/fastify/fastify/issues/1864 for the discussion about this feature -- https://promisesaplus.com/ for the definition of thenables - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/then for the signature diff --git a/docs/Reference/Request.md b/docs/Reference/Request.md index 9784ed1f518..4931853d3cb 100644 --- a/docs/Reference/Request.md +++ b/docs/Reference/Request.md @@ -4,45 +4,86 @@ The first parameter of the handler function is `Request`. Request is a core Fastify object containing the following fields: -- `query` - the parsed querystring, its format is specified by - [`querystringParser`](./Server.md#querystringparser) -- `body` - the request payload, see [Content-Type - Parser](./ContentTypeParser.md) for details on what request payloads Fastify - natively parses and how to support other content types -- `params` - the params matching the URL -- [`headers`](#headers) - the headers getter and setter -- `raw` - the incoming HTTP request from Node core -- `server` - The Fastify server instance, scoped to the current [encapsulation - context](./Encapsulation.md) -- `id` - the request ID -- `log` - the logger instance of the incoming request -- `ip` - the IP address of the incoming request -- `ips` - an array of the IP addresses, ordered from closest to furthest, in the +- `query` - The parsed querystring, its format is specified by + [`querystringParser`](./Server.md#querystringparser). +- `body` - The request payload, see [Content-Type Parser](./ContentTypeParser.md) + for details on what request payloads Fastify natively parses and how to support + other content types. +- `params` - The params matching the URL. +- [`headers`](#headers) - The headers getter and setter. +- `raw` - The incoming HTTP request from Node core. +- `server` - The Fastify server instance, scoped to the current + [encapsulation context](./Encapsulation.md). +- `id` - The request ID. +- `log` - The logger instance of the incoming request. +- `ip` - The IP address of the incoming request. +- `ips` - An array of the IP addresses, ordered from closest to furthest, in the `X-Forwarded-For` header of the incoming request (only when the - [`trustProxy`](./Server.md#factory-trust-proxy) option is enabled) -- `hostname` - the host of the incoming request (derived from `X-Forwarded-Host` + [`trustProxy`](./Server.md#factory-trust-proxy) option is enabled). +- `host` - The host of the incoming request (derived from `X-Forwarded-Host` header when the [`trustProxy`](./Server.md#factory-trust-proxy) option is - enabled). For HTTP/2 compatibility it returns `:authority` if no host header - exists. -- `protocol` - the protocol of the incoming request (`https` or `http`) -- `method` - the method of the incoming request -- `url` - the URL of the incoming request -- `routerMethod` - the method defined for the router that is handling the - request -- `routerPath` - the path pattern defined for the router that is handling the - request -- `is404` - true if request is being handled by 404 handler, false if it is not -- `connection` - Deprecated, use `socket` instead. The underlying connection of - the incoming request. -- `socket` - the underlying connection of the incoming request -- `context` - A Fastify internal object. You should not use it directly or - modify it. It is useful to access one special key: + enabled). For HTTP/2 compatibility, it returns `:authority` if no host header + exists. The host header may return an empty string if `requireHostHeader` is + `false`, not provided with HTTP/1.0, or removed by schema validation. + ⚠ Security: this value comes from client-controlled headers; only trust it + when you control proxy behavior and have validated or allow-listed hosts. + No additional validation is performed beyond RFC parsing (see + [RFC 9110, section 7.2](https://www.rfc-editor.org/rfc/rfc9110#section-7.2) and + [RFC 3986, section 3.2.2](https://www.rfc-editor.org/rfc/rfc3986#section-3.2.2)). +- `hostname` - The hostname derived from the `host` property of the incoming request. +- `port` - The port from the `host` property, which may refer to the port the + server is listening on. +- `protocol` - The protocol of the incoming request (`https` or `http`). +- `method` - The method of the incoming request. +- `url` - The URL of the incoming request. +- `originalUrl` - Similar to `url`, allows access to the original `url` in + case of internal re-routing. +- `is404` - `true` if request is being handled by 404 handler, `false` otherwise. +- `socket` - The underlying connection of the incoming request. +- `signal` - An `AbortSignal` that aborts when the handler timeout + fires or the client disconnects. Created lazily on first access, so + there is zero overhead when not used. When + [`handlerTimeout`](./Server.md#factory-handler-timeout) is configured, + the signal is pre-created and also aborts on timeout. Pass it to + `fetch()`, database queries, or any API accepting a `signal` option + for cooperative cancellation. On timeout, `signal.reason` is the + `FST_ERR_HANDLER_TIMEOUT` error; on client disconnect it is a generic + `AbortError`. Check `signal.reason.code` to distinguish the two cases. +- `context` - Deprecated, use `request.routeOptions.config` instead. A Fastify + internal object. Do not use or modify it directly. It is useful to access one + special key: - `context.config` - The route [`config`](./Routes.md#routes-config) object. +- `routeOptions` - The route [`option`](./Routes.md#routes-options) object. + - `bodyLimit` - Either server limit or route limit. + - `handlerTimeout` - The handler timeout configured for this route. + - `config` - The [`config`](./Routes.md#routes-config) object for this route. + - `method` - The HTTP method for the route. + - `url` - The path of the URL to match this route. + - `handler` - The handler for this route. + - `attachValidation` - Attach `validationError` to request (if there is + a schema defined). + - `logLevel` - Log level defined for this route. + - `schema` - The JSON schemas definition for this route. + - `version` - A semver compatible string that defines the version of the endpoint. + - `exposeHeadRoute` - Creates a sibling HEAD route for any GET routes. + - `prefixTrailingSlash` - String used to determine how to handle passing `/` + as a route with a prefix. +- [.getValidationFunction(schema | httpPart)](#getvalidationfunction) - + Returns a validation function for the specified schema or HTTP part, if + set or cached. +- [.compileValidationSchema(schema, [httpPart])](#compilevalidationschema) - + Compiles the specified schema and returns a validation function using the + default (or customized) `ValidationCompiler`. The optional `httpPart` is + forwarded to the `ValidationCompiler` if provided, defaults to `null`. +- [.validateInput(data, schema | httpPart, [httpPart])](#validate) - + Validates the input using the specified schema and returns the serialized + payload. If `httpPart` is provided, the function uses the serializer for + that HTTP Status Code. Defaults to `null`. ### Headers -The `request.headers` is a getter that returns an Object with the headers of the -incoming request. You can set custom headers like this: +The `request.headers` is a getter that returns an object with the headers of the +incoming request. Set custom headers as follows: ```js request.headers = { @@ -51,12 +92,16 @@ request.headers = { } ``` -This operation will add to the request headers the new values that can be read -calling `request.headers.bar`. Moreover, you can still access the standard -request's headers with the `request.raw.headers` property. +This operation adds new values to the request headers, accessible via +`request.headers.bar`. Standard request headers remain accessible via +`request.raw.headers`. -> Note: For performance reason on `not found` route, you may see that we will -add an extra property `Symbol('fastify.RequestAcceptVersion')` on the headers. +For performance reasons, `Symbol('fastify.RequestAcceptVersion')` may be added +to headers on `not found` routes. + +> ℹ️ Note: +> Schema validation may mutate the `request.headers` and +> `request.raw.headers` objects, causing the headers to become empty. ```js fastify.post('/:params', options, function (request, reply) { @@ -69,11 +114,182 @@ fastify.post('/:params', options, function (request, reply) { console.log(request.id) console.log(request.ip) console.log(request.ips) + console.log(request.host) console.log(request.hostname) + console.log(request.port) console.log(request.protocol) console.log(request.url) - console.log(request.routerMethod) - console.log(request.routerPath) + console.log(request.routeOptions.method) + console.log(request.routeOptions.bodyLimit) + console.log(request.routeOptions.method) + console.log(request.routeOptions.url) + console.log(request.routeOptions.attachValidation) + console.log(request.routeOptions.logLevel) + console.log(request.routeOptions.version) + console.log(request.routeOptions.exposeHeadRoute) + console.log(request.routeOptions.prefixTrailingSlash) + console.log(request.routeOptions.logLevel) request.log.info('some info') }) ``` +### .getValidationFunction(schema | httpPart) + + +By calling this function with a provided `schema` or `httpPart`, it returns a +`validation` function to validate diverse inputs. It returns `undefined` if no +serialization function is found using the provided inputs. + +This function has an `errors` property. Errors encountered during the last +validation are assigned to `errors`. + +```js +const validate = request + .getValidationFunction({ + type: 'object', + properties: { + foo: { + type: 'string' + } + } + }) +console.log(validate({ foo: 'bar' })) // true +console.log(validate.errors) // null + +// or + +const validate = request + .getValidationFunction('body') +console.log(validate({ foo: 0.5 })) // false +console.log(validate.errors) // validation errors +``` + +See [.compileValidationSchema(schema, [httpStatus])](#compileValidationSchema) +for more information on compiling validation schemas. + +### .compileValidationSchema(schema, [httpPart]) + + +This function compiles a validation schema and returns a function to validate data. +The returned function (a.k.a. _validation function_) is compiled using the provided +[`SchemaController#ValidationCompiler`](./Server.md#schema-controller). A `WeakMap` +is used to cache this, reducing compilation calls. + +The optional parameter `httpPart`, if provided, is forwarded to the +`ValidationCompiler`, allowing it to compile the validation function if a custom +`ValidationCompiler` is provided for the route. + +This function has an `errors` property. Errors encountered during the last +validation are assigned to `errors`. + +```js +const validate = request + .compileValidationSchema({ + type: 'object', + properties: { + foo: { + type: 'string' + } + } + }) +console.log(validate({ foo: 'bar' })) // true +console.log(validate.errors) // null + +// or + +const validate = request + .compileValidationSchema({ + type: 'object', + properties: { + foo: { + type: 'string' + } + } + }, 200) +console.log(validate({ hello: 'world' })) // false +console.log(validate.errors) // validation errors +``` + +Be careful when using this function, as it caches compiled validation functions +based on the provided schema. If schemas are mutated or changed, the validation +functions will not detect the alterations and will reuse the previously compiled +validation function, as the cache is based on the schema's reference. + +If schema properties need to be changed, create a new schema object to benefit +from the cache mechanism. + +Using the following schema as an example: +```js +const schema1 = { + type: 'object', + properties: { + foo: { + type: 'string' + } + } +} +``` + +*Not* +```js +const validate = request.compileValidationSchema(schema1) + +// Later on... +schema1.properties.foo.type. = 'integer' +const newValidate = request.compileValidationSchema(schema1) + +console.log(newValidate === validate) // true +``` + +*Instead* +```js +const validate = request.compileValidationSchema(schema1) + +// Later on... +const newSchema = Object.assign({}, schema1) +newSchema.properties.foo.type = 'integer' + +const newValidate = request.compileValidationSchema(newSchema) + +console.log(newValidate === validate) // false +``` + +### .validateInput(data, [schema | httpPart], [httpPart]) + + +This function validates the input based on the provided schema or HTTP part. If +both are provided, the `httpPart` parameter takes precedence. + +If no validation function exists for a given `schema`, a new validation function +will be compiled, forwarding the `httpPart` if provided. + +```js +request + .validateInput({ foo: 'bar'}, { + type: 'object', + properties: { + foo: { + type: 'string' + } + } + }) // true + +// or + +request + .validateInput({ foo: 'bar'}, { + type: 'object', + properties: { + foo: { + type: 'string' + } + } + }, 'body') // true + +// or + +request + .validateInput({ hello: 'world'}, 'query') // false +``` + +See [.compileValidationSchema(schema, [httpStatus])](#compileValidationSchema) +for more information on compiling validation schemas. diff --git a/docs/Reference/Routes.md b/docs/Reference/Routes.md index f213871c5dd..3cb215319c4 100644 --- a/docs/Reference/Routes.md +++ b/docs/Reference/Routes.md @@ -2,9 +2,8 @@ ## Routes -The route methods will configure the endpoints of your application. You have two -ways to declare a route with Fastify: the shorthand method and the full -declaration. +The route methods configure the endpoints of the application. Routes can be +declared using the shorthand method or the full declaration. - [Full declaration](#full-declaration) - [Routes options](#routes-options) @@ -32,15 +31,17 @@ fastify.route(options) ### Routes options -*`method`: currently it supports `'DELETE'`, `'GET'`, `'HEAD'`, `'PATCH'`, - `'POST'`, `'PUT'` and `'OPTIONS'`. It could also be an array of methods. +* `method`: currently it supports `GET`, `HEAD`, `TRACE`, `DELETE`, + `OPTIONS`, `PATCH`, `PUT` and `POST`. To accept more methods, + the [`addHttpMethod`](./Server.md#addHttpMethod) must be used. + It could also be an array of methods. * `url`: the path of the URL to match this route (alias: `path`). * `schema`: an object containing the schemas for the request and response. They need to be in [JSON Schema](https://json-schema.org/) format, check [here](./Validation-and-Serialization.md) for more info. - * `body`: validates the body of the request if it is a POST, PUT, or PATCH - method. + * `body`: validates the body of the request if it is a POST, PUT, PATCH, + TRACE, SEARCH, PROPFIND, PROPPATCH or LOCK method. * `querystring` or `query`: validates the querystring. This can be a complete JSON Schema object, with the property `type` of `object` and `properties` object of parameters, or simply the values of what would be contained in the @@ -58,8 +59,9 @@ fastify.route(options) one. * `onRequest(request, reply, done)`: a [function](./Hooks.md#onrequest) called as soon as a request is received, it could also be an array of functions. -* `preParsing(request, reply, done)`: a [function](./Hooks.md#preparsing) called - before parsing the request, it could also be an array of functions. +* `preParsing(request, reply, payload, done)`: a + [function](./Hooks.md#preparsing) called before parsing the request, it could + also be an array of functions. * `preValidation(request, reply, done)`: a [function](./Hooks.md#prevalidation) called after the shared `preValidation` hooks, useful if you need to perform authentication at route level for example, it could also be an array of @@ -76,7 +78,7 @@ fastify.route(options) when a response has been sent, so you will not be able to send more data to the client. It could also be an array of functions. * `onTimeout(request, reply, done)`: a [function](./Hooks.md#ontimeout) called - when a request is timed out and the HTTP socket has been hanged up. + when a request is timed out and the HTTP socket has been hung up. * `onError(request, reply, error, done)`: a [function](./Hooks.md#onerror) called when an Error is thrown or sent to the client by the route handler. * `handler(request, reply)`: the function that will handle this request. The @@ -88,12 +90,20 @@ fastify.route(options) To access the default handler, you can access `instance.errorHandler`. Note that this will point to fastify's default `errorHandler` only if a plugin hasn't overridden it already. +* `childLoggerFactory(logger, binding, opts, rawReq)`: a custom factory function + that will be called to produce a child logger instance for every request. + See [`childLoggerFactory`](./Server.md#childloggerfactory) for more info. + Overrides the default logger factory, and anything set by + [`setChildLoggerFactory`](./Server.md#setchildloggerfactory), for requests to + the route. To access the default factory, you can access + `instance.childLoggerFactory`. Note that this will point to Fastify's default + `childLoggerFactory` only if a plugin hasn't overridden it already. * `validatorCompiler({ schema, method, url, httpPart })`: function that builds schemas for request validations. See the [Validation and Serialization](./Validation-and-Serialization.md#schema-validator) documentation. -* `serializerCompiler({ { schema, method, url, httpStatus } })`: function that - builds schemas for response serialization. See the [Validation and +* `serializerCompiler({ { schema, method, url, httpStatus, contentType } })`: + function that builds schemas for response serialization. See the [Validation and Serialization](./Validation-and-Serialization.md#schema-serializer) documentation. * `schemaErrorFormatter(errors, dataVar)`: function that formats the errors from @@ -105,11 +115,21 @@ fastify.route(options) larger than this number of bytes. Must be an integer. You may also set this option globally when first creating the Fastify instance with `fastify(options)`. Defaults to `1048576` (1 MiB). +* `handlerTimeout`: maximum number of milliseconds for the route's full + lifecycle. Overrides the server-level + [`handlerTimeout`](./Server.md#factory-handler-timeout). Must be a positive + integer. When the timeout fires, `request.signal` is aborted and a 503 error + is sent through the error handler (which can be customized per-route). * `logLevel`: set log level for this route. See below. * `logSerializers`: set serializers to log for this route. * `config`: object used to store custom configuration. * `version`: a [semver](https://semver.org/) compatible string that defined the version of the endpoint. [Example](#version-constraints). +* `constraints`: defines route restrictions based on request properties or + values, enabling customized matching using + [find-my-way](https://github.com/delvedor/find-my-way) constraints. Includes + built-in `version` and `host` constraints, with support for custom constraint + strategies. * `prefixTrailingSlash`: string used to determine how to handle passing `/` as a route with a prefix. * `both` (default): Will register both `/prefix` and `/prefix/`. @@ -123,11 +143,12 @@ fastify.route(options) * `reply` is defined in [Reply](./Reply.md). -**Notice:** The documentation of `onRequest`, `preParsing`, `preValidation`, -`preHandler`, `preSerialization`, `onSend`, and `onResponse` are described in -more detail in [Hooks](./Hooks.md). Additionally, to send a response before the -request is handled by the `handler` please refer to [Respond to a request from a -hook](./Hooks.md#respond-to-a-request-from-a-hook). +> ℹ️ Note: +> The documentation for `onRequest`, `preParsing`, `preValidation`, +> `preHandler`, `preSerialization`, `onSend`, and `onResponse` is detailed in +> [Hooks](./Hooks.md). To send a response before the request is handled by the +> `handler`, see [Respond to a request from +> a hook](./Hooks.md#respond-to-a-request-from-a-hook). Example: ```js @@ -136,8 +157,11 @@ fastify.route({ url: '/', schema: { querystring: { - name: { type: 'string' }, - excitement: { type: 'integer' } + type: 'object', + properties: { + name: { type: 'string' }, + excitement: { type: 'integer' } + } }, response: { 200: { @@ -216,17 +240,18 @@ const opts = { fastify.get('/', opts) ``` -> Note: if the handler is specified in both the `options` and as the third -> parameter to the shortcut method then throws duplicate `handler` error. +> ℹ️ Note: +> Specifying the handler in both `options` and as the third parameter to +> the shortcut method throws a duplicate `handler` error. ### Url building Fastify supports both static and dynamic URLs. -To register a **parametric** path, use the *colon* before the parameter name. -For **wildcard**, use the *star*. *Remember that static routes are always -checked before parametric and wildcard.* +To register a **parametric** path, use a *colon* before the parameter name. For +**wildcard**, use a *star*. Static routes are always checked before parametric +and wildcard routes. ```js // parametric @@ -248,9 +273,8 @@ fastify.get('/example/:userId/:secretToken', function (request, reply) { fastify.get('/example/*', function (request, reply) {}) ``` -Regular expression routes are supported as well, but be aware that you have to -escape slashes. Take note that RegExp is also very expensive in terms of -performance! +Regular expression routes are supported, but slashes must be escaped. +Take note that RegExp is also very expensive in terms of performance! ```js // parametric with regexp fastify.get('/example/:file(^\\d+).png', function (request, reply) { @@ -288,13 +312,24 @@ fastify.get('/example/at/:hour(^\\d{2})h:minute(^\\d{2})m', function (request, r In this case as parameter separator it is possible to use whatever character is not matched by the regular expression. -Having a route with multiple parameters may negatively affect performance, so -prefer a single parameter approach whenever possible, especially on routes that -are on the hot path of your application. If you are interested in how we handle -the routing, check out [find-my-way](https://github.com/delvedor/find-my-way). +The last parameter can be made optional by adding a question mark ("?") to the +end of the parameter name. +```js +fastify.get('/example/posts/:id?', function (request, reply) { + const { id } = request.params; + // your code here +}) +``` +In this case, `/example/posts` and `/example/posts/1` are both valid. The +optional param will be `undefined` if not specified. + +Having a route with multiple parameters may negatively affect performance. +Prefer a single parameter approach, especially on routes that are on the hot +path of your application. For more details, see +[find-my-way](https://github.com/delvedor/find-my-way). -If you want a path containing a colon without declaring a parameter, use a -double colon. For example: +To include a colon in a path without declaring a parameter, use a double colon. +For example: ```js fastify.post('/name::verb') // will be interpreted as /name:verb ``` @@ -305,23 +340,23 @@ fastify.post('/name::verb') // will be interpreted as /name:verb Are you an `async/await` user? We have you covered! ```js fastify.get('/', options, async function (request, reply) { - var data = await getData() - var processed = await processData(data) + const data = await getData() + const processed = await processData(data) return processed }) ``` -As you can see, we are not calling `reply.send` to send back the data to the -user. You just need to return the body and you are done! +As shown, `reply.send` is not called to send data back to the user. Simply +return the body and you are done! -If you need it you can also send back the data to the user with `reply.send`. In -this case do not forget to `return reply` or `await reply` in your `async` -handler or you will introduce a race condition in certain situations. +If needed, you can also send data back with `reply.send`. In this case, do not +forget to `return reply` or `await reply` in your `async` handler to avoid race +conditions. ```js fastify.get('/', options, async function (request, reply) { - var data = await getData() - var processed = await processData(data) + const data = await getData() + const processed = await processData(data) return reply.send(processed) }) ``` @@ -349,48 +384,43 @@ fastify.get('/', options, async function (request, reply) { }) ``` -**Warning:** -* When using both `return value` and `reply.send(value)` at the same time, the - first one that happens takes precedence, the second value will be discarded, - and a *warn* log will also be emitted because you tried to send a response - twice. -* Calling `reply.send()` outside of the promise is possible but requires special - attention. For more details read [promise-resolution](#promise-resolution). -* You cannot return `undefined`. For more details read - [promise-resolution](#promise-resolution). +> ⚠ Warning: +> * When using both `return value` and `reply.send(value)`, the first one takes +> precedence, the second is discarded, and a *warn* log is emitted. +> * Calling `reply.send()` outside of the promise is possible but requires special +> attention. See [promise-resolution](#promise-resolution). +> * `undefined` cannot be returned. See [promise-resolution](#promise-resolution). ### Promise resolution -If your handler is an `async` function or returns a promise, you should be aware -of the special behavior that is necessary to support the callback and promise -control-flow. When the handler's promise is resolved, the reply will be -automatically sent with its value unless you explicitly await or return `reply` -in your handler. +If the handler is an `async` function or returns a promise, be aware of the +special behavior to support callback and promise control-flow. When the +handler's promise resolves, the reply is automatically sent with its value +unless you explicitly await or return `reply` in the handler. -1. If you want to use `async/await` or promises but respond with a value with - `reply.send`: +1. If using `async/await` or promises but responding with `reply.send`: - **Do** `return reply` / `await reply`. - **Do not** forget to call `reply.send`. -2. If you want to use `async/await` or promises: +2. If using `async/await` or promises: - **Do not** use `reply.send`. - - **Do** return the value that you want to send. + - **Do** return the value to send. -In this way, we can support both `callback-style` and `async-await`, with the -minimum trade-off. Despite so much freedom we highly recommend going with only -one style because error handling should be handled in a consistent way within -your application. +This approach supports both `callback-style` and `async-await` with minimal +trade-off. However, it is recommended to use only one style for consistent +error handling within your application. -**Notice**: Every async function returns a promise by itself. +> ℹ️ Note: +> Every async function returns a promise by itself. ### Route Prefixing -Sometimes you need to maintain two or more different versions of the same API; a -classic approach is to prefix all the routes with the API version number, -`/v1/user` for example. Fastify offers you a fast and smart way to create -different versions of the same API without changing all the route names by hand, -*route prefixing*. Let's see how it works: +Sometimes maintaining multiple versions of the same API is necessary. A common +approach is to prefix routes with the API version number, e.g., `/v1/user`. +Fastify offers a fast and smart way to create different versions of the same API +without changing all the route names by hand, called *route prefixing*. Here is +how it works: ```js // server.js @@ -417,19 +447,18 @@ module.exports = function (fastify, opts, done) { done() } ``` -Fastify will not complain because you are using the same name for two different -routes, because at compilation time it will handle the prefix automatically -*(this also means that the performance will not be affected at all!)*. +Fastify will not complain about using the same name for two different routes +because it handles the prefix automatically at compilation time. This ensures +performance is not affected. -Now your clients will have access to the following routes: +Now clients will have access to the following routes: - `/v1/user` - `/v2/user` -You can do this as many times as you want, it also works for nested `register`, -and route parameters are supported as well. +This can be done multiple times and works for nested `register`. Route +parameters are also supported. -In case you want to use prefix for all of your routes, you can put them inside a -plugin: +To use a prefix for all routes, place them inside a plugin: ```js const fastify = require('fastify')() @@ -441,23 +470,21 @@ const route = { schema: {}, } -fastify.register(function(app, _, done) { +fastify.register(function (app, _, done) { app.get('/users', () => {}) app.route(route) done() }, { prefix: '/v1' }) // global route prefix -await fastify.listen({ port: 0 }) +await fastify.listen({ port: 3000 }) ``` ### Route Prefixing and fastify-plugin -Be aware that if you use -[`fastify-plugin`](https://github.com/fastify/fastify-plugin) for wrapping your -routes, this option will not work. You can still make it work by wrapping a -plugin in a plugin, e. g.: +If using [`fastify-plugin`](https://github.com/fastify/fastify-plugin) to wrap +routes, this option will not work. To make it work, wrap a plugin in a plugin: ```js const fp = require('fastify-plugin') const routes = require('./lib/routes') @@ -473,27 +500,23 @@ module.exports = fp(async function (app, opts) { #### Handling of / route inside prefixed plugins -The `/` route has different behavior depending on if the prefix ends with `/` or -not. As an example, if we consider a prefix `/something/`, adding a `/` route -will only match `/something/`. If we consider a prefix `/something`, adding a -`/` route will match both `/something` and `/something/`. +The `/` route behaves differently based on whether the prefix ends with `/`. +For example, with a prefix `/something/`, adding a `/` route matches only +`/something/`. With a prefix `/something`, adding a `/` route matches both +`/something` and `/something/`. See the `prefixTrailingSlash` route option above to change this behavior. ### Custom Log Level -You might need different log levels in your routes; Fastify achieves this in a -very straightforward way. - -You just need to pass the option `logLevel` to the plugin option or the route -option with the -[value](https://github.com/pinojs/pino/blob/master/docs/api.md#level-string) -that you need. +Different log levels can be set for routes in Fastify by passing the `logLevel` +option to the plugin or route with the desired +[value](https://github.com/pinojs/pino/blob/main/docs/api.md#level-string). -Be aware that if you set the `logLevel` at plugin level, also the +Be aware that setting `logLevel` at the plugin level also affects [`setNotFoundHandler`](./Server.md#setnotfoundhandler) and -[`setErrorHandler`](./Server.md#seterrorhandler) will be affected. +[`setErrorHandler`](./Server.md#seterrorhandler). ```js // server.js @@ -505,22 +528,21 @@ fastify.register(require('./routes/events'), { logLevel: 'debug' }) fastify.listen({ port: 3000 }) ``` -Or you can directly pass it to a route: +Or pass it directly to a route: ```js fastify.get('/', { logLevel: 'warn' }, (request, reply) => { reply.send({ hello: 'world' }) }) ``` -*Remember that the custom log level is applied only to the routes, and not to -the global Fastify Logger, accessible with `fastify.log`* +*Remember that the custom log level applies only to routes, not to the global +Fastify Logger, accessible with `fastify.log`.* ### Custom Log Serializer -In some contexts, you may need to log a large object but it could be a waste of -resources for some routes. In this case, you can define custom -[`serializers`](https://github.com/pinojs/pino/blob/master/docs/api.md#serializers-object) -and attach them in the right context! +In some contexts, logging a large object may waste resources. Define custom +[`serializers`](https://github.com/pinojs/pino/blob/main/docs/api.md#serializers-object) +and attach them in the appropriate context. ```js const fastify = require('fastify')({ logger: true }) @@ -539,7 +561,7 @@ fastify.register(require('./routes/events'), { fastify.listen({ port: 3000 }) ``` -You can inherit serializers by context: +Serializers can be inherited by context: ```js const fastify = Fastify({ @@ -551,7 +573,7 @@ const fastify = Fastify({ method: req.method, url: req.url, headers: req.headers, - hostname: req.hostname, + host: req.host, remoteAddress: req.ip, remotePort: req.socket.remotePort } @@ -588,7 +610,7 @@ retrieve it in the handler. const fastify = require('fastify')() function handler (req, reply) { - reply.send(reply.context.config.output) + reply.send(reply.routeOptions.config.output) } fastify.get('/en', { config: { output: 'hello world!' } }, handler) @@ -600,31 +622,30 @@ fastify.listen({ port: 3000 }) ### Constraints -Fastify supports constraining routes to match only certain requests based on -some property of the request, like the `Host` header, or any other value via +Fastify supports constraining routes to match certain requests based on +properties like the `Host` header or any other value via [`find-my-way`](https://github.com/delvedor/find-my-way) constraints. Constraints are specified in the `constraints` property of the route options. -Fastify has two built-in constraints ready for use: the `version` constraint and -the `host` constraint, and you can add your own custom constraint strategies to -inspect other parts of a request to decide if a route should be executed for a -request. +Fastify has two built-in constraints: `version` and `host`. Custom constraint +strategies can be added to inspect other parts of a request to decide if a route +should be executed. #### Version Constraints You can provide a `version` key in the `constraints` option to a route. -Versioned routes allow you to declare multiple handlers for the same HTTP route -path, which will then be matched according to each request's `Accept-Version` -header. The `Accept-Version` header value should follow the -[semver](https://semver.org/) specification, and routes should be declared with -exact semver versions for matching. +Versioned routes allows multiple handlers to be declared for the same HTTP +route path, matched according to the request's `Accept-Version` header. +The `Accept-Version` header value should follow the +[semver](https://semver.org/) specification, and routes should be declared +with exact semver versions for matching. Fastify will require a request `Accept-Version` header to be set if the route has a version set, and will prefer a versioned route to a non-versioned route for the same path. Advanced version ranges and pre-releases currently are not supported. -*Be aware that using this feature will cause a degradation of the overall -performances of the router.* +> ℹ️ Note: +> Using this feature can degrade the router's performance. ```js fastify.route({ @@ -647,50 +668,49 @@ fastify.inject({ }) ``` -> ## ⚠ Security Notice -> Remember to set a +> ⚠ Warning: +> Set a > [`Vary`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Vary) -> header in your responses with the value you are using for defining the -> versioning (e.g.: `'Accept-Version'`), to prevent cache poisoning attacks. You -> can also configure this as part of your Proxy/CDN. +> header in responses with the value used for versioning +> (e.g., `'Accept-Version'`) to prevent cache poisoning attacks. +> This can also be configured in a Proxy/CDN. > > ```js > const append = require('vary').append -> fastify.addHook('onSend', async (req, reply) => { -> if (req.headers['accept-version']) { // or the custom header you are using +> fastify.addHook('onSend', (req, reply, payload, done) => { +> if (req.headers['accept-version']) { // or the custom header being used > let value = reply.getHeader('Vary') || '' > const header = Array.isArray(value) ? value.join(', ') : String(value) -> if ((value = append(header, 'Accept-Version'))) { // or the custom header you are using +> if ((value = append(header, 'Accept-Version'))) { // or the custom header being used > reply.header('Vary', value) > } > } +> done() > }) > ``` -If you declare multiple versions with the same major or minor, Fastify will +If multiple versions with the same major or minor are declared, Fastify will always choose the highest compatible with the `Accept-Version` header value. -If the request will not have the `Accept-Version` header, a 404 error will be -returned. +If the request lacks an `Accept-Version` header, a 404 error will be returned. -It is possible to define a custom version matching logic. This can be done -through the [`constraints`](./Server.md#constraints) configuration when creating -a Fastify server instance. +Custom version matching logic can be defined through the +[`constraints`](./Server.md#constraints) configuration when creating a Fastify +server instance. #### Host Constraints -You can provide a `host` key in the `constraints` route option for to limit that -route to only be matched for certain values of the request `Host` header. `host` -constraint values can be specified as strings for exact matches or RegExps for -arbitrary host matching. +Provide a `host` key in the `constraints` route option to limit the route to +certain values of the request `Host` header. `host` constraint values can be +specified as strings for exact matches or RegExps for arbitrary host matching. ```js fastify.route({ method: 'GET', url: '/', - constraints: { host: 'auth.fastify.io' }, + constraints: { host: 'auth.fastify.example' }, handler: function (request, reply) { - reply.send('hello world from auth.fastify.io') + reply.send('hello world from auth.fastify.example') } }) @@ -698,7 +718,7 @@ fastify.inject({ method: 'GET', url: '/', headers: { - 'Host': 'example.com' + 'Host': 'fastify.example' } }, (err, res) => { // 404 because the host doesn't match the constraint @@ -708,10 +728,10 @@ fastify.inject({ method: 'GET', url: '/', headers: { - 'Host': 'auth.fastify.io' + 'Host': 'auth.fastify.dev' } }, (err, res) => { - // => 'hello world from auth.fastify.io' + // => 'hello world from auth.fastify.dev' }) ``` @@ -722,30 +742,61 @@ matching wildcard subdomains (or any other pattern): fastify.route({ method: 'GET', url: '/', - constraints: { host: /.*\.fastify\.io/ }, // will match any subdomain of fastify.io + constraints: { host: /.*\.fastify\.example/ }, // will match any subdomain of fastify.dev handler: function (request, reply) { reply.send('hello world from ' + request.headers.host) } }) ``` -### ⚠ HTTP version check - -Fastify will check the HTTP version of every request, based on configuration -options ([http2](./Server.md#http2), [https](./Server.md#https), and -[serverFactory](./Server.md#serverfactory)), to determine if it matches one or -all of the > following versions: `2.0`, `1.1`, and `1.0`. If Fastify receives a -different HTTP version in the request it will return a `505 HTTP Version Not -Supported` error. - -| | 2.0 | 1.1 | 1.0 | skip | -|:------------------------:|:---:|:---:|:---:|:----:| -| http2 | ✓ | | | | -| http2 + https | ✓ | | | | -| http2 + https.allowHTTP1 | ✓ | ✓ | ✓ | | -| https | | ✓ | ✓ | | -| http | | ✓ | ✓ | | -| serverFactory | | | | ✓ | - - Note: The internal HTTP version check will be removed in the future when Node - implements [this feature](https://github.com/nodejs/node/issues/43115). +#### Asynchronous Custom Constraints + +Custom constraints can be provided, and the `constraint` criteria can be +fetched from another source such as a database. Use asynchronous custom +constraints as a last resort, as they impact router performance. + +```js +function databaseOperation(field, done) { + done(null, field) +} + +const secret = { + // strategy name for referencing in the route handler `constraints` options + name: 'secret', + // storage factory for storing routes in the find-my-way route tree + storage: function () { + let handlers = {} + return { + get: (type) => { return handlers[type] || null }, + set: (type, store) => { handlers[type] = store } + } + }, + // function to get the value of the constraint from each incoming request + deriveConstraint: (req, ctx, done) => { + databaseOperation(req.headers['secret'], done) + }, + // optional flag marking if handlers without constraints can match requests that have a value for this constraint + mustMatchWhenDerived: true +} +``` + +> ⚠ Warning: +> When using asynchronous constraints, avoid returning errors inside the +> callback. If errors are unavoidable, provide a custom `frameworkErrors` +> handler to manage them. Otherwise, route selection may break or expose +> sensitive information. +> +> ```js +> const Fastify = require('fastify') +> +> const fastify = Fastify({ +> frameworkErrors: function (err, req, res) { +> if (err instanceof Fastify.errorCodes.FST_ERR_ASYNC_CONSTRAINT) { +> res.code(400) +> return res.send("Invalid header provided") +> } else { +> res.send(err) +> } +> } +> }) +> ``` diff --git a/docs/Reference/Server.md b/docs/Reference/Server.md index e0204aecf5f..36827525808 100644 --- a/docs/Reference/Server.md +++ b/docs/Reference/Server.md @@ -9,6 +9,7 @@ options object which is used to customize the resulting instance. This document describes the properties available in that options object. - [Factory](#factory) + - [`http`](#http) - [`http2`](#http2) - [`https`](#https) - [`connectionTimeout`](#connectiontimeout) @@ -16,26 +17,19 @@ describes the properties available in that options object. - [`forceCloseConnections`](#forcecloseconnections) - [`maxRequestsPerSocket`](#maxrequestspersocket) - [`requestTimeout`](#requesttimeout) - - [`ignoreTrailingSlash`](#ignoretrailingslash) - - [`ignoreDuplicateSlashes`](#ignoreduplicateslashes) - - [`maxParamLength`](#maxparamlength) - [`bodyLimit`](#bodylimit) - [`onProtoPoisoning`](#onprotopoisoning) - [`onConstructorPoisoning`](#onconstructorpoisoning) - [`logger`](#logger) + - [`loggerInstance`](#loggerinstance) - [`disableRequestLogging`](#disablerequestlogging) - [`serverFactory`](#serverfactory) - - [`jsonShorthand`](#jsonshorthand) - - [`caseSensitive`](#casesensitive) - - [`allowUnsafeRegex`](#allowunsaferegex) - [`requestIdHeader`](#requestidheader) - [`requestIdLogLabel`](#requestidloglabel) - [`genReqId`](#genreqid) - [`trustProxy`](#trustproxy) - [`pluginTimeout`](#plugintimeout) - - [`querystringParser`](#querystringparser) - [`exposeHeadRoutes`](#exposeheadroutes) - - [`constraints`](#constraints) - [`return503OnClosing`](#return503onclosing) - [`ajv`](#ajv) - [`serializerOpts`](#serializeropts) @@ -43,6 +37,19 @@ describes the properties available in that options object. - [`frameworkErrors`](#frameworkerrors) - [`clientErrorHandler`](#clienterrorhandler) - [`rewriteUrl`](#rewriteurl) + - [`allowErrorHandlerOverride`](#allowerrorhandleroverride) + - [RouterOptions](#routeroptions) + - [`allowUnsafeRegex`](#allowunsaferegex) + - [`buildPrettyMeta`](#buildprettymeta) + - [`caseSensitive`](#casesensitive) + - [`constraints`](#constraints) + - [`defaultRoute`](#defaultroute) + - [`ignoreDuplicateSlashes`](#ignoreduplicateslashes) + - [`ignoreTrailingSlash`](#ignoretrailingslash) + - [`maxParamLength`](#maxparamlength) + - [`onBadUrl`](#onbadurl) + - [`querystringParser`](#querystringparser) + - [`useSemicolonDelimiter`](#usesemicolondelimiter) - [Instance](#instance) - [Server Methods](#server-methods) - [server](#server) @@ -50,10 +57,10 @@ describes the properties available in that options object. - [ready](#ready) - [listen](#listen) - [addresses](#addresses) - - [getDefaultRoute](#getdefaultroute) - - [setDefaultRoute](#setdefaultroute) - [routing](#routing) - [route](#route) + - [hasRoute](#hasroute) + - [findRoute](#findroute) - [close](#close) - [decorate\*](#decorate) - [register](#register) @@ -61,9 +68,11 @@ describes the properties available in that options object. - [prefix](#prefix) - [pluginName](#pluginname) - [hasPlugin](#hasplugin) + - [listeningOrigin](#listeningorigin) - [log](#log) - [version](#version) - [inject](#inject) + - [addHttpMethod](#addHttpMethod) - [addSchema](#addschema) - [getSchemas](#getschemas) - [getSchema](#getschema) @@ -77,6 +86,8 @@ describes the properties available in that options object. - [schemaController](#schemacontroller) - [setNotFoundHandler](#setnotfoundhandler) - [setErrorHandler](#seterrorhandler) + - [setChildLoggerFactory](#setchildloggerfactory) + - [setGenReqId](#setgenreqid) - [addConstraintStrategy](#addconstraintstrategy) - [hasConstraintStrategy](#hasconstraintstrategy) - [printRoutes](#printroutes) @@ -88,204 +99,232 @@ describes the properties available in that options object. - [getDefaultJsonParser](#getdefaultjsonparser) - [defaultTextParser](#defaulttextparser) - [errorHandler](#errorhandler) + - [childLoggerFactory](#childloggerfactory) + - [Symbol.asyncDispose](#symbolasyncdispose) - [initialConfig](#initialconfig) +### `http` + + ++ Default: `null` + +An object used to configure the server's listening socket. The options +are the same as the Node.js core [`createServer` +method](https://nodejs.org/docs/latest-v20.x/api/http.html#httpcreateserveroptions-requestlistener). + +This option is ignored if options [`http2`](#factory-http2) or +[`https`](#factory-https) are set. + ### `http2` ++ Default: `false` + If `true` Node.js core's -[HTTP/2](https://nodejs.org/dist/latest-v14.x/docs/api/http2.html) module is +[HTTP/2](https://nodejs.org/dist/latest-v20.x/docs/api/http2.html) module is used for binding the socket. -+ Default: `false` - ### `https` ++ Default: `null` + An object used to configure the server's listening socket for TLS. The options are the same as the Node.js core [`createServer` -method](https://nodejs.org/dist/latest-v14.x/docs/api/https.html#https_https_createserver_options_requestlistener). +method](https://nodejs.org/dist/latest-v20.x/docs/api/https.html#https_https_createserver_options_requestlistener). When this property is `null`, the socket will not be configured for TLS. This option also applies when the [`http2`](#factory-http2) option is set. -+ Default: `null` - ### `connectionTimeout` ++ Default: `0` (no timeout) + Defines the server timeout in milliseconds. See documentation for [`server.timeout` property](https://nodejs.org/api/http.html#http_server_timeout) to understand -the effect of this option. When `serverFactory` option is specified, this option -is ignored. +the effect of this option. -+ Default: `0` (no timeout) +When `serverFactory` option is specified this option is ignored. ### `keepAliveTimeout` ++ Default: `72000` (72 seconds) + Defines the server keep-alive timeout in milliseconds. See documentation for [`server.keepAliveTimeout` property](https://nodejs.org/api/http.html#http_server_keepalivetimeout) to understand the effect of this option. This option only applies when HTTP/1 is in -use. Also, when `serverFactory` option is specified, this option is ignored. +use. -+ Default: `72000` (72 seconds) +When `serverFactory` option is specified this option is ignored. ### `forceCloseConnections` ++ Default: `"idle"` if the HTTP server allows it, `false` otherwise + When set to `true`, upon [`close`](#close) the server will iterate the current persistent connections and [destroy their sockets](https://nodejs.org/dist/latest-v16.x/docs/api/net.html#socketdestroyerror). -> Important: connections are not inspected to determine if requests have been -> completed. +When used with HTTP/2 server, it will also close all active HTTP/2 sessions. + +> ℹ️ Note: +> Since Node.js v24 active sessions are closed by default + + +> ⚠ Warning: +> Connections are not inspected to determine if requests have +> been completed. Fastify will prefer the HTTP server's [`closeAllConnections`](https://nodejs.org/dist/latest-v18.x/docs/api/http.html#servercloseallconnections) -method if supported, otherwise it will use internal connection tracking. +method if supported, otherwise, it will use internal connection tracking. When set to `"idle"`, upon [`close`](#close) the server will iterate the current persistent connections which are not sending a request or waiting for a response -and destroy their sockets. The value is supported only if the HTTP server +and destroy their sockets. The value is only supported if the HTTP server supports the [`closeIdleConnections`](https://nodejs.org/dist/latest-v18.x/docs/api/http.html#servercloseidleconnections) method, otherwise attempting to set it will throw an exception. -+ Default: `"idle"` if the HTTP server allows it, `false` otherwise - ### `maxRequestsPerSocket` -Defines the maximum number of requests socket can handle before closing keep -alive connection. See documentation for [`server.maxRequestsPerSocket` ++ Default: `0` (no limit) + +Defines the maximum number of requests a socket can handle before closing keep +alive connection. See [`server.maxRequestsPerSocket` property](https://nodejs.org/dist/latest/docs/api/http.html#http_server_maxrequestspersocket) to understand the effect of this option. This option only applies when HTTP/1.1 is in use. Also, when `serverFactory` option is specified, this option is ignored. -> At the time of this writing, only node version greater or equal to 16.10.0 -> support this option. Check the Node.js documentation for availability in the -> version you are running. -+ Default: `0` (no limit) +> ℹ️ Note: +> At the time of writing, only node >= v16.10.0 supports this option. ### `requestTimeout` ++ Default: `0` (no limit) + Defines the maximum number of milliseconds for receiving the entire request from -the client. [`server.requestTimeout` +the client. See [`server.requestTimeout` property](https://nodejs.org/dist/latest/docs/api/http.html#http_server_requesttimeout) -to understand the effect of this option. Also, when `serverFactory` option is -specified, this option is ignored. It must be set to a non-zero value (e.g. 120 -seconds) to protect against potential Denial-of-Service attacks in case the -server is deployed without a reverse proxy in front. -> At the time of this writing, only node version greater or equal to 14.11.0 -> support this option. Check the Node.js documentation for availability in the -> version you are running. +to understand the effect of this option. -+ Default: `0` (no limit) +When `serverFactory` option is specified, this option is ignored. +It must be set to a non-zero value (e.g. 120 seconds) to protect against potential +Denial-of-Service attacks in case the server is deployed without a reverse proxy +in front. -### `ignoreTrailingSlash` - +> ℹ️ Note: +> At the time of writing, only node >= v14.11.0 supports this option -Fastify uses [find-my-way](https://github.com/delvedor/find-my-way) to handle -routing. By default, Fastify is set to take into account the trailing slashes. -Paths like `/foo` and `/foo/` will be treated as different paths. If you want to -change this, set this flag to `true`. That way, both `/foo` and `/foo/` will -point to the same route. This option applies to *all* route registrations for -the resulting server instance. +### `handlerTimeout` + -+ Default: `false` ++ Default: `0` (no timeout) + +Defines the maximum number of milliseconds allowed for processing a request +through the entire route lifecycle (from routing through onRequest, parsing, +validation, handler execution, and serialization). If the response is not sent +within this time, a `503 Service Unavailable` error is returned and +`request.signal` is aborted. + +Unlike `connectionTimeout` and `requestTimeout` (which operate at the socket +level), `handlerTimeout` is an application-level timeout that works correctly +with HTTP keep-alive connections. It can be overridden per-route via +[route options](./Routes.md#routes-options). When set at both levels, the +route-level value takes precedence. Routes without an explicit `handlerTimeout` +inherit the server default. Once a server-level timeout is set, individual +routes cannot opt out of it — they can only override it with a different +positive integer. + +The timeout is **cooperative**: when it fires, Fastify sends the 503 error +response, but the handler's async work continues to run. Use +[`request.signal`](./Request.md) to detect cancellation and stop ongoing work +(database queries, HTTP requests, etc.). APIs that accept a `signal` option +(`fetch()`, database drivers, `stream.pipeline()`) will cancel automatically. + +The timeout error (`FST_ERR_HANDLER_TIMEOUT`) is sent through the route's +[error handler](./Routes.md#routes-options), which can be customized per-route +to change the status code or response body. + +When `reply.hijack()` is called, the timeout timer is cleared — the handler +takes full responsibility for the response lifecycle. + +> ℹ️ Note: +> `handlerTimeout` does not apply to 404 handlers or custom not-found handlers +> set via `setNotFoundHandler()`, as they bypass the route handler lifecycle. ```js const fastify = require('fastify')({ - ignoreTrailingSlash: true + handlerTimeout: 10000 // 10s default for all routes }) -// registers both "/foo" and "/foo/" -fastify.get('/foo/', function (req, reply) { - reply.send('foo') +// Override per-route +fastify.get('/slow-report', { handlerTimeout: 120000 }, async (request) => { + // Use request.signal for cooperative cancellation + const data = await db.query(longQuery, { signal: request.signal }) + return data }) -// registers both "/bar" and "/bar/" -fastify.get('/bar', function (req, reply) { - reply.send('bar') -}) -``` - -### `ignoreDuplicateSlashes` - - -Fastify uses [find-my-way](https://github.com/delvedor/find-my-way) to handle -routing. You can use `ignoreDuplicateSlashes` option to remove duplicate slashes -from the path. It removes duplicate slashes in the route path and in the request -URL. This option applies to *all* route registrations for the resulting server -instance. - -Note that when `ignoreTrailingSlash` and `ignoreDuplicateSlashes` are both set -to true, Fastify will remove duplicate slashes, and then trailing slashes, -meaning //a//b//c// will be converted to /a/b/c. - -+ Default: `false` - -```js -const fastify = require('fastify')({ - ignoreDuplicateSlashes: true -}) - -// registers "/foo/bar/" -fastify.get('///foo//bar//', function (req, reply) { - reply.send('foo') +// Customize the timeout response +fastify.get('/custom-timeout', { + handlerTimeout: 5000, + errorHandler: (error, request, reply) => { + if (error.code === 'FST_ERR_HANDLER_TIMEOUT') { + reply.code(504).send({ error: 'Gateway Timeout' }) + } else { + reply.send(error) + } + } +}, async (request) => { + const result = await externalService.call({ signal: request.signal }) + return result }) ``` -### `maxParamLength` - - -You can set a custom length for parameters in parametric (standard, regex, and -multi) routes by using `maxParamLength` option; the default value is 100 -characters. - -This can be useful especially if you have a regex-based route, protecting you -against [DoS -attacks](https://www.owasp.org/index.php/Regular_expression_Denial_of_Service_-_ReDoS). - -*If the maximum length limit is reached, the not found route will be invoked.* - ### `bodyLimit` -Defines the maximum payload, in bytes, the server is allowed to accept. - + Default: `1048576` (1MiB) +Defines the maximum payload, in bytes, the server is allowed to accept. +The default body reader sends [`FST_ERR_CTP_BODY_TOO_LARGE`](./Errors.md#fst_err_ctp_body_too_large) +reply, if the size of the body exceeds this limit. +If [`preParsing` hook](./Hooks.md#preparsing) is provided, this limit is applied +to the size of the stream the hook returns (i.e. the size of "decoded" body). + ### `onProtoPoisoning` ++ Default: `'error'` + Defines what action the framework must take when parsing a JSON object with `__proto__`. This functionality is provided by [secure-json-parse](https://github.com/fastify/secure-json-parse). See [Prototype Poisoning](../Guides/Prototype-Poisoning.md) for more details about prototype poisoning attacks. -Possible values are `'error'`, `'remove'` and `'ignore'`. - -+ Default: `'error'` +Possible values are `'error'`, `'remove'`, or `'ignore'`. ### `onConstructorPoisoning` ++ Default: `'error'` + Defines what action the framework must take when parsing a JSON object with `constructor`. This functionality is provided by [secure-json-parse](https://github.com/fastify/secure-json-parse). See [Prototype Poisoning](../Guides/Prototype-Poisoning.md) for more details about prototype poisoning attacks. -Possible values are `'error'`, `'remove'` and `'ignore'`. - -+ Default: `'error'` +Possible values are `'error'`, `'remove'`, or `'ignore'`. ### `logger` @@ -298,9 +337,6 @@ The possible values this property may have are: + Default: `false`. The logger is disabled. All logging methods will point to a null logger [abstract-logging](https://npm.im/abstract-logging) instance. -+ `pinoInstance`: a previously instantiated instance of Pino. The internal - logger will point to this instance. - + `object`: a standard Pino [options object](https://github.com/pinojs/pino/blob/c77d8ec5ce/docs/API.md#constructor). This will be passed directly to the Pino constructor. If the following @@ -320,9 +356,15 @@ The possible values this property may have are: ``` Any user-supplied serializer will override the default serializer of the corresponding property. -+ `loggerInstance`: a custom logger instance. The logger must conform to the - Pino interface by having the following methods: `info`, `error`, `debug`, - `fatal`, `warn`, `trace`, `child`. For example: + +### `loggerInstance` + + ++ Default: `null` + +A custom logger instance. The logger must be a Pino instance or conform to the +Pino interface by having the following methods: `info`, `error`, `debug`, +`fatal`, `warn`, `trace`, `child`. For example: ```js const pino = require('pino')(); @@ -340,19 +382,44 @@ The possible values this property may have are: }, }; - const fastify = require('fastify')({logger: customLogger}); + const fastify = require('fastify')({ loggerInstance: customLogger }); ``` ### `disableRequestLogging` -By default, when logging is enabled, Fastify will issue an `info` level log ++ Default: `false` + +When logging is enabled, Fastify will issue an `info` level log message when a request is received and when the response for that request has been sent. By setting this option to `true`, these log messages will be disabled. This allows for more flexible request start and end logging by attaching custom `onRequest` and `onResponse` hooks. -+ Default: `false` +This option can also be a function that receives the Fastify request object +and returns a boolean. This allows for conditional request logging based on the +request properties (e.g., URL, headers, decorations). + +```js +const fastify = require('fastify')({ + logger: true, + disableRequestLogging: (request) => { + // Disable logging for health check endpoints + return request.url === '/health' || request.url === '/ready' + } +}) +``` + +The other log entries that will be disabled are: +- an error log written by the default `onResponse` hook on reply callback errors +- the error and info logs written by the `defaultErrorHandler` +on error management +- the info log written by the `fourOhFour` handler when a +non existent route is requested + +Other log messages emitted by Fastify will stay enabled, +like deprecation warnings and messages +emitted when requests are received while the server is closing. ```js // Examples of hooks to replicate the disabled functionality. @@ -367,9 +434,6 @@ fastify.addHook('onResponse', (req, reply, done) => { }) ``` -Please note that this setting will also disable an error log written by the -default `onResponse` hook on reply callback errors. - ### `serverFactory` @@ -404,114 +468,59 @@ enhance the server instance inside the `serverFactory` function before the `return` statement. -### `jsonShorthand` - - -+ Default: `true` - -Internally, and by default, Fastify will automatically infer the root properties -of JSON Schemas if it does not find valid root properties according to the JSON -Schema spec. If you wish to implement your own schema validation compiler, for -example: to parse schemas as JTD instead of JSON Schema, then you can explicitly -set this option to `false` to make sure the schemas you receive are unmodified -and are not being treated internally as JSON Schema. - -```js -const AjvJTD = require('ajv/dist/jtd'/* only valid for AJV v7+ */) -const ajv = new AjvJTD({ - // This would let you throw at start for invalid JTD schema objects - allErrors: process.env.NODE_ENV === 'development' -}) -const fastify = Fastify({ jsonShorthand: false }) -fastify.setValidatorCompiler(({ schema }) => { - return ajv.compile(schema) -}) -fastify.post('/', { - schema: { - body: { - properties: { - foo: { type: 'uint8' } - } - } - }, - handler (req, reply) { reply.send({ ok: 1 }) } -}) -``` - -**Note: Fastify does not currently throw on invalid schemas, so if you turn this -off in an existing project, you need to be careful that none of your existing -schemas become invalid as a result, since they will be treated as a catch-all.** - -### `caseSensitive` - - -By default, value equal to `true`, routes are registered as case sensitive. That -is, `/foo` is not equivalent to `/Foo`. When set to `false`, routes are -registered in a fashion such that `/foo` is equivalent to `/Foo` which is -equivalent to `/FOO`. - -By setting `caseSensitive` to `false`, all paths will be matched as lowercase, -but the route parameters or wildcards will maintain their original letter -casing. - -```js -fastify.get('/user/:username', (request, reply) => { - // Given the URL: /USER/NodeJS - console.log(request.params.username) // -> 'NodeJS' -}) -``` - -Please note that setting this option to `false` goes against -[RFC3986](https://tools.ietf.org/html/rfc3986#section-6.2.2.1). - -Also note, this setting will not affect query strings. If you want to change the -way query strings are handled take a look at -[`querystringParser`](#querystringparser). +### `requestIdHeader` + ++ Default: `'request-id'` -### `allowUnsafeRegex` - +The header name used to set the request-id. See [the +request-id](./Logging.md#logging-request-id) section. +Setting `requestIdHeader` to `true` will set the `requestIdHeader` to +`"request-id"`. +Setting `requestIdHeader` to a non-empty string will use +the specified string as the `requestIdHeader`. +By default `requestIdHeader` is set to `false` and will immediately use [genReqId](#genreqid). +Setting `requestIdHeader` to an empty String (`""`) will set the +requestIdHeader to `false`. -The allowUnsafeRegex setting is false by default, so routes only allow safe -regular expressions. To use unsafe expressions, set allowUnsafeRegex to true. ++ Default: `false` ```js -fastify.get('/user/:id(^([0-9]+){4}$)', (request, reply) => { - // Throws an error without allowUnsafeRegex = true +const fastify = require('fastify')({ + requestIdHeader: 'x-custom-id', // -> use 'X-Custom-Id' header if available + //requestIdHeader: false, // -> always use genReqId }) ``` -Under the hood: [FindMyWay](https://github.com/delvedor/find-my-way) More info -about safe regexp: [Safe-regex2](https://www.npmjs.com/package/safe-regex2) - - -### `requestIdHeader` - - -The header name used to know the request-id. See [the -request-id](./Logging.md#logging-request-id) section. - -+ Default: `'request-id'` +> ⚠ Warning: +> Enabling this allows any callers to set `reqId` to a +> value of their choosing. +> No validation is performed on `requestIdHeader`. ### `requestIdLogLabel` -Defines the label used for the request identifier when logging the request. - + Default: `'reqId'` +Defines the label used for the request identifier when logging the request. + ### `genReqId` -Function for generating the request-id. It will receive the incoming request as -a parameter. This function is expected to be error-free. - + Default: `value of 'request-id' header if provided or monotonically increasing integers` +Function for generating the request-id. It will receive the _raw_ incoming +request as a parameter. This function is expected to be error-free. + Especially in distributed systems, you may want to override the default ID generation behavior as shown below. For generating `UUID`s you may want to check -out [hyperid](https://github.com/mcollina/hyperid) +out [hyperid](https://github.com/mcollina/hyperid). + +> ℹ️ Note: +> `genReqId` will be not called if the header set in +> [requestIdHeader](#requestidheader) is available (defaults to +> 'request-id'). ```js let i = 0 @@ -520,21 +529,9 @@ const fastify = require('fastify')({ }) ``` -**Note: genReqId will _not_ be called if the header set in -[requestIdHeader](#requestidheader) is available (defaults to -'request-id').** - ### `trustProxy` -By enabling the `trustProxy` option, Fastify will know that it is sitting behind -a proxy and that the `X-Forwarded-*` header fields may be trusted, which -otherwise may be easily spoofed. - -```js -const fastify = Fastify({ trustProxy: true }) -``` - + Default: `false` + `true/false`: Trust all proxies (`true`) or do not trust any proxies (`false`). @@ -542,86 +539,360 @@ const fastify = Fastify({ trustProxy: true }) comma separated values (e.g. `'127.0.0.1,192.168.1.1/24'`). + `Array`: Trust only given IP/CIDR list (e.g. `['127.0.0.1']`). + `number`: Trust the nth hop from the front-facing proxy server as the client. -+ `Function`: Custom trust function that takes `address` as first arg ++ `Function`: Custom trust function that takes `address` as first argument ```js function myTrustFn(address, hop) { return address === '1.2.3.4' || hop === 1 } ``` +By enabling the `trustProxy` option, Fastify will know that it is sitting behind +a proxy and that the `X-Forwarded-*` header fields may be trusted, which +otherwise may be easily spoofed. + +```js +const fastify = Fastify({ trustProxy: true }) +``` + For more examples, refer to the -[`proxy-addr`](https://www.npmjs.com/package/proxy-addr) package. +[`@fastify/proxy-addr`](https://www.npmjs.com/package/@fastify/proxy-addr) package. -You may access the `ip`, `ips`, `hostname` and `protocol` values on the +You may access the `ip`, `ips`, `host` and `protocol` values on the [`request`](./Request.md) object. ```js fastify.get('/', (request, reply) => { console.log(request.ip) console.log(request.ips) - console.log(request.hostname) + console.log(request.host) console.log(request.protocol) }) ``` -**Note: if a request contains multiple x-forwarded-host or -x-forwarded-proto headers, it is only the last one that is used to -derive request.hostname and request.protocol** +> ℹ️ Note: +> If a request contains multiple `x-forwarded-host` or `x-forwarded-proto` +> headers, it is only the last one that is used to derive `request.hostname` +> and `request.protocol`. ### `pluginTimeout` ++ Default: `10000` + The maximum amount of time in *milliseconds* in which a plugin can load. If not, [`ready`](#ready) will complete with an `Error` with code `'ERR_AVVIO_PLUGIN_TIMEOUT'`. When set to `0`, disables this check. This controls [avvio](https://www.npmjs.com/package/avvio) 's `timeout` parameter. -+ Default: `10000` - ### `querystringParser` -The default query string parser that Fastify uses is the Node.js's core -`querystring` module. +The default query string parser that Fastify uses is a more performant fork +of Node.js's core `querystring` module called +[`fast-querystring`](https://github.com/anonrig/fast-querystring). -You can change this default setting by passing the option `querystringParser` -and use a custom one, such as [`qs`](https://www.npmjs.com/package/qs). +You can use this option to use a custom parser, such as +[`qs`](https://www.npmjs.com/package/qs). + +If you only want the keys (and not the values) to be case insensitive we +recommend using a custom parser to convert only the keys to lowercase. ```js const qs = require('qs') const fastify = require('fastify')({ - querystringParser: str => qs.parse(str) + routerOptions: { + querystringParser: str => qs.parse(str) + } }) ``` -You can also use Fastify's default parser but change some handling behaviour, +You can also use Fastify's default parser but change some handling behavior, like the example below for case insensitive keys and values: ```js -const querystring = require('querystring') +const querystring = require('fast-querystring') const fastify = require('fastify')({ - querystringParser: str => querystring.parse(str.toLowerCase()) + routerOptions: { + querystringParser: str => querystring.parse(str.toLowerCase()) + } }) ``` -Note, if you only want the keys (and not the values) to be case insensitive we -recommend using a custom parser to convert only the keys to lowercase. - ### `exposeHeadRoutes` ++ Default: `true` + Automatically creates a sibling `HEAD` route for each `GET` route defined. If you want a custom `HEAD` handler without disabling this option, make sure to define it before the `GET` route. +### `return503OnClosing` + + ++ Default: `true` + +When `true`, any request arriving after [`close`](#close) has been called will +receive a `503 Service Unavailable` response with `Connection: close` header +(HTTP/1.1). This lets load balancers detect that the server is shutting down and +stop routing traffic to it. + +When `false`, requests arriving during the closing phase are routed and +processed normally. They will still receive a `Connection: close` header so that +clients do not attempt to reuse the connection. + +### `ajv` + + +Configure the Ajv v8 instance used by Fastify without providing a custom one. +The default configuration is explained in the +[#schema-validator](./Validation-and-Serialization.md#schema-validator) section. + +```js +const fastify = require('fastify')({ + ajv: { + customOptions: { + removeAdditional: 'all' // Refer to [ajv options](https://ajv.js.org/options.html#removeadditional) + }, + plugins: [ + require('ajv-merge-patch'), + [require('ajv-keywords'), 'instanceof'] + // Usage: [plugin, pluginOptions] - Plugin with options + // Usage: plugin - Plugin without options + ], + onCreate: (ajv) => { + // Modify the ajv instance as you need. + ajv.addFormat('myFormat', (data) => typeof data === 'string') + } + } +}) +``` + +### `serializerOpts` + + +Customize the options of the default +[`fast-json-stringify`](https://github.com/fastify/fast-json-stringify#options) +instance that serializes the response's payload: + +```js +const fastify = require('fastify')({ + serializerOpts: { + rounding: 'ceil' + } +}) +``` + +### `http2SessionTimeout` + + ++ Default: `72000` + +Set a default +[timeout](https://nodejs.org/api/http2.html#http2sessionsettimeoutmsecs-callback) +to every incoming HTTP/2 session in milliseconds. The session will be closed on +the timeout. + +This option is needed to offer a graceful "close" experience when using +HTTP/2. The low default has been chosen to mitigate denial of service attacks. +When the server is behind a load balancer or can scale automatically this value +can be increased to fit the use case. Node core defaults this to `0`. + +### `frameworkErrors` + + ++ Default: `null` + +Fastify provides default error handlers for the most common use cases. It is +possible to override one or more of those handlers with custom code using this +option. + +> ℹ️ Note: +> Only `FST_ERR_BAD_URL` and `FST_ERR_ASYNC_CONSTRAINT` are implemented at present. + +```js +const fastify = require('fastify')({ + frameworkErrors: function (error, req, res) { + if (error instanceof FST_ERR_BAD_URL) { + res.code(400) + return res.send("Provided url is not valid") + } else if(error instanceof FST_ERR_ASYNC_CONSTRAINT) { + res.code(400) + return res.send("Provided header is not valid") + } else { + res.send(err) + } + } +}) +``` + +### `clientErrorHandler` + + +Set a +[clientErrorHandler](https://nodejs.org/api/http.html#http_event_clienterror) +that listens to `error` events emitted by client connections and responds with a +`400`. + +It is possible to override the default `clientErrorHandler` using this option. + ++ Default: +```js +function defaultClientErrorHandler (err, socket) { + if (err.code === 'ECONNRESET') { + return + } + + const body = JSON.stringify({ + error: http.STATUS_CODES['400'], + message: 'Client Error', + statusCode: 400 + }) + this.log.trace({ err }, 'client error') + + if (socket.writable) { + socket.end([ + 'HTTP/1.1 400 Bad Request', + `Content-Length: ${body.length}`, + `Content-Type: application/json\r\n\r\n${body}` + ].join('\r\n')) + } +} +``` + +> ℹ️ Note: +> `clientErrorHandler` operates with raw sockets. The handler is expected to +> return a properly formed HTTP response that includes a status line, HTTP headers +> and a message body. Before attempting to write the socket, the handler should +> check if the socket is still writable as it may have already been destroyed. + +```js +const fastify = require('fastify')({ + clientErrorHandler: function (err, socket) { + const body = JSON.stringify({ + error: { + message: 'Client error', + code: '400' + } + }) + + // `this` is bound to fastify instance + this.log.trace({ err }, 'client error') + + // the handler is responsible for generating a valid HTTP response + socket.end([ + 'HTTP/1.1 400 Bad Request', + `Content-Length: ${body.length}`, + `Content-Type: application/json\r\n\r\n${body}` + ].join('\r\n')) + } +}) +``` + +### `rewriteUrl` + + +Set a sync callback function that must return a string that allows rewriting +URLs. This is useful when you are behind a proxy that changes the URL. +Rewriting a URL will modify the `url` property of the `req` object. + +Note that `rewriteUrl` is called _before_ routing, it is not encapsulated and it +is an instance-wide configuration. + +```js +// @param {object} req The raw Node.js HTTP request, not the `FastifyRequest` object. +// @this Fastify The root Fastify instance (not an encapsulated instance). +// @returns {string} The path that the request should be mapped to. +function rewriteUrl (req) { + if (req.url === '/hi') { + this.log.debug({ originalUrl: req.url, url: '/hello' }, 'rewrite url'); + return '/hello' + } else { + return req.url; + } +} +``` + +## RouterOptions + + +Fastify uses [`find-my-way`](https://github.com/delvedor/find-my-way) for its +HTTP router. The `routerOptions` parameter allows passing +[`find-my-way` options](https://github.com/delvedor/find-my-way?tab=readme-ov-file#findmywayoptions) +to customize the HTTP router within Fastify. + +### `allowUnsafeRegex` + + ++ Default `false` + +Fastify uses [find-my-way](https://github.com/delvedor/find-my-way) which is, +disabled by default, so routes only allow safe regular expressions. To use +unsafe expressions, set `allowUnsafeRegex` to `true`. + +```js +fastify.get('/user/:id(^([0-9]+){4}$)', (request, reply) => { + // Throws an error without allowUnsafeRegex = true +}) +``` + + +### `buildPrettyMeta` + + +Fastify uses [find-my-way](https://github.com/delvedor/find-my-way) which +supports, `buildPrettyMeta` where you can assign a `buildPrettyMeta` +function to sanitize a route's store object to use with the `prettyPrint` +functions. This function should accept a single object and return an object. + +```js +fastify.get('/user/:username', (request, reply) => { + routerOptions: { + buildPrettyMeta: route => { + const cleanMeta = Object.assign({}, route.store) + + // remove private properties + Object.keys(cleanMeta).forEach(k => { + if (typeof k === 'symbol') delete cleanMeta[k] + }) + + return cleanMeta // this will show up in the pretty print output! + }) + } +}) +``` + +### `caseSensitive` + + + Default: `true` +When `true` routes are registered as case-sensitive. That is, `/foo` +is not equal to `/Foo`. +When `false` then routes are case-insensitive. + +Please note that setting this option to `false` goes against +[RFC3986](https://datatracker.ietf.org/doc/html/rfc3986#section-6.2.2.1). + +By setting `caseSensitive` to `false`, all paths will be matched as lowercase, +but the route parameters or wildcards will maintain their original letter +casing. +This option does not affect query strings, please refer to +[`querystringParser`](#querystringparser) to change their handling. + +```js +fastify.get('/user/:username', (request, reply) => { + // Given the URL: /USER/NodeJS + console.log(request.params.username) // -> 'NodeJS' +}) +``` + ### `constraints` -Fastify's built in route constraints are provided by `find-my-way`, which allow -constraining routes by `version` or `host`. You are able to add new constraint -strategies, or override the built in strategies by providing a `constraints` +Fastify's built-in route constraints are provided by `find-my-way`, which +allows constraining routes by `version` or `host`. You can add new constraint +strategies, or override the built-in strategies, by providing a `constraints` object with strategies for `find-my-way`. You can find more information on constraint strategies in the [find-my-way](https://github.com/delvedor/find-my-way) documentation. @@ -641,173 +912,214 @@ const customVersionStrategy = { } const fastify = require('fastify')({ - constraints: { - version: customVersionStrategy + routerOptions: { + constraints: { + version: customVersionStrategy + } } }) ``` -### `return503OnClosing` - +### `defaultRoute` + -Returns 503 after calling `close` server method. If `false`, the server routes -the incoming request as usual. +Fastify uses [find-my-way](https://github.com/delvedor/find-my-way) which supports, +can pass a default route with the option defaultRoute. -+ Default: `true` +```js +const fastify = require('fastify')({ + routerOptions: { + defaultRoute: (req, res) => { + res.statusCode = 404 + res.end() + } + } +}) +``` -### `ajv` - +> ℹ️ Note: +> The `req` and `res` objects passed to `defaultRoute` are the raw Node.js +> `IncomingMessage` and `ServerResponse` instances. They do **not** expose the +> Fastify-specific methods available on `FastifyRequest`/`FastifyReply` (for +> example, `res.send`). -Configure the Ajv v8 instance used by Fastify without providing a custom one. -The default configuration is explained in the -[#schema-validator](Validation-and-Serialization.md#schema-validator) section. +### `ignoreDuplicateSlashes` + -```js -const fastify = require('fastify')({ - ajv: { - customOptions: { - removeAdditional: 'all' // Refer to [ajv options](https://ajv.js.org/#options) - }, - plugins: [ - require('ajv-merge-patch'), - [require('ajv-keywords'), 'instanceof'] - // Usage: [plugin, pluginOptions] - Plugin with options - // Usage: plugin - Plugin without options - ] ++ Default: `false` + +Fastify uses [find-my-way](https://github.com/delvedor/find-my-way) to handle +routing. You can use `ignoreDuplicateSlashes` option to remove duplicate slashes +from the path. It removes duplicate slashes in the route path and the request +URL. This option applies to *all* route registrations for the resulting server +instance. + +When `ignoreTrailingSlash` and `ignoreDuplicateSlashes` are both set +to `true` Fastify will remove duplicate slashes, and then trailing slashes, +meaning `//a//b//c//` will be converted to `/a/b/c`. + +```js +const fastify = require('fastify')({ + routerOptions: { + ignoreDuplicateSlashes: true } }) + +// registers "/foo/bar/" +fastify.get('///foo//bar//', function (req, reply) { + reply.send('foo') +}) ``` -### `serializerOpts` - +### `ignoreTrailingSlash` + -Customize the options of the default -[`fast-json-stringify`](https://github.com/fastify/fast-json-stringify#options) -instance that serialize the response's payload: ++ Default: `false` + +Fastify uses [find-my-way](https://github.com/delvedor/find-my-way) to handle +routing. By default, Fastify will take into account the trailing slashes. +Paths like `/foo` and `/foo/` are treated as different paths. If you want to +change this, set this flag to `true`. That way, both `/foo` and `/foo/` will +point to the same route. This option applies to *all* route registrations for +the resulting server instance. ```js const fastify = require('fastify')({ - serializerOpts: { - rounding: 'ceil' + routerOptions: { + ignoreTrailingSlash: true } }) + +// registers both "/foo" and "/foo/" +fastify.get('/foo/', function (req, reply) { + reply.send('foo') +}) + +// registers both "/bar" and "/bar/" +fastify.get('/bar', function (req, reply) { + reply.send('bar') +}) ``` -### `http2SessionTimeout` - +### `maxParamLength` + -Set a default -[timeout](https://nodejs.org/api/http2.html#http2_http2session_settimeout_msecs_callback) -to every incoming HTTP/2 session. The session will be closed on the timeout. -Default: `72000` ms. ++ Default: `100` -Note that this is needed to offer the graceful "close" experience when using -HTTP/2. The low default has been chosen to mitigate denial of service attacks. -When the server is behind a load balancer or can scale automatically this value -can be increased to fit the use case. Node core defaults this to `0`. ` +You can set a custom length for parameters in parametric (standard, regex, and +multi) routes by using `maxParamLength` option; the default value is 100 +characters. If the maximum length limit is reached, the not found route will +be invoked. -### `frameworkErrors` - +This can be useful especially if you have a regex-based route, protecting you +against [ReDoS +attacks](https://www.owasp.org/index.php/Regular_expression_Denial_of_Service_-_ReDoS). -+ Default: `null` -Fastify provides default error handlers for the most common use cases. It is -possible to override one or more of those handlers with custom code using this -option. +### `onBadUrl` + -*Note: Only `FST_ERR_BAD_URL` is implemented at the moment.* +Fastify uses [find-my-way](https://github.com/delvedor/find-my-way) which supports, +the use case of a badly formatted url (eg: /hello/%world), by default find-my-way +will invoke the defaultRoute, unless you specify the onBadUrl option. ```js const fastify = require('fastify')({ - frameworkErrors: function (error, req, res) { - if (error instanceof FST_ERR_BAD_URL) { - res.code(400) - return res.send("Provided url is not valid") - } else { - res.send(err) + routerOptions: { + onBadUrl: (path, req, res) => { + res.statusCode = 400 + res.end(`Bad path: ${path}`) } } }) ``` -### `clientErrorHandler` - +As with `defaultRoute`, `req` and `res` are the raw Node.js request/response +objects and do not provide Fastify's decorated helpers. -Set a -[clientErrorHandler](https://nodejs.org/api/http.html#http_event_clienterror) -that listens to `error` events emitted by client connections and responds with a -`400`. +### `querystringParser` + -It is possible to override the default `clientErrorHandler` using this option. +The default query string parser that Fastify uses is the Node.js's core +`querystring` module. -+ Default: -```js -function defaultClientErrorHandler (err, socket) { - if (err.code === 'ECONNRESET') { - return - } +You can use this option to use a custom parser, such as +[`qs`](https://www.npmjs.com/package/qs). - const body = JSON.stringify({ - error: http.STATUS_CODES['400'], - message: 'Client Error', - statusCode: 400 - }) - this.log.trace({ err }, 'client error') +If you only want the keys (and not the values) to be case insensitive we +recommend using a custom parser to convert only the keys to lowercase. - if (socket.writable) { - socket.end([ - 'HTTP/1.1 400 Bad Request', - `Content-Length: ${body.length}`, - `Content-Type: application/json\r\n\r\n${body}` - ].join('\r\n')) +```js +const qs = require('qs') +const fastify = require('fastify')({ + routerOptions: { + querystringParser: str => qs.parse(str) } -} +}) ``` -*Note: `clientErrorHandler` operates with raw socket. The handler is expected to -return a properly formed HTTP response that includes a status line, HTTP headers -and a message body. Before attempting to write the socket, the handler should -check if the socket is still writable as it may have already been destroyed.* +You can also use Fastify's default parser but change some handling behavior, +like the example below for case insensitive keys and values: ```js +const querystring = require('node:querystring') const fastify = require('fastify')({ - clientErrorHandler: function (err, socket) { - const body = JSON.stringify({ - error: { - message: 'Client error', - code: '400' - } - }) + routerOptions: { + querystringParser: str => querystring.parse(str.toLowerCase()) + } +}) +``` - // `this` is bound to fastify instance - this.log.trace({ err }, 'client error') +### `useSemicolonDelimiter` + - // the handler is responsible for generating a valid HTTP response - socket.end([ - 'HTTP/1.1 400 Bad Request', - `Content-Length: ${body.length}`, - `Content-Type: application/json\r\n\r\n${body}` - ].join('\r\n')) ++ Default `false` + +Fastify uses [find-my-way](https://github.com/delvedor/find-my-way) which supports, +separating the path and query string with a `;` character (code 59), e.g. `/dev;foo=bar`. +This decision originated from [delvedor/find-my-way#76] +(https://github.com/delvedor/find-my-way/issues/76). Thus, this option will support +backwards compatibility for the need to split on `;`. To enable support for splitting +on `;` set `useSemicolonDelimiter` to `true`. + +```js +const fastify = require('fastify')({ + routerOptions: { + useSemicolonDelimiter: true } }) + +fastify.get('/dev', async (request, reply) => { + // An example request such as `/dev;foo=bar` + // Will produce the following query params result `{ foo = 'bar' }` + return request.query +}) ``` -### `rewriteUrl` - +### `allowErrorHandlerOverride` + -Set a sync callback function that must return a string that allows rewriting -URLs. +* **Default:** `true` + +> ⚠ Warning: +> This option will be set to `false` by default +> in the next major release. -> Rewriting a URL will modify the `url` property of the `req` object +When set to `false`, it prevents `setErrorHandler` from being called +multiple times within the same scope, ensuring that the previous error +handler is not unintentionally overridden. + +#### Example of incorrect usage: ```js -function rewriteUrl (req) { // req is the Node.js HTTP request - return req.url === '/hi' ? '/hello' : req.url; -} -``` +app.setErrorHandler(function freeSomeResources () { + // Never executed, memory leaks +}) -Note that `rewriteUrl` is called _before_ routing, it is not encapsulated and it -is an instance-wide configuration. +app.setErrorHandler(function anotherErrorHandler () { + // Overrides the previous handler +}) +``` ## Instance @@ -820,6 +1132,10 @@ is an instance-wide configuration. [server](https://nodejs.org/api/http.html#http_class_http_server) object as returned by the [**`Fastify factory function`**](#factory). +> ⚠ Warning: +> If utilized improperly, certain Fastify features could be disrupted. +> It is recommended to only use it for attaching listeners. + #### after @@ -889,14 +1205,27 @@ fastify.ready().then(() => { Starts the server and internally waits for the `.ready()` event. The signature is `.listen([options][, callback])`. Both the `options` object and the -`callback` parameters follow the [Node.js -core][https://nodejs.org/api/net.html#serverlistenoptions-callback] parameter -definitions. +`callback` parameters extend the [Node.js +core](https://nodejs.org/api/net.html#serverlistenoptions-callback) options +object. Thus, all core options are available with the following additional +Fastify specific options: + +* listenTextResolver: Set an optional resolver for the text to log after server +has been successfully started. It is possible to override the default +`Server listening at [address]` log entry using this option. + + ```js + server.listen({ + port: 9080, + listenTextResolver: (address) => { return `Prometheus metrics server is listening at ${address}` } + }) + ``` By default, the server will listen on the address(es) resolved by `localhost` when no specific host is provided. If listening on any available interface is desired, then specifying `0.0.0.0` for the address will listen on all IPv4 -addresses. The following table details the possible values for `host` when +addresses. The address argument provided above will then return the first such +IPv4 address. The following table details the possible values for `host` when targeting `localhost`, and what the result of those values for `host` will be. Host | IPv4 | IPv6 @@ -997,49 +1326,63 @@ const addresses = fastify.addresses() Note that the array contains the `fastify.server.address()` too. -#### getDefaultRoute - +#### routing + -The `defaultRoute` handler handles requests that do not match any URL specified -by your Fastify application. This defaults to the 404 handler, but can be -overridden with [setDefaultRoute](#setdefaultroute). Method to get the -`defaultRoute` for the server: +Method to access the `lookup` method of the internal router and match the +request to the appropriate handler: ```js -const defaultRoute = fastify.getDefaultRoute() +fastify.routing(req, res) ``` -#### setDefaultRoute - +#### route + + +Method to add routes to the server, it also has shorthand functions, check +[here](./Routes.md). + +#### hasRoute + -**Note**: The default 404 handler, or one set using `setNotFoundHandler`, will -never trigger if the default route is overridden. Use -[setNotFoundHandler](#setnotfoundhandler) if you want to customize 404 handling -instead. Method to set the `defaultRoute` for the server: +Method to check if a route is already registered to the internal router. It +expects an object as the payload. `url` and `method` are mandatory fields. It +is possible to also specify `constraints`. The method returns `true` if the +route is registered or `false` if not. ```js -const defaultRoute = function (req, res) { - res.end('hello world') -} +const routeExists = fastify.hasRoute({ + url: '/', + method: 'GET', + constraints: { version: '1.0.0' } // optional +}) -fastify.setDefaultRoute(defaultRoute) +if (routeExists === false) { + // add route +} ``` -#### routing - +#### findRoute + -Method to access the `lookup` method of the internal router and match the -request to the appropriate handler: +Method to retrieve a route already registered to the internal router. It +expects an object as the payload. `url` and `method` are mandatory fields. It +is possible to also specify `constraints`. +The method returns a route object or `null` if the route cannot be found. ```js -fastify.routing(req, res) -``` +const route = fastify.findRoute({ + url: '/artists/:artistId', + method: 'GET', + constraints: { version: '1.0.0' } // optional +}) -#### route - +if (route !== null) { + // perform some route checks + console.log(route.params) // `{artistId: ':artistId'}` +} +``` -Method to add routes to the server, it also has shorthand functions, check -[here](./Routes.md). #### close @@ -1061,6 +1404,53 @@ fastify.close().then(() => { }) ``` +##### Shutdown lifecycle + +When `fastify.close()` is called, the following steps happen in order: + +1. The server is flagged as **closing**. New incoming requests receive a + `Connection: close` header (HTTP/1.1) and are handled according to + [`return503OnClosing`](#factory-return-503-on-closing). +2. [`preClose`](./Hooks.md#pre-close) hooks execute. The server is still + processing in-flight requests at this point. +3. **Connection draining** based on the + [`forceCloseConnections`](#forcecloseconnections) option: + - `"idle"` — idle keep-alive connections are closed; in-flight requests + continue. + - `true` — all persistent connections are destroyed immediately. + - `false` — no forced closure; idle connections remain open until they time + out naturally (see [`keepAliveTimeout`](#keepalivetimeout)). +4. The HTTP server **stops accepting** new TCP connections + (`server.close()`). Node.js waits for all in-flight requests to complete + before invoking the callback. +5. [`onClose`](./Hooks.md#on-close) hooks execute. All in-flight requests have + completed and the server is no longer listening. +6. The `close` callback (or the returned Promise) resolves. + +``` +fastify.close() called + │ + ├─▶ closing = true (new requests receive 503) + │ + ├─▶ preClose hooks + │ (in-flight requests still active) + │ + ├─▶ Connection draining (forceCloseConnections) + │ + ├─▶ server.close() + │ (waits for in-flight requests to complete) + │ + ├─▶ onClose hooks + │ (server stopped, all requests done) + │ + └─▶ close callback / Promise resolves +``` + +> ℹ️ Note: +> Upgraded connections (such as WebSocket) are not tracked by the HTTP +> server and will prevent `server.close()` from completing. Close them +> explicitly in a [`preClose`](./Hooks.md#pre-close) hook. + #### decorate* @@ -1112,24 +1502,28 @@ fastify.register(function (instance, opts, done) { Name of the current plugin. The root plugin is called `'fastify'`. There are -three ways to define a name (in order). +different ways to define a name (in order). 1. If you use [fastify-plugin](https://github.com/fastify/fastify-plugin) the metadata `name` is used. -2. If you `module.exports` a plugin the filename is used. -3. If you use a regular [function - declaration](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Functions#Defining_functions) +2. If the exported plugin has the `Symbol.for('fastify.display-name')` property, + then the value of that property is used. + Example: `pluginFn[Symbol.for('fastify.display-name')] = "Custom Name"` +3. If you `module.exports` a plugin the filename is used. +4. If you use a regular [function + declaration](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Functions#defining_functions) the function name is used. *Fallback*: The first two lines of your plugin will represent the plugin name. Newlines are replaced by ` -- `. This will help to identify the root cause when you deal with many plugins. -Important: If you have to deal with nested plugins, the name differs with the -usage of the [fastify-plugin](https://github.com/fastify/fastify-plugin) because -no new scope is created and therefore we have no place to attach contextual -data. In that case, the plugin name will represent the boot order of all -involved plugins in the format of `fastify -> plugin-A -> plugin-B`. +> ⚠ Warning: +> If you have to deal with nested plugins, the name differs with the usage of +> the [fastify-plugin](https://github.com/fastify/fastify-plugin) because +> no new scope is created and therefore we have no place to attach contextual +> data. In that case, the plugin name will represent the boot order of all +> involved plugins in the format of `fastify -> plugin-A -> plugin-B`. #### hasPlugin @@ -1150,6 +1544,15 @@ fastify.ready(() => { }) ``` +#### listeningOrigin + + +The current origin the server is listening to. +For example, a TCP socket based server returns +a base address like `http://127.0.0.1:3000`, +and a Unix socket server will return the socket +path, e.g. `fastify.temp.sock`. + #### log @@ -1168,6 +1571,35 @@ used by plugins. Fake HTTP injection (for testing purposes) [here](../Guides/Testing.md#benefits-of-using-fastifyinject). +#### addHttpMethod + + +Fastify supports the `GET`, `HEAD`, `TRACE`, `DELETE`, `OPTIONS`, +`PATCH`, `PUT` and `POST` HTTP methods by default. +The `addHttpMethod` method allows to add any non standard HTTP +methods to the server that are [supported by Node.js](https://nodejs.org/api/http.html#httpmethods). + +```js +// Add a new HTTP method called 'MKCOL' that supports a request body +fastify.addHttpMethod('MKCOL', { hasBody: true, }) + +// Add a new HTTP method called 'COPY' that does not support a request body +fastify.addHttpMethod('COPY') +``` + +After calling `addHttpMethod`, it is possible to use the route shorthand +methods to define routes for the new HTTP method: + +```js +fastify.addHttpMethod('MKCOL', { hasBody: true }) +fastify.mkcol('/', (req, reply) => { + // Handle the 'MKCOL' request +}) +``` + +> ⚠ Warning: +> `addHttpMethod` overrides existing methods. + #### addSchema @@ -1223,7 +1655,9 @@ Set the schema error formatter for all routes. See Set the schema serializer compiler for all routes. See [#schema-serializer](./Validation-and-Serialization.md#schema-serializer). -**Note:** [`setReplySerializer`](#set-reply-serializer) has priority if set! + +> ℹ️ Note: +> [`setReplySerializer`](#set-reply-serializer) has priority if set! #### validatorCompiler @@ -1258,9 +1692,7 @@ This property can be used to fully manage: - `compilersFactory`: what module must compile the JSON schemas It can be useful when your schemas are stored in another data structure that is -unknown to Fastify. See [issue -#2446](https://github.com/fastify/fastify/issues/2446) for an example of what -this property helps to resolve. +unknown to Fastify. Another use case is to tweak all the schemas processing. Doing so it is possible to use Ajv v8 JTD or Standalone feature. To use such as JTD or the Standalone @@ -1300,7 +1732,7 @@ const fastify = Fastify({ }, /** - * The compilers factory let you fully control the validator and serializer + * The compilers factory lets you fully control the validator and serializer * in the Fastify's lifecycle, providing the encapsulation to your compilers. */ compilersFactory: { @@ -1335,7 +1767,7 @@ const fastify = Fastify({ buildSerializer: function factory (externalSchemas, serializerOptsServerOption) { // This factory function must return a schema serializer compiler. // See [#schema-serializer](./Validation-and-Serialization.md#schema-serializer) for details. - return function serializerCompiler ({ schema, method, url, httpStatus }) { + return function serializerCompiler ({ schema, method, url, httpStatus, contentType }) { return data => JSON.stringify(data) } } @@ -1352,15 +1784,20 @@ call is encapsulated by prefix, so different plugins can set different not found handlers if a different [`prefix` option](./Plugins.md#route-prefixing-option) is passed to `fastify.register()`. The handler is treated as a regular route handler so requests will go through the full [Fastify -lifecycle](./Lifecycle.md#lifecycle). *async-await* is supported as well. +lifecycle](./Lifecycle.md#lifecycle) for unexisting URLs. +*async-await* is supported as well. +Badly formatted URLs are sent to the [`onBadUrl`](#onbadurl) +handler instead. You can also register [`preValidation`](./Hooks.md#route-hooks) and [`preHandler`](./Hooks.md#route-hooks) hooks for the 404 handler. -_Note: The `preValidation` hook registered using this method will run for a -route that Fastify does not recognize and **not** when a route handler manually -calls [`reply.callNotFound`](./Reply.md#call-not-found)_. In which case, only -preHandler will be run. +> ℹ️ Note: +> The `preValidation` hook registered using this method will run for a +> route that Fastify does not recognize and **not** when a route handler manually +> calls [`reply.callNotFound`](./Reply.md#call-not-found). In which case, only +> preHandler will be run. + ```js fastify.setNotFoundHandler({ @@ -1391,16 +1828,34 @@ plugins are registered. If you would like to augment the behavior of the default arguments `fastify.setNotFoundHandler()` within the context of these registered plugins. +> ℹ️ Note: +> Some config properties from the request object will be +> undefined inside the custom not found handler. E.g.: +> `request.routeOptions.url`, `routeOptions.method` and `routeOptions.config`. +> This method design goal is to allow calling the common not found route. +> To return a per-route customized 404 response, you can do it in +> the response itself. + #### setErrorHandler `fastify.setErrorHandler(handler(error, request, reply))`: Set a function that -will be called whenever an error happens. The handler is bound to the Fastify -instance and is fully encapsulated, so different plugins can set different error -handlers. *async-await* is supported as well. - -*Note: If the error `statusCode` is less than 400, Fastify will automatically -set it at 500 before calling the error handler.* +will be invoked whenever an exception is thrown during the request lifecycle. +The handler is bound to the Fastify instance and is fully encapsulated, so +different plugins can set different error handlers. *async-await* is +supported as well. + +If the error `statusCode` is less than 400, Fastify will automatically +set it to 500 before calling the error handler. + +`setErrorHandler` will ***not*** catch: +- exceptions thrown in an `onResponse` hook because the response has already been + sent to the client. Use the `onSend` hook instead. +- not found (404) errors. Use [`setNotFoundHandler`](#set-not-found-handler) + instead. +- Stream errors thrown during piping into the response socket, as + headers/response were already sent to the client. + Use custom in-stream data to signal such errors. ```js fastify.setErrorHandler(function (error, request, reply) { @@ -1416,7 +1871,7 @@ is set. It can be accessed using `fastify.errorHandler` and it logs the error with respect to its `statusCode`. ```js -var statusCode = error.statusCode +const statusCode = error.statusCode if (statusCode >= 500) { log.error(error) } else if (statusCode >= 400) { @@ -1426,6 +1881,107 @@ if (statusCode >= 500) { } ``` +> ⚠ Warning: +> Avoid calling setErrorHandler multiple times in the same scope. +> See [`allowErrorHandlerOverride`](#allowerrorhandleroverride). + +##### Custom error handler for stream replies + + +If `Content-Type` differs between the endpoint and error handler, explicitly +define it in both. For example, if the endpoint returns an `application/text` +stream and the error handler responds with `application/json`, the error handler +must explicitly set `Content-Type`. Otherwise, it will fail serialization with +a `500` status code. Alternatively, always respond with serialized data in the +error handler by manually calling a serialization method (e.g., +`JSON.stringify`). + +```js +fastify.setErrorHandler((err, req, reply) => { + reply + .code(400) + .type('application/json') + .send({ error: err.message }) +}) +``` + +```js +fastify.setErrorHandler((err, req, reply) => { + reply + .code(400) + .send(JSON.stringify({ error: err.message })) +}) +``` + +#### setChildLoggerFactory + + +`fastify.setChildLoggerFactory(factory(logger, bindings, opts, rawReq))`: Set a +function that will be called when creating a child logger instance for each request +which allows for modifying or adding child logger bindings and logger options, or +returning a custom child logger implementation. + +Child logger bindings have a performance advantage over per-log bindings because +they are pre-serialized by Pino when the child logger is created. + +The first parameter is the parent logger instance, followed by the default bindings +and logger options which should be passed to the child logger, and finally +the raw request (not a Fastify request object). The function is bound with `this` +being the Fastify instance. + +For example: +```js +const fastify = require('fastify')({ + childLoggerFactory: function (logger, bindings, opts, rawReq) { + // Calculate additional bindings from the request if needed + bindings.traceContext = rawReq.headers['x-cloud-trace-context'] + return logger.child(bindings, opts) + } +}) +``` + +The handler is bound to the Fastify instance and is fully encapsulated, so +different plugins can set different logger factories. + +#### setgenreqid + + +`fastify.setGenReqId(function (rawReq))` Synchronous function for setting the request-id +for additional Fastify instances. It will receive the _raw_ incoming request as +a parameter. The provided function should not throw an Error in any case. + +Especially in distributed systems, you may want to override the default ID +generation behavior to handle custom ways of generating different IDs in +order to handle different use cases. Such as observability or webhooks plugins. + +For example: +```js +const fastify = require('fastify')({ + genReqId: (req) => { + return 'base' + } +}) + +fastify.register((instance, opts, done) => { + instance.setGenReqId((req) => { + // custom request ID for `/webhooks` + return 'webhooks-id' + }) + done() +}, { prefix: '/webhooks' }) + +fastify.register((instance, opts, done) => { + instance.setGenReqId((req) => { + // custom request ID for `/observability` + return 'observability-id' + }) + done() +}, { prefix: '/observability' }) +``` + +The handler is bound to the Fastify instance and is fully encapsulated, so +different plugins can set a different request ID. + #### addConstraintStrategy @@ -1470,35 +2026,61 @@ a custom constraint strategy with the same name. #### printRoutes -`fastify.printRoutes()`: Prints the representation of the internal radix tree -used by the router, useful for debugging. Alternatively, `fastify.printRoutes({ -commonPrefix: false })` can be used to print the flattened routes tree. +`fastify.printRoutes()`: Fastify router builds a tree of routes for each HTTP +method. If you call the prettyPrint without specifying an HTTP method, it will +merge all the trees into one and print it. The merged tree doesn't represent the +internal router structure. **Do not use it for debugging.** *Remember to call it inside or after a `ready` call.* ```js fastify.get('/test', () => {}) fastify.get('/test/hello', () => {}) -fastify.get('/hello/world', () => {}) -fastify.get('/helicopter', () => {}) +fastify.get('/testing', () => {}) +fastify.get('/testing/:param', () => {}) +fastify.put('/update', () => {}) fastify.ready(() => { console.log(fastify.printRoutes()) // └── / // ├── test (GET) - // │ └── /hello (GET) - // └── hel - // ├── lo/world (GET) - // └── licopter (GET) + // │ ├── /hello (GET) + // │ └── ing (GET) + // │ └── / + // │ └── :param (GET) + // └── update (PUT) +}) +``` - console.log(fastify.printRoutes({ commonPrefix: false })) - // └── / (-) - // ├── test (GET) - // │ └── /hello (GET) - // ├── hello/world (GET) - // └── helicopter (GET) +If you want to print the internal router tree, you should specify the `method` +param. Printed tree will represent the internal router structure. +**You can use it for debugging.** -}) +```js + console.log(fastify.printRoutes({ method: 'GET' })) + // └── / + // └── test (GET) + // ├── /hello (GET) + // └── ing (GET) + // └── / + // └── :param (GET) + + console.log(fastify.printRoutes({ method: 'PUT' })) + // └── / + // └── update (PUT) +``` + +`fastify.printRoutes({ commonPrefix: false })` will print compressed trees. This +may be useful when you have a large number of routes with common prefixes. +It doesn't represent the internal router structure. **Do not use it for debugging.** + +```js + console.log(fastify.printRoutes({ commonPrefix: false })) + // ├── /test (GET) + // │ ├── /hello (GET) + // │ └── ing (GET) + // │ └── /:param (GET) + // └── /update (PUT) ``` `fastify.printRoutes({ includeMeta: (true | []) })` will display properties from @@ -1508,26 +2090,51 @@ A shorthand option, `fastify.printRoutes({ includeHooks: true })` will include all [hooks](./Hooks.md). ```js - console.log(fastify.printRoutes({ includeHooks: true, includeMeta: ['metaProperty'] })) + fastify.get('/test', () => {}) + fastify.get('/test/hello', () => {}) + + const onTimeout = () => {} + + fastify.addHook('onRequest', () => {}) + fastify.addHook('onTimeout', onTimeout) + + console.log(fastify.printRoutes({ includeHooks: true, includeMeta: ['errorHandler'] })) // └── / - // ├── test (GET) - // │ • (onRequest) ["anonymous()","namedFunction()"] - // │ • (metaProperty) "value" - // │ └── /hello (GET) - // └── hel - // ├── lo/world (GET) - // │ • (onTimeout) ["anonymous()"] - // └── licopter (GET) + // └── test (GET) + // • (onTimeout) ["onTimeout()"] + // • (onRequest) ["anonymous()"] + // • (errorHandler) "defaultErrorHandler()" + // test (HEAD) + // • (onTimeout) ["onTimeout()"] + // • (onRequest) ["anonymous()"] + // • (onSend) ["headRouteOnSendHandler()"] + // • (errorHandler) "defaultErrorHandler()" + // └── /hello (GET) + // • (onTimeout) ["onTimeout()"] + // • (onRequest) ["anonymous()"] + // • (errorHandler) "defaultErrorHandler()" + // /hello (HEAD) + // • (onTimeout) ["onTimeout()"] + // • (onRequest) ["anonymous()"] + // • (onSend) ["headRouteOnSendHandler()"] + // • (errorHandler) "defaultErrorHandler()" console.log(fastify.printRoutes({ includeHooks: true })) // └── / - // ├── test (GET) - // │ • (onRequest) ["anonymous()","namedFunction()"] - // │ └── /hello (GET) - // └── hel - // ├── lo/world (GET) - // │ • (onTimeout) ["anonymous()"] - // └── licopter (GET) + // └── test (GET) + // • (onTimeout) ["onTimeout()"] + // • (onRequest) ["anonymous()"] + // test (HEAD) + // • (onTimeout) ["onTimeout()"] + // • (onRequest) ["anonymous()"] + // • (onSend) ["headRouteOnSendHandler()"] + // └── /hello (GET) + // • (onTimeout) ["onTimeout()"] + // • (onRequest) ["anonymous()"] + // /hello (HEAD) + // • (onTimeout) ["onTimeout()"] + // • (onRequest) ["anonymous()"] + // • (onSend) ["headRouteOnSendHandler()"] ``` #### printPlugins @@ -1558,7 +2165,7 @@ fastify.ready(() => { `fastify.addContentTypeParser(content-type, options, parser)` is used to pass -custom parser for a given content type. Useful for adding parsers for custom +a custom parser for a given content type. Useful for adding parsers for custom content types, e.g. `text/json, application/vnd.oasis.opendocument.text`. `content-type` can be a string, string array or RegExp. @@ -1625,7 +2232,7 @@ information. `fastify.defaultTextParser()` can be used to parse content as plain text. ```js -fastify.addContentTypeParser('text/json', { asString: true }, fastify.defaultTextParser()) +fastify.addContentTypeParser('text/json', { asString: true }, fastify.defaultTextParser) ``` #### errorHandler @@ -1647,33 +2254,76 @@ fastify.get('/', { }, handler) ``` +#### childLoggerFactory + + +`fastify.childLoggerFactory` returns the custom logger factory function for the +Fastify instance. See the [`childLoggerFactory` config option](#setchildloggerfactory) +for more info. + +#### Symbol.asyncDispose + + +`fastify[Symbol.asyncDispose]` is a symbol that can be used to define an +asynchronous function that will be called when the Fastify instance is closed. + +It's commonly used alongside the `using` TypeScript keyword to ensure that +resources are cleaned up when the Fastify instance is closed. + +This combines perfectly inside short lived processes or unit tests, where you must +close all Fastify resources after returning from inside the function. + +```ts +test('Uses app and closes it afterwards', async () => { + await using app = fastify(); + // do something with app. +}) +``` + +In the above example, Fastify is closed automatically after the test finishes. + +Read more about the +[ECMAScript Explicit Resource Management](https://tc39.es/proposal-explicit-resource-management) +and the [using keyword](https://devblogs.microsoft.com/typescript/announcing-typescript-5-2/) +introduced in TypeScript 5.2. + #### initialConfig `fastify.initialConfig`: Exposes a frozen read-only object registering the initial options passed down by the user to the Fastify instance. -Currently the properties that can be exposed are: +The properties that can currently be exposed are: - connectionTimeout - keepAliveTimeout +- handlerTimeout - bodyLimit - caseSensitive -- allowUnsafeRegex - http2 - https (it will return `false`/`true` or `{ allowHTTP1: true/false }` if explicitly passed) -- ignoreTrailingSlash - disableRequestLogging -- maxParamLength - onProtoPoisoning - onConstructorPoisoning - pluginTimeout - requestIdHeader - requestIdLogLabel - http2SessionTimeout +- routerOptions + - allowUnsafeRegex + - buildPrettyMeta + - caseSensitive + - constraints + - defaultRoute + - ignoreDuplicateSlashes + - ignoreTrailingSlash + - maxParamLength + - onBadUrl + - querystringParser + - useSemicolonDelimiter ```js -const { readFileSync } = require('fs') +const { readFileSync } = require('node:fs') const Fastify = require('fastify') const fastify = Fastify({ @@ -1683,9 +2333,11 @@ const fastify = Fastify({ cert: readFileSync('./fastify.cert') }, logger: { level: 'trace'}, - ignoreTrailingSlash: true, - maxParamLength: 200, - caseSensitive: true, + routerOptions: { + ignoreTrailingSlash: true, + maxParamLength: 200, + caseSensitive: true, + }, trustProxy: '127.0.0.1,192.168.1.1/24', }) @@ -1693,10 +2345,12 @@ console.log(fastify.initialConfig) /* will log : { - caseSensitive: true, https: { allowHTTP1: true }, - ignoreTrailingSlash: true, - maxParamLength: 200 + routerOptions: { + caseSensitive: true, + ignoreTrailingSlash: true, + maxParamLength: 200 + } } */ @@ -1706,10 +2360,12 @@ fastify.register(async (instance, opts) => { /* will return : { - caseSensitive: true, https: { allowHTTP1: true }, - ignoreTrailingSlash: true, - maxParamLength: 200 + routerOptions: { + caseSensitive: true, + ignoreTrailingSlash: true, + maxParamLength: 200 + } } */ }) diff --git a/docs/Reference/Type-Providers.md b/docs/Reference/Type-Providers.md index 3ec4215686d..566442bbe9b 100644 --- a/docs/Reference/Type-Providers.md +++ b/docs/Reference/Type-Providers.md @@ -2,94 +2,100 @@ ## Type Providers -Type Providers are a TypeScript only feature that enables Fastify to statically -infer type information directly from inline JSON Schema. They are an alternative -to specifying generic arguments on routes; and can greatly reduce the need to -keep associated types for each schema defined in your project. +Type Providers are a TypeScript feature that enables Fastify to infer type +information from inline JSON Schema. They are an alternative to specifying +generic arguments on routes and can reduce the need to keep associated types for +each schema in a project. ### Providers -Type Providers are offered as additional packages you will need to install into -your project. Each provider uses a different inference library under the hood; -allowing you to select the library most appropriate for your needs. Type -Provider packages follow a `@fastify/type-provider-{provider-name}` naming -convention. +Official Type Provider packages follow the +`@fastify/type-provider-{provider-name}` naming convention. +Several community providers are also available. The following inference packages are supported: -- `json-schema-to-ts` - - [github](https://github.com/ThomasAribart/json-schema-to-ts) -- `typebox` - [github](https://github.com/sinclairzx81/typebox) +- [`json-schema-to-ts`](https://github.com/ThomasAribart/json-schema-to-ts) +- [`typebox`](https://github.com/sinclairzx81/typebox) +- [`zod`](https://github.com/colinhacks/zod) + +See also the Type Provider wrapper packages for each of the packages respectively: + +- [`@fastify/type-provider-json-schema-to-ts`](https://github.com/fastify/fastify-type-provider-json-schema-to-ts) +- [`@fastify/type-provider-typebox`](https://github.com/fastify/fastify-type-provider-typebox) +- [`fastify-type-provider-zod`](https://github.com/turkerdev/fastify-type-provider-zod) + (3rd party) ### Json Schema to Ts -The following sets up a `json-schema-to-ts` Type Provider +The following sets up a `json-schema-to-ts` Type Provider: ```bash $ npm i @fastify/type-provider-json-schema-to-ts ``` ```typescript -import { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts' - import fastify from 'fastify' +import { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts' const server = fastify().withTypeProvider() server.get('/route', { - schema: { - querystring: { - type: 'object', - properties: { - foo: { type: 'number' }, - bar: { type: 'string' }, - }, - required: ['foo', 'bar'] - } - } as const // don't forget to use const ! - + schema: { + querystring: { + type: 'object', + properties: { + foo: { type: 'number' }, + bar: { type: 'string' }, + }, + required: ['foo', 'bar'] + } + } }, (request, reply) => { - // type Query = { foo: number, bar: string } - - const { foo, bar } = request.query // type safe! + // type Query = { foo: number, bar: string } + const { foo, bar } = request.query // type safe! }) ``` ### TypeBox -The following sets up a TypeBox Type Provider +The following sets up a TypeBox Type Provider: ```bash -$ npm i @fastify/type-provider-typebox +$ npm i typebox @fastify/type-provider-typebox ``` ```typescript -import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox' -import { Type } from '@sinclair/typebox' - import fastify from 'fastify' +import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox' +import { Type } from 'typebox' const server = fastify().withTypeProvider() server.get('/route', { - schema: { - querystring: Type.Object({ - foo: Type.Number(), - bar: Type.String() - }) - } + schema: { + querystring: Type.Object({ + foo: Type.Number(), + bar: Type.String() + }) + } }, (request, reply) => { - // type Query = { foo: number, bar: string } - - const { foo, bar } = request.query // type safe! + // type Query = { foo: number, bar: string } + const { foo, bar } = request.query // type safe! }) ``` -See also the [TypeBox -documentation](https://github.com/sinclairzx81/typebox#validation) on how to set -up AJV to work with TypeBox. +See the [TypeBox +documentation](https://sinclairzx81.github.io/typebox/#/docs/overview/2_setup) +for setting-up AJV to work with TypeBox. + +### Zod + +See [official documentation](https://github.com/turkerdev/fastify-type-provider-zod) +for Zod Type Provider instructions. + ### Scoped Type-Provider @@ -103,7 +109,7 @@ Example: import Fastify from 'fastify' import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox' import { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts' -import { Type } from '@sinclair/typebox' +import { Type } from 'typebox' const fastify = Fastify() @@ -134,7 +140,7 @@ function pluginWithJsonSchema(fastify: FastifyInstance, _opts, done): void { y: { type: 'number' }, z: { type: 'boolean' } }, - } as const + } } }, (req) => { const { x, y, z } = req.body // type safe @@ -146,23 +152,16 @@ fastify.register(pluginWithJsonSchema) fastify.register(pluginWithTypebox) ``` -It's also important to mention that once the types don't propagate globally, -_currently_ is not possible to avoid multiple registrations on routes when -dealing with several scopes, see bellow: +It is important to note that since the types do not propagate globally, it is +currently not possible to avoid multiple registrations on routes when dealing +with several scopes, as shown below: ```ts import Fastify from 'fastify' import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox' -import { Type } from '@sinclair/typebox' +import { Type } from 'typebox' -const server = Fastify({ - ajv: { - customOptions: { - strict: 'log', - keywords: ['kind', 'modifier'], - }, - }, -}).withTypeProvider() +const server = Fastify().withTypeProvider() server.register(plugin1) // wrong server.register(plugin2) // correct @@ -177,7 +176,7 @@ function plugin1(fastify: FastifyInstance, _opts, done): void { }) } }, (req) => { - // it doesn't works! in a new scope needs to call `withTypeProvider` again + // In a new scope, call `withTypeProvider` again to ensure it works const { x, y, z } = req.body }); done() @@ -204,8 +203,8 @@ function plugin2(fastify: FastifyInstance, _opts, done): void { ### Type Definition of FastifyInstance + TypeProvider -When working with modules one has to make use of `FastifyInstance` with Type -Provider generics. See the example below: +When working with modules, use `FastifyInstance` with Type Provider generics. +See the example below: ```ts // index.ts @@ -213,14 +212,7 @@ import Fastify from 'fastify' import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox' import { registerRoutes } from './routes' -const server = Fastify({ - ajv: { - customOptions: { - strict: 'log', - keywords: ['kind', 'modifier'], - }, - }, -}).withTypeProvider() +const server = Fastify().withTypeProvider() registerRoutes(server) @@ -229,10 +221,10 @@ server.listen({ port: 3000 }) ```ts // routes.ts -import { Type } from '@sinclair/typebox' +import { Type } from 'typebox' import { FastifyInstance, - FastifyLoggerInstance, + FastifyBaseLogger, RawReplyDefaultExpression, RawRequestDefaultExpression, RawServerDefault @@ -243,7 +235,7 @@ type FastifyTypebox = FastifyInstance< RawServerDefault, RawRequestDefaultExpression, RawReplyDefaultExpression, - FastifyLoggerInstance, + FastifyBaseLogger, TypeBoxTypeProvider >; diff --git a/docs/Reference/TypeScript.md b/docs/Reference/TypeScript.md index 9143de1c6eb..56e0e39c491 100644 --- a/docs/Reference/TypeScript.md +++ b/docs/Reference/TypeScript.md @@ -34,7 +34,7 @@ examples there is further, more detailed documentation for the type system. This example will get you up and running with Fastify and TypeScript. It results in a blank http Fastify server. -1. Create a new npm project, install Fastify, and install typescript & node.js +1. Create a new npm project, install Fastify, and install typescript & Node.js types as peer dependencies: ```bash npm init -y @@ -58,8 +58,9 @@ in a blank http Fastify server. or use one of the [recommended ones](https://github.com/tsconfig/bases#node-14-tsconfigjson). -*Note: Set `target` property in `tsconfig.json` to `es2017` or greater to avoid -[FastifyDeprecation](https://github.com/fastify/fastify/issues/3284) warning.* +> ℹ️ Note: +> Set `target` property in `tsconfig.json` to `es2017` or greater to avoid +> [FastifyDeprecation](https://github.com/fastify/fastify/issues/3284) warning. 4. Create an `index.ts` file - this will contain the server code 5. Add the following code block to your file: @@ -106,7 +107,7 @@ generic types for route schemas and the dynamic properties located on the route-level `request` object. 1. If you did not complete the previous example, follow steps 1-4 to get set up. -2. Inside `index.ts`, define two interfaces `IQuerystring` and `IHeaders`: +2. Inside `index.ts`, define three interfaces `IQuerystring`,`IHeaders` and `IReply`: ```typescript interface IQuerystring { username: string; @@ -116,8 +117,14 @@ route-level `request` object. interface IHeaders { 'h-Custom': string; } + + interface IReply { + 200: { success: boolean }; + 302: { url: string }; + '4xx': { error: string }; + } ``` -3. Using the two interfaces, define a new API route and pass them as generics. +3. Using the three interfaces, define a new API route and pass them as generics. The shorthand route methods (i.e. `.get`) accept a generic object `RouteGenericInterface` containing five named properties: `Body`, `Querystring`, `Params`, `Headers` and `Reply`. The interfaces `Body`, @@ -127,18 +134,26 @@ route-level `request` object. ```typescript server.get<{ Querystring: IQuerystring, - Headers: IHeaders + Headers: IHeaders, + Reply: IReply }>('/auth', async (request, reply) => { const { username, password } = request.query const customerHeader = request.headers['h-Custom'] // do something with request data - return `logged in!` + // chaining .statusCode/.code calls with .send allows type narrowing. For example: + // this works + reply.code(200).send({ success: true }); + // but this gives a type error + reply.code(200).send('uh-oh'); + // it even works for wildcards + reply.code(404).send({ error: 'Not found' }); + return { success: true } }) ``` 4. Build and run the server code with `npm run build` and `npm run start` -5. Query the api +5. Query the API ```bash curl localhost:8080/auth?username=admin&password=Password123! ``` @@ -149,7 +164,8 @@ route-level `request` object. ```typescript server.get<{ Querystring: IQuerystring, - Headers: IHeaders + Headers: IHeaders, + Reply: IReply }>('/auth', { preValidation: (request, reply, done) => { const { username, password } = request.query @@ -158,7 +174,7 @@ route-level `request` object. }, async (request, reply) => { const customerHeader = request.headers['h-Custom'] // do something with request data - return `logged in!` + return { success: true } }) ``` 7. Build and run and query with the `username` query string option set to @@ -181,43 +197,44 @@ Serialization](./Validation-and-Serialization.md) documentation for more info. Also it has the advantage to use the defined type within your handlers (including pre-validation, etc.). -Here are some options how to achieve this. +Here are some options on how to achieve this. #### Fastify Type Providers Fastify offers two packages wrapping `json-schema-to-ts` and `typebox`: -- `@fastify/type-provider-json-schema-to-ts` -- `@fastify/type-provider-typebox` +- [`@fastify/type-provider-json-schema-to-ts`](https://github.com/fastify/fastify-type-provider-json-schema-to-ts) +- [`@fastify/type-provider-typebox`](https://github.com/fastify/fastify-type-provider-typebox) + +And a `zod` wrapper by a third party called [`fastify-type-provider-zod`](https://github.com/turkerdev/fastify-type-provider-zod) They simplify schema validation setup and you can read more about them in [Type Providers](./Type-Providers.md) page. -Below is how to setup schema validation using vanilla `typebox` and -`json-schema-to-ts` packages. +Below is how to setup schema validation using the `typebox`, +`json-schema-to-typescript`, and `json-schema-to-ts` packages without type +providers. -#### typebox +#### TypeBox -A useful library for building types and a schema at once is -[typebox](https://www.npmjs.com/package/@sinclair/typebox) along with -[fastify-type-provider-typebox](https://github.com/fastify/fastify-type-provider-typebox). -With typebox you define your schema within your code and use them -directly as types or schemas as you need them. +A useful library for building types and a schema at once is [TypeBox](https://www.npmjs.com/package/typebox). +With TypeBox you define your schema within your code and use them directly as +types or schemas as you need them. When you want to use it for validation of some payload in a fastify route you can do it as follows: -1. Install `typebox` and `fastify-type-provider-typebox` in your project. +1. Install `typebox` in your project. ```bash - npm i @sinclair/typebox @fastify/type-provider-typebox + npm i typebox ``` 2. Define the schema you need with `Type` and create the respective type with `Static`. ```typescript - import { Static, Type } from '@sinclair/typebox' + import { Static, Type } from 'typebox' export const User = Type.Object({ name: Type.String(), @@ -231,12 +248,11 @@ can do it as follows: ```typescript import Fastify from 'fastify' - import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox' // ... - const fastify = Fastify().withTypeProvider() + const fastify = Fastify() - app.post<{ Body: UserType, Reply: UserType }>( + fastify.post<{ Body: UserType, Reply: UserType }>( '/', { schema: { @@ -254,25 +270,12 @@ can do it as follows: ) ``` - **Note** For Ajv version 7 and above is required to use the `ajvTypeBoxPlugin`: - - ```typescript - import Fastify from 'fastify' - import { ajvTypeBoxPlugin, TypeBoxTypeProvider } from '@fastify/type-provider-typebox' - - const fastify = Fastify({ - ajv: { - plugins: [ajvTypeBoxPlugin] - } - }).withTypeProvider() - ``` - -#### Schemas in JSON Files +#### json-schema-to-typescript -In the last example we used interfaces to define the types for the request -querystring and headers. Many users will already be using JSON Schemas to define -these properties, and luckily there is a way to transform existing JSON Schemas -into TypeScript interfaces! +In the last example we used Typebox to define the types and schemas for our +route. Many users will already be using JSON Schemas to define these properties, +and luckily there is a way to transform existing JSON Schemas into TypeScript +interfaces! 1. If you did not complete the 'Getting Started' example, go back and follow steps 1-4 first. @@ -500,7 +503,7 @@ Fastify Plugin in a TypeScript Project. `"compilerOptions"` object. ```json { - "compileOptions": { + "compilerOptions": { "declaration": true } } @@ -592,13 +595,13 @@ your plugin. } module.exports = fp(myPlugin, { - fastify: '3.x', + fastify: '5.x', name: 'my-plugin' // this is used by fastify-plugin to derive the property name }) ``` 5. Open `index.d.ts` and add the following code: ```typescript - import { FastifyPlugin } from 'fastify' + import { FastifyPluginCallback } from 'fastify' interface PluginOptions { //... @@ -619,7 +622,7 @@ your plugin. // fastify-plugin automatically adds named export, so be sure to add also this type // the variable name is derived from `options.name` property if `module.exports.myPlugin` is missing - export const myPlugin: FastifyPlugin + export const myPlugin: FastifyPluginCallback // fastify-plugin automatically adds `.default` property to the exported plugin. See the note below export default myPlugin @@ -630,7 +633,7 @@ newer, automatically adds `.default` property and a named export to the exported plugin. Be sure to `export default` and `export const myPlugin` in your typings to provide the best developer experience. For a complete example you can check out -[@fastify/swagger](https://github.com/fastify/fastify-swagger/blob/master/index.d.ts). +[@fastify/swagger](https://github.com/fastify/fastify-swagger/blob/main/index.d.ts). With those files completed, the plugin is now ready to be consumed by any TypeScript project! @@ -685,6 +688,143 @@ Or even explicit config on tsconfig } ``` +#### `getDecorator` + +Fastify's `getDecorator` method retrieves decorators with enhanced type safety. + +The `getDecorator` method supports generic type parameters for enhanced type +safety: + +```typescript +// Type-safe decorator retrieval +const usersRepository = fastify.getDecorator('usersRepository') +const session = request.getDecorator('session') +const sendSuccess = reply.getDecorator('sendSuccess') +``` + +**Alternative to Module Augmentation** + +Decorators are typically typed via module augmentation: + +```typescript +declare module 'fastify' { + interface FastifyInstance { + usersRepository: IUsersRepository + } + interface FastifyRequest { + session: ISession + } + interface FastifyReply { + sendSuccess: SendSuccessFn + } +} +``` + +This approach modifies the Fastify instance globally, which may lead to conflicts +and inconsistent behavior in multi-server setups or with plugin encapsulation. + +Using `getDecorator` allows limiting types scope: + +```typescript +serverOne.register(async function (fastify) { + const usersRepository = fastify.getDecorator( + 'usersRepository' + ) + + fastify.decorateRequest('session', null) + fastify.addHook('onRequest', async (req, reply) => { + req.setDecorator('session', { user: 'Jean' }) + }) + + fastify.get('/me', (request, reply) => { + const session = request.getDecorator('session') + reply.send(session) + }) +}) + +serverTwo.register(async function (fastify) { + const usersRepository = fastify.getDecorator( + 'usersRepository' + ) + + fastify.decorateReply('sendSuccess', function (data) { + return this.send({ success: true }) + }) + + fastify.get('/success', async (request, reply) => { + const sendSuccess = reply.getDecorator('sendSuccess') + await sendSuccess() + }) +}) +``` + +**Bound Functions Inference** + +To save time, it is common to infer function types instead of writing them manually: + +```typescript +function sendSuccess (this: FastifyReply) { + return this.send({ success: true }) +} + +export type SendSuccess = typeof sendSuccess +``` + +However, `getDecorator` returns functions with the `this` context already **bound**, +meaning the `this` parameter disappears from the function signature. + +To correctly type it, use the `OmitThisParameter` utility: + +```typescript +function sendSuccess (this: FastifyReply) { + return this.send({ success: true }) +} + +type BoundSendSuccess = OmitThisParameter + +fastify.decorateReply('sendSuccess', sendSuccess) +fastify.get('/success', async (request, reply) => { + const sendSuccess = reply.getDecorator('sendSuccess') + await sendSuccess() +}) +``` + +#### `setDecorator` + +Fastify's `setDecorator` method provides enhanced type safety for updating request +decorators. + +The `setDecorator` method provides enhanced type safety for updating request +decorators: + +```typescript +fastify.decorateRequest('user', '') +fastify.addHook('preHandler', async (req, reply) => { + // Type-safe decorator setting + req.setDecorator('user', 'Bob Dylan') +}) +``` + +**Type Safety Benefits** + +If the `FastifyRequest` interface does not declare the decorator, type assertions +are typically needed: + +```typescript +fastify.addHook('preHandler', async (req, reply) => { + (req as typeof req & { user: string }).user = 'Bob Dylan' +}) +``` + +The `setDecorator` method eliminates the need for explicit type assertions +while providing type safety: + +```typescript +fastify.addHook('preHandler', async (req, reply) => { + req.setDecorator('user', 'Bob Dylan') +}) +``` + ## Code Completion In Vanilla JavaScript Vanilla JavaScript can use the published types to provide code completion (e.g. @@ -713,8 +853,8 @@ constraint value(s). Read these articles for more information on TypeScript generics. - [Generic Parameter Default](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-3.html#generic-parameter-defaults) -- [Generic - Constraints](https://www.typescriptlang.org/docs/handbook/generics.html#generic-constraints) +- [Generic Constraints](https://www.typescriptlang.org/docs/handbook/2/generics.html#generic-constraints) + #### How to import @@ -780,7 +920,7 @@ There are a couple supported import methods with the Fastify type system. Many type definitions share the same generic parameters; they are all documented, in detail, within this section. -Most definitions depend on `@node/types` modules `http`, `https`, and `http2` +Most definitions depend on `@types/node` modules `http`, `https`, and `http2` ##### RawServer Underlying Node.js server type @@ -827,7 +967,7 @@ Constraints: `string | Buffer` #### Fastify -##### fastify<[RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric], [Logger][LoggerGeneric]>(opts?: [FastifyServerOptions][FastifyServerOptions]): [FastifyInstance][FastifyInstance] +##### fastify< [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric], [Logger][LoggerGeneric]>(opts?: [FastifyServerOptions][FastifyServerOptions]): [FastifyInstance][FastifyInstance] [src](https://github.com/fastify/fastify/blob/main/fastify.d.ts#L19) The main Fastify API method. By default creates an HTTP server. Utilizing @@ -850,17 +990,22 @@ const server = fastify() Check out the Learn By Example - [Getting Started](#getting-started) example for a more detailed http server walkthrough. -###### Example 2: HTTPS sever +###### Example 2: HTTPS server 1. Create the following imports from `@types/node` and `fastify` ```typescript - import fs from 'fs' - import path from 'path' + import fs from 'node:fs' + import path from 'node:path' import fastify from 'fastify' ``` -2. Follow the steps in this official [Node.js https server - guide](https://nodejs.org/en/knowledge/HTTP/servers/how-to-create-a-HTTPS-server/) - to create the `key.pem` and `cert.pem` files +2. Perform the following steps before setting up a Fastify HTTPS server +to create the `key.pem` and `cert.pem` files: +```sh +openssl genrsa -out key.pem +openssl req -new -key key.pem -out csr.pem +openssl x509 -req -days 9999 -in csr.pem -signkey key.pem -out cert.pem +rm csr.pem +``` 3. Instantiate a Fastify https server and add a route: ```typescript const server = fastify({ @@ -911,7 +1056,7 @@ specified at server instantiation, the custom type becomes available on all further instances of the custom type. ```typescript import fastify from 'fastify' -import http from 'http' +import http from 'node:http' interface customRequest extends http.IncomingMessage { mySpecialProp: string @@ -963,7 +1108,7 @@ Union type of: `'DELETE' | 'GET' | 'HEAD' | 'PATCH' | 'POST' | 'PUT' | ##### fastify.RawServerBase [src](https://github.com/fastify/fastify/blob/main/types/utils.d.ts#L13) -Dependant on `@types/node` modules `http`, `https`, `http2` +Dependent on `@types/node` modules `http`, `https`, `http2` Union type of: `http.Server | https.Server | http2.Http2Server | http2.Http2SecureServer` @@ -971,13 +1116,13 @@ http2.Http2SecureServer` ##### fastify.RawServerDefault [src](https://github.com/fastify/fastify/blob/main/types/utils.d.ts#L18) -Dependant on `@types/node` modules `http` +Dependent on `@types/node` modules `http` Type alias for `http.Server` --- -##### fastify.FastifyServerOptions<[RawServer][RawServerGeneric], [Logger][LoggerGeneric]> +##### fastify.FastifyServerOptions< [RawServer][RawServerGeneric], [Logger][LoggerGeneric]> [src](https://github.com/fastify/fastify/blob/main/fastify.d.ts#L29) @@ -988,7 +1133,7 @@ generic parameters are passed down through that method. See the main [fastify][Fastify] method type definition section for examples on instantiating a Fastify server with TypeScript. -##### fastify.FastifyInstance<[RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RequestGeneric][FastifyRequestGenericInterface], [Logger][LoggerGeneric]> +##### fastify.FastifyInstance< [RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RequestGeneric][FastifyRequestGenericInterface], [Logger][LoggerGeneric]> [src](https://github.com/fastify/fastify/blob/main/types/instance.d.ts#L16) @@ -1011,7 +1156,7 @@ details on this interface. #### Request -##### fastify.FastifyRequest<[RequestGeneric][FastifyRequestGenericInterface], [RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric]> +##### fastify.FastifyRequest< [RequestGeneric][FastifyRequestGenericInterface], [RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric]> [src](https://github.com/fastify/fastify/blob/main/types/request.d.ts#L15) This interface contains properties of Fastify request object. The properties @@ -1085,12 +1230,12 @@ server.get('/', async (request, reply) => { ``` If you want to see a detailed example of using this interface check out the -Learn by Example section: [JSON Schema](#jsonschema). +Learn by Example section: [JSON Schema](#json-schema). ##### fastify.RawRequestDefaultExpression\<[RawServer][RawServerGeneric]\> [src](https://github.com/fastify/fastify/blob/main/types/utils.d.ts#L23) -Dependant on `@types/node` modules `http`, `https`, `http2` +Dependent on `@types/node` modules `http`, `https`, `http2` Generic parameter `RawServer` defaults to [`RawServerDefault`][RawServerDefault] @@ -1099,8 +1244,8 @@ returns `http.IncomingMessage`, otherwise, it returns `http2.Http2ServerRequest`. ```typescript -import http from 'http' -import http2 from 'http2' +import http from 'node:http' +import http2 from 'node:http2' import { RawRequestDefaultExpression } from 'fastify' RawRequestDefaultExpression // -> http.IncomingMessage @@ -1111,7 +1256,7 @@ RawRequestDefaultExpression // -> http2.Http2ServerRequest #### Reply -##### fastify.FastifyReply<[RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric], [RequestGeneric][FastifyRequestGenericInterface], [ContextConfig][ContextConfigGeneric]> +##### fastify.FastifyReply<[RequestGeneric][FastifyRequestGenericInterface], [RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric], [ContextConfig][ContextConfigGeneric]> [src](https://github.com/fastify/fastify/blob/main/types/reply.d.ts#L32) This interface contains the custom properties that Fastify adds to the standard @@ -1147,10 +1292,10 @@ declare module 'fastify' { } ``` -##### fastify.RawReplyDefaultExpression<[RawServer][RawServerGeneric]> +##### fastify.RawReplyDefaultExpression< [RawServer][RawServerGeneric]> [src](https://github.com/fastify/fastify/blob/main/types/utils.d.ts#L27) -Dependant on `@types/node` modules `http`, `https`, `http2` +Dependent on `@types/node` modules `http`, `https`, `http2` Generic parameter `RawServer` defaults to [`RawServerDefault`][RawServerDefault] @@ -1159,8 +1304,8 @@ returns `http.ServerResponse`, otherwise, it returns `http2.Http2ServerResponse`. ```typescript -import http from 'http' -import http2 from 'http2' +import http from 'node:http' +import http2 from 'node:http2' import { RawReplyDefaultExpression } from 'fastify' RawReplyDefaultExpression // -> http.ServerResponse @@ -1179,19 +1324,19 @@ When creating plugins for Fastify, it is recommended to use the `fastify-plugin` module. Additionally, there is a guide to creating plugins with TypeScript and Fastify available in the Learn by Example, [Plugins](#plugins) section. -##### fastify.FastifyPluginCallback<[Options][FastifyPluginOptions]> +##### fastify.FastifyPluginCallback< [Options][FastifyPluginOptions]> [src](https://github.com/fastify/fastify/blob/main/types/plugin.d.ts#L9) Interface method definition used within the [`fastify.register()`][FastifyRegister] method. -##### fastify.FastifyPluginAsync<[Options][FastifyPluginOptions]> +##### fastify.FastifyPluginAsync< [Options][FastifyPluginOptions]> [src](https://github.com/fastify/fastify/blob/main/types/plugin.d.ts#L20) Interface method definition used within the [`fastify.register()`][FastifyRegister] method. -##### fastify.FastifyPlugin<[Options][FastifyPluginOptions]> +##### fastify.FastifyPlugin< [Options][FastifyPluginOptions]> [src](https://github.com/fastify/fastify/blob/main/types/plugin.d.ts#L29) Interface method definition used within the @@ -1225,20 +1370,21 @@ a function signature with an underlying generic `Options` which is defaulted to FastifyPlugin parameter when calling this function so there is no need to specify the underlying generic. The options parameter is the intersection of the plugin's options and two additional optional properties: `prefix: string` and -`logLevel`: [LogLevel][LogLevel]. +`logLevel`: [LogLevel][LogLevel]. `FastifyPlugin` is deprecated use +`FastifyPluginCallback` and `FastifyPluginAsync` instead. Below is an example of the options inference in action: ```typescript const server = fastify() -const plugin: FastifyPlugin<{ +const plugin: FastifyPluginCallback<{ option1: string; option2: boolean; }> = function (instance, opts, done) { } -fastify().register(plugin, {}) // Error - options object is missing required properties -fastify().register(plugin, { option1: '', option2: true }) // OK - options object contains required properties +server().register(plugin, {}) // Error - options object is missing required properties +server().register(plugin, { option1: '', option2: true }) // OK - options object contains required properties ``` See the Learn By Example, [Plugins](#plugins) section for more detailed examples @@ -1259,7 +1405,7 @@ a function that returns the previously described intersection. Check out the [Specifying Logger Types](#example-5-specifying-logger-types) example for more details on specifying a custom logger. -##### fastify.FastifyLoggerOptions<[RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric]> +##### fastify.FastifyLoggerOptions< [RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric]> [src](https://github.com/fastify/fastify/blob/main/types/logger.d.ts#L17) @@ -1288,9 +1434,22 @@ Union type of: `'info' | 'error' | 'debug' | 'fatal' | 'warn' | 'trace'` The context type definition is similar to the other highly dynamic pieces of the type system. Route context is available in the route handler method. -##### fastify.FastifyContext +##### fastify.FastifyRequestContext + +[src](https://github.com/fastify/fastify/blob/main/types/context.d.ts#L11) + +An interface with a single required property `config` that is set by default to +`unknown`. Can be specified either using a generic or an overload. + +This type definition is potentially incomplete. If you are using it and can +provide more details on how to improve the definition, we strongly encourage you +to open an issue in the main +[fastify/fastify](https://github.com/fastify/fastify) repository. Thank you in +advanced! + +##### fastify.FastifyReplyContext -[src](https://github.com/fastify/fastify/blob/main/types/context.d.ts#L6) +[src](https://github.com/fastify/fastify/blob/main/types/context.d.ts#L11) An interface with a single required property `config` that is set by default to `unknown`. Can be specified either using a generic or an overload. @@ -1309,17 +1468,17 @@ One of the core principles in Fastify is its routing capabilities. Most of the types defined in this section are used under-the-hood by the Fastify instance `.route` and `.get/.post/.etc` methods. -##### fastify.RouteHandlerMethod<[RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric], [RequestGeneric][FastifyRequestGenericInterface], [ContextConfig][ContextConfigGeneric]> +##### fastify.RouteHandlerMethod< [RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric], [RequestGeneric][FastifyRequestGenericInterface], [ContextConfig][ContextConfigGeneric]> [src](https://github.com/fastify/fastify/blob/main/types/route.d.ts#L105) A type declaration for the route handler methods. Has two arguments, `request` -and `reply` which are typed by `FastifyRequest` and `FastifyReply` respectfully. +and `reply` which are typed by `FastifyRequest` and `FastifyReply` respectively. The generics parameters are passed through to these arguments. The method returns either `void` or `Promise` for synchronous and asynchronous -handlers respectfully. +handlers respectively. -##### fastify.RouteOptions<[RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric], [RequestGeneric][FastifyRequestGenericInterface], [ContextConfig][ContextConfigGeneric]> +##### fastify.RouteOptions< [RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric], [RequestGeneric][FastifyRequestGenericInterface], [ContextConfig][ContextConfigGeneric]> [src](https://github.com/fastify/fastify/blob/main/types/route.d.ts#L78) @@ -1331,14 +1490,14 @@ required properties: 3. `handler` the route handler method, see [RouteHandlerMethod][] for more details -##### fastify.RouteShorthandMethod<[RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric]> +##### fastify.RouteShorthandMethod< [RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric]> [src](https://github.com/fastify/fastify/blob/main/types/route.d.ts#12) An overloaded function interface for three kinds of shorthand route methods to be used in conjunction with the `.get/.post/.etc` methods. -##### fastify.RouteShorthandOptions<[RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric], [RequestGeneric][FastifyRequestGenericInterface], [ContextConfig][ContextConfigGeneric]> +##### fastify.RouteShorthandOptions< [RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric], [RequestGeneric][FastifyRequestGenericInterface], [ContextConfig][ContextConfigGeneric]> [src](https://github.com/fastify/fastify/blob/main/types/route.d.ts#55) @@ -1346,7 +1505,7 @@ An interface that covers all of the base options for a route. Each property on this interface is optional, and it serves as the base for the RouteOptions and RouteShorthandOptionsWithHandler interfaces. -##### fastify.RouteShorthandOptionsWithHandler<[RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric], [RequestGeneric][FastifyRequestGenericInterface], [ContextConfig][ContextConfigGeneric]> +##### fastify.RouteShorthandOptionsWithHandler< [RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric], [RequestGeneric][FastifyRequestGenericInterface], [ContextConfig][ContextConfigGeneric]> [src](https://github.com/fastify/fastify/blob/main/types/route.d.ts#93) @@ -1361,21 +1520,21 @@ interface `handler` which is of type RouteHandlerMethod A generic type that is either a `string` or `Buffer` -##### fastify.FastifyBodyParser<[RawBody][RawBodyGeneric], [RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric]> +##### fastify.FastifyBodyParser< [RawBody][RawBodyGeneric], [RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric]> [src](https://github.com/fastify/fastify/blob/main/types/content-type-parser.d.ts#L7) A function type definition for specifying a body parser method. Use the `RawBody` generic to specify the type of the body being parsed. -##### fastify.FastifyContentTypeParser<[RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric]> +##### fastify.FastifyContentTypeParser< [RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric]> [src](https://github.com/fastify/fastify/blob/main/types/content-type-parser.d.ts#L17) A function type definition for specifying a body parser method. Content is typed via the `RawRequest` generic. -##### fastify.AddContentTypeParser<[RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric]> +##### fastify.AddContentTypeParser< [RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric]> [src](https://github.com/fastify/fastify/blob/main/types/content-type-parser.d.ts#L46) @@ -1417,7 +1576,7 @@ This interface is passed to instance of FastifyError. #### Hooks -##### fastify.onRequestHookHandler<[RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric], [RequestGeneric][FastifyRequestGenericInterface], [ContextConfig][ContextConfigGeneric]>(request: [FastifyRequest][FastifyRequest], reply: [FastifyReply][FastifyReply], done: (err?: [FastifyError][FastifyError]) => void): Promise\ | void +##### fastify.onRequestHookHandler< [RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric], [RequestGeneric][FastifyRequestGenericInterface], [ContextConfig][ContextConfigGeneric]>(request: [FastifyRequest][FastifyRequest], reply: [FastifyReply][FastifyReply], done: (err?: [FastifyError][FastifyError]) => void): Promise\ | void [src](https://github.com/fastify/fastify/blob/main/types/hooks.d.ts#L17) @@ -1427,7 +1586,7 @@ no previous hook, the next hook will be `preParsing`. Notice: in the `onRequest` hook, request.body will always be null, because the body parsing happens before the `preHandler` hook. -##### fastify.preParsingHookHandler<[RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric], [RequestGeneric][FastifyRequestGenericInterface], [ContextConfig][ContextConfigGeneric]>(request: [FastifyRequest][FastifyRequest], reply: [FastifyReply][FastifyReply], done: (err?: [FastifyError][FastifyError]) => void): Promise\ | void +##### fastify.preParsingHookHandler< [RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric], [RequestGeneric][FastifyRequestGenericInterface], [ContextConfig][ContextConfigGeneric]>(request: [FastifyRequest][FastifyRequest], reply: [FastifyReply][FastifyReply], done: (err?: [FastifyError][FastifyError]) => void): Promise\ | void [src](https://github.com/fastify/fastify/blob/main/types/hooks.d.ts#L35) @@ -1442,31 +1601,32 @@ stream. This property is used to correctly match the request payload with the `Content-Length` header value. Ideally, this property should be updated on each received chunk. -##### fastify.preValidationHookHandler<[RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric], [RequestGeneric][FastifyRequestGenericInterface], [ContextConfig][ContextConfigGeneric]>(request: [FastifyRequest][FastifyRequest], reply: [FastifyReply][FastifyReply], done: (err?: [FastifyError][FastifyError]) => void): Promise\ | void +##### fastify.preValidationHookHandler< [RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric], [RequestGeneric][FastifyRequestGenericInterface], [ContextConfig][ContextConfigGeneric]>(request: [FastifyRequest][FastifyRequest], reply: [FastifyReply][FastifyReply], done: (err?: [FastifyError][FastifyError]) => void): Promise\ | void [src](https://github.com/fastify/fastify/blob/main/types/hooks.d.ts#L53) `preValidation` is the third hook to be executed in the request lifecycle. The previous hook was `preParsing`, the next hook will be `preHandler`. -##### fastify.preHandlerHookHandler<[RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric], [RequestGeneric][FastifyRequestGenericInterface], [ContextConfig][ContextConfigGeneric]>(request: [FastifyRequest][FastifyRequest], reply: [FastifyReply][FastifyReply], done: (err?: [FastifyError][FastifyError]) => void): Promise\ | void +##### fastify.preHandlerHookHandler< [RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric], [RequestGeneric][FastifyRequestGenericInterface], [ContextConfig][ContextConfigGeneric]>(request: [FastifyRequest][FastifyRequest], reply: [FastifyReply][FastifyReply], done: (err?: [FastifyError][FastifyError]) => void): Promise\ | void [src](https://github.com/fastify/fastify/blob/main/types/hooks.d.ts#L70) `preHandler` is the fourth hook to be executed in the request lifecycle. The previous hook was `preValidation`, the next hook will be `preSerialization`. -##### fastify.preSerializationHookHandler(request: [FastifyRequest][FastifyRequest], reply: [FastifyReply][FastifyReply], payload: PreSerializationPayload, done: (err: [FastifyError][FastifyError] | null, res?: unknown) => void): Promise\ | void +##### fastify.preSerializationHookHandler< PreSerializationPayload, [RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric], [RequestGeneric][FastifyRequestGenericInterface], [ContextConfig][ContextConfigGeneric]>(request: [FastifyRequest][FastifyRequest], reply: [FastifyReply][FastifyReply], payload: PreSerializationPayload, done: (err: [FastifyError][FastifyError] | null, res?: unknown) => void): Promise\ | void [src](https://github.com/fastify/fastify/blob/main/types/hooks.d.ts#L94) `preSerialization` is the fifth hook to be executed in the request lifecycle. The previous hook was `preHandler`, the next hook will be `onSend`. -Note: the hook is NOT called if the payload is a string, a Buffer, a stream or -null. +> ℹ️ Note: +> The hook is NOT called if the payload is a string, a Buffer, +> a stream, or null. -##### fastify.onSendHookHandler(request: [FastifyRequest][FastifyRequest], reply: [FastifyReply][FastifyReply], payload: OnSendPayload, done: (err: [FastifyError][FastifyError] | null, res?: unknown) => void): Promise\ | void +##### fastify.onSendHookHandler< OnSendPayload, [RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric], [RequestGeneric][FastifyRequestGenericInterface], [ContextConfig][ContextConfigGeneric]>(request: [FastifyRequest][FastifyRequest], reply: [FastifyReply][FastifyReply], payload: OnSendPayload, done: (err: [FastifyError][FastifyError] | null, res?: unknown) => void): Promise\ | void [src](https://github.com/fastify/fastify/blob/main/types/hooks.d.ts#L114) @@ -1474,10 +1634,11 @@ You can change the payload with the `onSend` hook. It is the sixth hook to be executed in the request lifecycle. The previous hook was `preSerialization`, the next hook will be `onResponse`. -Note: If you change the payload, you may only change it to a string, a Buffer, a -stream, or null. +> ℹ️ Note: +> If you change the payload, you may only change it to a string, +> a Buffer, a stream, or null. -##### fastify.onResponseHookHandler<[RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric], [RequestGeneric][FastifyRequestGenericInterface], [ContextConfig][ContextConfigGeneric]>(request: [FastifyRequest][FastifyRequest], reply: [FastifyReply][FastifyReply], done: (err?: [FastifyError][FastifyError]) => void): Promise\ | void +##### fastify.onResponseHookHandler< [RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric], [RequestGeneric][FastifyRequestGenericInterface], [ContextConfig][ContextConfigGeneric]>(request: [FastifyRequest][FastifyRequest], reply: [FastifyReply][FastifyReply], done: (err?: [FastifyError][FastifyError]) => void): Promise\ | void [src](https://github.com/fastify/fastify/blob/main/types/hooks.d.ts#L134) @@ -1488,7 +1649,7 @@ The onResponse hook is executed when a response has been sent, so you will not be able to send more data to the client. It can however be useful for sending data to external services, for example to gather statistics. -##### fastify.onErrorHookHandler<[RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric], [RequestGeneric][FastifyRequestGenericInterface], [ContextConfig][ContextConfigGeneric]>(request: [FastifyRequest][FastifyRequest], reply: [FastifyReply][FastifyReply], error: [FastifyError][FastifyError], done: () => void): Promise\ | void +##### fastify.onErrorHookHandler< [RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric], [RequestGeneric][FastifyRequestGenericInterface], [ContextConfig][ContextConfigGeneric]>(request: [FastifyRequest][FastifyRequest], reply: [FastifyReply][FastifyReply], error: [FastifyError][FastifyError], done: () => void): Promise\ | void [src](https://github.com/fastify/fastify/blob/main/types/hooks.d.ts#L154) @@ -1498,14 +1659,12 @@ specific header in case of error. It is not intended for changing the error, and calling reply.send will throw an exception. -This hook will be executed only after the customErrorHandler has been executed, -and only if the customErrorHandler sends an error back to the user (Note that -the default customErrorHandler always sends the error back to the user). +This hook will be executed before the customErrorHandler. Notice: unlike the other hooks, pass an error to the done function is not supported. -##### fastify.onRouteHookHandler<[RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric], [RequestGeneric][FastifyRequestGenericInterface], [ContextConfig][ContextConfigGeneric]>(opts: [RouteOptions][RouteOptions] & { path: string; prefix: string }): Promise\ | void +##### fastify.onRouteHookHandler< [RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric], [RequestGeneric][FastifyRequestGenericInterface], [ContextConfig][ContextConfigGeneric]>(opts: [RouteOptions][RouteOptions] & \{ path: string; prefix: string }): Promise\ | void [src](https://github.com/fastify/fastify/blob/main/types/hooks.d.ts#L174) @@ -1513,7 +1672,7 @@ Triggered when a new route is registered. Listeners are passed a routeOptions object as the sole parameter. The interface is synchronous, and, as such, the listener does not get passed a callback -##### fastify.onRegisterHookHandler<[RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric], [Logger][LoggerGeneric]>(instance: [FastifyInstance][FastifyInstance], done: (err?: [FastifyError][FastifyError]) => void): Promise\ | void +##### fastify.onRegisterHookHandler< [RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric], [Logger][LoggerGeneric]>(instance: [FastifyInstance][FastifyInstance], done: (err?: [FastifyError][FastifyError]) => void): Promise\ | void [src](https://github.com/fastify/fastify/blob/main/types/hooks.d.ts#L191) @@ -1523,9 +1682,10 @@ created. The hook will be executed before the registered code. This hook can be useful if you are developing a plugin that needs to know when a plugin context is formed, and you want to operate in that specific context. -Note: This hook will not be called if a plugin is wrapped inside fastify-plugin. +> ℹ️ Note: +> This hook will not be called if a plugin is wrapped inside fastify-plugin. -##### fastify.onCloseHookHandler<[RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric], [Logger][LoggerGeneric]>(instance: [FastifyInstance][FastifyInstance], done: (err?: [FastifyError][FastifyError]) => void): Promise\ | void +##### fastify.onCloseHookHandler< [RawServer][RawServerGeneric], [RawRequest][RawRequestGeneric], [RawReply][RawReplyGeneric], [Logger][LoggerGeneric]>(instance: [FastifyInstance][FastifyInstance], done: (err?: [FastifyError][FastifyError]) => void): Promise\ | void [src](https://github.com/fastify/fastify/blob/main/types/hooks.d.ts#L206) diff --git a/docs/Reference/Validation-and-Serialization.md b/docs/Reference/Validation-and-Serialization.md index 9732a85d648..399c7a202f6 100644 --- a/docs/Reference/Validation-and-Serialization.md +++ b/docs/Reference/Validation-and-Serialization.md @@ -1,72 +1,73 @@

Fastify

## Validation and Serialization -Fastify uses a schema-based approach, and even if it is not mandatory we -recommend using [JSON Schema](https://json-schema.org/) to validate your routes -and serialize your outputs. Internally, Fastify compiles the schema into a -highly performant function. - -Validation will only be attempted if the content type is `application-json`, as -described in the documentation for the [content type -parser](./ContentTypeParser.md). - -All the examples in this section are using the [JSON Schema Draft -7](https://json-schema.org/specification-links.html#draft-7) specification. - -> ## ⚠ Security Notice -> Treat the schema definition as application code. Validation and serialization -> features dynamically evaluate code with `new Function()`, which is not safe to -> use with user-provided schemas. See [Ajv](https://npm.im/ajv) and -> [fast-json-stringify](https://npm.im/fast-json-stringify) for more details. +Fastify uses a schema-based approach. We recommend using +[JSON Schema](https://json-schema.org/) to validate routes and serialize outputs. +Fastify compiles the schema into a highly performant function. + +Validation is only attempted if the content type is `application/json`, +unless the body schema uses the [`content`](#body-content-type-validation) +property to specify validation per content type. When the body schema defines +a `content` field, it must enumerate all possible content types the +application expects to handle with the associated handler. + +All examples use the +[JSON Schema Draft 7](https://json-schema.org/specification-links.html#draft-7) +specification. + +> ⚠ Warning: +> Treat schema definitions as application code. Validation and serialization +> features use `new Function()`, which is unsafe with user-provided schemas. See +> [Ajv](https://npm.im/ajv) and +> [fast-json-stringify](https://npm.im/fast-json-stringify) for details. > -> Moreover, the [`$async` Ajv -> feature](https://ajv.js.org/guide/async-validation.html) should not be used as -> part of the first validation strategy. This option is used to access Databases -> and reading them during the validation process may lead to Denial of Service -> Attacks to your application. If you need to run `async` tasks, use [Fastify's -> hooks](./Hooks.md) instead after validation completes, such as `preHandler`. - +> Whilst Fastify supports the +> [`$async` Ajv feature](https://ajv.js.org/guide/async-validation.html), +> it should not be used for initial validation. Accessing databases during +> validation may lead to Denial of Service attacks. Use +> [Fastify's hooks](./Hooks.md) like `preHandler` for `async` tasks after validation. +> +> When using custom validators with async `preValidation` hooks, +> validators **must return** `{error}` objects instead of throwing errors. +> Throwing errors from custom validators will cause unhandled promise rejections +> that crash the application when combined with async hooks. See the +> [custom validator examples](#using-other-validation-libraries) below for the +> correct pattern. ### Core concepts -The validation and the serialization tasks are processed by two different, and -customizable, actors: -- [Ajv v8](https://www.npmjs.com/package/ajv) for the validation of a request +Validation and serialization are handled by two customizable dependencies: +- [Ajv v8](https://www.npmjs.com/package/ajv) for request validation - [fast-json-stringify](https://www.npmjs.com/package/fast-json-stringify) for - the serialization of a response's body + response body serialization -These two separate entities share only the JSON schemas added to Fastify's -instance through `.addSchema(schema)`. +These dependencies share only the JSON schemas added to Fastify's instance via +`.addSchema(schema)`. #### Adding a shared schema -Thanks to the `addSchema` API, you can add multiple schemas to the Fastify -instance and then reuse them in multiple parts of your application. As usual, -this API is encapsulated. +The `addSchema` API allows adding multiple schemas to the Fastify instance for +reuse throughout the application. This API is encapsulated. -The shared schemas can be reused through the JSON Schema +Shared schemas can be reused with the JSON Schema [**`$ref`**](https://tools.ietf.org/html/draft-handrews-json-schema-01#section-8) -keyword. Here is an overview of _how_ references work: +keyword. Here is an overview of how references work: -+ `myField: { $ref: '#foo'}` will search for field with `$id: '#foo'` inside the ++ `myField: { $ref: '#foo' }` searches for `$id: '#foo'` in the current schema ++ `myField: { $ref: '#/definitions/foo' }` searches for `definitions.foo` in the current schema -+ `myField: { $ref: '#/definitions/foo'}` will search for field - `definitions.foo` inside the current schema -+ `myField: { $ref: 'http://url.com/sh.json#'}` will search for a shared schema - added with `$id: 'http://url.com/sh.json'` -+ `myField: { $ref: 'http://url.com/sh.json#/definitions/foo'}` will search for - a shared schema added with `$id: 'http://url.com/sh.json'` and will use the - field `definitions.foo` -+ `myField: { $ref: 'http://url.com/sh.json#foo'}` will search for a shared - schema added with `$id: 'http://url.com/sh.json'` and it will look inside of - it for object with `$id: '#foo'` - ++ `myField: { $ref: 'http://url.com/sh.json#' }` searches for a shared schema + with `$id: 'http://url.com/sh.json'` ++ `myField: { $ref: 'http://url.com/sh.json#/definitions/foo' }` searches for a + shared schema with `$id: 'http://url.com/sh.json'` and uses `definitions.foo` ++ `myField: { $ref: 'http://url.com/sh.json#foo' }` searches for a shared schema + with `$id: 'http://url.com/sh.json'` and looks for `$id: '#foo'` within it **Simple usage:** ```js fastify.addSchema({ - $id: 'http://example.com/', + $id: 'http://fastify.example/', type: 'object', properties: { hello: { type: 'string' } @@ -78,7 +79,7 @@ fastify.post('/', { schema: { body: { type: 'array', - items: { $ref: 'http://example.com#/properties/hello' } + items: { $ref: 'http://fastify.example#/properties/hello' } } } }) @@ -107,9 +108,9 @@ fastify.post('/', { #### Retrieving the shared schemas -If the validator and the serializer are customized, the `.addSchema` method will -not be useful since the actors are no longer controlled by Fastify. To access -the schemas added to the Fastify instance, you can simply use `.getSchemas()`: +If the validator and serializer are customized, `.addSchema` is not useful since +Fastify no longer controls them. To access schemas added to the Fastify instance, +use `.getSchemas()`: ```js fastify.addSchema({ @@ -124,8 +125,8 @@ const mySchemas = fastify.getSchemas() const mySchema = fastify.getSchema('schemaId') ``` -As usual, the function `getSchemas` is encapsulated and returns the shared -schemas available in the selected scope: +The `getSchemas` function is encapsulated and returns shared schemas available +in the selected scope: ```js fastify.addSchema({ $id: 'one', my: 'hello' }) @@ -149,25 +150,22 @@ fastify.register((instance, opts, done) => { ### Validation -The route validation internally relies upon [Ajv -v8](https://www.npmjs.com/package/ajv) which is a high-performance JSON Schema -validator. Validating the input is very easy: just add the fields that you need -inside the route schema, and you are done! - -The supported validations are: -- `body`: validates the body of the request if it is a POST, PUT, or PATCH - method. +Route validation relies on [Ajv v8](https://www.npmjs.com/package/ajv), a +high-performance JSON Schema validator. To validate input, add the required +fields to the route schema. + +Supported validations include: +- `body`: validates the request body for POST, PUT, or PATCH methods. - `querystring` or `query`: validates the query string. -- `params`: validates the route params. +- `params`: validates the route parameters. - `headers`: validates the request headers. -All the validations can be a complete JSON Schema object (with a `type` property -of `'object'` and a `'properties'` object containing parameters) or a simpler -variation in which the `type` and `properties` attributes are forgone and the -parameters are listed at the top level (see the example below). +Validations can be a complete JSON Schema object with a `type` of `'object'` and +a `'properties'` object containing parameters, or a simpler variation listing +parameters at the top level. -> ℹ If you need to use the latest version of Ajv (v8) you should read how to do -> it in the [`schemaController`](./Server.md#schema-controller) section. +> ℹ For using the latest Ajv (v8), refer to the +> [`schemaController`](./Server.md#schema-controller) section. Example: ```js @@ -234,9 +232,64 @@ const schema = { fastify.post('/the/url', { schema }, handler) ``` -*Note that Ajv will try to [coerce](https://ajv.js.org/coercion.html) the values -to the types specified in your schema `type` keywords, both to pass the -validation and to use the correctly typed data afterwards.* +#### Body Content-Type Validation + + +For `body` schema, it is further possible to differentiate the schema per content +type by nesting the schemas inside `content` property. The schema validation +will be applied based on the `Content-Type` header in the request. + +```js +fastify.post('/the/url', { + schema: { + body: { + content: { + 'application/json': { + schema: { type: 'object' } + }, + 'text/plain': { + schema: { type: 'string' } + } + // Other content types will not be validated + } + } + } +}, handler) +``` + +> **Important:** When using [custom content type +> parsers](./ContentTypeParser.md), the parsed body will **only** be validated +> if the request's content type is listed in the `content` object above. If +> a parser for a content type (e.g., `application/yaml`) is defined, +> but it is not not included in the body schema's `content` property, +> the incoming data will be parsed but **not validated**. +> +> ```js +> // Add a custom parser for YAML +> fastify.addContentTypeParser('application/yaml', { parseAs: 'string' }, (req, body, done) => { +> done(null, YAML.parse(body)) +> }) +> +> fastify.post('/the/url', { +> schema: { +> body: { +> content: { +> 'application/json': { +> schema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] } +> }, +> // Without this entry, application/yaml requests will NOT be validated +> 'application/yaml': { +> schema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] } +> } +> } +> } +> } +> }, handler) +> ``` + +Note that Ajv will try to [coerce](https://ajv.js.org/coercion.html) values to +the types specified in the schema `type` keywords, both to pass validation and +to use the correctly typed data afterwards. The Ajv default configuration in Fastify supports coercing array parameters in `querystring`. Example: @@ -268,14 +321,14 @@ fastify.listen({ port: 3000 }, (err) => { ```sh curl -X GET "http://localhost:3000/?ids=1 -{"params":{"hello":["1"]}} +{"params":{"ids":["1"]}} ``` -You can also specify a custom schema validator for each parameter type (body, +A custom schema validator can be specified for each parameter type (body, querystring, params, headers). -For example, the following code disable type coercion only for the `body` -parameters, changing the ajv default options: +For example, the following code disables type coercion only for the `body` +parameters, changing the Ajv default options: ```js const schemaCompilers = { @@ -313,16 +366,40 @@ server.setValidatorCompiler(req => { }) ``` -For further information see [here](https://ajv.js.org/coercion.html) +When type coercion is enabled, using `anyOf` with nullable primitive types +can produce unexpected results. For example, a value of `0` or `false` may be +coerced to `null` because Ajv evaluates `anyOf` schemas in order and applies +type coercion during matching. This means the `{ "type": "null" }` branch can +match before the intended type: + +```json +{ + "anyOf": [ + { "type": "null" }, + { "type": "number" } + ] +} +``` + +To avoid this, use the `nullable` keyword instead of `anyOf` for primitive +types: + +```json +{ + "type": "number", + "nullable": true +} +``` + +For more information, see [Ajv Coercion](https://ajv.js.org/coercion.html). #### Ajv Plugins -You can provide a list of plugins you want to use with the default `ajv` -instance. Note that the plugin must be **compatible with the Ajv version shipped -within Fastify**. +A list of plugins can be provided for use with the default `ajv` instance. +Ensure the plugin is **compatible with the Ajv version shipped within Fastify**. -> Refer to [`ajv options`](./Server.md#ajv) to check plugins format +> Refer to [`ajv options`](./Server.md#ajv) to check plugins format. ```js const fastify = require('fastify')({ @@ -383,31 +460,32 @@ fastify.post('/foo', { #### Validator Compiler -The `validatorCompiler` is a function that returns a function that validates the -body, URL parameters, headers, and query string. The default -`validatorCompiler` returns a function that implements the -[ajv](https://ajv.js.org/) validation interface. Fastify uses it internally to -speed the validation up. +The `validatorCompiler` is a function that returns a function to validate the +body, URL parameters, headers, and query string. The default `validatorCompiler` +returns a function that implements the [ajv](https://ajv.js.org/) validation +interface. Fastify uses it internally to speed up validation. Fastify's [baseline ajv configuration](https://github.com/fastify/ajv-compiler#ajv-configuration) is: ```js { - coerceTypes: true, // change data type of data to match type keyword + coerceTypes: 'array', // change data type of data to match type keyword useDefaults: true, // replace missing properties and items with the values from corresponding default keyword - removeAdditional: true, // remove additional properties + removeAdditional: true, // remove additional properties if additionalProperties is set to false, see: https://ajv.js.org/guide/modifying-data.html#removing-additional-properties + uriResolver: require('fast-uri'), + addUsedSchema: false, // Explicitly set allErrors to `false`. // When set to `true`, a DoS attack is possible. allErrors: false } ``` -This baseline configuration can be modified by providing -[`ajv.customOptions`](./Server.md#factory-ajv) to your Fastify factory. +Modify the baseline configuration by providing +[`ajv.customOptions`](./Server.md#factory-ajv) to the Fastify factory. -If you want to change or set additional config options, you will need to create -your own instance and override the existing one like: +To change or set additional config options, create a custom instance and +override the existing one: ```js const fastify = require('fastify')() @@ -423,29 +501,41 @@ fastify.setValidatorCompiler(({ schema, method, url, httpPart }) => { return ajv.compile(schema) }) ``` -_**Note:** If you use a custom instance of any validator (even Ajv), you have to -add schemas to the validator instead of Fastify, since Fastify's default -validator is no longer used, and Fastify's `addSchema` method has no idea what -validator you are using._ + +> ℹ️ Note: +> When using a custom validator instance, add schemas to the validator +> instead of Fastify. Fastify's `addSchema` method will not recognize the custom +> validator. ##### Using other validation libraries -The `setValidatorCompiler` function makes it easy to substitute `ajv` with -almost any Javascript validation library ([joi](https://github.com/hapijs/joi/), -[yup](https://github.com/jquense/yup/), ...) or a custom one: +The `setValidatorCompiler` function allows substituting `ajv` with other +JavaScript validation libraries like [joi](https://github.com/hapijs/joi/) or +[yup](https://github.com/jquense/yup/), or a custom one: ```js const Joi = require('joi') +fastify.setValidatorCompiler(({ schema }) => { + return (data) => { + try { + const { error, value } = schema.validate(data) + if (error) { + return { error } // Return the error, do not throw it + } + return { value } + } catch (e) { + return { error: e } // Catch any unexpected errors too + } + } +}) + fastify.post('/the/url', { schema: { body: Joi.object().keys({ hello: Joi.string().required() }).required() - }, - validatorCompiler: ({ schema, method, url, httpPart }) => { - return data => schema.validate(data) } }, handler) ``` @@ -484,10 +574,44 @@ fastify.post('/the/url', { }, handler) ``` +##### Custom Validator Best Practices + +When implementing custom validators, follow these patterns to ensure compatibility +with all Fastify features: + +** Always return objects, never throw:** +```js +return { value: validatedData } // On success +return { error: validationError } // On failure +``` + +** Use try-catch for safety:** +```js +fastify.setValidatorCompiler(({ schema }) => { + return (data) => { + try { + // Validation logic here + const result = schema.validate(data) + if (result.error) { + return { error: result.error } + } + return { value: result.value } + } catch (e) { + // Catch any unexpected errors + return { error: e } + } + } +}) +``` + +This pattern ensures validators work correctly with both sync and async +`preValidation` hooks, preventing unhandled promise rejections that can crash +an application. + ##### .statusCode property -All validation errors will be added a `.statusCode` property set to `400`. This guarantees -that the default error handler will set the status code of the response to `400`. +All validation errors have a `.statusCode` property set to `400`, ensuring the +default error handler sets the response status code to `400`. ```js fastify.setErrorHandler(function (error, request, reply) { @@ -500,30 +624,27 @@ fastify.setErrorHandler(function (error, request, reply) { Fastify's validation error messages are tightly coupled to the default validation engine: errors returned from `ajv` are eventually run through the -`schemaErrorFormatter` function which is responsible for building human-friendly -error messages. However, the `schemaErrorFormatter` function is written with -`ajv` in mind. As a result, you may run into odd or incomplete error messages -when using other validation libraries. +`schemaErrorFormatter` function which builds human-friendly error messages. +However, the `schemaErrorFormatter` function is written with `ajv` in mind. +This may result in odd or incomplete error messages when using other validation +libraries. -To circumvent this issue, you have 2 main options : +To circumvent this issue, there are two main options: -1. make sure your validation function (returned by your custom `schemaCompiler`) - returns errors in the same structure and format as `ajv` (although this could - prove to be difficult and tricky due to differences between validation - engines) -2. or use a custom `errorHandler` to intercept and format your 'custom' - validation errors +1. Ensure the validation function (returned by the custom `schemaCompiler`) + returns errors in the same structure and format as `ajv`. +2. Use a custom `errorHandler` to intercept and format custom validation errors. -To help you in writing a custom `errorHandler`, Fastify adds 2 properties to all -validation errors: +Fastify adds two properties to all validation errors to help write a custom +`errorHandler`: * `validation`: the content of the `error` property of the object returned by - the validation function (returned by your custom `schemaCompiler`) -* `validationContext`: the 'context' (body, params, query, headers) where the + the validation function (returned by the custom `schemaCompiler`) +* `validationContext`: the context (body, params, query, headers) where the validation error occurred -A very contrived example of such a custom `errorHandler` handling validation -errors is shown below: +A contrived example of such a custom `errorHandler` handling validation errors +is shown below: ```js const errorHandler = (error, request, reply) => { @@ -535,9 +656,9 @@ const errorHandler = (error, request, reply) => { // check if we have a validation error if (validation) { response = { - // validationContext will be 'body' or 'params' or 'headers' or 'query' + // validationContext will be 'body', 'params', 'headers', or 'query' message: `A validation error occurred when validating the ${validationContext}...`, - // this is the result of your validation library... + // this is the result of the validation library... errors: validation } } else { @@ -556,12 +677,10 @@ const errorHandler = (error, request, reply) => { ### Serialization -Usually, you will send your data to the clients as JSON, and Fastify has a -powerful tool to help you, -[fast-json-stringify](https://www.npmjs.com/package/fast-json-stringify), which -is used if you have provided an output schema in the route options. We encourage -you to use an output schema, as it can drastically increase throughput and help -prevent accidental disclosure of sensitive information. +Fastify uses [fast-json-stringify](https://www.npmjs.com/package/fast-json-stringify) +to send data as JSON if an output schema is provided in the route options. Using +an output schema can drastically increase throughput and help prevent accidental +disclosure of sensitive information. Example: ```js @@ -580,9 +699,8 @@ const schema = { fastify.post('/the/url', { schema }, handler) ``` -As you can see, the response schema is based on the status code. If you want to -use the same schema for multiple status codes, you can use `'2xx'` or `default`, -for example: +The response schema is based on the status code. To use the same schema for +multiple status codes, use `'2xx'` or `default`, for example: ```js const schema = { response: { @@ -611,17 +729,73 @@ const schema = { fastify.post('/the/url', { schema }, handler) ``` +A specific response schema can be defined for different content types. +For example: +```js +const schema = { + response: { + 200: { + description: 'Response schema that support different content types' + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + name: { type: 'string' }, + image: { type: 'string' }, + address: { type: 'string' } + } + } + }, + 'application/vnd.v1+json': { + schema: { + type: 'array', + items: { $ref: 'test' } + } + } + } + }, + '3xx': { + content: { + 'application/vnd.v2+json': { + schema: { + type: 'object', + properties: { + fullName: { type: 'string' }, + phone: { type: 'string' } + } + } + } + } + }, + default: { + content: { + // */* is match-all content-type + '*/*': { + schema: { + type: 'object', + properties: { + desc: { type: 'string' } + } + } + } + } + } + } +} + +fastify.post('/url', { schema }, handler) +``` #### Serializer Compiler -The `serializerCompiler` is a function that returns a function that must return -a string from an input object. When you define a response JSON Schema, you can -change the default serialization method by providing a function to serialize -every route where you do. +The `serializerCompiler` returns a function that must return a string from an +input object. When defining a response JSON Schema, change the default +serialization method by providing a function to serialize each route. ```js -fastify.setSerializerCompiler(({ schema, method, url, httpStatus }) => { +fastify.setSerializerCompiler(({ schema, method, url, httpStatus, contentType }) => { return data => JSON.stringify(data) }) @@ -632,21 +806,24 @@ fastify.get('/user', { schema: { response: { '2xx': { - id: { type: 'number' }, - name: { type: 'string' } + type: 'object', + properties: { + id: { type: 'number' }, + name: { type: 'string' } + } } } } }) ``` -*If you need a custom serializer in a very specific part of your code, you can -set one with [`reply.serializer(...)`](./Reply.md#serializerfunc).* +*To set a custom serializer in a specific part of the code, use +[`reply.serializer(...)`](./Reply.md#serializerfunc).* ### Error Handling When schema validation fails for a request, Fastify will automatically return a -status 400 response including the result from the validator in the payload. As -an example, if you have the following schema for your route +status 400 response including the result from the validator in the payload. For +example, if the following schema is used for a route: ```js const schema = { @@ -660,8 +837,8 @@ const schema = { } ``` -and fail to satisfy it, the route will immediately return a response with the -following payload +If the request fails to satisfy the schema, the route will return a response +with the following payload: ```js { @@ -671,10 +848,15 @@ following payload } ``` -If you want to handle errors inside the route, you can specify the -`attachValidation` option for your route. If there is a _validation error_, the -`validationError` property of the request will contain the `Error` object with -the raw `validation` result as shown below +> ⚠ Security Consideration: By default, validation error details from the schema +> are included in the response payload. If your organization requires sanitizing +> or customizing these error messages (e.g., to avoid exposing internal schema +> details), configure a custom error handler using +> [`setErrorHandler()`](./Server.md#seterrorhandler). + +To handle errors inside the route, specify the `attachValidation` option. If +there is a validation error, the `validationError` property of the request will +contain the `Error` object with the raw validation result as shown below: ```js const fastify = Fastify() @@ -689,13 +871,13 @@ fastify.post('/', { schema, attachValidation: true }, function (req, reply) { #### `schemaErrorFormatter` -If you want to format errors yourself, you can provide a sync function that must -return an error as the `schemaErrorFormatter` option to Fastify when -instantiating. The context function will be the Fastify server instance. +To format errors, provide a sync function that returns an error as the +`schemaErrorFormatter` option when instantiating Fastify. The context function +will be the Fastify server instance. `errors` is an array of Fastify schema errors `FastifySchemaValidationError`. -`dataVar` is the currently validated part of the schema. (params | body | -querystring | headers). +`dataVar` is the currently validated part of the schema (params, body, +querystring, headers). ```js const fastify = Fastify({ @@ -713,8 +895,8 @@ fastify.setSchemaErrorFormatter(function (errors, dataVar) { }) ``` -You can also use [setErrorHandler](./Server.md#seterrorhandler) to define a -custom response for validation errors such as +Use [setErrorHandler](./Server.md#seterrorhandler) to define a custom response +for validation errors such as: ```js fastify.setErrorHandler(function (error, request, reply) { @@ -724,25 +906,25 @@ fastify.setErrorHandler(function (error, request, reply) { }) ``` -If you want a custom error response in the schema without headaches, and -quickly, take a look at +For custom error responses in the schema, see [`ajv-errors`](https://github.com/epoberezkin/ajv-errors). Check out the [example](https://github.com/fastify/example/blob/HEAD/validation-messages/custom-errors-messages.js) usage. -> Make sure to install version 1.0.1 of `ajv-errors`, because later versions of -> it are not compatible with AJV v6 (the version shipped by Fastify v3). + +> Install version 1.0.1 of `ajv-errors`, as later versions are not compatible +> with AJV v6 (the version shipped by Fastify v3). Below is an example showing how to add **custom error messages for each property** of a schema by supplying custom AJV options. Inline comments in the -schema below describe how to configure it to show a different error message for -each case: +schema describe how to configure it to show a different error message for each +case: ```js const fastify = Fastify({ ajv: { customOptions: { jsonPointers: true, - // Warning: Enabling this option may lead to this security issue https://www.cvedetails.com/cve/CVE-2020-8192/ + // ⚠ Warning: Enabling this option may lead to this security issue https://www.cvedetails.com/cve/CVE-2020-8192/ allErrors: true }, plugins: [ @@ -786,8 +968,8 @@ fastify.post('/', { schema, }, (request, reply) => { }) ``` -If you want to return localized error messages, take a look at -[ajv-i18n](https://github.com/epoberezkin/ajv-i18n) +To return localized error messages, see +[ajv-i18n](https://github.com/epoberezkin/ajv-i18n). ```js const localize = require('ajv-i18n') @@ -821,8 +1003,8 @@ fastify.setErrorHandler(function (error, request, reply) { ### JSON Schema support -JSON Schema provides utilities to optimize your schemas that, in conjunction -with Fastify's shared schema, let you reuse all your schemas easily. +JSON Schema provides utilities to optimize schemas. Combined with Fastify's +shared schema, all schemas can be easily reused. | Use Case | Validator | Serializer | |-----------------------------------|-----------|------------| @@ -929,11 +1111,11 @@ const refToSharedSchemaDefinitions = { - [JSON Schema](https://json-schema.org/) - [Understanding JSON - Schema](https://spacetelescope.github.io/understanding-json-schema/) + Schema](https://json-schema.org/understanding-json-schema/about) - [fast-json-stringify documentation](https://github.com/fastify/fast-json-stringify) - [Ajv documentation](https://github.com/epoberezkin/ajv/blob/master/README.md) - [Ajv i18n](https://github.com/epoberezkin/ajv-i18n) - [Ajv custom errors](https://github.com/epoberezkin/ajv-errors) - Custom error handling with core methods with error file dumping - [example](https://github.com/fastify/example/tree/master/validation-messages) + [example](https://github.com/fastify/example/tree/main/validation-messages) diff --git a/docs/Reference/Warnings.md b/docs/Reference/Warnings.md new file mode 100644 index 00000000000..d07b23d1705 --- /dev/null +++ b/docs/Reference/Warnings.md @@ -0,0 +1,58 @@ + +

Fastify

+ +**Table of contents** +- [Warnings](#warnings) + - [Warnings In Fastify](#warnings-in-fastify) + - [Fastify Warning Codes](#fastify-warning-codes) + - [FSTWRN001](#FSTWRN001) + - [FSTWRN002](#FSTWRN002) + - [Fastify Deprecation Codes](#fastify-deprecation-codes) + - [FSTDEP022](#FSTDEP022) + +## Warnings + +### Warnings In Fastify + +Fastify uses Node.js's [warning event](https://nodejs.org/api/process.html#event-warning) +API to notify users of deprecated features and coding mistakes. Fastify's +warnings are recognizable by the `FSTWRN` and `FSTDEP` prefixes. When +encountering such a warning, it is highly recommended to determine the cause +using the [`--trace-warnings`](https://nodejs.org/api/cli.html#--trace-warnings) +and [`--trace-deprecation`](https://nodejs.org/api/cli.html#--trace-deprecation) +flags. These produce stack traces pointing to where the issue occurs in the +application's code. Issues opened about warnings without this information will +be closed due to lack of details. + +Warnings can also be disabled, though it is not recommended. If necessary, use +one of the following methods: + +- Set the `NODE_NO_WARNINGS` environment variable to `1` +- Pass the `--no-warnings` flag to the node process +- Set `no-warnings` in the `NODE_OPTIONS` environment variable + +For more information on disabling warnings, see [Node's documentation](https://nodejs.org/api/cli.html). + +Disabling warnings may cause issues when upgrading Fastify versions. Only +experienced users should consider disabling warnings. + +### Fastify Warning Codes + +| Code | Description | How to solve | Discussion | +| ---- | ----------- | ------------ | ---------- | +| FSTWRN001 | The specified schema for a route is missing. This may indicate the schema is not well specified. | Check the schema for the route. | [#4647](https://github.com/fastify/fastify/pull/4647) | +| FSTWRN002 | The %s plugin being registered mixes async and callback styles, which will result in an error in `fastify@5`. | Do not mix async and callback style. | [#5139](https://github.com/fastify/fastify/pull/5139) | + + +### Fastify Deprecation Codes + +Deprecation codes are supported by the Node.js CLI options: + +- [--no-deprecation](https://nodejs.org/api/cli.html#--no-deprecation) +- [--throw-deprecation](https://nodejs.org/api/cli.html#--throw-deprecation) +- [--trace-deprecation](https://nodejs.org/api/cli.html#--trace-deprecation) + + +| Code | Description | How to solve | Discussion | +| ---- | ----------- | ------------ | ---------- | +| FSTDEP022 | You are trying to access the deprecated router options on top option properties. | Use `options.routerOptions`. | [#5985](https://github.com/fastify/fastify/pull/5985) diff --git a/docs/index.md b/docs/index.md index 0a89105a957..625d373b903 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,8 +7,8 @@ The documentation for Fastify is split into two categories: The reference documentation utilizes a very formal style in an effort to document Fastify's API and implementation details thoroughly for the developer who needs -such. The guides category utilizes an informal, educational, style as a means to -introduce newcomers to core, and advanced, Fastify concepts. +such. The guides category utilizes an informal educational style as a means to +introduce newcomers to core and advanced Fastify concepts. ## Where To Start diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 00000000000..e498cc5af4c --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,35 @@ +'use strict' +const neostandard = require('neostandard') + +module.exports = [ + ...neostandard({ + ignores: [ + 'lib/config-validator.js', + 'lib/error-serializer.js', + 'test/same-shape.test.js', + 'test/types/import.js' + ], + ts: true + }), + { + rules: { + 'comma-dangle': ['error', 'never'], + 'max-len': ['error', { + code: 120, + tabWidth: 2, + ignoreUrls: true, + ignoreStrings: true, + ignoreTemplateLiterals: true, + ignoreRegExpLiterals: true, + ignoreComments: true, + ignoreTrailingComments: true + }] + } + }, + { + files: ['**/*.d.ts'], + rules: { + 'max-len': 'off' + } + } +] diff --git a/examples/asyncawait.js b/examples/asyncawait.js index de5d57f7d9f..676285398b7 100644 --- a/examples/asyncawait.js +++ b/examples/asyncawait.js @@ -32,5 +32,7 @@ fastify }) fastify.listen({ port: 3000 }, err => { - if (err) throw err + if (err) { + throw err + } }) diff --git a/examples/benchmark/body.json b/examples/benchmark/body.json new file mode 100644 index 00000000000..eeedd400762 --- /dev/null +++ b/examples/benchmark/body.json @@ -0,0 +1,3 @@ +{ + "hello": "world" +} \ No newline at end of file diff --git a/examples/benchmark/parser.js b/examples/benchmark/parser.js new file mode 100644 index 00000000000..665c8a832e6 --- /dev/null +++ b/examples/benchmark/parser.js @@ -0,0 +1,47 @@ +'use strict' + +const fastify = require('../../fastify')({ + logger: false +}) + +const jsonParser = require('fast-json-body') +const querystring = require('node:querystring') + +// Handled by fastify +// curl -X POST -d '{"hello":"world"}' -H'Content-type: application/json' http://localhost:3000/ + +// curl -X POST -d '{"hello":"world"}' -H'Content-type: application/jsoff' http://localhost:3000/ +fastify.addContentTypeParser('application/jsoff', function (request, payload, done) { + jsonParser(payload, function (err, body) { + done(err, body) + }) +}) + +// curl -X POST -d 'hello=world' -H'Content-type: application/x-www-form-urlencoded' http://localhost:3000/ +fastify.addContentTypeParser('application/x-www-form-urlencoded', function (request, payload, done) { + let body = '' + payload.on('data', function (data) { + body += data + }) + payload.on('end', function () { + try { + const parsed = querystring.parse(body) + done(null, parsed) + } catch (e) { + done(e) + } + }) + payload.on('error', done) +}) + +// curl -X POST -d '{"hello":"world"}' -H'Content-type: application/vnd.custom+json' http://localhost:3000/ +fastify.addContentTypeParser(/^application\/.+\+json$/, { parseAs: 'string' }, fastify.getDefaultJsonParser('error', 'ignore')) + +fastify + .post('/', function (req, reply) { + reply.send(req.body) + }) + +fastify.listen({ port: 3000 }, (err, address) => { + if (err) throw err +}) diff --git a/examples/benchmark/webstream.js b/examples/benchmark/webstream.js new file mode 100644 index 00000000000..7ed5fcd166b --- /dev/null +++ b/examples/benchmark/webstream.js @@ -0,0 +1,27 @@ +'use strict' + +const fastify = require('../../fastify')({ + logger: false +}) + +const payload = JSON.stringify({ hello: 'world' }) + +fastify.get('/', function (req, reply) { + const stream = new ReadableStream({ + start (controller) { + controller.enqueue(payload) + controller.close() + } + }) + return new Response(stream, { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8' + } + }) +}) + +fastify.listen({ port: 3000 }, (err, address) => { + if (err) throw err + console.log(`Server listening on ${address}`) +}) diff --git a/examples/hooks.js b/examples/hooks.js index e087b3a66c3..cc54229296a 100644 --- a/examples/hooks.js +++ b/examples/hooks.js @@ -68,6 +68,9 @@ fastify .addHook('onRoute', function (routeOptions) { console.log('onRoute') }) + .addHook('onListen', async function () { + console.log('onListen') + }) .addHook('onClose', function (instance, done) { console.log('onClose') done() diff --git a/examples/http2.js b/examples/http2.js index 26bbcfe2aa4..e1a1bd0d2f6 100644 --- a/examples/http2.js +++ b/examples/http2.js @@ -1,7 +1,7 @@ 'use strict' -const fs = require('fs') -const path = require('path') +const fs = require('node:fs') +const path = require('node:path') const fastify = require('../fastify')({ http2: true, https: { @@ -33,5 +33,7 @@ fastify }) fastify.listen({ port: 3000 }, err => { - if (err) throw err + if (err) { + throw err + } }) diff --git a/examples/https.js b/examples/https.js index 33266980adc..2255552d4cf 100644 --- a/examples/https.js +++ b/examples/https.js @@ -1,7 +1,7 @@ 'use strict' -const fs = require('fs') -const path = require('path') +const fs = require('node:fs') +const path = require('node:path') const fastify = require('../fastify')({ https: { key: fs.readFileSync(path.join(__dirname, '../test/https/fastify.key')), @@ -32,5 +32,7 @@ fastify }) fastify.listen({ port: 3000 }, err => { - if (err) throw err + if (err) { + throw err + } }) diff --git a/examples/parser.js b/examples/parser.js index 2f8d25f9499..2dd50b07c65 100644 --- a/examples/parser.js +++ b/examples/parser.js @@ -2,7 +2,7 @@ const fastify = require('../fastify')({ logger: true }) const jsonParser = require('fast-json-body') -const querystring = require('querystring') +const querystring = require('node:querystring') // Handled by fastify // curl -X POST -d '{"hello":"world"}' -H'Content-type: application/json' http://localhost:3000/ @@ -47,5 +47,7 @@ fastify }) fastify.listen({ port: 3000 }, err => { - if (err) throw err + if (err) { + throw err + } }) diff --git a/examples/shared-schema.js b/examples/shared-schema.js index c50b7908099..82adb889ead 100644 --- a/examples/shared-schema.js +++ b/examples/shared-schema.js @@ -32,5 +32,7 @@ fastify }) fastify.listen({ port: 3000 }, err => { - if (err) throw err + if (err) { + throw err + } }) diff --git a/examples/simple-stream.js b/examples/simple-stream.js index 8a354ae0505..2b7fe851b07 100644 --- a/examples/simple-stream.js +++ b/examples/simple-stream.js @@ -4,7 +4,7 @@ const fastify = require('../fastify')({ logger: false }) -const Readable = require('stream').Readable +const Readable = require('node:stream').Readable fastify .get('/', function (req, reply) { @@ -13,6 +13,8 @@ fastify }) fastify.listen({ port: 3000 }, (err, address) => { - if (err) throw err + if (err) { + throw err + } fastify.log.info(`server listening on ${address}`) }) diff --git a/examples/simple.js b/examples/simple.js index 3c7495a1dfd..f2aeb9b889f 100644 --- a/examples/simple.js +++ b/examples/simple.js @@ -26,5 +26,7 @@ fastify }) fastify.listen({ port: 3000 }, (err, address) => { - if (err) throw err + if (err) { + throw err + } }) diff --git a/examples/typescript-server.ts b/examples/typescript-server.ts index 71f7ee0ba62..d1e24247d31 100644 --- a/examples/typescript-server.ts +++ b/examples/typescript-server.ts @@ -10,8 +10,8 @@ * node examples/typescript-server.js */ -import fastify, { FastifyInstance, RouteShorthandOptions } from '../fastify'; -import { Server, IncomingMessage, ServerResponse } from 'http'; +import fastify, { FastifyInstance, RouteShorthandOptions } from '../fastify' +import { Server, IncomingMessage, ServerResponse } from 'node:http' // Create an http server. We pass the relevant typings for our http version used. // By passing types we get correctly typed access to the underlying http objects in routes. @@ -20,7 +20,7 @@ const server: FastifyInstance< Server, IncomingMessage, ServerResponse -> = fastify({ logger: true }); +> = fastify({ logger: true }) // Define interfaces for our request. We can create these automatically // off our JSON Schema files (See TypeScript.md) but for the purpose of this @@ -53,26 +53,27 @@ const opts: RouteShorthandOptions = { } } } -}; +} // Add our route handler with correct types -server.get<{ +server.post<{ Querystring: PingQuerystring; Params: PingParams; Headers: PingHeaders; Body: PingBody; }>('/ping/:bar', opts, (request, reply) => { - console.log(request.query); // this is of type `PingQuerystring` - console.log(request.params); // this is of type `PingParams` - console.log(request.headers); // this is of type `PingHeaders` - console.log(request.body); // this is of type `PingBody` - reply.code(200).send({ pong: 'it worked!' }); -}); + console.log(request.query) // this is of type `PingQuerystring` + console.log(request.params) // this is of type `PingParams` + console.log(request.headers) // this is of type `PingHeaders` + console.log(request.body) // this is of type `PingBody` + reply.code(200).send({ pong: 'it worked!' }) +}) // Start your server server.listen({ port: 8080 }, (err, address) => { if (err) { - console.error(err); - process.exit(1); + console.error(err) + process.exit(1) } -}); + console.log(`server listening on ${address}`) +}) diff --git a/examples/use-plugin.js b/examples/use-plugin.js index fdcf26213c0..8eedefc4f4b 100644 --- a/examples/use-plugin.js +++ b/examples/use-plugin.js @@ -1,3 +1,5 @@ +'use strict' + const fastify = require('../fastify')({ logger: true }) const opts = { @@ -15,7 +17,9 @@ const opts = { } } fastify.register(require('./plugin'), opts, function (err) { - if (err) throw err + if (err) { + throw err + } }) fastify.listen({ port: 3000 }, function (err) { diff --git a/fastify.d.ts b/fastify.d.ts index 6c343fa4caa..6e74460a50f 100644 --- a/fastify.d.ts +++ b/fastify.d.ts @@ -1,25 +1,212 @@ -import * as http from 'http' -import * as http2 from 'http2' -import * as https from 'https' -import { ConstraintStrategy, HTTPVersion } from 'find-my-way' +import * as http from 'node:http' +import * as http2 from 'node:http2' +import * as https from 'node:https' +import { Socket } from 'node:net' -import { FastifyRequest, RequestGenericInterface } from './types/request' -import { RawServerBase, RawServerDefault, RawRequestDefaultExpression, RawReplyDefaultExpression } from './types/utils' -import { FastifyBaseLogger, FastifyLoggerInstance, FastifyLoggerOptions, PinoLoggerOptions } from './types/logger' -import { FastifyInstance } from './types/instance' -import { FastifyServerFactory } from './types/serverFactory' -import { Options as AjvOptions } from '@fastify/ajv-compiler' -import { Options as FJSOptions } from '@fastify/fast-json-stringify-compiler' +import { BuildCompilerFromPool, ValidatorFactory } from '@fastify/ajv-compiler' import { FastifyError } from '@fastify/error' +import { Options as FJSOptions, SerializerFactory } from '@fastify/fast-json-stringify-compiler' +import { Config as FindMyWayConfig, ConstraintStrategy, HTTPVersion } from 'find-my-way' +import { InjectOptions, CallbackFunc as LightMyRequestCallback, Chain as LightMyRequestChain, Response as LightMyRequestResponse } from 'light-my-request' + +import { AddContentTypeParser, ConstructorAction, FastifyBodyParser, FastifyContentTypeParser, getDefaultJsonParser, hasContentTypeParser, ProtoAction } from './types/content-type-parser' +import { FastifyContextConfig, FastifyReplyContext, FastifyRequestContext } from './types/context' +import { FastifyErrorCodes } from './types/errors' +import { DoneFuncWithErrOrRes, HookHandlerDoneFunction, onCloseAsyncHookHandler, onCloseHookHandler, onErrorAsyncHookHandler, onErrorHookHandler, onListenAsyncHookHandler, onListenHookHandler, onReadyAsyncHookHandler, onReadyHookHandler, onRegisterHookHandler, onRequestAbortAsyncHookHandler, onRequestAbortHookHandler, onRequestAsyncHookHandler, onRequestHookHandler, onResponseAsyncHookHandler, onResponseHookHandler, onRouteHookHandler, onSendAsyncHookHandler, onSendHookHandler, onTimeoutAsyncHookHandler, onTimeoutHookHandler, preCloseAsyncHookHandler, preCloseHookHandler, preHandlerAsyncHookHandler, preHandlerHookHandler, preParsingAsyncHookHandler, preParsingHookHandler, preSerializationAsyncHookHandler, preSerializationHookHandler, preValidationAsyncHookHandler, preValidationHookHandler, RequestPayload } from './types/hooks' +import { FastifyInstance, FastifyListenOptions, PrintRoutesOptions } from './types/instance' +import { + FastifyBaseLogger, + FastifyChildLoggerFactory, + FastifyLogFn, + FastifyLoggerInstance, + FastifyLoggerOptions, + LogLevel, + PinoLoggerOptions +} from './types/logger' +import { FastifyPlugin, FastifyPluginAsync, FastifyPluginCallback, FastifyPluginOptions } from './types/plugin' +import { FastifyRegister, FastifyRegisterOptions, RegisterOptions } from './types/register' import { FastifyReply } from './types/reply' -import { FastifySchemaValidationError } from './types/schema' -import { ConstructorAction, ProtoAction } from "./types/content-type-parser"; -import { Socket } from 'net' -import { ValidatorCompiler } from '@fastify/ajv-compiler' -import { SerializerCompiler } from '@fastify/fast-json-stringify-compiler' -import { FastifySchema } from './types/schema' -import { FastifyContextConfig } from './types/context' -import { FastifyTypeProvider, FastifyTypeProviderDefault } from './types/type-provider' +import { FastifyRequest, RequestGenericInterface } from './types/request' +import { RouteGenericInterface, RouteHandler, RouteHandlerMethod, RouteOptions, RouteShorthandMethod, RouteShorthandOptions, RouteShorthandOptionsWithHandler } from './types/route' +import { FastifySchema, FastifySchemaValidationError, FastifySchemaCompiler, FastifySerializerCompiler, SchemaErrorDataVar, SchemaErrorFormatter } from './types/schema' +import { FastifyServerFactory, FastifyServerFactoryHandler } from './types/server-factory' +import { FastifyTypeProvider, FastifyTypeProviderDefault, SafePromiseLike } from './types/type-provider' +import { ContextConfigDefault, HTTPMethods, RawReplyDefaultExpression, RawRequestDefaultExpression, RawServerBase, RawServerDefault, RequestBodyDefault, RequestHeadersDefault, RequestParamsDefault, RequestQuerystringDefault } from './types/utils' + +declare module '@fastify/error' { + interface FastifyError { + validationContext?: SchemaErrorDataVar; + validation?: FastifySchemaValidationError[]; + } +} + +type Fastify = typeof fastify + +declare namespace fastify { + export const errorCodes: FastifyErrorCodes + + export type FastifyHttp2SecureOptions< + Server extends http2.Http2SecureServer, + Logger extends FastifyBaseLogger = FastifyBaseLogger + > = FastifyServerOptions & { + http2: true, + https: http2.SecureServerOptions, + http2SessionTimeout?: number + } + + export type FastifyHttp2Options< + Server extends http2.Http2Server, + Logger extends FastifyBaseLogger = FastifyBaseLogger + > = FastifyServerOptions & { + http2: true, + http2SessionTimeout?: number + } + + export type FastifyHttpsOptions< + Server extends https.Server, + Logger extends FastifyBaseLogger = FastifyBaseLogger + > = FastifyServerOptions & { + https: https.ServerOptions | null + } + + export type FastifyHttpOptions< + Server extends http.Server, + Logger extends FastifyBaseLogger = FastifyBaseLogger + > = FastifyServerOptions & { + http?: http.ServerOptions | null + } + + type FindMyWayVersion = RawServer extends http.Server ? HTTPVersion.V1 : HTTPVersion.V2 + type FindMyWayConfigForServer = FindMyWayConfig> + + export interface ConnectionError extends Error { + code: string, + bytesParsed: number, + rawPacket: { + type: string, + data: number[] + } + } + + type TrustProxyFunction = (address: string, hop: number) => boolean + + export type FastifyRouterOptions = Omit, 'defaultRoute' | 'onBadUrl' | 'querystringParser'> & { + defaultRoute?: ( + req: RawRequestDefaultExpression, + res: RawReplyDefaultExpression + ) => void, + onBadUrl?: ( + path: string, + req: RawRequestDefaultExpression, + res: RawReplyDefaultExpression + ) => void, + querystringParser?: (str: string) => { [key: string]: unknown } + } + + /** + * Options for a fastify server instance. Utilizes conditional logic on the generic server parameter to enforce certain https and http2 + */ + export type FastifyServerOptions< + RawServer extends RawServerBase = RawServerDefault, + Logger extends FastifyBaseLogger = FastifyBaseLogger + > = { + ignoreTrailingSlash?: boolean, + ignoreDuplicateSlashes?: boolean, + connectionTimeout?: number, + keepAliveTimeout?: number, + maxRequestsPerSocket?: number, + forceCloseConnections?: boolean | 'idle', + requestTimeout?: number, + pluginTimeout?: number, + bodyLimit?: number, + handlerTimeout?: number, + maxParamLength?: number, + disableRequestLogging?: boolean | ((req: FastifyRequest) => boolean), + exposeHeadRoutes?: boolean, + onProtoPoisoning?: ProtoAction, + onConstructorPoisoning?: ConstructorAction, + logger?: boolean | FastifyLoggerOptions & PinoLoggerOptions, + loggerInstance?: Logger + serializerOpts?: FJSOptions | Record, + serverFactory?: FastifyServerFactory, + caseSensitive?: boolean, + allowUnsafeRegex?: boolean, + requestIdHeader?: string | false, + requestIdLogLabel?: string; + useSemicolonDelimiter?: boolean, + genReqId?: (req: RawRequestDefaultExpression) => string, + trustProxy?: boolean | string | string[] | number | TrustProxyFunction, + querystringParser?: (str: string) => { [key: string]: unknown }, + constraints?: { + [name: string]: ConstraintStrategy, unknown>, + }, + schemaController?: { + bucket?: (parentSchemas?: unknown) => { + add(schema: unknown): FastifyInstance; + getSchema(schemaId: string): unknown; + getSchemas(): Record; + }; + compilersFactory?: { + buildValidator?: ValidatorFactory; + buildSerializer?: SerializerFactory; + }; + }; + return503OnClosing?: boolean, + ajv?: Parameters[1], + frameworkErrors?: ( + error: FastifyError, + req: FastifyRequest, FastifySchema, TypeProvider>, + res: FastifyReply, RawReplyDefaultExpression, FastifyContextConfig, SchemaCompiler, TypeProvider> + ) => void, + rewriteUrl?: ( + // The RawRequestDefaultExpression, RawReplyDefaultExpression, and FastifyTypeProviderDefault parameters + // should be narrowed further but those generic parameters are not passed to this FastifyServerOptions type + this: FastifyInstance, RawReplyDefaultExpression, Logger, FastifyTypeProviderDefault>, + req: RawRequestDefaultExpression + ) => string, + schemaErrorFormatter?: SchemaErrorFormatter, + /** + * listener to error events emitted by client connections + */ + clientErrorHandler?: (error: ConnectionError, socket: Socket) => void, + childLoggerFactory?: FastifyChildLoggerFactory, + allowErrorHandlerOverride?: boolean + routerOptions?: FastifyRouterOptions, + } + + /** + * @deprecated use {@link FastifySchemaValidationError} + */ + export type ValidationResult = FastifySchemaValidationError + + /* Export additional types */ + export type { + LightMyRequestChain, InjectOptions, LightMyRequestResponse, LightMyRequestCallback, // 'light-my-request' + FastifyRequest, RequestGenericInterface, // './types/request' + FastifyReply, // './types/reply' + FastifyPluginCallback, FastifyPluginAsync, FastifyPluginOptions, FastifyPlugin, // './types/plugin' + FastifyListenOptions, FastifyInstance, PrintRoutesOptions, // './types/instance' + FastifyLoggerOptions, FastifyBaseLogger, FastifyLoggerInstance, FastifyLogFn, LogLevel, // './types/logger' + FastifyRequestContext, FastifyContextConfig, FastifyReplyContext, // './types/context' + RouteHandler, RouteHandlerMethod, RouteOptions, RouteShorthandMethod, RouteShorthandOptions, RouteShorthandOptionsWithHandler, RouteGenericInterface, // './types/route' + FastifyRegister, FastifyRegisterOptions, RegisterOptions, // './types/register' + FastifyBodyParser, FastifyContentTypeParser, AddContentTypeParser, hasContentTypeParser, getDefaultJsonParser, ProtoAction, ConstructorAction, // './types/content-type-parser' + FastifyError, // '@fastify/error' + FastifySchema, FastifySchemaValidationError, FastifySchemaCompiler, FastifySerializerCompiler, // './types/schema' + HTTPMethods, RawServerBase, RawRequestDefaultExpression, RawReplyDefaultExpression, RawServerDefault, ContextConfigDefault, RequestBodyDefault, RequestQuerystringDefault, RequestParamsDefault, RequestHeadersDefault, // './types/utils' + DoneFuncWithErrOrRes, HookHandlerDoneFunction, RequestPayload, onCloseAsyncHookHandler, onCloseHookHandler, onErrorAsyncHookHandler, onErrorHookHandler, onReadyAsyncHookHandler, onReadyHookHandler, onListenAsyncHookHandler, onListenHookHandler, onRegisterHookHandler, onRequestAsyncHookHandler, onRequestHookHandler, onResponseAsyncHookHandler, onResponseHookHandler, onRouteHookHandler, onSendAsyncHookHandler, onSendHookHandler, onTimeoutAsyncHookHandler, onTimeoutHookHandler, preHandlerAsyncHookHandler, preHandlerHookHandler, preParsingAsyncHookHandler, preParsingHookHandler, preSerializationAsyncHookHandler, preSerializationHookHandler, preValidationAsyncHookHandler, preValidationHookHandler, onRequestAbortHookHandler, onRequestAbortAsyncHookHandler, preCloseAsyncHookHandler, preCloseHookHandler, // './types/hooks' + FastifyServerFactory, FastifyServerFactoryHandler, // './types/serverFactory' + FastifyTypeProvider, FastifyTypeProviderDefault, SafePromiseLike, // './types/type-provider' + FastifyErrorCodes // './types/errors' + } + // named export + // import { plugin } from 'plugin' + // const { plugin } = require('plugin') + export const fastify: Fastify + // default export + // import plugin from 'plugin' + export { fastify as default } +} /** * Fastify factory function for the standard fastify http, https, or http2 server instance. @@ -33,177 +220,34 @@ declare function fastify< Server extends http2.Http2SecureServer, Request extends RawRequestDefaultExpression = RawRequestDefaultExpression, Reply extends RawReplyDefaultExpression = RawReplyDefaultExpression, - Logger extends FastifyBaseLogger = FastifyLoggerInstance, - TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, ->(opts: FastifyHttp2SecureOptions): FastifyInstance & PromiseLike> + Logger extends FastifyBaseLogger = FastifyBaseLogger, + TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault +> (opts: fastify.FastifyHttp2SecureOptions): FastifyInstance & SafePromiseLike> declare function fastify< Server extends http2.Http2Server, Request extends RawRequestDefaultExpression = RawRequestDefaultExpression, Reply extends RawReplyDefaultExpression = RawReplyDefaultExpression, - Logger extends FastifyBaseLogger = FastifyLoggerInstance, - TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, ->(opts: FastifyHttp2Options): FastifyInstance & PromiseLike> + Logger extends FastifyBaseLogger = FastifyBaseLogger, + TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault +> (opts: fastify.FastifyHttp2Options): FastifyInstance & SafePromiseLike> declare function fastify< Server extends https.Server, Request extends RawRequestDefaultExpression = RawRequestDefaultExpression, Reply extends RawReplyDefaultExpression = RawReplyDefaultExpression, - Logger extends FastifyBaseLogger = FastifyLoggerInstance, - TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, ->(opts: FastifyHttpsOptions): FastifyInstance & PromiseLike> + Logger extends FastifyBaseLogger = FastifyBaseLogger, + TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault +> (opts: fastify.FastifyHttpsOptions): FastifyInstance & SafePromiseLike> declare function fastify< Server extends http.Server, Request extends RawRequestDefaultExpression = RawRequestDefaultExpression, Reply extends RawReplyDefaultExpression = RawReplyDefaultExpression, - Logger extends FastifyBaseLogger = FastifyLoggerInstance, - TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, ->(opts?: FastifyServerOptions): FastifyInstance & PromiseLike> - -export default fastify - -export type FastifyHttp2SecureOptions< - Server extends http2.Http2SecureServer, - Logger extends FastifyBaseLogger = FastifyLoggerInstance -> = FastifyServerOptions & { - http2: true, - https: http2.SecureServerOptions, - http2SessionTimeout?: number -} - -export type FastifyHttp2Options< - Server extends http2.Http2Server, - Logger extends FastifyBaseLogger = FastifyLoggerInstance -> = FastifyServerOptions & { - http2: true, - http2SessionTimeout?: number -} - -export type FastifyHttpsOptions< - Server extends https.Server, - Logger extends FastifyBaseLogger = FastifyLoggerInstance -> = FastifyServerOptions & { - https: https.ServerOptions -} - -type FindMyWayVersion = RawServer extends http.Server ? HTTPVersion.V1 : HTTPVersion.V2 - -export interface ConnectionError extends Error { - code: string, - bytesParsed: number, - rawPacket: { - type: string, - data: number[] - } -} - -/** - * Options for a fastify server instance. Utilizes conditional logic on the generic server parameter to enforce certain https and http2 - */ -export type FastifyServerOptions< - RawServer extends RawServerBase = RawServerDefault, - Logger extends FastifyBaseLogger = FastifyLoggerInstance -> = { - ignoreTrailingSlash?: boolean, - ignoreDuplicateSlashes?: boolean, - connectionTimeout?: number, - keepAliveTimeout?: number, - maxRequestsPerSocket?: number, - forceCloseConnections?: boolean | 'idle', - requestTimeout?: number, - pluginTimeout?: number, - bodyLimit?: number, - maxParamLength?: number, - disableRequestLogging?: boolean, - exposeHeadRoutes?: boolean, - onProtoPoisoning?: ProtoAction, - onConstructorPoisoning?: ConstructorAction, - logger?: boolean | FastifyLoggerOptions & PinoLoggerOptions | Logger, - serializerOpts?: FJSOptions | Record, - serverFactory?: FastifyServerFactory, - caseSensitive?: boolean, - requestIdHeader?: string, - requestIdLogLabel?: string; - jsonShorthand?: boolean; - genReqId?: (req: FastifyRequest, FastifySchema, TypeProvider>) => string, - trustProxy?: boolean | string | string[] | number | TrustProxyFunction, - querystringParser?: (str: string) => { [key: string]: unknown }, - /** - * @deprecated Prefer using the `constraints.version` property - */ - versioning?: { - storage(): { - get(version: string): string | null, - set(version: string, store: Function): void - del(version: string): void, - empty(): void - }, - deriveVersion(req: Object, ctx?: Context): string // not a fan of using Object here. Also what is Context? Can either of these be better defined? - }, - constraints?: { - [name: string]: ConstraintStrategy, unknown>, - }, - schemaController?: { - bucket?: (parentSchemas?: unknown) => { - add(schema: unknown): FastifyInstance; - getSchema(schemaId: string): unknown; - getSchemas(): Record; - }; - compilersFactory?: { - buildValidator?: ValidatorCompiler; - buildSerializer?: SerializerCompiler; - }; - }; - return503OnClosing?: boolean, - ajv?: { - customOptions?: AjvOptions, - plugins?: (Function | [Function, unknown])[] - }, - frameworkErrors?: ( - error: FastifyError, - req: FastifyRequest, FastifySchema, TypeProvider>, - res: FastifyReply, RawReplyDefaultExpression, RequestGeneric, FastifyContextConfig, SchemaCompiler, TypeProvider> - ) => void, - rewriteUrl?: (req: RawRequestDefaultExpression) => string, - schemaErrorFormatter?: (errors: FastifySchemaValidationError[], dataVar: string) => Error, - /** - * listener to error events emitted by client connections - */ - clientErrorHandler?: (error: ConnectionError, socket: Socket) => void -} - -type TrustProxyFunction = (address: string, hop: number) => boolean - -declare module '@fastify/error' { - interface FastifyError { - validation?: ValidationResult[]; - } -} - -export interface ValidationResult { - keyword: string; - instancePath: string; - schemaPath: string; - params: Record; - message?: string; -} + Logger extends FastifyBaseLogger = FastifyBaseLogger, + TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault +> (opts?: fastify.FastifyHttpOptions): FastifyInstance & SafePromiseLike> -/* Export all additional types */ -export type { Chain as LightMyRequestChain, InjectOptions, Response as LightMyRequestResponse, CallbackFunc as LightMyRequestCallback } from 'light-my-request' -export { FastifyRequest, RequestGenericInterface } from './types/request' -export { FastifyReply } from './types/reply' -export { FastifyPluginCallback, FastifyPluginAsync, FastifyPluginOptions, FastifyPlugin } from './types/plugin' -export { FastifyInstance, PrintRoutesOptions } from './types/instance' -export { FastifyLoggerOptions, FastifyBaseLogger, FastifyLoggerInstance, FastifyLogFn, LogLevel } from './types/logger' -export { FastifyContext, FastifyContextConfig } from './types/context' -export { RouteHandler, RouteHandlerMethod, RouteOptions, RouteShorthandMethod, RouteShorthandOptions, RouteShorthandOptionsWithHandler } from './types/route' -export * from './types/register' -export { FastifyBodyParser, FastifyContentTypeParser, AddContentTypeParser, hasContentTypeParser, getDefaultJsonParser, ProtoAction, ConstructorAction } from './types/content-type-parser' -export { FastifyError } from '@fastify/error' -export { FastifySchema, FastifySchemaCompiler } from './types/schema' -export { HTTPMethods, RawServerBase, RawRequestDefaultExpression, RawReplyDefaultExpression, RawServerDefault, ContextConfigDefault, RequestBodyDefault, RequestQuerystringDefault, RequestParamsDefault, RequestHeadersDefault } from './types/utils' -export * from './types/hooks' -export { FastifyServerFactory, FastifyServerFactoryHandler } from './types/serverFactory' -export { FastifyTypeProvider, FastifyTypeProviderDefault } from './types/type-provider' -export { fastify } +// CJS export +// const fastify = require('fastify') +export = fastify diff --git a/fastify.js b/fastify.js index 7a3829b91ed..343ce5de5c6 100644 --- a/fastify.js +++ b/fastify.js @@ -1,9 +1,10 @@ 'use strict' -const VERSION = '4.2.0' +const VERSION = '5.8.2' const Avvio = require('avvio') -const http = require('http') +const http = require('node:http') +const diagnostics = require('node:diagnostics_channel') let lightMyRequest const { @@ -11,6 +12,7 @@ const { kChildren, kServerBindings, kBodyLimit, + kSupportedHTTPMethods, kRoutePrefix, kLogLevel, kLogSerializers, @@ -28,174 +30,87 @@ const { kSchemaErrorFormatter, kErrorHandler, kKeepAliveConnections, - kFourOhFourContext + kChildLoggerFactory, + kGenReqId, + kErrorHandlerAlreadySet, + kHandlerTimeout } = require('./lib/symbols.js') -const { createServer, compileValidateHTTPVersion } = require('./lib/server') +const { createServer } = require('./lib/server') const Reply = require('./lib/reply') const Request = require('./lib/request') -const supportedMethods = ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT', 'OPTIONS'] +const Context = require('./lib/context.js') const decorator = require('./lib/decorate') -const ContentTypeParser = require('./lib/contentTypeParser') +const ContentTypeParser = require('./lib/content-type-parser.js') const SchemaController = require('./lib/schema-controller') const { Hooks, hookRunnerApplication, supportedHooks } = require('./lib/hooks') -const { createLogger } = require('./lib/logger') -const pluginUtils = require('./lib/pluginUtils') -const reqIdGenFactory = require('./lib/reqIdGenFactory') -const { buildRouting, validateBodyLimitOption } = require('./lib/route') -const build404 = require('./lib/fourOhFour') -const getSecuredInitialConfig = require('./lib/initialConfigValidation') -const override = require('./lib/pluginOverride') -const warning = require('./lib/warnings') -const noopSet = require('./lib/noop-set') -const { defaultInitOptions } = getSecuredInitialConfig - +const { createChildLogger, defaultChildLoggerFactory, createLogger } = require('./lib/logger-factory') +const pluginUtils = require('./lib/plugin-utils.js') +const { getGenReqId, reqIdGenFactory } = require('./lib/req-id-gen-factory.js') +const { buildRouting, validateBodyLimitOption, buildRouterOptions } = require('./lib/route') +const build404 = require('./lib/four-oh-four') +const getSecuredInitialConfig = require('./lib/initial-config-validation.js') +const override = require('./lib/plugin-override') const { - FST_ERR_BAD_URL, - FST_ERR_FORCE_CLOSE_CONNECTIONS_IDLE_NOT_AVAILABLE, + appendStackTrace, AVVIO_ERRORS_MAP, - appendStackTrace + ...errorCodes } = require('./lib/errors') +const PonyPromise = require('./lib/promise') -const { buildErrorHandler } = require('./lib/error-handler.js') - -const onBadUrlContext = { - config: { - }, - onSend: [], - onError: [], - [kFourOhFourContext]: null -} - -function defaultBuildPrettyMeta (route) { - // return a shallow copy of route's sanitized context - - const cleanKeys = {} - const allowedProps = ['errorHandler', 'logLevel', 'logSerializers'] - - allowedProps.concat(supportedHooks).forEach(k => { - cleanKeys[k] = route.store[k] - }) - - return Object.assign({}, cleanKeys) -} - -function fastify (options) { - // Options validations - options = options || {} - - if (typeof options !== 'object') { - throw new TypeError('Options must be an object') - } - - if (options.querystringParser && typeof options.querystringParser !== 'function') { - throw new Error(`querystringParser option should be a function, instead got '${typeof options.querystringParser}'`) - } - - if (options.schemaController && options.schemaController.bucket && typeof options.schemaController.bucket !== 'function') { - throw new Error(`schemaController.bucket option should be a function, instead got '${typeof options.schemaController.bucket}'`) - } - - validateBodyLimitOption(options.bodyLimit) - - const requestIdHeader = options.requestIdHeader || defaultInitOptions.requestIdHeader - const genReqId = options.genReqId || reqIdGenFactory() - const requestIdLogLabel = options.requestIdLogLabel || 'reqId' - const bodyLimit = options.bodyLimit || defaultInitOptions.bodyLimit - const disableRequestLogging = options.disableRequestLogging || false - - const ajvOptions = Object.assign({ - customOptions: {}, - plugins: [] - }, options.ajv) - const frameworkErrors = options.frameworkErrors - - // Ajv options - if (!ajvOptions.customOptions || Object.prototype.toString.call(ajvOptions.customOptions) !== '[object Object]') { - throw new Error(`ajv.customOptions option should be an object, instead got '${typeof ajvOptions.customOptions}'`) - } - if (!ajvOptions.plugins || !Array.isArray(ajvOptions.plugins)) { - throw new Error(`ajv.plugins option should be an array, instead got '${typeof ajvOptions.plugins}'`) - } - - // Instance Fastify components - const { logger, hasLogger } = createLogger(options) +const { defaultInitOptions } = getSecuredInitialConfig - // Update the options with the fixed values - options.connectionTimeout = options.connectionTimeout || defaultInitOptions.connectionTimeout - options.keepAliveTimeout = options.keepAliveTimeout || defaultInitOptions.keepAliveTimeout - options.maxRequestsPerSocket = options.maxRequestsPerSocket || defaultInitOptions.maxRequestsPerSocket - options.requestTimeout = options.requestTimeout || defaultInitOptions.requestTimeout - options.logger = logger - options.genReqId = genReqId - options.requestIdHeader = requestIdHeader - options.requestIdLogLabel = requestIdLogLabel - options.disableRequestLogging = disableRequestLogging - options.ajv = ajvOptions - options.clientErrorHandler = options.clientErrorHandler || defaultClientErrorHandler +const { + FST_ERR_ASYNC_CONSTRAINT, + FST_ERR_BAD_URL, + FST_ERR_OPTIONS_NOT_OBJ, + FST_ERR_QSP_NOT_FN, + FST_ERR_SCHEMA_CONTROLLER_BUCKET_OPT_NOT_FN, + FST_ERR_AJV_CUSTOM_OPTIONS_OPT_NOT_OBJ, + FST_ERR_AJV_CUSTOM_OPTIONS_OPT_NOT_ARR, + FST_ERR_INSTANCE_ALREADY_LISTENING, + FST_ERR_REOPENED_CLOSE_SERVER, + FST_ERR_ROUTE_REWRITE_NOT_STR, + FST_ERR_SCHEMA_ERROR_FORMATTER_NOT_FN, + FST_ERR_ERROR_HANDLER_NOT_FN, + FST_ERR_ERROR_HANDLER_ALREADY_SET, + FST_ERR_ROUTE_METHOD_INVALID +} = errorCodes - const initialConfig = getSecuredInitialConfig(options) +const { buildErrorHandler } = require('./lib/error-handler.js') +const { FSTWRN004 } = require('./lib/warnings.js') - // exposeHeadRoutes have its default set from the validator - options.exposeHeadRoutes = initialConfig.exposeHeadRoutes +const initChannel = diagnostics.channel('fastify.initialization') - let constraints = options.constraints - if (options.versioning) { - warning.emit('FSTDEP009') - constraints = { - ...constraints, - version: { - name: 'version', - mustMatchWhenDerived: true, - storage: options.versioning.storage, - deriveConstraint: options.versioning.deriveVersion, - validate (value) { - if (typeof value !== 'string') { - throw new Error('Version constraint should be a string.') - } - } - } - } - } +/** + * @param {import('./fastify.js').FastifyServerOptions} serverOptions + */ +function fastify (serverOptions) { + const { + options, + genReqId, + disableRequestLogging, + hasLogger, + initialConfig + } = processOptions(serverOptions, defaultRoute, onBadUrl) // Default router - const router = buildRouting({ - config: { - defaultRoute, - onBadUrl, - constraints, - ignoreTrailingSlash: options.ignoreTrailingSlash || defaultInitOptions.ignoreTrailingSlash, - ignoreDuplicateSlashes: options.ignoreDuplicateSlashes || defaultInitOptions.ignoreDuplicateSlashes, - maxParamLength: options.maxParamLength || defaultInitOptions.maxParamLength, - caseSensitive: options.caseSensitive, - allowUnsafeRegex: options.allowUnsafeRegex || defaultInitOptions.allowUnsafeRegex, - buildPrettyMeta: defaultBuildPrettyMeta, - querystringParser: options.querystringParser - } - }) + const router = buildRouting(options.routerOptions) // 404 router, used for handling encapsulated 404 handlers const fourOhFour = build404(options) // HTTP server and its handler - const httpHandler = wrapRouting(router.routing, options) + const httpHandler = wrapRouting(router, options) - // we need to set this before calling createServer - options.http2SessionTimeout = initialConfig.http2SessionTimeout - const { server, listen } = createServer(options, httpHandler) - - const serverHasCloseAllConnections = typeof server.closeAllConnections === 'function' - const serverHasCloseIdleConnections = typeof server.closeIdleConnections === 'function' - - let forceCloseConnections = options.forceCloseConnections - if (forceCloseConnections === 'idle' && !serverHasCloseIdleConnections) { - throw new FST_ERR_FORCE_CLOSE_CONNECTIONS_IDLE_NOT_AVAILABLE() - } else if (typeof forceCloseConnections !== 'boolean') { - /* istanbul ignore next: only one branch can be valid in a given Node.js version */ - forceCloseConnections = serverHasCloseIdleConnections ? 'idle' : false - } - - const keepAliveConnections = !serverHasCloseAllConnections && forceCloseConnections === true ? new Set() : noopSet() + const { + server, + listen, + forceCloseConnections, + serverHasCloseAllConnections, + serverHasCloseHttp2Sessions, + keepAliveConnections + } = createServer(options, httpHandler) const setupResponseListeners = Reply.setupResponseListeners const schemaController = SchemaController.buildSchemaController(null, options.schemaController) @@ -206,13 +121,34 @@ function fastify (options) { [kState]: { listening: false, closing: false, - started: false + started: false, + ready: false, + booting: false, + aborted: false, + readyResolver: null }, [kKeepAliveConnections]: keepAliveConnections, + [kSupportedHTTPMethods]: { + bodyless: new Set([ + // Standard + 'GET', + 'HEAD', + 'TRACE' + ]), + bodywith: new Set([ + // Standard + 'DELETE', + 'OPTIONS', + 'PATCH', + 'PUT', + 'POST' + ]) + }, [kOptions]: options, [kChildren]: [], [kServerBindings]: [], - [kBodyLimit]: bodyLimit, + [kBodyLimit]: options.bodyLimit, + [kHandlerTimeout]: options.handlerTimeout, [kRoutePrefix]: '', [kLogLevel]: '', [kLogSerializers]: null, @@ -220,22 +156,23 @@ function fastify (options) { [kSchemaController]: schemaController, [kSchemaErrorFormatter]: null, [kErrorHandler]: buildErrorHandler(), + [kErrorHandlerAlreadySet]: false, + [kChildLoggerFactory]: options.childLoggerFactory || defaultChildLoggerFactory, [kReplySerializerDefault]: null, [kContentTypeParser]: new ContentTypeParser( - bodyLimit, + options.bodyLimit, (options.onProtoPoisoning || defaultInitOptions.onProtoPoisoning), (options.onConstructorPoisoning || defaultInitOptions.onConstructorPoisoning) ), [kReply]: Reply.buildReply(Reply), [kRequest]: Request.buildRequest(Request, options.trustProxy), [kFourOhFour]: fourOhFour, - [pluginUtils.registeredPlugins]: [], + [pluginUtils.kRegisteredPlugins]: [], [kPluginNameChain]: ['fastify'], [kAvvioBoot]: null, + [kGenReqId]: genReqId, // routing method routing: httpHandler, - getDefaultRoute: router.getDefaultRoute.bind(router), - setDefaultRoute: router.setDefaultRoute.bind(router), // routes shorthand methods delete: function _delete (url, options, handler) { return router.prepareRoute.call(this, { method: 'DELETE', url, options, handler }) @@ -246,6 +183,9 @@ function fastify (options) { head: function _head (url, options, handler) { return router.prepareRoute.call(this, { method: 'HEAD', url, options, handler }) }, + trace: function _trace (url, options, handler) { + return router.prepareRoute.call(this, { method: 'TRACE', url, options, handler }) + }, patch: function _patch (url, options, handler) { return router.prepareRoute.call(this, { method: 'PATCH', url, options, handler }) }, @@ -259,7 +199,7 @@ function fastify (options) { return router.prepareRoute.call(this, { method: 'OPTIONS', url, options, handler }) }, all: function _all (url, options, handler) { - return router.prepareRoute.call(this, { method: supportedMethods, url, options, handler }) + return router.prepareRoute.call(this, { method: this.supportedMethods, url, options, handler }) }, // extended route route: function _route (options) { @@ -267,8 +207,14 @@ function fastify (options) { // otherwise we should bind it after the declaration return router.route.call(this, { options }) }, + hasRoute: function _route (options) { + return router.hasRoute.call(this, { options }) + }, + findRoute: function _findRoute (options) { + return router.findRoute(options) + }, // expose logger instance - log: logger, + log: options.logger, // type provider withTypeProvider, // hooks @@ -282,6 +228,8 @@ function fastify (options) { setSchemaController, setReplySerializer, setSchemaErrorFormatter, + // set generated request id + setGenReqId, // custom parsers addContentTypeParser: ContentTypeParser.helpers.addContentTypeParser, hasContentTypeParser: ContentTypeParser.helpers.hasContentTypeParser, @@ -297,7 +245,7 @@ function fastify (options) { close: null, printPlugins: null, hasPlugin: function (name) { - return this[kPluginNameChain].includes(name) + return this[pluginUtils.kRegisteredPlugins].includes(name) || this[kPluginNameChain].includes(name) }, // http server listen, @@ -315,6 +263,8 @@ function fastify (options) { decorateRequest: decorator.decorateRequest, hasRequestDecorator: decorator.existRequest, hasReplyDecorator: decorator.existReply, + getDecorator: decorator.getInstanceDecorator, + addHttpMethod, // fake http injection inject, // pretty print of the registered routes @@ -322,6 +272,8 @@ function fastify (options) { // custom error handling setNotFoundHandler, setErrorHandler, + // child logger + setChildLoggerFactory, // Set fastify initial configuration options read-only object initialConfig, // constraint strategies @@ -330,6 +282,18 @@ function fastify (options) { } Object.defineProperties(fastify, { + listeningOrigin: { + get () { + const address = this.addresses().slice(-1).pop() + /* ignore if windows: unix socket is not testable on Windows platform */ + /* c8 ignore next 3 */ + if (typeof address === 'string') { + return address + } + const host = address.family === 'IPv6' ? `[${address.address}]` : address.address + return `${this[kOptions].https ? 'https' : 'http'}://${host}:${address.port}` + } + }, pluginName: { configurable: true, get () { @@ -351,6 +315,10 @@ function fastify (options) { configurable: true, get () { return this[kSchemaController].getSerializerCompiler() } }, + childLoggerFactory: { + configurable: true, + get () { return this[kChildLoggerFactory] } + }, version: { configurable: true, get () { return VERSION } @@ -360,6 +328,19 @@ function fastify (options) { get () { return this[kErrorHandler].func } + }, + genReqId: { + configurable: true, + get () { return this[kGenReqId] } + }, + supportedMethods: { + configurable: false, + get () { + return [ + ...this[kSupportedHTTPMethods].bodyless, + ...this[kSupportedHTTPMethods].bodywith + ] + } } }) @@ -396,32 +377,58 @@ function fastify (options) { fastify.onClose((instance, done) => { fastify[kState].closing = true router.closeRoutes() - if (fastify[kState].listening) { - // No new TCP connections are accepted - instance.server.close(done) - - /* istanbul ignore next: Cannot test this without Node.js core support */ - if (forceCloseConnections === 'idle') { - instance.server.closeIdleConnections() - /* istanbul ignore next: Cannot test this without Node.js core support */ - } else if (serverHasCloseAllConnections && forceCloseConnections) { - instance.server.closeAllConnections() - } else { - for (const conn of fastify[kKeepAliveConnections]) { - // We must invoke the destroy method instead of merely unreffing - // the sockets. If we only unref, then the callback passed to - // `fastify.close` will never be invoked; nor will any of the - // registered `onClose` hooks. - conn.destroy() - fastify[kKeepAliveConnections].delete(conn) + + hookRunnerApplication('preClose', fastify[kAvvioBoot], fastify, function () { + if (fastify[kState].listening) { + /* istanbul ignore next: Cannot test this without Node.js core support */ + if (forceCloseConnections === 'idle') { + // Not needed in Node 19 + instance.server.closeIdleConnections() + /* istanbul ignore next: Cannot test this without Node.js core support */ + } else if (serverHasCloseAllConnections && forceCloseConnections) { + instance.server.closeAllConnections() + } else if (forceCloseConnections === true) { + for (const conn of fastify[kKeepAliveConnections]) { + // We must invoke the destroy method instead of merely unreffing + // the sockets. If we only unref, then the callback passed to + // `fastify.close` will never be invoked; nor will any of the + // registered `onClose` hooks. + conn.destroy() + fastify[kKeepAliveConnections].delete(conn) + } } } - } else { - done(null) - } + + if (serverHasCloseHttp2Sessions) { + instance.server.closeHttp2Sessions() + } + + // No new TCP connections are accepted. + // We must call close on the server even if we are not listening + // otherwise memory will be leaked. + // https://github.com/nodejs/node/issues/48604 + if (!options.serverFactory || fastify[kState].listening) { + instance.server.close(function (err) { + /* c8 ignore next 6 */ + if (err && err.code !== 'ERR_SERVER_NOT_RUNNING') { + done(null) + } else { + done() + } + }) + } else { + process.nextTick(done, null) + } + }) }) }) + // Create bad URL context + const onBadUrlContext = new Context({ + server: fastify, + config: {} + }) + // Set the default 404 handler fastify.setNotFoundHandler() fourOhFour.arrange404(fastify) @@ -429,32 +436,30 @@ function fastify (options) { router.setup(options, { avvio, fourOhFour, - logger, hasLogger, setupResponseListeners, throwIfAlreadyStarted, - validateHTTPVersion: compileValidateHTTPVersion(options), keepAliveConnections }) // Delay configuring clientError handler so that it can access fastify state. server.on('clientError', options.clientErrorHandler.bind(fastify)) - try { - const dc = require('diagnostics_channel') - const initChannel = dc.channel('fastify.initialization') - if (initChannel.hasSubscribers) { - initChannel.publish({ fastify }) + if (initChannel.hasSubscribers) { + initChannel.publish({ fastify }) + } + + // Older nodejs versions may not have asyncDispose + if ('asyncDispose' in Symbol) { + fastify[Symbol.asyncDispose] = function dispose () { + return fastify.close() } - } catch (e) { - // This only happens if `diagnostics_channel` isn't available, i.e. earlier - // versions of Node.js. In that event, we don't care, so ignore the error. } return fastify function throwIfAlreadyStarted (msg) { - if (fastify[kState].started) throw new Error(msg) + if (fastify[kState].started) throw new FST_ERR_INSTANCE_ALREADY_LISTENING(msg) } // HTTP injection handling @@ -470,7 +475,7 @@ function fastify (options) { if (fastify[kState].started) { if (fastify[kState].closing) { // Force to return an error - const error = new Error('Server is closed') + const error = new FST_ERR_REOPENED_CLOSE_SERVER() if (cb) { cb(error) return @@ -500,25 +505,37 @@ function fastify (options) { } function ready (cb) { - let resolveReady - let rejectReady + if (this[kState].readyResolver !== null) { + if (cb != null) { + this[kState].readyResolver.promise.then(() => cb(null, fastify), cb) + return + } + + return this[kState].readyResolver.promise + } // run the hooks after returning the promise process.nextTick(runHooks) + // Create a promise no matter what + // It will work as a barrier for all the .ready() calls (ensuring single hook execution) + // as well as a flow control mechanism to chain cbs and further + // promises + this[kState].readyResolver = PonyPromise.withResolvers() + if (!cb) { - return new Promise(function (resolve, reject) { - resolveReady = resolve - rejectReady = reject - }) + return this[kState].readyResolver.promise + } else { + this[kState].readyResolver.promise.then(() => cb(null, fastify), cb) } function runHooks () { // start loading fastify[kAvvioBoot]((err, done) => { - if (err || fastify[kState].started) { + if (err || fastify[kState].started || fastify[kState].ready || fastify[kState].booting) { manageErr(err) } else { + fastify[kState].booting = true hookRunnerApplication('onReady', fastify[kAvvioBoot], fastify, manageErr) } done() @@ -533,18 +550,14 @@ function fastify (options) { ? appendStackTrace(err, new AVVIO_ERRORS_MAP[err.code](err.message)) : err - if (cb) { - if (err) { - cb(err) - } else { - cb(undefined, fastify) - } - } else { - if (err) { - return rejectReady(err) - } - resolveReady(fastify) + if (err) { + return fastify[kState].readyResolver.reject(err) } + + fastify[kState].readyResolver.resolve(fastify) + fastify[kState].booting = false + fastify[kState].ready = true + fastify[kState].readyResolver = null } } @@ -555,33 +568,42 @@ function fastify (options) { // wrapper that we expose to the user for hooks handling function addHook (name, fn) { - throwIfAlreadyStarted('Cannot call "addHook" when fastify instance is already started!') + throwIfAlreadyStarted('Cannot call "addHook"!') + + if (fn == null) { + throw new errorCodes.FST_ERR_HOOK_INVALID_HANDLER(name, fn) + } if (name === 'onSend' || name === 'preSerialization' || name === 'onError' || name === 'preParsing') { if (fn.constructor.name === 'AsyncFunction' && fn.length === 4) { - throw new Error('Async function has too many arguments. Async hooks should not use the \'done\' argument.') + throw new errorCodes.FST_ERR_HOOK_INVALID_ASYNC_HANDLER() } - } else if (name === 'onReady') { + } else if (name === 'onReady' || name === 'onListen') { if (fn.constructor.name === 'AsyncFunction' && fn.length !== 0) { - throw new Error('Async function has too many arguments. Async hooks should not use the \'done\' argument.') + throw new errorCodes.FST_ERR_HOOK_INVALID_ASYNC_HANDLER() + } + } else if (name === 'onRequestAbort') { + if (fn.constructor.name === 'AsyncFunction' && fn.length !== 1) { + throw new errorCodes.FST_ERR_HOOK_INVALID_ASYNC_HANDLER() } } else { if (fn.constructor.name === 'AsyncFunction' && fn.length === 3) { - throw new Error('Async function has too many arguments. Async hooks should not use the \'done\' argument.') + throw new errorCodes.FST_ERR_HOOK_INVALID_ASYNC_HANDLER() } } if (name === 'onClose') { - this.onClose(fn) - } else if (name === 'onReady') { - this[kHooks].add(name, fn) - } else if (name === 'onRoute') { - this[kHooks].validate(name, fn) + this.onClose(fn.bind(this)) + } else if (name === 'onReady' || name === 'onListen' || name === 'onRoute') { this[kHooks].add(name, fn) } else { this.after((err, done) => { - _addHook.call(this, name, fn) - done(err) + try { + _addHook.call(this, name, fn) + done(err) + } catch (err) { + done(err) + } }) } return this @@ -594,39 +616,12 @@ function fastify (options) { // wrapper that we expose to the user for schemas handling function addSchema (schema) { - throwIfAlreadyStarted('Cannot call "addSchema" when fastify instance is already started!') + throwIfAlreadyStarted('Cannot call "addSchema"!') this[kSchemaController].add(schema) this[kChildren].forEach(child => child.addSchema(schema)) return this } - function defaultClientErrorHandler (err, socket) { - // In case of a connection reset, the socket has been destroyed and there is nothing that needs to be done. - // https://nodejs.org/api/http.html#http_event_clienterror - if (err.code === 'ECONNRESET' || socket.destroyed) { - return - } - - const body = JSON.stringify({ - error: http.STATUS_CODES['400'], - message: 'Client Error', - statusCode: 400 - }) - - // Most devs do not know what to do with this error. - // In the vast majority of cases, it's a network error and/or some - // config issue on the load balancer side. - this.log.trace({ err }, 'client error') - // Copying standard node behaviour - // https://github.com/nodejs/node/blob/6ca23d7846cb47e84fd344543e394e50938540be/lib/_http_server.js#L666 - - // If the socket is not writable, there is no reason to try to send data. - if (socket.writable && socket.bytesWritten === 0) { - socket.write(`HTTP/1.1 400 Bad Request\r\nContent-Length: ${body.length}\r\nContent-Type: application/json\r\n\r\n${body}`) - } - socket.destroy(err) - } - // If the router does not match any route, every request will land here // req and res are Node.js core objects function defaultRoute (req, res) { @@ -641,54 +636,94 @@ function fastify (options) { } function onBadUrl (path, req, res) { - if (frameworkErrors) { - const id = genReqId(req) - const childLogger = logger.child({ reqId: id }) - - childLogger.info({ req }, 'incoming request') + if (options.frameworkErrors) { + const id = getGenReqId(onBadUrlContext.server, req) + const childLogger = createChildLogger(onBadUrlContext, options.logger, req, id) const request = new Request(id, null, req, null, childLogger, onBadUrlContext) const reply = new Reply(res, request, childLogger) - return frameworkErrors(new FST_ERR_BAD_URL(path), request, reply) + + const resolvedDisableRequestLogging = typeof disableRequestLogging === 'function' ? disableRequestLogging(req) : disableRequestLogging + if (resolvedDisableRequestLogging === false) { + childLogger.info({ req: request }, 'incoming request') + } + + return options.frameworkErrors(new FST_ERR_BAD_URL(path), request, reply) } - const body = `{"error":"Bad Request","message":"'${path}' is not a valid url component","statusCode":400}` + const body = JSON.stringify({ + error: 'Bad Request', + code: 'FST_ERR_BAD_URL', + message: `'${path}' is not a valid url component`, + statusCode: 400 + }) res.writeHead(400, { 'Content-Type': 'application/json', - 'Content-Length': body.length + 'Content-Length': Buffer.byteLength(body) }) res.end(body) } + function buildAsyncConstraintCallback (isAsync, req, res) { + if (isAsync === false) return undefined + return function onAsyncConstraintError (err) { + if (err) { + if (options.frameworkErrors) { + const id = getGenReqId(onBadUrlContext.server, req) + const childLogger = createChildLogger(onBadUrlContext, options.logger, req, id) + + const request = new Request(id, null, req, null, childLogger, onBadUrlContext) + const reply = new Reply(res, request, childLogger) + + const resolvedDisableRequestLogging = typeof disableRequestLogging === 'function' ? disableRequestLogging(req) : disableRequestLogging + if (resolvedDisableRequestLogging === false) { + childLogger.info({ req: request }, 'incoming request') + } + + return options.frameworkErrors(new FST_ERR_ASYNC_CONSTRAINT(), request, reply) + } + const body = '{"error":"Internal Server Error","message":"Unexpected error from async constraint","statusCode":500}' + res.writeHead(500, { + 'Content-Type': 'application/json', + 'Content-Length': body.length + }) + res.end(body) + } + } + } + function setNotFoundHandler (opts, handler) { - throwIfAlreadyStarted('Cannot call "setNotFoundHandler" when fastify instance is already started!') + throwIfAlreadyStarted('Cannot call "setNotFoundHandler"!') fourOhFour.setNotFoundHandler.call(this, opts, handler, avvio, router.routeHandler) return this } function setValidatorCompiler (validatorCompiler) { - throwIfAlreadyStarted('Cannot call "setValidatorCompiler" when fastify instance is already started!') + throwIfAlreadyStarted('Cannot call "setValidatorCompiler"!') this[kSchemaController].setValidatorCompiler(validatorCompiler) return this } function setSchemaErrorFormatter (errorFormatter) { - throwIfAlreadyStarted('Cannot call "setSchemaErrorFormatter" when fastify instance is already started!') + throwIfAlreadyStarted('Cannot call "setSchemaErrorFormatter"!') validateSchemaErrorFormatter(errorFormatter) this[kSchemaErrorFormatter] = errorFormatter.bind(this) return this } function setSerializerCompiler (serializerCompiler) { - throwIfAlreadyStarted('Cannot call "setSerializerCompiler" when fastify instance is already started!') + throwIfAlreadyStarted('Cannot call "setSerializerCompiler"!') this[kSchemaController].setSerializerCompiler(serializerCompiler) return this } function setSchemaController (schemaControllerOpts) { - throwIfAlreadyStarted('Cannot call "setSchemaController" when fastify instance is already started!') + throwIfAlreadyStarted('Cannot call "setSchemaController"!') const old = this[kSchemaController] - const schemaController = SchemaController.buildSchemaController(old, Object.assign({}, old.opts, schemaControllerOpts)) + const schemaController = SchemaController.buildSchemaController( + old, + Object.assign({}, old.opts, schemaControllerOpts) + ) this[kSchemaController] = schemaController this.getSchema = schemaController.getSchema.bind(schemaController) this.getSchemas = schemaController.getSchemas.bind(schemaController) @@ -696,7 +731,7 @@ function fastify (options) { } function setReplySerializer (replySerializer) { - throwIfAlreadyStarted('Cannot call "setReplySerializer" when fastify instance is already started!') + throwIfAlreadyStarted('Cannot call "setReplySerializer"!') this[kReplySerializerDefault] = replySerializer return this @@ -704,49 +739,238 @@ function fastify (options) { // wrapper that we expose to the user for configure the custom error handler function setErrorHandler (func) { - throwIfAlreadyStarted('Cannot call "setErrorHandler" when fastify instance is already started!') + throwIfAlreadyStarted('Cannot call "setErrorHandler"!') + + if (typeof func !== 'function') { + throw new FST_ERR_ERROR_HANDLER_NOT_FN() + } + if (!options.allowErrorHandlerOverride && this[kErrorHandlerAlreadySet]) { + throw new FST_ERR_ERROR_HANDLER_ALREADY_SET() + } else if (this[kErrorHandlerAlreadySet]) { + FSTWRN004("To disable this behavior, set 'allowErrorHandlerOverride' to false or ignore this message. For more information, visit: https://fastify.dev/docs/latest/Reference/Server/#allowerrorhandleroverride") + } + + this[kErrorHandlerAlreadySet] = true this[kErrorHandler] = buildErrorHandler(this[kErrorHandler], func.bind(this)) return this } + function setChildLoggerFactory (factory) { + throwIfAlreadyStarted('Cannot call "setChildLoggerFactory"!') + + this[kChildLoggerFactory] = factory + return this + } + function printRoutes (opts = {}) { // includeHooks:true - shortcut to include all supported hooks exported by fastify.Hooks - opts.includeMeta = opts.includeHooks ? opts.includeMeta ? supportedHooks.concat(opts.includeMeta) : supportedHooks : opts.includeMeta + opts.includeMeta = opts.includeHooks + ? opts.includeMeta ? supportedHooks.concat(opts.includeMeta) : supportedHooks + : opts.includeMeta return router.printRoutes(opts) } + + function wrapRouting (router, { rewriteUrl, logger }) { + let isAsync + return function preRouting (req, res) { + // only call isAsyncConstraint once + if (isAsync === undefined) isAsync = router.isAsyncConstraint() + if (rewriteUrl) { + req.originalUrl = req.url + const url = rewriteUrl.call(fastify, req) + if (typeof url === 'string') { + req.url = url + } else { + const err = new FST_ERR_ROUTE_REWRITE_NOT_STR(req.url, typeof url) + req.destroy(err) + } + } + router.routing(req, res, buildAsyncConstraintCallback(isAsync, req, res)) + } + } + + function setGenReqId (func) { + throwIfAlreadyStarted('Cannot call "setGenReqId"!') + + this[kGenReqId] = reqIdGenFactory(this[kOptions].requestIdHeader, func) + return this + } + + function addHttpMethod (method, { hasBody = false } = {}) { + if (typeof method !== 'string' || http.METHODS.indexOf(method) === -1) { + throw new FST_ERR_ROUTE_METHOD_INVALID() + } + + if (hasBody === true) { + this[kSupportedHTTPMethods].bodywith.add(method) + this[kSupportedHTTPMethods].bodyless.delete(method) + } else { + this[kSupportedHTTPMethods].bodywith.delete(method) + this[kSupportedHTTPMethods].bodyless.add(method) + } + + const _method = method.toLowerCase() + if (!this.hasDecorator(_method)) { + this.decorate(_method, function (url, options, handler) { + return router.prepareRoute.call(this, { method, url, options, handler }) + }) + } + + return this + } } -function validateSchemaErrorFormatter (schemaErrorFormatter) { - if (typeof schemaErrorFormatter !== 'function') { - throw new Error(`schemaErrorFormatter option should be a function, instead got ${typeof schemaErrorFormatter}`) - } else if (schemaErrorFormatter.constructor.name === 'AsyncFunction') { - throw new Error('schemaErrorFormatter option should not be an async function') +function processOptions (options, defaultRoute, onBadUrl) { + // Options validations + if (options && typeof options !== 'object') { + throw new FST_ERR_OPTIONS_NOT_OBJ() + } else { + // Shallow copy options object to prevent mutations outside of this function + options = Object.assign({}, options) + } + + if ( + (options.querystringParser && typeof options.querystringParser !== 'function') || + ( + options.routerOptions?.querystringParser && + typeof options.routerOptions.querystringParser !== 'function' + ) + ) { + throw new FST_ERR_QSP_NOT_FN(typeof (options.querystringParser ?? options.routerOptions.querystringParser)) + } + + if (options.schemaController && options.schemaController.bucket && typeof options.schemaController.bucket !== 'function') { + throw new FST_ERR_SCHEMA_CONTROLLER_BUCKET_OPT_NOT_FN(typeof options.schemaController.bucket) + } + + validateBodyLimitOption(options.bodyLimit) + + const requestIdHeader = typeof options.requestIdHeader === 'string' && options.requestIdHeader.length !== 0 ? options.requestIdHeader.toLowerCase() : (options.requestIdHeader === true && 'request-id') + const genReqId = reqIdGenFactory(requestIdHeader, options.genReqId) + const requestIdLogLabel = options.requestIdLogLabel || 'reqId' + options.bodyLimit = options.bodyLimit || defaultInitOptions.bodyLimit + const disableRequestLogging = options.disableRequestLogging || false + + const ajvOptions = Object.assign({ + customOptions: {}, + plugins: [] + }, options.ajv) + + if (!ajvOptions.customOptions || Object.prototype.toString.call(ajvOptions.customOptions) !== '[object Object]') { + throw new FST_ERR_AJV_CUSTOM_OPTIONS_OPT_NOT_OBJ(typeof ajvOptions.customOptions) + } + if (!ajvOptions.plugins || !Array.isArray(ajvOptions.plugins)) { + throw new FST_ERR_AJV_CUSTOM_OPTIONS_OPT_NOT_ARR(typeof ajvOptions.plugins) + } + + const { logger, hasLogger } = createLogger(options) + + // Update the options with the fixed values + options.connectionTimeout = options.connectionTimeout || defaultInitOptions.connectionTimeout + options.keepAliveTimeout = options.keepAliveTimeout || defaultInitOptions.keepAliveTimeout + options.maxRequestsPerSocket = options.maxRequestsPerSocket || defaultInitOptions.maxRequestsPerSocket + options.requestTimeout = options.requestTimeout || defaultInitOptions.requestTimeout + options.logger = logger + options.requestIdHeader = requestIdHeader + options.requestIdLogLabel = requestIdLogLabel + options.disableRequestLogging = disableRequestLogging + options.ajv = ajvOptions + options.clientErrorHandler = options.clientErrorHandler || defaultClientErrorHandler + options.allowErrorHandlerOverride = options.allowErrorHandlerOverride ?? defaultInitOptions.allowErrorHandlerOverride + + const initialConfig = getSecuredInitialConfig(options) + + // exposeHeadRoutes have its default set from the validator + options.exposeHeadRoutes = initialConfig.exposeHeadRoutes + + // we need to set this before calling createServer + options.http2SessionTimeout = initialConfig.http2SessionTimeout + + options.routerOptions = buildRouterOptions(options, { + defaultRoute, + onBadUrl, + ignoreTrailingSlash: defaultInitOptions.ignoreTrailingSlash, + ignoreDuplicateSlashes: defaultInitOptions.ignoreDuplicateSlashes, + maxParamLength: defaultInitOptions.maxParamLength, + allowUnsafeRegex: defaultInitOptions.allowUnsafeRegex, + buildPrettyMeta: defaultBuildPrettyMeta, + useSemicolonDelimiter: defaultInitOptions.useSemicolonDelimiter + }) + + return { + options, + genReqId, + disableRequestLogging, + hasLogger, + initialConfig } } -function wrapRouting (httpHandler, { rewriteUrl, logger }) { - if (!rewriteUrl) { - return httpHandler - } - return function preRouting (req, res) { - const originalUrl = req.url - const url = rewriteUrl(req) - if (originalUrl !== url) { - logger.debug({ originalUrl, url }, 'rewrite url') - if (typeof url === 'string') { - req.url = url - } else { - req.destroy(new Error(`Rewrite url for "${req.url}" needs to be of type "string" but received "${typeof url}"`)) - } - } - httpHandler(req, res) +function defaultBuildPrettyMeta (route) { + // return a shallow copy of route's sanitized context + + const cleanKeys = {} + const allowedProps = ['errorHandler', 'logLevel', 'logSerializers'] + + allowedProps.concat(supportedHooks).forEach(k => { + cleanKeys[k] = route.store[k] + }) + + return Object.assign({}, cleanKeys) +} + +function defaultClientErrorHandler (err, socket) { + // In case of a connection reset, the socket has been destroyed and there is nothing that needs to be done. + // https://nodejs.org/api/http.html#http_event_clienterror + if (err.code === 'ECONNRESET' || socket.destroyed) { + return + } + + let body, errorCode, errorStatus, errorLabel + + if (err.code === 'ERR_HTTP_REQUEST_TIMEOUT') { + errorCode = '408' + errorStatus = http.STATUS_CODES[errorCode] + body = `{"error":"${errorStatus}","message":"Client Timeout","statusCode":408}` + errorLabel = 'timeout' + } else if (err.code === 'HPE_HEADER_OVERFLOW') { + errorCode = '431' + errorStatus = http.STATUS_CODES[errorCode] + body = `{"error":"${errorStatus}","message":"Exceeded maximum allowed HTTP header size","statusCode":431}` + errorLabel = 'header_overflow' + } else { + errorCode = '400' + errorStatus = http.STATUS_CODES[errorCode] + body = `{"error":"${errorStatus}","message":"Client Error","statusCode":400}` + errorLabel = 'error' + } + + // Most devs do not know what to do with this error. + // In the vast majority of cases, it's a network error and/or some + // config issue on the load balancer side. + this.log.trace({ err }, `client ${errorLabel}`) + // Copying standard node behavior + // https://github.com/nodejs/node/blob/6ca23d7846cb47e84fd344543e394e50938540be/lib/_http_server.js#L666 + + // If the socket is not writable, there is no reason to try to send data. + if (socket.writable) { + socket.write(`HTTP/1.1 ${errorCode} ${errorStatus}\r\nContent-Length: ${body.length}\r\nContent-Type: application/json\r\n\r\n${body}`) + } + socket.destroy(err) +} + +function validateSchemaErrorFormatter (schemaErrorFormatter) { + if (typeof schemaErrorFormatter !== 'function') { + throw new FST_ERR_SCHEMA_ERROR_FORMATTER_NOT_FN(typeof schemaErrorFormatter) + } else if (schemaErrorFormatter.constructor.name === 'AsyncFunction') { + throw new FST_ERR_SCHEMA_ERROR_FORMATTER_NOT_FN('AsyncFunction') } } /** * These export configurations enable JS and TS developers - * to consumer fastify in whatever way best suits their needs. + * to consume fastify in whatever way best suits their needs. * Some examples of supported import syntax includes: * - `const fastify = require('fastify')` * - `const { fastify } = require('fastify')` @@ -756,5 +980,6 @@ function wrapRouting (httpHandler, { rewriteUrl, logger }) { * - `import fastify, { TSC_definition } from 'fastify'` */ module.exports = fastify +module.exports.errorCodes = errorCodes module.exports.fastify = fastify module.exports.default = fastify diff --git a/integration/server.js b/integration/server.js index 85a7716157a..39db37dfd79 100644 --- a/integration/server.js +++ b/integration/server.js @@ -1,3 +1,5 @@ +'use strict' + const Fastify = require('../fastify') const fastify = Fastify() diff --git a/lib/configValidator.js b/lib/config-validator.js similarity index 60% rename from lib/configValidator.js rename to lib/config-validator.js index 8d0e08715c5..825737b1a52 100644 --- a/lib/configValidator.js +++ b/lib/config-validator.js @@ -1,9 +1,9 @@ // This file is autogenerated by build/build-validation.js, do not edit -/* istanbul ignore file */ +/* c8 ignore start */ "use strict"; module.exports = validate10; module.exports.default = validate10; -const schema11 = {"type":"object","additionalProperties":false,"properties":{"connectionTimeout":{"type":"integer","default":0},"keepAliveTimeout":{"type":"integer","default":72000},"forceCloseConnections":{"oneOf":[{"type":"string","pattern":"idle"},{"type":"boolean"}]},"maxRequestsPerSocket":{"type":"integer","default":0,"nullable":true},"requestTimeout":{"type":"integer","default":0},"bodyLimit":{"type":"integer","default":1048576},"caseSensitive":{"type":"boolean","default":true},"allowUnsafeRegex":{"type":"boolean","default":false},"http2":{"type":"boolean"},"https":{"if":{"not":{"oneOf":[{"type":"boolean"},{"type":"null"},{"type":"object","additionalProperties":false,"required":["allowHTTP1"],"properties":{"allowHTTP1":{"type":"boolean"}}}]}},"then":{"setDefaultValue":true}},"ignoreTrailingSlash":{"type":"boolean","default":false},"ignoreDuplicateSlashes":{"type":"boolean","default":false},"disableRequestLogging":{"type":"boolean","default":false},"jsonShorthand":{"type":"boolean","default":true},"maxParamLength":{"type":"integer","default":100},"onProtoPoisoning":{"type":"string","default":"error"},"onConstructorPoisoning":{"type":"string","default":"error"},"pluginTimeout":{"type":"integer","default":10000},"requestIdHeader":{"type":"string","default":"request-id"},"requestIdLogLabel":{"type":"string","default":"reqId"},"http2SessionTimeout":{"type":"integer","default":72000},"exposeHeadRoutes":{"type":"boolean","default":true},"versioning":{"type":"object","additionalProperties":true,"required":["storage","deriveVersion"],"properties":{"storage":{},"deriveVersion":{}}},"constraints":{"type":"object","additionalProperties":{"type":"object","required":["name","storage","validate","deriveConstraint"],"additionalProperties":true,"properties":{"name":{"type":"string"},"storage":{},"validate":{},"deriveConstraint":{}}}}}}; +const schema11 = {"type":"object","additionalProperties":false,"properties":{"connectionTimeout":{"type":"integer","default":0},"keepAliveTimeout":{"type":"integer","default":72000},"forceCloseConnections":{"oneOf":[{"type":"string","pattern":"idle"},{"type":"boolean"}]},"maxRequestsPerSocket":{"type":"integer","default":0,"nullable":true},"requestTimeout":{"type":"integer","default":0},"handlerTimeout":{"type":"integer","default":0},"bodyLimit":{"type":"integer","default":1048576},"caseSensitive":{"type":"boolean","default":true},"allowUnsafeRegex":{"type":"boolean","default":false},"http2":{"type":"boolean"},"https":{"if":{"not":{"oneOf":[{"type":"boolean"},{"type":"null"},{"type":"object","additionalProperties":false,"required":["allowHTTP1"],"properties":{"allowHTTP1":{"type":"boolean"}}}]}},"then":{"setDefaultValue":true}},"ignoreTrailingSlash":{"type":"boolean","default":false},"ignoreDuplicateSlashes":{"type":"boolean","default":false},"disableRequestLogging":{"default":false},"maxParamLength":{"type":"integer","default":100},"onProtoPoisoning":{"type":"string","default":"error"},"onConstructorPoisoning":{"type":"string","default":"error"},"pluginTimeout":{"type":"integer","default":10000},"requestIdHeader":{"anyOf":[{"type":"boolean"},{"type":"string"}],"default":false},"requestIdLogLabel":{"type":"string","default":"reqId"},"http2SessionTimeout":{"type":"integer","default":72000},"exposeHeadRoutes":{"type":"boolean","default":true},"useSemicolonDelimiter":{"type":"boolean","default":false},"routerOptions":{"type":"object","additionalProperties":true,"properties":{"ignoreTrailingSlash":{"type":"boolean","default":false},"ignoreDuplicateSlashes":{"type":"boolean","default":false},"maxParamLength":{"type":"integer","default":100},"allowUnsafeRegex":{"type":"boolean","default":false},"useSemicolonDelimiter":{"type":"boolean","default":false}}},"constraints":{"type":"object","additionalProperties":{"type":"object","required":["name","storage","validate","deriveConstraint"],"additionalProperties":true,"properties":{"name":{"type":"string"},"storage":{},"validate":{},"deriveConstraint":{}}}}}}; const func2 = Object.prototype.hasOwnProperty; const pattern0 = new RegExp("idle", "u"); @@ -24,6 +24,9 @@ data.maxRequestsPerSocket = 0; if(data.requestTimeout === undefined){ data.requestTimeout = 0; } +if(data.handlerTimeout === undefined){ +data.handlerTimeout = 0; +} if(data.bodyLimit === undefined){ data.bodyLimit = 1048576; } @@ -42,9 +45,6 @@ data.ignoreDuplicateSlashes = false; if(data.disableRequestLogging === undefined){ data.disableRequestLogging = false; } -if(data.jsonShorthand === undefined){ -data.jsonShorthand = true; -} if(data.maxParamLength === undefined){ data.maxParamLength = 100; } @@ -58,7 +58,7 @@ if(data.pluginTimeout === undefined){ data.pluginTimeout = 10000; } if(data.requestIdHeader === undefined){ -data.requestIdHeader = "request-id"; +data.requestIdHeader = false; } if(data.requestIdLogLabel === undefined){ data.requestIdLogLabel = "reqId"; @@ -69,6 +69,9 @@ data.http2SessionTimeout = 72000; if(data.exposeHeadRoutes === undefined){ data.exposeHeadRoutes = true; } +if(data.useSemicolonDelimiter === undefined){ +data.useSemicolonDelimiter = false; +} const _errs1 = errors; for(const key0 in data){ if(!(func2.call(schema11.properties, key0))){ @@ -297,7 +300,7 @@ data["requestTimeout"] = coerced5; } var valid0 = _errs15 === errors; if(valid0){ -let data5 = data.bodyLimit; +let data5 = data.handlerTimeout; const _errs17 = errors; if(!(((typeof data5 == "number") && (!(data5 % 1) && !isNaN(data5))) && (isFinite(data5)))){ let dataType6 = typeof data5; @@ -308,45 +311,44 @@ if(dataType6 === "boolean" || data5 === null coerced6 = +data5; } else { -validate10.errors = [{instancePath:instancePath+"/bodyLimit",schemaPath:"#/properties/bodyLimit/type",keyword:"type",params:{type: "integer"},message:"must be integer"}]; +validate10.errors = [{instancePath:instancePath+"/handlerTimeout",schemaPath:"#/properties/handlerTimeout/type",keyword:"type",params:{type: "integer"},message:"must be integer"}]; return false; } } if(coerced6 !== undefined){ data5 = coerced6; if(data !== undefined){ -data["bodyLimit"] = coerced6; +data["handlerTimeout"] = coerced6; } } } var valid0 = _errs17 === errors; if(valid0){ -let data6 = data.caseSensitive; +let data6 = data.bodyLimit; const _errs19 = errors; -if(typeof data6 !== "boolean"){ +if(!(((typeof data6 == "number") && (!(data6 % 1) && !isNaN(data6))) && (isFinite(data6)))){ +let dataType7 = typeof data6; let coerced7 = undefined; if(!(coerced7 !== undefined)){ -if(data6 === "false" || data6 === 0 || data6 === null){ -coerced7 = false; -} -else if(data6 === "true" || data6 === 1){ -coerced7 = true; +if(dataType7 === "boolean" || data6 === null + || (dataType7 === "string" && data6 && data6 == +data6 && !(data6 % 1))){ +coerced7 = +data6; } else { -validate10.errors = [{instancePath:instancePath+"/caseSensitive",schemaPath:"#/properties/caseSensitive/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}]; +validate10.errors = [{instancePath:instancePath+"/bodyLimit",schemaPath:"#/properties/bodyLimit/type",keyword:"type",params:{type: "integer"},message:"must be integer"}]; return false; } } if(coerced7 !== undefined){ data6 = coerced7; if(data !== undefined){ -data["caseSensitive"] = coerced7; +data["bodyLimit"] = coerced7; } } } var valid0 = _errs19 === errors; if(valid0){ -let data7 = data.allowUnsafeRegex; +let data7 = data.caseSensitive; const _errs21 = errors; if(typeof data7 !== "boolean"){ let coerced8 = undefined; @@ -358,21 +360,20 @@ else if(data7 === "true" || data7 === 1){ coerced8 = true; } else { -validate10.errors = [{instancePath:instancePath+"/allowUnsafeRegex",schemaPath:"#/properties/allowUnsafeRegex/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}]; +validate10.errors = [{instancePath:instancePath+"/caseSensitive",schemaPath:"#/properties/caseSensitive/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}]; return false; } } if(coerced8 !== undefined){ data7 = coerced8; if(data !== undefined){ -data["allowUnsafeRegex"] = coerced8; +data["caseSensitive"] = coerced8; } } } var valid0 = _errs21 === errors; if(valid0){ -if(data.http2 !== undefined){ -let data8 = data.http2; +let data8 = data.allowUnsafeRegex; const _errs23 = errors; if(typeof data8 !== "boolean"){ let coerced9 = undefined; @@ -384,43 +385,69 @@ else if(data8 === "true" || data8 === 1){ coerced9 = true; } else { -validate10.errors = [{instancePath:instancePath+"/http2",schemaPath:"#/properties/http2/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}]; +validate10.errors = [{instancePath:instancePath+"/allowUnsafeRegex",schemaPath:"#/properties/allowUnsafeRegex/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}]; return false; } } if(coerced9 !== undefined){ data8 = coerced9; if(data !== undefined){ -data["http2"] = coerced9; +data["allowUnsafeRegex"] = coerced9; } } } var valid0 = _errs23 === errors; +if(valid0){ +if(data.http2 !== undefined){ +let data9 = data.http2; +const _errs25 = errors; +if(typeof data9 !== "boolean"){ +let coerced10 = undefined; +if(!(coerced10 !== undefined)){ +if(data9 === "false" || data9 === 0 || data9 === null){ +coerced10 = false; +} +else if(data9 === "true" || data9 === 1){ +coerced10 = true; +} +else { +validate10.errors = [{instancePath:instancePath+"/http2",schemaPath:"#/properties/http2/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}]; +return false; +} +} +if(coerced10 !== undefined){ +data9 = coerced10; +if(data !== undefined){ +data["http2"] = coerced10; +} +} +} +var valid0 = _errs25 === errors; } else { var valid0 = true; } if(valid0){ if(data.https !== undefined){ -let data9 = data.https; -const _errs25 = errors; -const _errs26 = errors; -let valid2 = true; +let data10 = data.https; const _errs27 = errors; const _errs28 = errors; +let valid2 = true; const _errs29 = errors; const _errs30 = errors; +const _errs31 = errors; +const _errs32 = errors; let valid4 = false; let passing1 = null; -const _errs31 = errors; -if(typeof data9 !== "boolean"){ -let coerced10 = undefined; -if(!(coerced10 !== undefined)){ -if(data9 === "false" || data9 === 0 || data9 === null){ -coerced10 = false; +const _errs33 = errors; +if(typeof data10 !== "boolean"){ +let coerced11 = undefined; +if(!(coerced11 !== undefined)){ +if(data10 === "false" || data10 === 0 || data10 === null){ +coerced11 = false; } -else if(data9 === "true" || data9 === 1){ -coerced10 = true; +else if(data10 === "true" || data10 === 1){ +coerced11 = true; } else { const err4 = {}; @@ -433,24 +460,24 @@ vErrors.push(err4); errors++; } } -if(coerced10 !== undefined){ -data9 = coerced10; +if(coerced11 !== undefined){ +data10 = coerced11; if(data !== undefined){ -data["https"] = coerced10; +data["https"] = coerced11; } } } -var _valid2 = _errs31 === errors; +var _valid2 = _errs33 === errors; if(_valid2){ valid4 = true; passing1 = 0; } -const _errs33 = errors; -if(data9 !== null){ -let coerced11 = undefined; -if(!(coerced11 !== undefined)){ -if(data9 === "" || data9 === 0 || data9 === false){ -coerced11 = null; +const _errs35 = errors; +if(data10 !== null){ +let coerced12 = undefined; +if(!(coerced12 !== undefined)){ +if(data10 === "" || data10 === 0 || data10 === false){ +coerced12 = null; } else { const err5 = {}; @@ -463,14 +490,14 @@ vErrors.push(err5); errors++; } } -if(coerced11 !== undefined){ -data9 = coerced11; +if(coerced12 !== undefined){ +data10 = coerced12; if(data !== undefined){ -data["https"] = coerced11; +data["https"] = coerced12; } } } -var _valid2 = _errs33 === errors; +var _valid2 = _errs35 === errors; if(_valid2 && valid4){ valid4 = false; passing1 = [passing1, 1]; @@ -480,11 +507,11 @@ if(_valid2){ valid4 = true; passing1 = 1; } -const _errs35 = errors; -if(errors === _errs35){ -if(data9 && typeof data9 == "object" && !Array.isArray(data9)){ +const _errs37 = errors; +if(errors === _errs37){ +if(data10 && typeof data10 == "object" && !Array.isArray(data10)){ let missing0; -if((data9.allowHTTP1 === undefined) && (missing0 = "allowHTTP1")){ +if((data10.allowHTTP1 === undefined) && (missing0 = "allowHTTP1")){ const err6 = {}; if(vErrors === null){ vErrors = [err6]; @@ -495,23 +522,23 @@ vErrors.push(err6); errors++; } else { -const _errs37 = errors; -for(const key1 in data9){ +const _errs39 = errors; +for(const key1 in data10){ if(!(key1 === "allowHTTP1")){ -delete data9[key1]; +delete data10[key1]; } } -if(_errs37 === errors){ -if(data9.allowHTTP1 !== undefined){ -let data10 = data9.allowHTTP1; -if(typeof data10 !== "boolean"){ -let coerced12 = undefined; -if(!(coerced12 !== undefined)){ -if(data10 === "false" || data10 === 0 || data10 === null){ -coerced12 = false; +if(_errs39 === errors){ +if(data10.allowHTTP1 !== undefined){ +let data11 = data10.allowHTTP1; +if(typeof data11 !== "boolean"){ +let coerced13 = undefined; +if(!(coerced13 !== undefined)){ +if(data11 === "false" || data11 === 0 || data11 === null){ +coerced13 = false; } -else if(data10 === "true" || data10 === 1){ -coerced12 = true; +else if(data11 === "true" || data11 === 1){ +coerced13 = true; } else { const err7 = {}; @@ -524,10 +551,10 @@ vErrors.push(err7); errors++; } } -if(coerced12 !== undefined){ -data10 = coerced12; -if(data9 !== undefined){ -data9["allowHTTP1"] = coerced12; +if(coerced13 !== undefined){ +data11 = coerced13; +if(data10 !== undefined){ +data10["allowHTTP1"] = coerced13; } } } @@ -546,7 +573,7 @@ vErrors.push(err8); errors++; } } -var _valid2 = _errs35 === errors; +var _valid2 = _errs37 === errors; if(_valid2 && valid4){ valid4 = false; passing1 = [passing1, 2]; @@ -569,17 +596,17 @@ vErrors.push(err9); errors++; } else { -errors = _errs30; +errors = _errs32; if(vErrors !== null){ -if(_errs30){ -vErrors.length = _errs30; +if(_errs32){ +vErrors.length = _errs32; } else { vErrors = null; } } } -var valid3 = _errs29 === errors; +var valid3 = _errs31 === errors; if(valid3){ const err10 = {}; if(vErrors === null){ @@ -591,30 +618,30 @@ vErrors.push(err10); errors++; } else { -errors = _errs28; +errors = _errs30; if(vErrors !== null){ -if(_errs28){ -vErrors.length = _errs28; +if(_errs30){ +vErrors.length = _errs30; } else { vErrors = null; } } } -var _valid1 = _errs27 === errors; -errors = _errs26; +var _valid1 = _errs29 === errors; +errors = _errs28; if(vErrors !== null){ -if(_errs26){ -vErrors.length = _errs26; +if(_errs28){ +vErrors.length = _errs28; } else { vErrors = null; } } if(_valid1){ -const _errs40 = errors; +const _errs42 = errors; data["https"] = true; -var _valid1 = _errs40 === errors; +var _valid1 = _errs42 === errors; valid2 = _valid1; } if(!valid2){ @@ -629,38 +656,13 @@ errors++; validate10.errors = vErrors; return false; } -var valid0 = _errs25 === errors; +var valid0 = _errs27 === errors; } else { var valid0 = true; } if(valid0){ -let data11 = data.ignoreTrailingSlash; -const _errs41 = errors; -if(typeof data11 !== "boolean"){ -let coerced13 = undefined; -if(!(coerced13 !== undefined)){ -if(data11 === "false" || data11 === 0 || data11 === null){ -coerced13 = false; -} -else if(data11 === "true" || data11 === 1){ -coerced13 = true; -} -else { -validate10.errors = [{instancePath:instancePath+"/ignoreTrailingSlash",schemaPath:"#/properties/ignoreTrailingSlash/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}]; -return false; -} -} -if(coerced13 !== undefined){ -data11 = coerced13; -if(data !== undefined){ -data["ignoreTrailingSlash"] = coerced13; -} -} -} -var valid0 = _errs41 === errors; -if(valid0){ -let data12 = data.ignoreDuplicateSlashes; +let data12 = data.ignoreTrailingSlash; const _errs43 = errors; if(typeof data12 !== "boolean"){ let coerced14 = undefined; @@ -672,20 +674,20 @@ else if(data12 === "true" || data12 === 1){ coerced14 = true; } else { -validate10.errors = [{instancePath:instancePath+"/ignoreDuplicateSlashes",schemaPath:"#/properties/ignoreDuplicateSlashes/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}]; +validate10.errors = [{instancePath:instancePath+"/ignoreTrailingSlash",schemaPath:"#/properties/ignoreTrailingSlash/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}]; return false; } } if(coerced14 !== undefined){ data12 = coerced14; if(data !== undefined){ -data["ignoreDuplicateSlashes"] = coerced14; +data["ignoreTrailingSlash"] = coerced14; } } } var valid0 = _errs43 === errors; if(valid0){ -let data13 = data.disableRequestLogging; +let data13 = data.ignoreDuplicateSlashes; const _errs45 = errors; if(typeof data13 !== "boolean"){ let coerced15 = undefined; @@ -697,69 +699,70 @@ else if(data13 === "true" || data13 === 1){ coerced15 = true; } else { -validate10.errors = [{instancePath:instancePath+"/disableRequestLogging",schemaPath:"#/properties/disableRequestLogging/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}]; +validate10.errors = [{instancePath:instancePath+"/ignoreDuplicateSlashes",schemaPath:"#/properties/ignoreDuplicateSlashes/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}]; return false; } } if(coerced15 !== undefined){ data13 = coerced15; if(data !== undefined){ -data["disableRequestLogging"] = coerced15; +data["ignoreDuplicateSlashes"] = coerced15; } } } var valid0 = _errs45 === errors; if(valid0){ -let data14 = data.jsonShorthand; +let data14 = data.maxParamLength; const _errs47 = errors; -if(typeof data14 !== "boolean"){ +if(!(((typeof data14 == "number") && (!(data14 % 1) && !isNaN(data14))) && (isFinite(data14)))){ +let dataType16 = typeof data14; let coerced16 = undefined; if(!(coerced16 !== undefined)){ -if(data14 === "false" || data14 === 0 || data14 === null){ -coerced16 = false; -} -else if(data14 === "true" || data14 === 1){ -coerced16 = true; +if(dataType16 === "boolean" || data14 === null + || (dataType16 === "string" && data14 && data14 == +data14 && !(data14 % 1))){ +coerced16 = +data14; } else { -validate10.errors = [{instancePath:instancePath+"/jsonShorthand",schemaPath:"#/properties/jsonShorthand/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}]; +validate10.errors = [{instancePath:instancePath+"/maxParamLength",schemaPath:"#/properties/maxParamLength/type",keyword:"type",params:{type: "integer"},message:"must be integer"}]; return false; } } if(coerced16 !== undefined){ data14 = coerced16; if(data !== undefined){ -data["jsonShorthand"] = coerced16; +data["maxParamLength"] = coerced16; } } } var valid0 = _errs47 === errors; if(valid0){ -let data15 = data.maxParamLength; +let data15 = data.onProtoPoisoning; const _errs49 = errors; -if(!(((typeof data15 == "number") && (!(data15 % 1) && !isNaN(data15))) && (isFinite(data15)))){ +if(typeof data15 !== "string"){ let dataType17 = typeof data15; let coerced17 = undefined; if(!(coerced17 !== undefined)){ -if(dataType17 === "boolean" || data15 === null - || (dataType17 === "string" && data15 && data15 == +data15 && !(data15 % 1))){ -coerced17 = +data15; +if(dataType17 == "number" || dataType17 == "boolean"){ +coerced17 = "" + data15; +} +else if(data15 === null){ +coerced17 = ""; } else { -validate10.errors = [{instancePath:instancePath+"/maxParamLength",schemaPath:"#/properties/maxParamLength/type",keyword:"type",params:{type: "integer"},message:"must be integer"}]; +validate10.errors = [{instancePath:instancePath+"/onProtoPoisoning",schemaPath:"#/properties/onProtoPoisoning/type",keyword:"type",params:{type: "string"},message:"must be string"}]; return false; } } if(coerced17 !== undefined){ data15 = coerced17; if(data !== undefined){ -data["maxParamLength"] = coerced17; +data["onProtoPoisoning"] = coerced17; } } } var valid0 = _errs49 === errors; if(valid0){ -let data16 = data.onProtoPoisoning; +let data16 = data.onConstructorPoisoning; const _errs51 = errors; if(typeof data16 !== "string"){ let dataType18 = typeof data16; @@ -772,105 +775,145 @@ else if(data16 === null){ coerced18 = ""; } else { -validate10.errors = [{instancePath:instancePath+"/onProtoPoisoning",schemaPath:"#/properties/onProtoPoisoning/type",keyword:"type",params:{type: "string"},message:"must be string"}]; +validate10.errors = [{instancePath:instancePath+"/onConstructorPoisoning",schemaPath:"#/properties/onConstructorPoisoning/type",keyword:"type",params:{type: "string"},message:"must be string"}]; return false; } } if(coerced18 !== undefined){ data16 = coerced18; if(data !== undefined){ -data["onProtoPoisoning"] = coerced18; +data["onConstructorPoisoning"] = coerced18; } } } var valid0 = _errs51 === errors; if(valid0){ -let data17 = data.onConstructorPoisoning; +let data17 = data.pluginTimeout; const _errs53 = errors; -if(typeof data17 !== "string"){ +if(!(((typeof data17 == "number") && (!(data17 % 1) && !isNaN(data17))) && (isFinite(data17)))){ let dataType19 = typeof data17; let coerced19 = undefined; if(!(coerced19 !== undefined)){ -if(dataType19 == "number" || dataType19 == "boolean"){ -coerced19 = "" + data17; -} -else if(data17 === null){ -coerced19 = ""; +if(dataType19 === "boolean" || data17 === null + || (dataType19 === "string" && data17 && data17 == +data17 && !(data17 % 1))){ +coerced19 = +data17; } else { -validate10.errors = [{instancePath:instancePath+"/onConstructorPoisoning",schemaPath:"#/properties/onConstructorPoisoning/type",keyword:"type",params:{type: "string"},message:"must be string"}]; +validate10.errors = [{instancePath:instancePath+"/pluginTimeout",schemaPath:"#/properties/pluginTimeout/type",keyword:"type",params:{type: "integer"},message:"must be integer"}]; return false; } } if(coerced19 !== undefined){ data17 = coerced19; if(data !== undefined){ -data["onConstructorPoisoning"] = coerced19; +data["pluginTimeout"] = coerced19; } } } var valid0 = _errs53 === errors; if(valid0){ -let data18 = data.pluginTimeout; +let data18 = data.requestIdHeader; const _errs55 = errors; -if(!(((typeof data18 == "number") && (!(data18 % 1) && !isNaN(data18))) && (isFinite(data18)))){ -let dataType20 = typeof data18; +const _errs56 = errors; +let valid6 = false; +const _errs57 = errors; +if(typeof data18 !== "boolean"){ let coerced20 = undefined; if(!(coerced20 !== undefined)){ -if(dataType20 === "boolean" || data18 === null - || (dataType20 === "string" && data18 && data18 == +data18 && !(data18 % 1))){ -coerced20 = +data18; +if(data18 === "false" || data18 === 0 || data18 === null){ +coerced20 = false; +} +else if(data18 === "true" || data18 === 1){ +coerced20 = true; } else { -validate10.errors = [{instancePath:instancePath+"/pluginTimeout",schemaPath:"#/properties/pluginTimeout/type",keyword:"type",params:{type: "integer"},message:"must be integer"}]; -return false; +const err12 = {instancePath:instancePath+"/requestIdHeader",schemaPath:"#/properties/requestIdHeader/anyOf/0/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}; +if(vErrors === null){ +vErrors = [err12]; +} +else { +vErrors.push(err12); +} +errors++; } } if(coerced20 !== undefined){ data18 = coerced20; if(data !== undefined){ -data["pluginTimeout"] = coerced20; +data["requestIdHeader"] = coerced20; } } } -var valid0 = _errs55 === errors; -if(valid0){ -let data19 = data.requestIdHeader; -const _errs57 = errors; -if(typeof data19 !== "string"){ -let dataType21 = typeof data19; +var _valid3 = _errs57 === errors; +valid6 = valid6 || _valid3; +if(!valid6){ +const _errs59 = errors; +if(typeof data18 !== "string"){ +let dataType21 = typeof data18; let coerced21 = undefined; if(!(coerced21 !== undefined)){ if(dataType21 == "number" || dataType21 == "boolean"){ -coerced21 = "" + data19; +coerced21 = "" + data18; } -else if(data19 === null){ +else if(data18 === null){ coerced21 = ""; } else { -validate10.errors = [{instancePath:instancePath+"/requestIdHeader",schemaPath:"#/properties/requestIdHeader/type",keyword:"type",params:{type: "string"},message:"must be string"}]; -return false; +const err13 = {instancePath:instancePath+"/requestIdHeader",schemaPath:"#/properties/requestIdHeader/anyOf/1/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err13]; +} +else { +vErrors.push(err13); +} +errors++; } } if(coerced21 !== undefined){ -data19 = coerced21; +data18 = coerced21; if(data !== undefined){ data["requestIdHeader"] = coerced21; } } } -var valid0 = _errs57 === errors; +var _valid3 = _errs59 === errors; +valid6 = valid6 || _valid3; +} +if(!valid6){ +const err14 = {instancePath:instancePath+"/requestIdHeader",schemaPath:"#/properties/requestIdHeader/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"}; +if(vErrors === null){ +vErrors = [err14]; +} +else { +vErrors.push(err14); +} +errors++; +validate10.errors = vErrors; +return false; +} +else { +errors = _errs56; +if(vErrors !== null){ +if(_errs56){ +vErrors.length = _errs56; +} +else { +vErrors = null; +} +} +} +var valid0 = _errs55 === errors; if(valid0){ -let data20 = data.requestIdLogLabel; -const _errs59 = errors; -if(typeof data20 !== "string"){ -let dataType22 = typeof data20; +let data19 = data.requestIdLogLabel; +const _errs61 = errors; +if(typeof data19 !== "string"){ +let dataType22 = typeof data19; let coerced22 = undefined; if(!(coerced22 !== undefined)){ if(dataType22 == "number" || dataType22 == "boolean"){ -coerced22 = "" + data20; +coerced22 = "" + data19; } -else if(data20 === null){ +else if(data19 === null){ coerced22 = ""; } else { @@ -879,23 +922,23 @@ return false; } } if(coerced22 !== undefined){ -data20 = coerced22; +data19 = coerced22; if(data !== undefined){ data["requestIdLogLabel"] = coerced22; } } } -var valid0 = _errs59 === errors; +var valid0 = _errs61 === errors; if(valid0){ -let data21 = data.http2SessionTimeout; -const _errs61 = errors; -if(!(((typeof data21 == "number") && (!(data21 % 1) && !isNaN(data21))) && (isFinite(data21)))){ -let dataType23 = typeof data21; +let data20 = data.http2SessionTimeout; +const _errs63 = errors; +if(!(((typeof data20 == "number") && (!(data20 % 1) && !isNaN(data20))) && (isFinite(data20)))){ +let dataType23 = typeof data20; let coerced23 = undefined; if(!(coerced23 !== undefined)){ -if(dataType23 === "boolean" || data21 === null - || (dataType23 === "string" && data21 && data21 == +data21 && !(data21 % 1))){ -coerced23 = +data21; +if(dataType23 === "boolean" || data20 === null + || (dataType23 === "string" && data20 && data20 == +data20 && !(data20 % 1))){ +coerced23 = +data20; } else { validate10.errors = [{instancePath:instancePath+"/http2SessionTimeout",schemaPath:"#/properties/http2SessionTimeout/type",keyword:"type",params:{type: "integer"},message:"must be integer"}]; @@ -903,23 +946,23 @@ return false; } } if(coerced23 !== undefined){ -data21 = coerced23; +data20 = coerced23; if(data !== undefined){ data["http2SessionTimeout"] = coerced23; } } } -var valid0 = _errs61 === errors; +var valid0 = _errs63 === errors; if(valid0){ -let data22 = data.exposeHeadRoutes; -const _errs63 = errors; -if(typeof data22 !== "boolean"){ +let data21 = data.exposeHeadRoutes; +const _errs65 = errors; +if(typeof data21 !== "boolean"){ let coerced24 = undefined; if(!(coerced24 !== undefined)){ -if(data22 === "false" || data22 === 0 || data22 === null){ +if(data21 === "false" || data21 === 0 || data21 === null){ coerced24 = false; } -else if(data22 === "true" || data22 === 1){ +else if(data21 === "true" || data21 === 1){ coerced24 = true; } else { @@ -928,73 +971,235 @@ return false; } } if(coerced24 !== undefined){ -data22 = coerced24; +data21 = coerced24; if(data !== undefined){ data["exposeHeadRoutes"] = coerced24; } } } -var valid0 = _errs63 === errors; +var valid0 = _errs65 === errors; if(valid0){ -if(data.versioning !== undefined){ -let data23 = data.versioning; -const _errs65 = errors; -if(errors === _errs65){ +let data22 = data.useSemicolonDelimiter; +const _errs67 = errors; +if(typeof data22 !== "boolean"){ +let coerced25 = undefined; +if(!(coerced25 !== undefined)){ +if(data22 === "false" || data22 === 0 || data22 === null){ +coerced25 = false; +} +else if(data22 === "true" || data22 === 1){ +coerced25 = true; +} +else { +validate10.errors = [{instancePath:instancePath+"/useSemicolonDelimiter",schemaPath:"#/properties/useSemicolonDelimiter/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}]; +return false; +} +} +if(coerced25 !== undefined){ +data22 = coerced25; +if(data !== undefined){ +data["useSemicolonDelimiter"] = coerced25; +} +} +} +var valid0 = _errs67 === errors; +if(valid0){ +if(data.routerOptions !== undefined){ +let data23 = data.routerOptions; +const _errs69 = errors; +if(errors === _errs69){ if(data23 && typeof data23 == "object" && !Array.isArray(data23)){ -let missing1; -if(((data23.storage === undefined) && (missing1 = "storage")) || ((data23.deriveVersion === undefined) && (missing1 = "deriveVersion"))){ -validate10.errors = [{instancePath:instancePath+"/versioning",schemaPath:"#/properties/versioning/required",keyword:"required",params:{missingProperty: missing1},message:"must have required property '"+missing1+"'"}]; +if(data23.ignoreTrailingSlash === undefined){ +data23.ignoreTrailingSlash = false; +} +if(data23.ignoreDuplicateSlashes === undefined){ +data23.ignoreDuplicateSlashes = false; +} +if(data23.maxParamLength === undefined){ +data23.maxParamLength = 100; +} +if(data23.allowUnsafeRegex === undefined){ +data23.allowUnsafeRegex = false; +} +if(data23.useSemicolonDelimiter === undefined){ +data23.useSemicolonDelimiter = false; +} +let data24 = data23.ignoreTrailingSlash; +const _errs72 = errors; +if(typeof data24 !== "boolean"){ +let coerced26 = undefined; +if(!(coerced26 !== undefined)){ +if(data24 === "false" || data24 === 0 || data24 === null){ +coerced26 = false; +} +else if(data24 === "true" || data24 === 1){ +coerced26 = true; +} +else { +validate10.errors = [{instancePath:instancePath+"/routerOptions/ignoreTrailingSlash",schemaPath:"#/properties/routerOptions/properties/ignoreTrailingSlash/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}]; return false; } } +if(coerced26 !== undefined){ +data24 = coerced26; +if(data23 !== undefined){ +data23["ignoreTrailingSlash"] = coerced26; +} +} +} +var valid7 = _errs72 === errors; +if(valid7){ +let data25 = data23.ignoreDuplicateSlashes; +const _errs74 = errors; +if(typeof data25 !== "boolean"){ +let coerced27 = undefined; +if(!(coerced27 !== undefined)){ +if(data25 === "false" || data25 === 0 || data25 === null){ +coerced27 = false; +} +else if(data25 === "true" || data25 === 1){ +coerced27 = true; +} else { -validate10.errors = [{instancePath:instancePath+"/versioning",schemaPath:"#/properties/versioning/type",keyword:"type",params:{type: "object"},message:"must be object"}]; +validate10.errors = [{instancePath:instancePath+"/routerOptions/ignoreDuplicateSlashes",schemaPath:"#/properties/routerOptions/properties/ignoreDuplicateSlashes/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}]; return false; } } -var valid0 = _errs65 === errors; +if(coerced27 !== undefined){ +data25 = coerced27; +if(data23 !== undefined){ +data23["ignoreDuplicateSlashes"] = coerced27; +} +} +} +var valid7 = _errs74 === errors; +if(valid7){ +let data26 = data23.maxParamLength; +const _errs76 = errors; +if(!(((typeof data26 == "number") && (!(data26 % 1) && !isNaN(data26))) && (isFinite(data26)))){ +let dataType28 = typeof data26; +let coerced28 = undefined; +if(!(coerced28 !== undefined)){ +if(dataType28 === "boolean" || data26 === null + || (dataType28 === "string" && data26 && data26 == +data26 && !(data26 % 1))){ +coerced28 = +data26; +} +else { +validate10.errors = [{instancePath:instancePath+"/routerOptions/maxParamLength",schemaPath:"#/properties/routerOptions/properties/maxParamLength/type",keyword:"type",params:{type: "integer"},message:"must be integer"}]; +return false; +} +} +if(coerced28 !== undefined){ +data26 = coerced28; +if(data23 !== undefined){ +data23["maxParamLength"] = coerced28; +} +} +} +var valid7 = _errs76 === errors; +if(valid7){ +let data27 = data23.allowUnsafeRegex; +const _errs78 = errors; +if(typeof data27 !== "boolean"){ +let coerced29 = undefined; +if(!(coerced29 !== undefined)){ +if(data27 === "false" || data27 === 0 || data27 === null){ +coerced29 = false; +} +else if(data27 === "true" || data27 === 1){ +coerced29 = true; +} +else { +validate10.errors = [{instancePath:instancePath+"/routerOptions/allowUnsafeRegex",schemaPath:"#/properties/routerOptions/properties/allowUnsafeRegex/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}]; +return false; +} +} +if(coerced29 !== undefined){ +data27 = coerced29; +if(data23 !== undefined){ +data23["allowUnsafeRegex"] = coerced29; +} +} +} +var valid7 = _errs78 === errors; +if(valid7){ +let data28 = data23.useSemicolonDelimiter; +const _errs80 = errors; +if(typeof data28 !== "boolean"){ +let coerced30 = undefined; +if(!(coerced30 !== undefined)){ +if(data28 === "false" || data28 === 0 || data28 === null){ +coerced30 = false; +} +else if(data28 === "true" || data28 === 1){ +coerced30 = true; +} +else { +validate10.errors = [{instancePath:instancePath+"/routerOptions/useSemicolonDelimiter",schemaPath:"#/properties/routerOptions/properties/useSemicolonDelimiter/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}]; +return false; +} +} +if(coerced30 !== undefined){ +data28 = coerced30; +if(data23 !== undefined){ +data23["useSemicolonDelimiter"] = coerced30; +} +} +} +var valid7 = _errs80 === errors; +} +} +} +} +} +else { +validate10.errors = [{instancePath:instancePath+"/routerOptions",schemaPath:"#/properties/routerOptions/type",keyword:"type",params:{type: "object"},message:"must be object"}]; +return false; +} +} +var valid0 = _errs69 === errors; } else { var valid0 = true; } if(valid0){ if(data.constraints !== undefined){ -let data24 = data.constraints; -const _errs68 = errors; -if(errors === _errs68){ -if(data24 && typeof data24 == "object" && !Array.isArray(data24)){ -for(const key2 in data24){ -let data25 = data24[key2]; -const _errs71 = errors; -if(errors === _errs71){ -if(data25 && typeof data25 == "object" && !Array.isArray(data25)){ -let missing2; -if(((((data25.name === undefined) && (missing2 = "name")) || ((data25.storage === undefined) && (missing2 = "storage"))) || ((data25.validate === undefined) && (missing2 = "validate"))) || ((data25.deriveConstraint === undefined) && (missing2 = "deriveConstraint"))){ -validate10.errors = [{instancePath:instancePath+"/constraints/" + key2.replace(/~/g, "~0").replace(/\//g, "~1"),schemaPath:"#/properties/constraints/additionalProperties/required",keyword:"required",params:{missingProperty: missing2},message:"must have required property '"+missing2+"'"}]; +let data29 = data.constraints; +const _errs82 = errors; +if(errors === _errs82){ +if(data29 && typeof data29 == "object" && !Array.isArray(data29)){ +for(const key2 in data29){ +let data30 = data29[key2]; +const _errs85 = errors; +if(errors === _errs85){ +if(data30 && typeof data30 == "object" && !Array.isArray(data30)){ +let missing1; +if(((((data30.name === undefined) && (missing1 = "name")) || ((data30.storage === undefined) && (missing1 = "storage"))) || ((data30.validate === undefined) && (missing1 = "validate"))) || ((data30.deriveConstraint === undefined) && (missing1 = "deriveConstraint"))){ +validate10.errors = [{instancePath:instancePath+"/constraints/" + key2.replace(/~/g, "~0").replace(/\//g, "~1"),schemaPath:"#/properties/constraints/additionalProperties/required",keyword:"required",params:{missingProperty: missing1},message:"must have required property '"+missing1+"'"}]; return false; } else { -if(data25.name !== undefined){ -let data26 = data25.name; -if(typeof data26 !== "string"){ -let dataType25 = typeof data26; -let coerced25 = undefined; -if(!(coerced25 !== undefined)){ -if(dataType25 == "number" || dataType25 == "boolean"){ -coerced25 = "" + data26; -} -else if(data26 === null){ -coerced25 = ""; +if(data30.name !== undefined){ +let data31 = data30.name; +if(typeof data31 !== "string"){ +let dataType31 = typeof data31; +let coerced31 = undefined; +if(!(coerced31 !== undefined)){ +if(dataType31 == "number" || dataType31 == "boolean"){ +coerced31 = "" + data31; +} +else if(data31 === null){ +coerced31 = ""; } else { validate10.errors = [{instancePath:instancePath+"/constraints/" + key2.replace(/~/g, "~0").replace(/\//g, "~1")+"/name",schemaPath:"#/properties/constraints/additionalProperties/properties/name/type",keyword:"type",params:{type: "string"},message:"must be string"}]; return false; } } -if(coerced25 !== undefined){ -data26 = coerced25; -if(data25 !== undefined){ -data25["name"] = coerced25; +if(coerced31 !== undefined){ +data31 = coerced31; +if(data30 !== undefined){ +data30["name"] = coerced31; } } } @@ -1006,8 +1211,8 @@ validate10.errors = [{instancePath:instancePath+"/constraints/" + key2.replace(/ return false; } } -var valid6 = _errs71 === errors; -if(!valid6){ +var valid8 = _errs85 === errors; +if(!valid8){ break; } } @@ -1017,7 +1222,7 @@ validate10.errors = [{instancePath:instancePath+"/constraints",schemaPath:"#/pro return false; } } -var valid0 = _errs68 === errors; +var valid0 = _errs82 === errors; } else { var valid0 = true; @@ -1057,4 +1262,5 @@ return errors === 0; } -module.exports.defaultInitOptions = {"connectionTimeout":0,"keepAliveTimeout":72000,"maxRequestsPerSocket":0,"requestTimeout":0,"bodyLimit":1048576,"caseSensitive":true,"allowUnsafeRegex":false,"disableRequestLogging":false,"jsonShorthand":true,"ignoreTrailingSlash":false,"ignoreDuplicateSlashes":false,"maxParamLength":100,"onProtoPoisoning":"error","onConstructorPoisoning":"error","pluginTimeout":10000,"requestIdHeader":"request-id","requestIdLogLabel":"reqId","http2SessionTimeout":72000,"exposeHeadRoutes":true} +module.exports.defaultInitOptions = {"connectionTimeout":0,"keepAliveTimeout":72000,"maxRequestsPerSocket":0,"requestTimeout":0,"handlerTimeout":0,"bodyLimit":1048576,"caseSensitive":true,"allowUnsafeRegex":false,"disableRequestLogging":false,"ignoreTrailingSlash":false,"ignoreDuplicateSlashes":false,"maxParamLength":100,"onProtoPoisoning":"error","onConstructorPoisoning":"error","pluginTimeout":10000,"requestIdHeader":false,"requestIdLogLabel":"reqId","http2SessionTimeout":72000,"exposeHeadRoutes":true,"useSemicolonDelimiter":false,"allowErrorHandlerOverride":true,"routerOptions":{"ignoreTrailingSlash":false,"ignoreDuplicateSlashes":false,"maxParamLength":100,"allowUnsafeRegex":false,"useSemicolonDelimiter":false}} +/* c8 ignore stop */ diff --git a/lib/content-type-parser.js b/lib/content-type-parser.js new file mode 100644 index 00000000000..6bb3260ce79 --- /dev/null +++ b/lib/content-type-parser.js @@ -0,0 +1,413 @@ +'use strict' + +const { AsyncResource } = require('node:async_hooks') +const { FifoMap: Fifo } = require('toad-cache') +const { parse: secureJsonParse } = require('secure-json-parse') +const ContentType = require('./content-type') +const { + kDefaultJsonParse, + kContentTypeParser, + kBodyLimit, + kRequestPayloadStream, + kState, + kTestInternals, + kReplyIsError, + kRouteContext +} = require('./symbols') + +const { + FST_ERR_CTP_INVALID_TYPE, + FST_ERR_CTP_EMPTY_TYPE, + FST_ERR_CTP_ALREADY_PRESENT, + FST_ERR_CTP_INVALID_HANDLER, + FST_ERR_CTP_INVALID_PARSE_TYPE, + FST_ERR_CTP_BODY_TOO_LARGE, + FST_ERR_CTP_INVALID_MEDIA_TYPE, + FST_ERR_CTP_INVALID_CONTENT_LENGTH, + FST_ERR_CTP_EMPTY_JSON_BODY, + FST_ERR_CTP_INSTANCE_ALREADY_STARTED, + FST_ERR_CTP_INVALID_JSON_BODY +} = require('./errors') +const { FSTSEC001 } = require('./warnings') + +function ContentTypeParser (bodyLimit, onProtoPoisoning, onConstructorPoisoning) { + this[kDefaultJsonParse] = getDefaultJsonParser(onProtoPoisoning, onConstructorPoisoning) + // using a map instead of a plain object to avoid prototype hijack attacks + this.customParsers = new Map() + this.customParsers.set('application/json', new Parser(true, false, bodyLimit, this[kDefaultJsonParse])) + this.customParsers.set('text/plain', new Parser(true, false, bodyLimit, defaultPlainTextParser)) + this.parserList = ['application/json', 'text/plain'] + this.parserRegExpList = [] + this.cache = new Fifo(100) +} + +ContentTypeParser.prototype.add = function (contentType, opts, parserFn) { + const contentTypeIsString = typeof contentType === 'string' + + if (contentTypeIsString) { + contentType = contentType.trim().toLowerCase() + if (contentType.length === 0) throw new FST_ERR_CTP_EMPTY_TYPE() + } else if (!(contentType instanceof RegExp)) { + throw new FST_ERR_CTP_INVALID_TYPE() + } + + if (typeof parserFn !== 'function') { + throw new FST_ERR_CTP_INVALID_HANDLER() + } + + if (this.existingParser(contentType)) { + throw new FST_ERR_CTP_ALREADY_PRESENT(contentType) + } + + if (opts.parseAs !== undefined) { + if (opts.parseAs !== 'string' && opts.parseAs !== 'buffer') { + throw new FST_ERR_CTP_INVALID_PARSE_TYPE(opts.parseAs) + } + } + + const parser = new Parser( + opts.parseAs === 'string', + opts.parseAs === 'buffer', + opts.bodyLimit, + parserFn + ) + + if (contentType === '*') { + this.customParsers.set('', parser) + } else { + if (contentTypeIsString) { + const ct = new ContentType(contentType) + if (ct.isValid === false) { + throw new FST_ERR_CTP_INVALID_TYPE() + } + const normalizedContentType = ct.toString() + this.parserList.unshift(normalizedContentType) + this.customParsers.set(normalizedContentType, parser) + } else { + validateRegExp(contentType) + this.parserRegExpList.unshift(contentType) + this.customParsers.set(contentType.toString(), parser) + } + } +} + +ContentTypeParser.prototype.hasParser = function (contentType) { + if (typeof contentType === 'string') { + contentType = new ContentType(contentType).toString() + } else { + if (!(contentType instanceof RegExp)) throw new FST_ERR_CTP_INVALID_TYPE() + contentType = contentType.toString() + } + + return this.customParsers.has(contentType) +} + +ContentTypeParser.prototype.existingParser = function (contentType) { + if (typeof contentType === 'string') { + const ct = new ContentType(contentType).toString() + if (contentType === 'application/json' && this.customParsers.has(contentType)) { + return this.customParsers.get(ct).fn !== this[kDefaultJsonParse] + } + if (contentType === 'text/plain' && this.customParsers.has(contentType)) { + return this.customParsers.get(ct).fn !== defaultPlainTextParser + } + } + + return this.hasParser(contentType) +} + +ContentTypeParser.prototype.getParser = function (contentType) { + if (typeof contentType === 'string') { + contentType = new ContentType(contentType) + } + const ct = contentType.toString() + + let parser = this.cache.get(ct) + if (parser !== undefined) return parser + parser = this.customParsers.get(ct) + if (parser !== undefined) { + this.cache.set(ct, parser) + return parser + } + + // We have conflicting desires across our test suite. In some cases, we + // expect to get a parser by just passing the media-type. In others, we expect + // to get a parser registered under the media-type while also providing + // parameters. And in yet others, we expect to register a parser under the + // media-type and have it apply to any request with a header that starts + // with that type. + parser = this.customParsers.get(contentType.mediaType) + if (parser !== undefined) { + return parser + } + + for (let j = 0; j !== this.parserRegExpList.length; ++j) { + const parserRegExp = this.parserRegExpList[j] + if (parserRegExp.test(ct)) { + parser = this.customParsers.get(parserRegExp.toString()) + this.cache.set(ct, parser) + return parser + } + } + + return this.customParsers.get('') +} + +ContentTypeParser.prototype.removeAll = function () { + this.customParsers = new Map() + this.parserRegExpList = [] + this.parserList = [] + this.cache = new Fifo(100) +} + +ContentTypeParser.prototype.remove = function (contentType) { + let parsers + + if (typeof contentType === 'string') { + contentType = new ContentType(contentType).toString() + parsers = this.parserList + } else { + if (!(contentType instanceof RegExp)) throw new FST_ERR_CTP_INVALID_TYPE() + contentType = contentType.toString() + parsers = this.parserRegExpList + } + + const removed = this.customParsers.delete(contentType) + const idx = parsers.findIndex(ct => ct.toString() === contentType) + + if (idx > -1) { + parsers.splice(idx, 1) + } + + return removed || idx > -1 +} + +ContentTypeParser.prototype.run = function (contentType, handler, request, reply) { + const parser = this.getParser(contentType) + + if (parser === undefined) { + if (request.is404 === true) { + handler(request, reply) + return + } + + reply[kReplyIsError] = true + reply.send(new FST_ERR_CTP_INVALID_MEDIA_TYPE()) + return + } + + const resource = new AsyncResource('content-type-parser:run', request) + const done = resource.bind(onDone) + + if (parser.asString === true || parser.asBuffer === true) { + rawBody( + request, + reply, + reply[kRouteContext]._parserOptions, + parser, + done + ) + return + } + + const result = parser.fn(request, request[kRequestPayloadStream], done) + if (result && typeof result.then === 'function') { + result.then(body => { done(null, body) }, done) + } + + function onDone (error, body) { + resource.emitDestroy() + if (error != null) { + // We must close the connection as the client may + // send more data + reply.header('connection', 'close') + reply[kReplyIsError] = true + reply.send(error) + return + } + request.body = body + handler(request, reply) + } +} + +function rawBody (request, reply, options, parser, done) { + const asString = parser.asString === true + const limit = options.limit === null ? parser.bodyLimit : options.limit + const contentLength = Number(request.headers['content-length']) + + if (contentLength > limit) { + done(new FST_ERR_CTP_BODY_TOO_LARGE(), undefined) + return + } + + let receivedLength = 0 + let body = asString ? '' : [] + const payload = request[kRequestPayloadStream] || request.raw + + if (asString) { + payload.setEncoding('utf8') + } + + payload.on('data', onData) + payload.on('end', onEnd) + payload.on('error', onEnd) + payload.resume() + + function onData (chunk) { + receivedLength += asString ? Buffer.byteLength(chunk) : chunk.length + const { receivedEncodedLength = 0 } = payload + // The resulting body length must not exceed bodyLimit (see "zip bomb"). + // The case when encoded length is larger than received length is rather theoretical, + // unless the stream returned by preParsing hook is broken and reports wrong value. + if (receivedLength > limit || receivedEncodedLength > limit) { + payload.removeListener('data', onData) + payload.removeListener('end', onEnd) + payload.removeListener('error', onEnd) + done(new FST_ERR_CTP_BODY_TOO_LARGE(), undefined) + return + } + + if (asString) { + body += chunk + } else { + body.push(chunk) + } + } + + function onEnd (err) { + payload.removeListener('data', onData) + payload.removeListener('end', onEnd) + payload.removeListener('error', onEnd) + + if (err != null) { + if (!(typeof err.statusCode === 'number' && err.statusCode >= 400)) { + err.statusCode = 400 + } + done(err, undefined) + return + } + + if (!Number.isNaN(contentLength) && (payload.receivedEncodedLength || receivedLength) !== contentLength) { + done(new FST_ERR_CTP_INVALID_CONTENT_LENGTH(), undefined) + return + } + + if (!asString) { + body = Buffer.concat(body) + } + + const result = parser.fn(request, body, done) + if (result && typeof result.then === 'function') { + result.then(body => { done(null, body) }, done) + } + } +} + +function getDefaultJsonParser (onProtoPoisoning, onConstructorPoisoning) { + const parseOptions = { protoAction: onProtoPoisoning, constructorAction: onConstructorPoisoning } + + return defaultJsonParser + + function defaultJsonParser (req, body, done) { + if (body.length === 0) { + done(new FST_ERR_CTP_EMPTY_JSON_BODY(), undefined) + return + } + try { + done(null, secureJsonParse(body, parseOptions)) + } catch { + done(new FST_ERR_CTP_INVALID_JSON_BODY(), undefined) + } + } +} + +function defaultPlainTextParser (req, body, done) { + done(null, body) +} + +function Parser (asString, asBuffer, bodyLimit, fn) { + this.asString = asString + this.asBuffer = asBuffer + this.bodyLimit = bodyLimit + this.fn = fn +} + +function buildContentTypeParser (c) { + const contentTypeParser = new ContentTypeParser() + contentTypeParser[kDefaultJsonParse] = c[kDefaultJsonParse] + contentTypeParser.customParsers = new Map(c.customParsers.entries()) + contentTypeParser.parserList = c.parserList.slice() + contentTypeParser.parserRegExpList = c.parserRegExpList.slice() + return contentTypeParser +} + +function addContentTypeParser (contentType, opts, parser) { + if (this[kState].started) { + throw new FST_ERR_CTP_INSTANCE_ALREADY_STARTED('addContentTypeParser') + } + + if (typeof opts === 'function') { + parser = opts + opts = {} + } + + if (!opts) opts = {} + if (!opts.bodyLimit) opts.bodyLimit = this[kBodyLimit] + + if (Array.isArray(contentType)) { + contentType.forEach((type) => this[kContentTypeParser].add(type, opts, parser)) + } else { + this[kContentTypeParser].add(contentType, opts, parser) + } + + return this +} + +function hasContentTypeParser (contentType) { + return this[kContentTypeParser].hasParser(contentType) +} + +function removeContentTypeParser (contentType) { + if (this[kState].started) { + throw new FST_ERR_CTP_INSTANCE_ALREADY_STARTED('removeContentTypeParser') + } + + if (Array.isArray(contentType)) { + for (const type of contentType) { + this[kContentTypeParser].remove(type) + } + } else { + this[kContentTypeParser].remove(contentType) + } +} + +function removeAllContentTypeParsers () { + if (this[kState].started) { + throw new FST_ERR_CTP_INSTANCE_ALREADY_STARTED('removeAllContentTypeParsers') + } + + this[kContentTypeParser].removeAll() +} + +function validateRegExp (regexp) { + // RegExp should either start with ^ or include ;? + // It can ensure the user is properly detect the essence + // MIME types. + if (regexp.source[0] !== '^' && regexp.source.includes(';?') === false) { + FSTSEC001(regexp.source) + } +} + +module.exports = ContentTypeParser +module.exports.helpers = { + buildContentTypeParser, + addContentTypeParser, + hasContentTypeParser, + removeContentTypeParser, + removeAllContentTypeParsers +} +module.exports.defaultParsers = { + getDefaultJsonParser, + defaultTextParser: defaultPlainTextParser +} +module.exports[kTestInternals] = { rawBody } diff --git a/lib/content-type.js b/lib/content-type.js new file mode 100644 index 00000000000..1e3e51562d2 --- /dev/null +++ b/lib/content-type.js @@ -0,0 +1,160 @@ +'use strict' + +/** + * keyValuePairsReg is used to split the parameters list into associated + * key value pairings. The leading `(?:^|;)\s*` anchor ensures the regex + * only attempts matches at parameter boundaries, preventing quadratic + * backtracking on malformed input. + * + * @see https://httpwg.org/specs/rfc9110.html#parameter + * @type {RegExp} + */ +const keyValuePairsReg = /(?:^|;)\s*([\w!#$%&'*+.^`|~-]+)=([^;]*)/gm + +/** + * typeNameReg is used to validate that the first part of the media-type + * does not use disallowed characters. Types must consist solely of + * characters that match the specified character class. It must terminate + * with a matching character. + * + * @see https://httpwg.org/specs/rfc9110.html#rule.token.separators + * @type {RegExp} + */ +const typeNameReg = /^[\w!#$%&'*+.^`|~-]+$/ + +/** + * subtypeNameReg is used to validate that the second part of the media-type + * does not use disallowed characters. Subtypes must consist solely of + * characters that match the specified character class, and optionally + * terminated with any amount of whitespace characters. Without the terminating + * anchor (`$`), the regular expression will match the leading portion of a + * string instead of the whole string. + * + * @see https://httpwg.org/specs/rfc9110.html#rule.token.separators + * @type {RegExp} + */ +const subtypeNameReg = /^[\w!#$%&'*+.^`|~-]+\s*$/ + +/** + * ContentType parses and represents the value of the content-type header. + * + * @see https://httpwg.org/specs/rfc9110.html#media.type + * @see https://httpwg.org/specs/rfc9110.html#parameter + */ +class ContentType { + #valid = false + #empty = true + #type = '' + #subtype = '' + #parameters = new Map() + #string + + constructor (headerValue) { + if (headerValue == null || headerValue === '' || headerValue === 'undefined') { + return + } + + let sepIdx = headerValue.indexOf(';') + if (sepIdx === -1) { + // The value is the simplest `type/subtype` variant. + sepIdx = headerValue.indexOf('/') + if (sepIdx === -1) { + // Got a string without the correct `type/subtype` format. + return + } + + const type = headerValue.slice(0, sepIdx).trimStart().toLowerCase() + const subtype = headerValue.slice(sepIdx + 1).trimEnd().toLowerCase() + + if ( + typeNameReg.test(type) === true && + subtypeNameReg.test(subtype) === true + ) { + this.#valid = true + this.#empty = false + this.#type = type + this.#subtype = subtype + } + + return + } + + // We have a `type/subtype; params=list...` header value. + const mediaType = headerValue.slice(0, sepIdx).toLowerCase() + const paramsList = headerValue.slice(sepIdx + 1).trim() + + sepIdx = mediaType.indexOf('/') + if (sepIdx === -1) { + // We got an invalid string like `something; params=list...`. + return + } + const type = mediaType.slice(0, sepIdx).trimStart() + const subtype = mediaType.slice(sepIdx + 1).trimEnd() + + if ( + typeNameReg.test(type) === false || + subtypeNameReg.test(subtype) === false + ) { + // Some portion of the media-type is using invalid characters. Therefore, + // the content-type header is invalid. + return + } + this.#type = type + this.#subtype = subtype + this.#valid = true + this.#empty = false + + let matches = keyValuePairsReg.exec(paramsList) + while (matches) { + const key = matches[1] + const value = matches[2] + if (value[0] === '"') { + if (value.at(-1) !== '"') { + this.#parameters.set(key, 'invalid quoted string') + matches = keyValuePairsReg.exec(paramsList) + continue + } + // We should probably verify the value matches a quoted string + // (https://httpwg.org/specs/rfc9110.html#rule.quoted-string) value. + // But we are not really doing much with the parameter values, so we + // are omitting that at this time. + this.#parameters.set(key, value.slice(1, value.length - 1)) + } else { + this.#parameters.set(key, value) + } + matches = keyValuePairsReg.exec(paramsList) + } + } + + get [Symbol.toStringTag] () { return 'ContentType' } + + get isEmpty () { return this.#empty } + + get isValid () { return this.#valid } + + get mediaType () { return `${this.#type}/${this.#subtype}` } + + get type () { return this.#type } + + get subtype () { return this.#subtype } + + get parameters () { return this.#parameters } + + toString () { + /* c8 ignore next: we don't need to verify the cache */ + if (this.#string) return this.#string + const parameters = [] + for (const [key, value] of this.#parameters.entries()) { + parameters.push(`${key}="${value}"`) + } + const result = [this.#type, '/', this.#subtype] + if (parameters.length > 0) { + result.push('; ') + result.push(parameters.join('; ')) + } + this.#string = result.join('') + return this.#string + } +} + +module.exports = ContentType diff --git a/lib/contentTypeParser.js b/lib/contentTypeParser.js deleted file mode 100644 index be9c829ae1c..00000000000 --- a/lib/contentTypeParser.js +++ /dev/null @@ -1,348 +0,0 @@ -'use strict' - -const { AsyncResource } = require('async_hooks') -let lru = require('tiny-lru') -// Needed to handle Webpack and faux modules -// See https://github.com/fastify/fastify/issues/2356 -// and https://github.com/fastify/fastify/discussions/2907. -lru = typeof lru === 'function' ? lru : lru.default - -const secureJson = require('secure-json-parse') -const { - kDefaultJsonParse, - kContentTypeParser, - kBodyLimit, - kRequestPayloadStream, - kState, - kTestInternals, - kReplyIsError -} = require('./symbols') - -const { - FST_ERR_CTP_INVALID_TYPE, - FST_ERR_CTP_EMPTY_TYPE, - FST_ERR_CTP_ALREADY_PRESENT, - FST_ERR_CTP_INVALID_HANDLER, - FST_ERR_CTP_INVALID_PARSE_TYPE, - FST_ERR_CTP_BODY_TOO_LARGE, - FST_ERR_CTP_INVALID_MEDIA_TYPE, - FST_ERR_CTP_INVALID_CONTENT_LENGTH, - FST_ERR_CTP_EMPTY_JSON_BODY -} = require('./errors') - -function ContentTypeParser (bodyLimit, onProtoPoisoning, onConstructorPoisoning) { - this[kDefaultJsonParse] = getDefaultJsonParser(onProtoPoisoning, onConstructorPoisoning) - this.customParsers = {} - this.customParsers['application/json'] = new Parser(true, false, bodyLimit, this[kDefaultJsonParse]) - this.customParsers['text/plain'] = new Parser(true, false, bodyLimit, defaultPlainTextParser) - this.parserList = ['application/json', 'text/plain'] - this.parserRegExpList = [] - this.cache = lru(100) -} - -ContentTypeParser.prototype.add = function (contentType, opts, parserFn) { - const contentTypeIsString = typeof contentType === 'string' - - if (!contentTypeIsString && !(contentType instanceof RegExp)) throw new FST_ERR_CTP_INVALID_TYPE() - if (contentTypeIsString && contentType.length === 0) throw new FST_ERR_CTP_EMPTY_TYPE() - if (typeof parserFn !== 'function') throw new FST_ERR_CTP_INVALID_HANDLER() - - if (this.existingParser(contentType)) { - throw new FST_ERR_CTP_ALREADY_PRESENT(contentType) - } - - if (opts.parseAs !== undefined) { - if (opts.parseAs !== 'string' && opts.parseAs !== 'buffer') { - throw new FST_ERR_CTP_INVALID_PARSE_TYPE(opts.parseAs) - } - } - - const parser = new Parser( - opts.parseAs === 'string', - opts.parseAs === 'buffer', - opts.bodyLimit, - parserFn - ) - - if (contentTypeIsString && contentType === '*') { - this.customParsers[''] = parser - } else { - if (contentTypeIsString) { - this.parserList.unshift(contentType) - } else { - this.parserRegExpList.unshift(contentType) - } - this.customParsers[contentType] = parser - } -} - -ContentTypeParser.prototype.hasParser = function (contentType) { - return contentType in this.customParsers -} - -ContentTypeParser.prototype.existingParser = function (contentType) { - if (contentType === 'application/json') { - return this.customParsers['application/json'] && this.customParsers['application/json'].fn !== this[kDefaultJsonParse] - } - if (contentType === 'text/plain') { - return this.customParsers['text/plain'] && this.customParsers['text/plain'].fn !== defaultPlainTextParser - } - - return contentType in this.customParsers -} - -ContentTypeParser.prototype.getParser = function (contentType) { - // eslint-disable-next-line no-var - for (var i = 0; i !== this.parserList.length; ++i) { - const parserName = this.parserList[i] - if (contentType.indexOf(parserName) > -1) { - const parser = this.customParsers[parserName] - this.cache.set(contentType, parser) - return parser - } - } - - // eslint-disable-next-line no-var - for (var j = 0; j !== this.parserRegExpList.length; ++j) { - const parserRegExp = this.parserRegExpList[j] - if (parserRegExp.test(contentType)) { - const parser = this.customParsers[parserRegExp] - this.cache.set(contentType, parser) - return parser - } - } - - return this.customParsers[''] -} - -ContentTypeParser.prototype.removeAll = function () { - this.customParsers = {} - this.parserRegExpList = [] - this.parserList = [] - this.cache = lru(100) -} - -ContentTypeParser.prototype.remove = function (contentType) { - if (!(typeof contentType === 'string' || contentType instanceof RegExp)) throw new FST_ERR_CTP_INVALID_TYPE() - - delete this.customParsers[contentType] - - const parsers = typeof contentType === 'string' ? this.parserList : this.parserRegExpList - - const idx = parsers.findIndex(ct => ct.toString() === contentType.toString()) - - if (idx > -1) { - parsers.splice(idx, 1) - } -} - -ContentTypeParser.prototype.run = function (contentType, handler, request, reply) { - const parser = this.cache.get(contentType) || this.getParser(contentType) - const resource = new AsyncResource('content-type-parser:run', request) - - if (parser === undefined) { - reply.send(new FST_ERR_CTP_INVALID_MEDIA_TYPE(contentType || undefined)) - } else if (parser.asString === true || parser.asBuffer === true) { - rawBody( - request, - reply, - reply.context._parserOptions, - parser, - done - ) - } else { - const result = parser.fn(request, request[kRequestPayloadStream], done) - - if (result && typeof result.then === 'function') { - result.then(body => done(null, body), done) - } - } - - function done (error, body) { - // We cannot use resource.bind() because it is broken in node v12 and v14 - resource.runInAsyncScope(() => { - if (error) { - reply[kReplyIsError] = true - reply.send(error) - } else { - request.body = body - handler(request, reply) - } - }) - } -} - -function rawBody (request, reply, options, parser, done) { - const asString = parser.asString - const limit = options.limit === null ? parser.bodyLimit : options.limit - const contentLength = request.headers['content-length'] === undefined - ? NaN - : Number.parseInt(request.headers['content-length'], 10) - - if (contentLength > limit) { - reply.send(new FST_ERR_CTP_BODY_TOO_LARGE()) - return - } - - let receivedLength = 0 - let body = asString === true ? '' : [] - - const payload = request[kRequestPayloadStream] || request.raw - - if (asString === true) { - payload.setEncoding('utf8') - } - - payload.on('data', onData) - payload.on('end', onEnd) - payload.on('error', onEnd) - payload.resume() - - function onData (chunk) { - receivedLength += chunk.length - - if ((payload.receivedEncodedLength || receivedLength) > limit) { - payload.removeListener('data', onData) - payload.removeListener('end', onEnd) - payload.removeListener('error', onEnd) - reply.send(new FST_ERR_CTP_BODY_TOO_LARGE()) - return - } - - if (asString === true) { - body += chunk - } else { - body.push(chunk) - } - } - - function onEnd (err) { - payload.removeListener('data', onData) - payload.removeListener('end', onEnd) - payload.removeListener('error', onEnd) - - if (err !== undefined) { - err.statusCode = 400 - reply[kReplyIsError] = true - reply.code(err.statusCode).send(err) - return - } - - if (asString === true) { - receivedLength = Buffer.byteLength(body) - } - - if (!Number.isNaN(contentLength) && (payload.receivedEncodedLength || receivedLength) !== contentLength) { - reply.send(new FST_ERR_CTP_INVALID_CONTENT_LENGTH()) - return - } - - if (asString === false) { - body = Buffer.concat(body) - } - - const result = parser.fn(request, body, done) - if (result && typeof result.then === 'function') { - result.then(body => done(null, body), done) - } - } -} - -function getDefaultJsonParser (onProtoPoisoning, onConstructorPoisoning) { - return defaultJsonParser - - function defaultJsonParser (req, body, done) { - if (body === '' || body == null) { - return done(new FST_ERR_CTP_EMPTY_JSON_BODY(), undefined) - } - let json - try { - json = secureJson.parse(body, { protoAction: onProtoPoisoning, constructorAction: onConstructorPoisoning }) - } catch (err) { - err.statusCode = 400 - return done(err, undefined) - } - done(null, json) - } -} - -function defaultPlainTextParser (req, body, done) { - done(null, body) -} - -function Parser (asString, asBuffer, bodyLimit, fn) { - this.asString = asString - this.asBuffer = asBuffer - this.bodyLimit = bodyLimit - this.fn = fn -} - -function buildContentTypeParser (c) { - const contentTypeParser = new ContentTypeParser() - contentTypeParser[kDefaultJsonParse] = c[kDefaultJsonParse] - Object.assign(contentTypeParser.customParsers, c.customParsers) - contentTypeParser.parserList = c.parserList.slice() - return contentTypeParser -} - -function addContentTypeParser (contentType, opts, parser) { - if (this[kState].started) { - throw new Error('Cannot call "addContentTypeParser" when fastify instance is already started!') - } - - if (typeof opts === 'function') { - parser = opts - opts = {} - } - - if (!opts) opts = {} - if (!opts.bodyLimit) opts.bodyLimit = this[kBodyLimit] - - if (Array.isArray(contentType)) { - contentType.forEach((type) => this[kContentTypeParser].add(type, opts, parser)) - } else { - this[kContentTypeParser].add(contentType, opts, parser) - } - - return this -} - -function hasContentTypeParser (contentType) { - return this[kContentTypeParser].hasParser(contentType) -} - -function removeContentTypeParser (contentType) { - if (this[kState].started) { - throw new Error('Cannot call "removeContentTypeParser" when fastify instance is already started!') - } - - if (Array.isArray(contentType)) { - for (const type of contentType) { - this[kContentTypeParser].remove(type) - } - } else { - this[kContentTypeParser].remove(contentType) - } -} - -function removeAllContentTypeParsers () { - if (this[kState].started) { - throw new Error('Cannot call "removeAllContentTypeParsers" when fastify instance is already started!') - } - - this[kContentTypeParser].removeAll() -} - -module.exports = ContentTypeParser -module.exports.helpers = { - buildContentTypeParser, - addContentTypeParser, - hasContentTypeParser, - removeContentTypeParser, - removeAllContentTypeParsers -} -module.exports.defaultParsers = { - getDefaultJsonParser, - defaultTextParser: defaultPlainTextParser -} -module.exports[kTestInternals] = { rawBody } diff --git a/lib/context.js b/lib/context.js index 6baf0bf2f7d..7a3a991315d 100644 --- a/lib/context.js +++ b/lib/context.js @@ -5,29 +5,41 @@ const { kReplySerializerDefault, kSchemaErrorFormatter, kErrorHandler, + kChildLoggerFactory, + kOptions, kReply, kRequest, kBodyLimit, kLogLevel, kContentTypeParser, - kRouteByFastify + kRouteByFastify, + kRequestCacheValidateFns, + kReplyCacheSerializeFns, + kHandlerTimeout } = require('./symbols.js') -// Objects that holds the context of every request +// Object that holds the context of every request // Every route holds an instance of this object. function Context ({ schema, handler, config, + requestIdLogLabel, + childLoggerFactory, errorHandler, bodyLimit, logLevel, logSerializers, attachValidation, + validatorCompiler, + serializerCompiler, replySerializer, schemaErrorFormatter, + exposeHeadRoute, + prefixTrailingSlash, server, - isFastify + isFastify, + handlerTimeout }) { this.schema = schema this.handler = handler @@ -40,20 +52,35 @@ function Context ({ this.onTimeout = null this.preHandler = null this.onResponse = null + this.preSerialization = null + this.onRequestAbort = null this.config = config this.errorHandler = errorHandler || server[kErrorHandler] + this.requestIdLogLabel = requestIdLogLabel || server[kOptions].requestIdLogLabel + this.childLoggerFactory = childLoggerFactory || server[kChildLoggerFactory] this._middie = null this._parserOptions = { limit: bodyLimit || server[kBodyLimit] } + this.exposeHeadRoute = exposeHeadRoute + this.prefixTrailingSlash = prefixTrailingSlash this.logLevel = logLevel || server[kLogLevel] this.logSerializers = logSerializers this[kFourOhFourContext] = null this.attachValidation = attachValidation this[kReplySerializerDefault] = replySerializer - this.schemaErrorFormatter = schemaErrorFormatter || server[kSchemaErrorFormatter] || defaultSchemaErrorFormatter + this.schemaErrorFormatter = + schemaErrorFormatter || + server[kSchemaErrorFormatter] || + defaultSchemaErrorFormatter this[kRouteByFastify] = isFastify + this[kRequestCacheValidateFns] = null + this[kReplyCacheSerializeFns] = null + this.validatorCompiler = validatorCompiler || null + this.serializerCompiler = serializerCompiler || null + + this.handlerTimeout = handlerTimeout || server[kHandlerTimeout] || 0 this.server = server } @@ -61,8 +88,7 @@ function defaultSchemaErrorFormatter (errors, dataVar) { let text = '' const separator = ', ' - // eslint-disable-next-line no-var - for (var i = 0; i !== errors.length; ++i) { + for (let i = 0; i !== errors.length; ++i) { const e = errors[i] text += dataVar + (e.instancePath || '') + ' ' + e.message + separator } diff --git a/lib/decorate.js b/lib/decorate.js index a688aee0c68..12073bb31c0 100644 --- a/lib/decorate.js +++ b/lib/decorate.js @@ -1,7 +1,5 @@ 'use strict' -/* eslint no-prototype-builtins: 0 */ - const { kReply, kRequest, @@ -13,13 +11,13 @@ const { FST_ERR_DEC_ALREADY_PRESENT, FST_ERR_DEC_MISSING_DEPENDENCY, FST_ERR_DEC_AFTER_START, - FST_ERR_DEC_DEPENDENCY_INVALID_TYPE + FST_ERR_DEC_REFERENCE_TYPE, + FST_ERR_DEC_DEPENDENCY_INVALID_TYPE, + FST_ERR_DEC_UNDECLARED } = require('./errors') -const warning = require('./warnings') - function decorate (instance, name, fn, dependencies) { - if (instance.hasOwnProperty(name)) { + if (Object.hasOwn(instance, name)) { throw new FST_ERR_DEC_ALREADY_PRESENT(name) } @@ -35,9 +33,21 @@ function decorate (instance, name, fn, dependencies) { } } +function getInstanceDecorator (name) { + if (!checkExistence(this, name)) { + throw new FST_ERR_DEC_UNDECLARED(name, 'instance') + } + + if (typeof this[name] === 'function') { + return this[name].bind(this) + } + + return this[name] +} + function decorateConstructor (konstructor, name, fn, dependencies) { const instance = konstructor.prototype - if (instance.hasOwnProperty(name) || hasKey(konstructor, name)) { + if (Object.hasOwn(instance, name) || hasKey(konstructor, name)) { throw new FST_ERR_DEC_ALREADY_PRESENT(name) } @@ -58,7 +68,7 @@ function decorateConstructor (konstructor, name, fn, dependencies) { function checkReferenceType (name, fn) { if (typeof fn === 'object' && fn && !(typeof fn.getter === 'function' || typeof fn.setter === 'function')) { - warning.emit('FSTDEP006', name) + throw new FST_ERR_DEC_REFERENCE_TYPE(name, typeof fn) } } @@ -102,8 +112,7 @@ function checkDependencies (instance, name, deps) { throw new FST_ERR_DEC_DEPENDENCY_INVALID_TYPE(name) } - // eslint-disable-next-line no-var - for (var i = 0; i !== deps.length; ++i) { + for (let i = 0; i !== deps.length; ++i) { if (!checkExistence(instance, deps[i])) { throw new FST_ERR_DEC_MISSING_DEPENDENCY(deps[i]) } @@ -137,5 +146,7 @@ module.exports = { existReply: checkReplyExistence, dependencies: checkDependencies, decorateReply, - decorateRequest + decorateRequest, + getInstanceDecorator, + hasKey } diff --git a/lib/error-handler.js b/lib/error-handler.js index dcc64ececc2..f9a1899ca99 100644 --- a/lib/error-handler.js +++ b/lib/error-handler.js @@ -1,13 +1,19 @@ 'use strict' -const statusCodes = require('http').STATUS_CODES -const wrapThenable = require('./wrapThenable') +const statusCodes = require('node:http').STATUS_CODES +const wrapThenable = require('./wrap-thenable.js') +const { setErrorStatusCode } = require('./error-status.js') const { - kReplyHeaders, kReplyNextErrorHandler, kReplyIsRunningOnErrorHook, kReplyHasStatusCode + kReplyHeaders, + kReplyNextErrorHandler, + kReplyIsRunningOnErrorHook, + kRouteContext, + kDisableRequestLogging } = require('./symbols.js') const { - FST_ERR_REP_INVALID_PAYLOAD_TYPE + FST_ERR_REP_INVALID_PAYLOAD_TYPE, + FST_ERR_FAILED_ERROR_SERIALIZATION } = require('./errors') const { getSchemaSerializer } = require('./schemas') @@ -24,16 +30,18 @@ const rootErrorHandler = { function handleError (reply, error, cb) { reply[kReplyIsRunningOnErrorHook] = false - const context = reply.context + const context = reply[kRouteContext] if (reply[kReplyNextErrorHandler] === false) { fallbackErrorHandler(error, reply, function (reply, payload) { try { reply.raw.writeHead(reply.raw.statusCode, reply[kReplyHeaders]) } catch (error) { - reply.log.warn( - { req: reply.request, res: reply, err: error }, - error && error.message - ) + if (!reply.log[kDisableRequestLogging]) { + reply.log.warn( + { req: reply.request, res: reply, err: error }, + error?.message + ) + } reply.raw.writeHead(reply.raw.statusCode) } reply.raw.end(payload) @@ -45,6 +53,8 @@ function handleError (reply, error, cb) { // In case the error handler throws, we set the next errorHandler so we can error again reply[kReplyNextErrorHandler] = Object.getPrototypeOf(errorHandler) + // we need to remove content-type to allow content-type guessing for serialization + delete reply[kReplyHeaders]['content-type'] delete reply[kReplyHeaders]['content-length'] const func = errorHandler.func @@ -55,32 +65,37 @@ function handleError (reply, error, cb) { return } - const result = func(error, reply.request, reply) - if (result !== undefined) { - if (result !== null && typeof result.then === 'function') { - wrapThenable(result, reply) - } else { - reply.send(result) + try { + const result = func(error, reply.request, reply) + if (result !== undefined) { + if (result !== null && typeof result.then === 'function') { + wrapThenable(result, reply) + } else { + reply.send(result) + } } + } catch (err) { + reply.send(err) } } function defaultErrorHandler (error, request, reply) { setErrorHeaders(error, reply) - if (!reply[kReplyHasStatusCode] || reply.statusCode === 200) { - const statusCode = error.statusCode || error.status - reply.code(statusCode >= 400 ? statusCode : 500) - } + setErrorStatusCode(reply, error) if (reply.statusCode < 500) { - reply.log.info( - { res: reply, err: error }, - error && error.message - ) + if (!reply.log[kDisableRequestLogging]) { + reply.log.info( + { res: reply, err: error }, + error?.message + ) + } } else { - reply.log.error( - { req: request, res: reply, err: error }, - error && error.message - ) + if (!reply.log[kDisableRequestLogging]) { + reply.log.error( + { req: request, res: reply, err: error }, + error?.message + ) + } } reply.send(error) } @@ -88,37 +103,37 @@ function defaultErrorHandler (error, request, reply) { function fallbackErrorHandler (error, reply, cb) { const res = reply.raw const statusCode = reply.statusCode + reply[kReplyHeaders]['content-type'] = reply[kReplyHeaders]['content-type'] ?? 'application/json; charset=utf-8' let payload try { - const serializerFn = getSchemaSerializer(reply.context, statusCode) - payload = (serializerFn === false) - ? serializeError({ + const serializerFn = getSchemaSerializer(reply[kRouteContext], statusCode, reply[kReplyHeaders]['content-type']) + if (serializerFn === false) { + payload = serializeError({ error: statusCodes[statusCode + ''], code: error.code, message: error.message, statusCode }) - : serializerFn(Object.create(error, { + } else { + payload = serializerFn(Object.create(error, { error: { value: statusCodes[statusCode + ''] }, message: { value: error.message }, statusCode: { value: statusCode } })) + } } catch (err) { - // error is always FST_ERR_SCH_SERIALIZATION_BUILD because this is called from route/compileSchemasForSerialization - reply.log.error({ err, statusCode: res.statusCode }, 'The serializer for the given status code failed') + if (!reply.log[kDisableRequestLogging]) { + // error is always FST_ERR_SCH_SERIALIZATION_BUILD because this is called from route/compileSchemasForSerialization + reply.log.error({ err, statusCode: res.statusCode }, 'The serializer for the given status code failed') + } reply.code(500) - payload = serializeError({ - error: statusCodes['500'], - message: err.message, - statusCode: 500 - }) + payload = serializeError(new FST_ERR_FAILED_ERROR_SERIALIZATION(err.message, error.message)) } if (typeof payload !== 'string' && !Buffer.isBuffer(payload)) { payload = serializeError(new FST_ERR_REP_INVALID_PAYLOAD_TYPE(typeof payload)) } - reply[kReplyHeaders]['content-type'] = 'application/json; charset=utf-8' reply[kReplyHeaders]['content-length'] = '' + Buffer.byteLength(payload) cb(reply, payload) diff --git a/lib/error-serializer.js b/lib/error-serializer.js index 480effc7b74..c52d8a0d2fa 100644 --- a/lib/error-serializer.js +++ b/lib/error-serializer.js @@ -1,250 +1,134 @@ // This file is autogenerated by build/build-error-serializer.js, do not edit -/* istanbul ignore file */ +/* c8 ignore start */ 'use strict' - - -class Serializer { - constructor (options = {}) { - switch (options.rounding) { - case 'floor': - this.parseInteger = Math.floor - break - case 'ceil': - this.parseInteger = Math.ceil - break - case 'round': - this.parseInteger = Math.round - break - default: - this.parseInteger = Math.trunc - break - } - } - - asAny (i) { - return JSON.stringify(i) - } - - asNull () { - return 'null' - } - - asInteger (i) { - if (typeof i === 'bigint') { - return i.toString() - } else if (Number.isInteger(i)) { - return '' + i - } else { - /* eslint no-undef: "off" */ - const integer = this.parseInteger(i) - if (Number.isNaN(integer) || !Number.isFinite(integer)) { - throw new Error(`The value "${i}" cannot be converted to an integer.`) - } else { - return '' + integer - } - } - } - - asIntegerNullable (i) { - return i === null ? 'null' : this.asInteger(i) - } - - asNumber (i) { - const num = Number(i) - if (Number.isNaN(num)) { - throw new Error(`The value "${i}" cannot be converted to a number.`) - } else if (!Number.isFinite(num)) { - return null - } else { - return '' + num - } - } - - asNumberNullable (i) { - return i === null ? 'null' : this.asNumber(i) - } - - asBoolean (bool) { - return bool && 'true' || 'false' // eslint-disable-line - } - - asBooleanNullable (bool) { - return bool === null ? 'null' : this.asBoolean(bool) - } - - asDatetime (date) { - const quotes = '"' - if (date instanceof Date) { - return quotes + date.toISOString() + quotes - } - return this.asString(date) - } - - asDatetimeNullable (date) { - return date === null ? 'null' : this.asDatetime(date) - } - - asDate (date) { - const quotes = '"' - if (date instanceof Date) { - return quotes + new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(0, 10) + quotes - } - return this.asString(date) - } + const Serializer = require('fast-json-stringify/lib/serializer') + const serializerState = {"mode":"standalone"} + const serializer = Serializer.restoreFromState(serializerState) - asDateNullable (date) { - return date === null ? 'null' : this.asDate(date) - } + const validator = null - asTime (date) { - const quotes = '"' - if (date instanceof Date) { - return quotes + new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(11, 19) + quotes - } - return this.asString(date) - } - - asTimeNullable (date) { - return date === null ? 'null' : this.asTime(date) - } - asString (str) { - const quotes = '"' - if (str instanceof Date) { - return quotes + str.toISOString() + quotes - } else if (str === null) { - return quotes + quotes - } else if (str instanceof RegExp) { - str = str.source - } else if (typeof str !== 'string') { - str = str.toString() - } - - if (str.length < 42) { - return this.asStringSmall(str) - } else { - return JSON.stringify(str) - } - } - - asStringNullable (str) { - return str === null ? 'null' : this.asString(str) - } - - // magically escape strings for json - // relying on their charCodeAt - // everything below 32 needs JSON.stringify() - // every string that contain surrogate needs JSON.stringify() - // 34 and 92 happens all the time, so we - // have a fast case for them - asStringSmall (str) { - const l = str.length - let result = '' - let last = 0 - let found = false - let surrogateFound = false - let point = 255 - // eslint-disable-next-line - for (var i = 0; i < l && point >= 32; i++) { - point = str.charCodeAt(i) - if (point >= 0xD800 && point <= 0xDFFF) { - // The current character is a surrogate. - surrogateFound = true - } - if (point === 34 || point === 92) { - result += str.slice(last, i) + '\\' - last = i - found = true - } - } - - if (!found) { - result = str - } else { - result += str.slice(last) - } - return ((point < 32) || (surrogateFound === true)) ? JSON.stringify(str) : '"' + result + '"' - } -} - - - - const serializer = new Serializer({"mode":"standalone"}) - + module.exports = function anonymous(validator,serializer +) { + +const { + asString, + asNumber, + asBoolean, + asDateTime, + asDate, + asTime, + asUnsafeString +} = serializer + +const asInteger = serializer.asInteger.bind(serializer) + + + const JSON_STR_BEGIN_OBJECT = '{' + const JSON_STR_END_OBJECT = '}' + const JSON_STR_BEGIN_ARRAY = '[' + const JSON_STR_END_ARRAY = ']' + const JSON_STR_COMMA = ',' + const JSON_STR_COLONS = ':' + const JSON_STR_QUOTE = '"' + const JSON_STR_EMPTY_OBJECT = JSON_STR_BEGIN_OBJECT + JSON_STR_END_OBJECT + const JSON_STR_EMPTY_ARRAY = JSON_STR_BEGIN_ARRAY + JSON_STR_END_ARRAY + const JSON_STR_EMPTY_STRING = JSON_STR_QUOTE + JSON_STR_QUOTE + const JSON_STR_NULL = 'null' - function main (input) { - let json = '' - json += anonymous0(input) - return json - } - function anonymous0 (input) { - // # - var obj = (input && typeof input.toJSON === 'function') + // # + function anonymous0 (input) { + const obj = (input && typeof input.toJSON === 'function') ? input.toJSON() : input - var json = '{' - var addComma = false - - if (obj["statusCode"] !== undefined) { - - if (addComma) { - json += ',' - } else { - addComma = true - } + if (obj === null) return JSON_STR_EMPTY_OBJECT - json += "\"statusCode\"" + ':' - json += serializer.asNumber.bind(serializer)(obj["statusCode"]) - } - - if (obj["code"] !== undefined) { - - if (addComma) { - json += ',' - } else { - addComma = true - } + let value +let json = JSON_STR_BEGIN_OBJECT +let addComma = false - json += "\"code\"" + ':' - json += serializer.asString.bind(serializer)(obj["code"]) + value = obj["statusCode"] + if (value !== undefined) { + !addComma && (addComma = true) || (json += JSON_STR_COMMA) + json += "\"statusCode\":" + json += asNumber(value) } - - if (obj["error"] !== undefined) { - - if (addComma) { - json += ',' - } else { - addComma = true - } - json += "\"error\"" + ':' - json += serializer.asString.bind(serializer)(obj["error"]) + value = obj["code"] + if (value !== undefined) { + !addComma && (addComma = true) || (json += JSON_STR_COMMA) + json += "\"code\":" + + if (typeof value !== 'string') { + if (value === null) { + json += JSON_STR_EMPTY_STRING + } else if (value instanceof Date) { + json += JSON_STR_QUOTE + value.toISOString() + JSON_STR_QUOTE + } else if (value instanceof RegExp) { + json += asString(value.source) + } else { + json += asString(value.toString()) + } + } else { + json += asString(value) + } + } - - if (obj["message"] !== undefined) { + + value = obj["error"] + if (value !== undefined) { + !addComma && (addComma = true) || (json += JSON_STR_COMMA) + json += "\"error\":" + + if (typeof value !== 'string') { + if (value === null) { + json += JSON_STR_EMPTY_STRING + } else if (value instanceof Date) { + json += JSON_STR_QUOTE + value.toISOString() + JSON_STR_QUOTE + } else if (value instanceof RegExp) { + json += asString(value.source) + } else { + json += asString(value.toString()) + } + } else { + json += asString(value) + } - if (addComma) { - json += ',' - } else { - addComma = true - } + } - json += "\"message\"" + ':' - json += serializer.asString.bind(serializer)(obj["message"]) + value = obj["message"] + if (value !== undefined) { + !addComma && (addComma = true) || (json += JSON_STR_COMMA) + json += "\"message\":" + + if (typeof value !== 'string') { + if (value === null) { + json += JSON_STR_EMPTY_STRING + } else if (value instanceof Date) { + json += JSON_STR_QUOTE + value.toISOString() + JSON_STR_QUOTE + } else if (value instanceof RegExp) { + json += asString(value.source) + } else { + json += asString(value.toString()) + } + } else { + json += asString(value) + } + } - - json += '}' - return json + + return json + JSON_STR_END_OBJECT + } + const main = anonymous0 + return main - - - module.exports = main - +}(validator, serializer) +/* c8 ignore stop */ diff --git a/lib/error-status.js b/lib/error-status.js new file mode 100644 index 00000000000..e6baea2b0e5 --- /dev/null +++ b/lib/error-status.js @@ -0,0 +1,14 @@ +'use strict' + +const { + kReplyHasStatusCode +} = require('./symbols') + +function setErrorStatusCode (reply, err) { + if (!reply[kReplyHasStatusCode] || reply.statusCode === 200) { + const statusCode = err && (err.statusCode || err.status) + reply.code(statusCode >= 400 ? statusCode : 500) + } +} + +module.exports = { setErrorStatusCode } diff --git a/lib/errors.js b/lib/errors.js index 8633b997415..a5ce70ddd03 100644 --- a/lib/errors.js +++ b/lib/errors.js @@ -1,6 +1,7 @@ 'use strict' const createError = require('@fastify/error') + const codes = { /** * Basic @@ -10,6 +11,65 @@ const codes = { 'Not Found', 404 ), + FST_ERR_OPTIONS_NOT_OBJ: createError( + 'FST_ERR_OPTIONS_NOT_OBJ', + 'Options must be an object', + 500, + TypeError + ), + FST_ERR_QSP_NOT_FN: createError( + 'FST_ERR_QSP_NOT_FN', + "querystringParser option should be a function, instead got '%s'", + 500, + TypeError + ), + FST_ERR_SCHEMA_CONTROLLER_BUCKET_OPT_NOT_FN: createError( + 'FST_ERR_SCHEMA_CONTROLLER_BUCKET_OPT_NOT_FN', + "schemaController.bucket option should be a function, instead got '%s'", + 500, + TypeError + ), + FST_ERR_SCHEMA_ERROR_FORMATTER_NOT_FN: createError( + 'FST_ERR_SCHEMA_ERROR_FORMATTER_NOT_FN', + "schemaErrorFormatter option should be a non async function. Instead got '%s'.", + 500, + TypeError + ), + FST_ERR_AJV_CUSTOM_OPTIONS_OPT_NOT_OBJ: createError( + 'FST_ERR_AJV_CUSTOM_OPTIONS_OPT_NOT_OBJ', + "ajv.customOptions option should be an object, instead got '%s'", + 500, + TypeError + ), + FST_ERR_AJV_CUSTOM_OPTIONS_OPT_NOT_ARR: createError( + 'FST_ERR_AJV_CUSTOM_OPTIONS_OPT_NOT_ARR', + "ajv.plugins option should be an array, instead got '%s'", + 500, + TypeError + ), + FST_ERR_VALIDATION: createError( + 'FST_ERR_VALIDATION', + '%s', + 400 + ), + FST_ERR_LISTEN_OPTIONS_INVALID: createError( + 'FST_ERR_LISTEN_OPTIONS_INVALID', + "Invalid listen options: '%s'", + 500, + TypeError + ), + FST_ERR_ERROR_HANDLER_NOT_FN: createError( + 'FST_ERR_ERROR_HANDLER_NOT_FN', + 'Error Handler must be a function', + 500, + TypeError + ), + FST_ERR_ERROR_HANDLER_ALREADY_SET: createError( + 'FST_ERR_ERROR_HANDLER_ALREADY_SET', + "Error Handler already set in this scope. Set 'allowErrorHandlerOverride: true' to allow overriding.", + 500, + TypeError + ), /** * ContentTypeParser @@ -50,7 +110,7 @@ const codes = { ), FST_ERR_CTP_INVALID_MEDIA_TYPE: createError( 'FST_ERR_CTP_INVALID_MEDIA_TYPE', - 'Unsupported Media Type: %s', + 'Unsupported Media Type', 415 ), FST_ERR_CTP_INVALID_CONTENT_LENGTH: createError( @@ -64,6 +124,16 @@ const codes = { "Body cannot be empty when content-type is set to 'application/json'", 400 ), + FST_ERR_CTP_INVALID_JSON_BODY: createError( + 'FST_ERR_CTP_INVALID_JSON_BODY', + "Body is not valid JSON but content-type is set to 'application/json'", + 400 + ), + FST_ERR_CTP_INSTANCE_ALREADY_STARTED: createError( + 'FST_ERR_CTP_INSTANCE_ALREADY_STARTED', + 'Cannot call "%s" when fastify instance is already started!', + 400 + ), /** * decorate @@ -74,7 +144,9 @@ const codes = { ), FST_ERR_DEC_DEPENDENCY_INVALID_TYPE: createError( 'FST_ERR_DEC_DEPENDENCY_INVALID_TYPE', - "The dependencies of decorator '%s' must be of type Array." + "The dependencies of decorator '%s' must be of type Array.", + 500, + TypeError ), FST_ERR_DEC_MISSING_DEPENDENCY: createError( 'FST_ERR_DEC_MISSING_DEPENDENCY', @@ -84,6 +156,14 @@ const codes = { 'FST_ERR_DEC_AFTER_START', "The decorator '%s' has been added after start!" ), + FST_ERR_DEC_REFERENCE_TYPE: createError( + 'FST_ERR_DEC_REFERENCE_TYPE', + "The decorator '%s' of type '%s' is a reference type. Use the { getter, setter } interface instead." + ), + FST_ERR_DEC_UNDECLARED: createError( + 'FST_ERR_DEC_UNDECLARED', + "No decorator '%s' has been declared on %s." + ), /** * hooks @@ -96,7 +176,19 @@ const codes = { ), FST_ERR_HOOK_INVALID_HANDLER: createError( 'FST_ERR_HOOK_INVALID_HANDLER', - 'The hook callback must be a function', + '%s hook should be a function, instead got %s', + 500, + TypeError + ), + FST_ERR_HOOK_INVALID_ASYNC_HANDLER: createError( + 'FST_ERR_HOOK_INVALID_ASYNC_HANDLER', + 'Async function has too many arguments. Async hooks should not use the \'done\' argument.', + 500, + TypeError + ), + FST_ERR_HOOK_NOT_SUPPORTED: createError( + 'FST_ERR_HOOK_NOT_SUPPORTED', + '%s hook not supported!', 500, TypeError ), @@ -106,13 +198,13 @@ const codes = { */ FST_ERR_MISSING_MIDDLEWARE: createError( 'FST_ERR_MISSING_MIDDLEWARE', - 'You must register a plugin for handling middlewares, visit fastify.io/docs/latest/Reference/Middleware/ for more info.', + 'You must register a plugin for handling middlewares, visit fastify.dev/docs/latest/Reference/Middleware/ for more info.', 500 ), FST_ERR_HOOK_TIMEOUT: createError( 'FST_ERR_HOOK_TIMEOUT', - "A callback for '%s' hook timed out. You may have forgotten to call 'done' function or to resolve a Promise" + "A callback for '%s' hook%s timed out. You may have forgotten to call 'done' function or to resolve a Promise" ), /** @@ -123,6 +215,34 @@ const codes = { 'Cannot specify both logger.stream and logger.file options' ), + FST_ERR_LOG_INVALID_LOGGER: createError( + 'FST_ERR_LOG_INVALID_LOGGER', + "Invalid logger object provided. The logger instance should have these functions(s): '%s'.", + 500, + TypeError + ), + + FST_ERR_LOG_INVALID_LOGGER_INSTANCE: createError( + 'FST_ERR_LOG_INVALID_LOGGER_INSTANCE', + 'loggerInstance only accepts a logger instance.', + 500, + TypeError + ), + + FST_ERR_LOG_INVALID_LOGGER_CONFIG: createError( + 'FST_ERR_LOG_INVALID_LOGGER_CONFIG', + 'logger options only accepts a configuration object.', + 500, + TypeError + ), + + FST_ERR_LOG_LOGGER_AND_LOGGER_INSTANCE_PROVIDED: createError( + 'FST_ERR_LOG_LOGGER_AND_LOGGER_INSTANCE_PROVIDED', + 'You cannot provide both logger and loggerInstance. Please provide only one.', + 500, + TypeError + ), + /** * reply */ @@ -132,13 +252,23 @@ const codes = { 500, TypeError ), + FST_ERR_REP_RESPONSE_BODY_CONSUMED: createError( + 'FST_ERR_REP_RESPONSE_BODY_CONSUMED', + 'Response.body is already consumed.' + ), + FST_ERR_REP_READABLE_STREAM_LOCKED: createError( + 'FST_ERR_REP_READABLE_STREAM_LOCKED', + 'ReadableStream was locked. You should call releaseLock() method on reader before sending.' + ), FST_ERR_REP_ALREADY_SENT: createError( 'FST_ERR_REP_ALREADY_SENT', - 'Reply was already sent.' + 'Reply was already sent, did you forget to "return reply" in "%s" (%s)?' ), FST_ERR_REP_SENT_VALUE: createError( 'FST_ERR_REP_SENT_VALUE', - 'The only possible value for reply.sent is true.' + 'The only possible value for reply.sent is true.', + 500, + TypeError ), FST_ERR_SEND_INSIDE_ONERR: createError( 'FST_ERR_SEND_INSIDE_ONERR', @@ -160,6 +290,22 @@ const codes = { 'FST_ERR_BAD_TRAILER_VALUE', "Called reply.trailer('%s', fn) with an invalid type: %s. Expected a function." ), + FST_ERR_FAILED_ERROR_SERIALIZATION: createError( + 'FST_ERR_FAILED_ERROR_SERIALIZATION', + 'Failed to serialize an error. Error: %s. Original error: %s' + ), + FST_ERR_MISSING_SERIALIZATION_FN: createError( + 'FST_ERR_MISSING_SERIALIZATION_FN', + 'Missing serialization function. Key "%s"' + ), + FST_ERR_MISSING_CONTENTTYPE_SERIALIZATION_FN: createError( + 'FST_ERR_MISSING_CONTENTTYPE_SERIALIZATION_FN', + 'Missing serialization function. Key "%s:%s"' + ), + FST_ERR_REQ_INVALID_VALIDATION_INVOCATION: createError( + 'FST_ERR_REQ_INVALID_VALIDATION_INVOCATION', + 'Invalid validation invocation. Missing validation function for HTTP part "%s" nor schema provided.' + ), /** * schemas @@ -172,6 +318,10 @@ const codes = { 'FST_ERR_SCH_ALREADY_PRESENT', "Schema with id '%s' already declared!" ), + FST_ERR_SCH_CONTENT_MISSING_SCHEMA: createError( + 'FST_ERR_SCH_CONTENT_MISSING_SCHEMA', + "Schema is missing for the content type '%s'" + ), FST_ERR_SCH_DUPLICATE: createError( 'FST_ERR_SCH_DUPLICATE', "Schema with '%s' already present!" @@ -184,13 +334,9 @@ const codes = { 'FST_ERR_SCH_SERIALIZATION_BUILD', 'Failed building the serialization schema for %s: %s, due to error %s' ), - - /** - * http2 - */ - FST_ERR_HTTP2_INVALID_VERSION: createError( - 'FST_ERR_HTTP2_INVALID_VERSION', - 'HTTP2 is available only from node >= 8.8.1' + FST_ERR_SCH_RESPONSE_SCHEMA_NOT_NESTED_2XX: createError( + 'FST_ERR_SCH_RESPONSE_SCHEMA_NOT_NESTED_2XX', + 'response schemas should be nested under a valid status code, e.g { 2xx: { type: "object" } }' ), /** @@ -215,18 +361,80 @@ const codes = { FST_ERR_BAD_URL: createError( 'FST_ERR_BAD_URL', "'%s' is not a valid url component", - 400 + 400, + URIError ), - FST_ERR_DEFAULT_ROUTE_INVALID_TYPE: createError( - 'FST_ERR_DEFAULT_ROUTE_INVALID_TYPE', - 'The defaultRoute type should be a function', - 500, - TypeError + FST_ERR_ASYNC_CONSTRAINT: createError( + 'FST_ERR_ASYNC_CONSTRAINT', + 'Unexpected error from async constraint', + 500 ), FST_ERR_INVALID_URL: createError( 'FST_ERR_INVALID_URL', "URL must be a string. Received '%s'", - 400 + 400, + TypeError + ), + FST_ERR_ROUTE_OPTIONS_NOT_OBJ: createError( + 'FST_ERR_ROUTE_OPTIONS_NOT_OBJ', + 'Options for "%s:%s" route must be an object', + 500, + TypeError + ), + FST_ERR_ROUTE_DUPLICATED_HANDLER: createError( + 'FST_ERR_ROUTE_DUPLICATED_HANDLER', + 'Duplicate handler for "%s:%s" route is not allowed!', + 500 + ), + FST_ERR_ROUTE_HANDLER_NOT_FN: createError( + 'FST_ERR_ROUTE_HANDLER_NOT_FN', + 'Error Handler for %s:%s route, if defined, must be a function', + 500, + TypeError + ), + FST_ERR_ROUTE_MISSING_HANDLER: createError( + 'FST_ERR_ROUTE_MISSING_HANDLER', + 'Missing handler function for "%s:%s" route.', + 500 + ), + FST_ERR_ROUTE_METHOD_INVALID: createError( + 'FST_ERR_ROUTE_METHOD_INVALID', + 'Provided method is invalid!', + 500, + TypeError + ), + FST_ERR_ROUTE_METHOD_NOT_SUPPORTED: createError( + 'FST_ERR_ROUTE_METHOD_NOT_SUPPORTED', + '%s method is not supported.', + 500 + ), + FST_ERR_ROUTE_BODY_VALIDATION_SCHEMA_NOT_SUPPORTED: createError( + 'FST_ERR_ROUTE_BODY_VALIDATION_SCHEMA_NOT_SUPPORTED', + 'Body validation schema for %s:%s route is not supported!', + 500 + ), + FST_ERR_ROUTE_BODY_LIMIT_OPTION_NOT_INT: createError( + 'FST_ERR_ROUTE_BODY_LIMIT_OPTION_NOT_INT', + "'bodyLimit' option must be an integer > 0. Got '%s'", + 500, + TypeError + ), + FST_ERR_HANDLER_TIMEOUT: createError( + 'FST_ERR_HANDLER_TIMEOUT', + "Request timed out after %s ms on route '%s'", + 503 + ), + FST_ERR_ROUTE_HANDLER_TIMEOUT_OPTION_NOT_INT: createError( + 'FST_ERR_ROUTE_HANDLER_TIMEOUT_OPTION_NOT_INT', + "'handlerTimeout' option must be an integer > 0. Got '%s'", + 500, + TypeError + ), + FST_ERR_ROUTE_REWRITE_NOT_STR: createError( + 'FST_ERR_ROUTE_REWRITE_NOT_STR', + 'Rewrite url for "%s" needs to be of type "string" but received "%s"', + 500, + TypeError ), /** @@ -240,6 +448,10 @@ const codes = { 'FST_ERR_REOPENED_SERVER', 'Fastify is already listening' ), + FST_ERR_INSTANCE_ALREADY_LISTENING: createError( + 'FST_ERR_INSTANCE_ALREADY_LISTENING', + 'Fastify instance is already listening. %s' + ), /** * plugin @@ -248,39 +460,57 @@ const codes = { 'FST_ERR_PLUGIN_VERSION_MISMATCH', "fastify-plugin: %s - expected '%s' fastify version, '%s' is installed" ), + FST_ERR_PLUGIN_NOT_PRESENT_IN_INSTANCE: createError( + 'FST_ERR_PLUGIN_NOT_PRESENT_IN_INSTANCE', + "The decorator '%s'%s is not present in %s" + ), + FST_ERR_PLUGIN_INVALID_ASYNC_HANDLER: createError( + 'FST_ERR_PLUGIN_INVALID_ASYNC_HANDLER', + 'The %s plugin being registered mixes async and callback styles. Async plugin should not mix async and callback style.', + 500, + TypeError + ), /** - * Avvio Errors map + * Avvio Errors */ - AVVIO_ERRORS_MAP: { - AVV_ERR_CALLBACK_NOT_FN: createError( - 'FST_ERR_PLUGIN_CALLBACK_NOT_FN', - 'fastify-plugin: %s' - ), - AVV_ERR_PLUGIN_NOT_VALID: createError( - 'FST_ERR_PLUGIN_NOT_VALID', - 'fastify-plugin: %s' - ), - AVV_ERR_ROOT_PLG_BOOTED: createError( - 'FST_ERR_ROOT_PLG_BOOTED', - 'fastify-plugin: %s' - ), - AVV_ERR_PARENT_PLG_LOADED: createError( - 'FST_ERR_PARENT_PLUGIN_BOOTED', - 'fastify-plugin: %s' - ), - AVV_ERR_READY_TIMEOUT: createError( - 'FST_ERR_PLUGIN_TIMEOUT', - 'fastify-plugin: %s' - ) - }, + FST_ERR_PLUGIN_CALLBACK_NOT_FN: createError( + 'FST_ERR_PLUGIN_CALLBACK_NOT_FN', + 'fastify-plugin: %s', + 500, + TypeError + ), + FST_ERR_PLUGIN_NOT_VALID: createError( + 'FST_ERR_PLUGIN_NOT_VALID', + 'fastify-plugin: %s' + ), + FST_ERR_ROOT_PLG_BOOTED: createError( + 'FST_ERR_ROOT_PLG_BOOTED', + 'fastify-plugin: %s' + ), + FST_ERR_PARENT_PLUGIN_BOOTED: createError( + 'FST_ERR_PARENT_PLUGIN_BOOTED', + 'fastify-plugin: %s' + ), + FST_ERR_PLUGIN_TIMEOUT: createError( + 'FST_ERR_PLUGIN_TIMEOUT', + 'fastify-plugin: %s' + ) +} - // Util function - appendStackTrace (oldErr, newErr) { - newErr.cause = oldErr +function appendStackTrace (oldErr, newErr) { + newErr.cause = oldErr - return newErr - } + return newErr } module.exports = codes +module.exports.appendStackTrace = appendStackTrace +module.exports.AVVIO_ERRORS_MAP = { + AVV_ERR_CALLBACK_NOT_FN: codes.FST_ERR_PLUGIN_CALLBACK_NOT_FN, + AVV_ERR_PLUGIN_NOT_VALID: codes.FST_ERR_PLUGIN_NOT_VALID, + AVV_ERR_ROOT_PLG_BOOTED: codes.FST_ERR_ROOT_PLG_BOOTED, + AVV_ERR_PARENT_PLG_LOADED: codes.FST_ERR_PARENT_PLUGIN_BOOTED, + AVV_ERR_READY_TIMEOUT: codes.FST_ERR_PLUGIN_TIMEOUT, + AVV_ERR_PLUGIN_EXEC_TIMEOUT: codes.FST_ERR_PLUGIN_TIMEOUT +} diff --git a/lib/fourOhFour.js b/lib/four-oh-four.js similarity index 82% rename from lib/fourOhFour.js rename to lib/four-oh-four.js index e6e828046cc..140cd046aba 100644 --- a/lib/fourOhFour.js +++ b/lib/four-oh-four.js @@ -10,27 +10,26 @@ const { kCanSetNotFoundHandler, kFourOhFourLevelInstance, kFourOhFourContext, - kHooks + kHooks, + kErrorHandler } = require('./symbols.js') const { lifecycleHooks } = require('./hooks') const { buildErrorHandler } = require('./error-handler.js') -const fourOhFourContext = { - config: { - }, - onSend: [], - onError: [], - errorHandler: buildErrorHandler() -} +const { + FST_ERR_NOT_FOUND +} = require('./errors') +const { createChildLogger } = require('./logger-factory') +const { getGenReqId } = require('./req-id-gen-factory.js') /** * Each fastify instance have a: - * kFourOhFourLevelInstance: point to a fastify instance that has the 404 handler setted + * kFourOhFourLevelInstance: point to a fastify instance that has the 404 handler set * kCanSetNotFoundHandler: bool to track if the 404 handler has already been set * kFourOhFour: the singleton instance of this 404 module * kFourOhFourContext: the context in the reply object where the handler will be executed */ function fourOhFour (options) { - const { logger, genReqId } = options + const { logger, disableRequestLogging } = options // 404 router, used for handling encapsulated 404 handlers const router = FindMyWay({ onBadUrl: createOnBadUrl(), defaultRoute: fourOhFourFallBack }) @@ -44,12 +43,16 @@ function fourOhFour (options) { instance[kCanSetNotFoundHandler] = true // we need to bind instance for the context router.onBadUrl = router.onBadUrl.bind(instance) + router.defaultRoute = router.defaultRoute.bind(instance) } function basic404 (request, reply) { const { url, method } = request.raw const message = `Route ${method}:${url} not found` - request.log.info(message) + const resolvedDisableRequestLogging = typeof disableRequestLogging === 'function' ? disableRequestLogging(request.raw) : disableRequestLogging + if (!resolvedDisableRequestLogging) { + request.log.info(message) + } reply.code(404).send({ message, error: 'Not Found', @@ -59,9 +62,9 @@ function fourOhFour (options) { function createOnBadUrl () { return function onBadUrl (path, req, res) { - const id = genReqId(req) - const childLogger = logger.child({ reqId: id }) const fourOhFourContext = this[kFourOhFourLevelInstance][kFourOhFourContext] + const id = getGenReqId(fourOhFourContext.server, req) + const childLogger = createChildLogger(fourOhFourContext, logger, req, id) const request = new Request(id, null, req, null, childLogger, fourOhFourContext) const reply = new Reply(res, request, childLogger) @@ -148,6 +151,9 @@ function fourOhFour (options) { .map(h => h.bind(this)) context[hook] = toSet.length ? toSet : null } + context.errorHandler = opts.errorHandler + ? buildErrorHandler(this[kErrorHandler], opts.errorHandler) + : this[kErrorHandler] }) if (this[kFourOhFourContext] !== null && prefix === '/') { @@ -166,8 +172,9 @@ function fourOhFour (options) { // we might want to do some hard debugging // here, let's print out as much info as // we can - const id = genReqId(req) - const childLogger = logger.child({ reqId: id }) + const fourOhFourContext = this[kFourOhFourLevelInstance][kFourOhFourContext] + const id = getGenReqId(fourOhFourContext.server, req) + const childLogger = createChildLogger(fourOhFourContext, logger, req, id) childLogger.info({ req }, 'incoming request') @@ -176,7 +183,7 @@ function fourOhFour (options) { request.log.warn('the default handler for 404 did not catch this, this is likely a fastify bug, please report it') request.log.warn(router.prettyPrint()) - reply.code(404).send(new Error('Not Found')) + reply.code(404).send(new FST_ERR_NOT_FOUND()) } } diff --git a/lib/handle-request.js b/lib/handle-request.js new file mode 100644 index 00000000000..8d81d633603 --- /dev/null +++ b/lib/handle-request.js @@ -0,0 +1,195 @@ +'use strict' + +const diagnostics = require('node:diagnostics_channel') +const ContentType = require('./content-type') +const wrapThenable = require('./wrap-thenable') +const { validate: validateSchema } = require('./validation') +const { preValidationHookRunner, preHandlerHookRunner } = require('./hooks') +const { FST_ERR_CTP_INVALID_MEDIA_TYPE } = require('./errors') +const { setErrorStatusCode } = require('./error-status') +const { + kReplyIsError, + kRouteContext, + kFourOhFourContext, + kSupportedHTTPMethods +} = require('./symbols') + +const channels = diagnostics.tracingChannel('fastify.request.handler') + +function handleRequest (err, request, reply) { + if (reply.sent === true) return + if (err != null) { + reply[kReplyIsError] = true + reply.send(err) + return + } + + const method = request.method + + if (this[kSupportedHTTPMethods].bodyless.has(method)) { + handler(request, reply) + return + } + + if (this[kSupportedHTTPMethods].bodywith.has(method)) { + const headers = request.headers + const ctHeader = headers['content-type'] + + if (ctHeader === undefined) { + const contentLength = headers['content-length'] + const transferEncoding = headers['transfer-encoding'] + const isEmptyBody = transferEncoding === undefined && + (contentLength === undefined || contentLength === '0') + + if (isEmptyBody) { + // Request has no body to parse + handler(request, reply) + return + } + + request[kRouteContext].contentTypeParser.run('', handler, request, reply) + return + } + + const contentType = new ContentType(ctHeader) + if (contentType.isValid === false) { + reply[kReplyIsError] = true + reply.status(415).send(new FST_ERR_CTP_INVALID_MEDIA_TYPE()) + return + } + request[kRouteContext].contentTypeParser.run(contentType.toString(), handler, request, reply) + return + } + + // Return 404 instead of 405 see https://github.com/fastify/fastify/pull/862 for discussion + handler(request, reply) +} + +function handler (request, reply) { + try { + if (request[kRouteContext].preValidation !== null) { + preValidationHookRunner( + request[kRouteContext].preValidation, + request, + reply, + preValidationCallback + ) + } else { + preValidationCallback(null, request, reply) + } + } catch (err) { + preValidationCallback(err, request, reply) + } +} + +function preValidationCallback (err, request, reply) { + if (reply.sent === true) return + + if (err != null) { + reply[kReplyIsError] = true + reply.send(err) + return + } + + const validationErr = validateSchema(reply[kRouteContext], request) + const isAsync = (validationErr && typeof validationErr.then === 'function') || false + + if (isAsync) { + const cb = validationCompleted.bind(null, request, reply) + validationErr.then(cb, cb) + } else { + validationCompleted(request, reply, validationErr) + } +} + +function validationCompleted (request, reply, validationErr) { + if (validationErr) { + if (reply[kRouteContext].attachValidation === false) { + reply.send(validationErr) + return + } + + reply.request.validationError = validationErr + } + + // preHandler hook + if (request[kRouteContext].preHandler !== null) { + preHandlerHookRunner( + request[kRouteContext].preHandler, + request, + reply, + preHandlerCallback + ) + } else { + preHandlerCallback(null, request, reply) + } +} + +function preHandlerCallback (err, request, reply) { + if (reply.sent) return + + const context = request[kRouteContext] + + if (!channels.hasSubscribers || context[kFourOhFourContext] === null) { + preHandlerCallbackInner(err, request, reply) + } else { + const store = { + request, + reply, + async: false, + route: { + url: context.config.url, + method: context.config.method + } + } + channels.start.runStores(store, preHandlerCallbackInner, undefined, err, request, reply, store) + } +} + +function preHandlerCallbackInner (err, request, reply, store) { + const context = request[kRouteContext] + + try { + if (err != null) { + reply[kReplyIsError] = true + if (store) { + store.error = err + // Set status code before publishing so subscribers see the correct value + setErrorStatusCode(reply, err) + channels.error.publish(store) + } + reply.send(err) + return + } + + let result + + try { + result = context.handler(request, reply) + } catch (err) { + if (store) { + store.error = err + // Set status code before publishing so subscribers see the correct value + setErrorStatusCode(reply, err) + channels.error.publish(store) + } + + reply[kReplyIsError] = true + reply.send(err) + return + } + + if (result !== undefined) { + if (result !== null && typeof result.then === 'function') { + wrapThenable(result, reply, store) + } else { + reply.send(result) + } + } + } finally { + if (store) channels.end.publish(store) + } +} + +module.exports = handleRequest +module.exports[Symbol.for('internals')] = { handler, preHandlerCallback } diff --git a/lib/handleRequest.js b/lib/handleRequest.js deleted file mode 100644 index 282fb6a320b..00000000000 --- a/lib/handleRequest.js +++ /dev/null @@ -1,143 +0,0 @@ -'use strict' - -const { validate: validateSchema } = require('./validation') -const { hookRunner, hookIterator } = require('./hooks') -const wrapThenable = require('./wrapThenable') -const { - kReplyIsError -} = require('./symbols') - -function handleRequest (err, request, reply) { - if (reply.sent === true) return - if (err != null) { - reply[kReplyIsError] = true - reply.send(err) - return - } - - const method = request.raw.method - const headers = request.headers - - if (method === 'GET' || method === 'HEAD') { - handler(request, reply) - return - } - - const contentType = headers['content-type'] - - if (method === 'POST' || method === 'PUT' || method === 'PATCH') { - if (contentType === undefined) { - if ( - headers['transfer-encoding'] === undefined && - (headers['content-length'] === '0' || headers['content-length'] === undefined) - ) { // Request has no body to parse - handler(request, reply) - } else { - reply.context.contentTypeParser.run('', handler, request, reply) - } - } else { - reply.context.contentTypeParser.run(contentType, handler, request, reply) - } - return - } - - if (method === 'OPTIONS' || method === 'DELETE') { - if ( - contentType !== undefined && - ( - headers['transfer-encoding'] !== undefined || - headers['content-length'] !== undefined - ) - ) { - reply.context.contentTypeParser.run(contentType, handler, request, reply) - } else { - handler(request, reply) - } - return - } - - // Return 404 instead of 405 see https://github.com/fastify/fastify/pull/862 for discussion - handler(request, reply) -} - -function handler (request, reply) { - try { - if (reply.context.preValidation !== null) { - hookRunner( - reply.context.preValidation, - hookIterator, - request, - reply, - preValidationCallback - ) - } else { - preValidationCallback(null, request, reply) - } - } catch (err) { - preValidationCallback(err, request, reply) - } -} - -function preValidationCallback (err, request, reply) { - if (reply.sent === true) return - - if (err != null) { - reply[kReplyIsError] = true - reply.send(err) - return - } - - const result = validateSchema(reply.context, request) - if (result) { - if (reply.context.attachValidation === false) { - reply.send(result) - return - } - - reply.request.validationError = result - } - - // preHandler hook - if (reply.context.preHandler !== null) { - hookRunner( - reply.context.preHandler, - hookIterator, - request, - reply, - preHandlerCallback - ) - } else { - preHandlerCallback(null, request, reply) - } -} - -function preHandlerCallback (err, request, reply) { - if (reply.sent) return - - if (err != null) { - reply[kReplyIsError] = true - reply.send(err) - return - } - - let result - - try { - result = reply.context.handler(request, reply) - } catch (err) { - reply[kReplyIsError] = true - reply.send(err) - return - } - - if (result !== undefined) { - if (result !== null && typeof result.then === 'function') { - wrapThenable(result, reply) - } else { - reply.send(result) - } - } -} - -module.exports = handleRequest -module.exports[Symbol.for('internals')] = { handler, preHandlerCallback } diff --git a/lib/headRoute.js b/lib/head-route.js similarity index 57% rename from lib/headRoute.js rename to lib/head-route.js index 798624835ea..63f3519c1e0 100644 --- a/lib/headRoute.js +++ b/lib/head-route.js @@ -3,15 +3,27 @@ function headRouteOnSendHandler (req, reply, payload, done) { // If payload is undefined if (payload === undefined) { reply.header('content-length', '0') - return done(null, null) + done(null, null) + return } + // node:stream if (typeof payload.resume === 'function') { payload.on('error', (err) => { reply.log.error({ err }, 'Error on Stream found for HEAD route') }) payload.resume() - return done(null, null) + done(null, null) + return + } + + // node:stream/web + if (typeof payload.getReader === 'function') { + payload.cancel('Stream cancelled by HEAD route').catch((err) => { + reply.log.error({ err }, 'Error on Stream found for HEAD route') + }) + done(null, null) + return } const size = '' + Buffer.byteLength(payload) @@ -23,7 +35,9 @@ function headRouteOnSendHandler (req, reply, payload, done) { function parseHeadOnSendHandlers (onSendHandlers) { if (onSendHandlers == null) return headRouteOnSendHandler - return Array.isArray(onSendHandlers) ? [...onSendHandlers, headRouteOnSendHandler] : [onSendHandlers, headRouteOnSendHandler] + return Array.isArray(onSendHandlers) + ? [...onSendHandlers, headRouteOnSendHandler] + : [onSendHandlers, headRouteOnSendHandler] } module.exports = { diff --git a/lib/hooks.js b/lib/hooks.js index 5111f1ed432..1393300cf42 100644 --- a/lib/hooks.js +++ b/lib/hooks.js @@ -4,6 +4,8 @@ const applicationHooks = [ 'onRoute', 'onRegister', 'onReady', + 'onListen', + 'preClose', 'onClose' ] const lifecycleHooks = [ @@ -15,7 +17,8 @@ const lifecycleHooks = [ 'preHandler', 'onSend', 'onResponse', - 'onError' + 'onError', + 'onRequestAbort' ] const supportedHooks = lifecycleHooks.concat(applicationHooks) const { @@ -23,13 +26,15 @@ const { FST_ERR_HOOK_INVALID_HANDLER, FST_ERR_SEND_UNDEFINED_ERR, FST_ERR_HOOK_TIMEOUT, + FST_ERR_HOOK_NOT_SUPPORTED, AVVIO_ERRORS_MAP, appendStackTrace } = require('./errors') const { kChildren, - kHooks + kHooks, + kRequestPayloadStream } = require('./symbols') function Hooks () { @@ -44,15 +49,20 @@ function Hooks () { this.onRoute = [] this.onRegister = [] this.onReady = [] + this.onListen = [] this.onTimeout = [] + this.onRequestAbort = [] + this.preClose = [] } +Hooks.prototype = Object.create(null) + Hooks.prototype.validate = function (hook, fn) { if (typeof hook !== 'string') throw new FST_ERR_HOOK_INVALID_TYPE() - if (typeof fn !== 'function') throw new FST_ERR_HOOK_INVALID_HANDLER() - if (supportedHooks.indexOf(hook) === -1) { - throw new Error(`${hook} hook not supported!`) + if (Array.isArray(this[hook]) === false) { + throw new FST_ERR_HOOK_NOT_SUPPORTED(hook) } + if (typeof fn !== 'function') throw new FST_ERR_HOOK_INVALID_HANDLER(hook, Object.prototype.toString.call(fn)) } Hooks.prototype.add = function (hook, fn) { @@ -73,7 +83,10 @@ function buildHooks (h) { hooks.onRoute = h.onRoute.slice() hooks.onRegister = h.onRegister.slice() hooks.onTimeout = h.onTimeout.slice() + hooks.onRequestAbort = h.onRequestAbort.slice() hooks.onReady = [] + hooks.onListen = [] + hooks.preClose = [] return hooks } @@ -85,9 +98,12 @@ function hookRunnerApplication (hookName, boot, server, cb) { next() function exit (err) { + const hookFnName = hooks[i - 1]?.name + const hookFnFragment = hookFnName ? ` "${hookFnName}"` : '' + if (err) { if (err.code === 'AVV_ERR_READY_TIMEOUT') { - err = appendStackTrace(err, new FST_ERR_HOOK_TIMEOUT(hookName)) + err = appendStackTrace(err, new FST_ERR_HOOK_TIMEOUT(hookName, hookFnFragment)) } else { err = AVVIO_ERRORS_MAP[err.code] != null ? appendStackTrace(err, new AVVIO_ERRORS_MAP[err.code](err.message)) @@ -149,31 +165,139 @@ function hookRunnerApplication (hookName, boot, server, cb) { return } + try { + const ret = fn.call(server) + if (ret && typeof ret.then === 'function') { + ret.then(done, done) + return + } + } catch (error) { + err = error + } + + done(err) // auto done + } + } +} + +function onListenHookRunner (server) { + const hooks = server[kHooks].onListen + const hooksLen = hooks.length + + let i = 0 + let c = 0 + + next() + + function next (err) { + err && server.log.error(err) + + if ( + i === hooksLen + ) { + while (c < server[kChildren].length) { + const child = server[kChildren][c++] + onListenHookRunner(child) + } + return + } + + wrap(hooks[i++], server, next) + } + + async function wrap (fn, server, done) { + if (fn.length === 1) { + try { + fn.call(server, done) + } catch (e) { + done(e) + } + return + } + try { const ret = fn.call(server) if (ret && typeof ret.then === 'function') { ret.then(done, done) return } + done() + } catch (error) { + done(error) + } + } +} + +function hookRunnerGenerator (iterator) { + return function hookRunner (functions, request, reply, cb) { + let i = 0 + + function next (err) { + if (err || i === functions.length) { + cb(err, request, reply) + return + } - done(err) // auto done + let result + try { + result = iterator(functions[i++], request, reply, next) + } catch (error) { + cb(error, request, reply) + return + } + if (result && typeof result.then === 'function') { + result.then(handleResolve, handleReject) + } + } + + function handleResolve () { + next() + } + + function handleReject (err) { + if (!err) { + err = new FST_ERR_SEND_UNDEFINED_ERR() + } + + cb(err, request, reply) } + + next() } } -function hookRunner (functions, runner, request, reply, cb) { +function onResponseHookIterator (fn, request, reply, next) { + return fn(request, reply, next) +} + +const onResponseHookRunner = hookRunnerGenerator(onResponseHookIterator) +const preValidationHookRunner = hookRunnerGenerator(hookIterator) +const preHandlerHookRunner = hookRunnerGenerator(hookIterator) +const onTimeoutHookRunner = hookRunnerGenerator(hookIterator) +const onRequestHookRunner = hookRunnerGenerator(hookIterator) + +function onSendHookRunner (functions, request, reply, payload, cb) { let i = 0 - function next (err) { - if (err || i === functions.length) { - cb(err, request, reply) + function next (err, newPayload) { + if (err) { + cb(err, request, reply, payload) + return + } + + if (newPayload !== undefined) { + payload = newPayload + } + + if (i === functions.length) { + cb(null, request, reply, payload) return } let result try { - result = runner(functions[i++], request, reply, next) + result = functions[i++](request, reply, payload, next) } catch (error) { - next(error) + cb(error, request, reply) return } if (result && typeof result.then === 'function') { @@ -181,8 +305,8 @@ function hookRunner (functions, runner, request, reply, cb) { } } - function handleResolve () { - next() + function handleResolve (newPayload) { + next(null, newPayload) } function handleReject (err) { @@ -190,37 +314,39 @@ function hookRunner (functions, runner, request, reply, cb) { err = new FST_ERR_SEND_UNDEFINED_ERR() } - cb(err, request, reply) + cb(err, request, reply, payload) } next() } -function onSendHookRunner (functions, request, reply, payload, cb) { +const preSerializationHookRunner = onSendHookRunner + +function preParsingHookRunner (functions, request, reply, cb) { let i = 0 function next (err, newPayload) { - if (err) { - cb(err, request, reply, payload) + if (reply.sent) { return } if (newPayload !== undefined) { - payload = newPayload + request[kRequestPayloadStream] = newPayload } - if (i === functions.length) { - cb(null, request, reply, payload) + if (err || i === functions.length) { + cb(err, request, reply) return } let result try { - result = functions[i++](request, reply, payload, next) + result = functions[i++](request, reply, request[kRequestPayloadStream], next) } catch (error) { - next(error) + cb(error, request, reply) return } + if (result && typeof result.then === 'function') { result.then(handleResolve, handleReject) } @@ -235,7 +361,43 @@ function onSendHookRunner (functions, request, reply, payload, cb) { err = new FST_ERR_SEND_UNDEFINED_ERR() } - cb(err, request, reply, payload) + cb(err, request, reply) + } + + next() +} + +function onRequestAbortHookRunner (functions, request, cb) { + let i = 0 + + function next (err) { + if (err || i === functions.length) { + cb(err, request) + return + } + + let result + try { + result = functions[i++](request, next) + } catch (error) { + cb(error, request) + return + } + if (result && typeof result.then === 'function') { + result.then(handleResolve, handleReject) + } + } + + function handleResolve () { + next() + } + + function handleReject (err) { + if (!err) { + err = new FST_ERR_SEND_UNDEFINED_ERR() + } + + cb(err, request) } next() @@ -249,10 +411,19 @@ function hookIterator (fn, request, reply, next) { module.exports = { Hooks, buildHooks, - hookRunner, + hookRunnerGenerator, + preParsingHookRunner, + onResponseHookRunner, onSendHookRunner, + preSerializationHookRunner, + onRequestAbortHookRunner, hookIterator, hookRunnerApplication, + onListenHookRunner, + preHandlerHookRunner, + preValidationHookRunner, + onRequestHookRunner, + onTimeoutHookRunner, lifecycleHooks, supportedHooks } diff --git a/lib/initialConfigValidation.js b/lib/initial-config-validation.js similarity index 95% rename from lib/initialConfigValidation.js rename to lib/initial-config-validation.js index c56d8c54e34..d8f3d6f1018 100644 --- a/lib/initialConfigValidation.js +++ b/lib/initial-config-validation.js @@ -1,6 +1,6 @@ 'use strict' -const validate = require('./configValidator') +const validate = require('./config-validator') const deepClone = require('rfdc')({ circles: true, proto: false }) const { FST_ERR_INIT_OPTS_INVALID } = require('./errors') diff --git a/lib/logger-factory.js b/lib/logger-factory.js new file mode 100644 index 00000000000..69592d7a077 --- /dev/null +++ b/lib/logger-factory.js @@ -0,0 +1,136 @@ +'use strict' + +const { + FST_ERR_LOG_LOGGER_AND_LOGGER_INSTANCE_PROVIDED, + FST_ERR_LOG_INVALID_LOGGER_CONFIG, + FST_ERR_LOG_INVALID_LOGGER_INSTANCE, + FST_ERR_LOG_INVALID_LOGGER +} = require('./errors') + +/** + * Utility for creating a child logger with the appropriate bindings, logger factory + * and validation. + * @param {object} context + * @param {import('../fastify').FastifyBaseLogger} logger + * @param {import('../fastify').RawRequestDefaultExpression} req + * @param {string} reqId + * @param {import('../types/logger.js').ChildLoggerOptions?} loggerOpts + * + * @returns {object} New logger instance, inheriting all parent bindings, + * with child bindings added. + */ +function createChildLogger (context, logger, req, reqId, loggerOpts) { + const loggerBindings = { + [context.requestIdLogLabel]: reqId + } + const child = context.childLoggerFactory.call(context.server, logger, loggerBindings, loggerOpts || {}, req) + + // Optimization: bypass validation if the factory is our own default factory + if (context.childLoggerFactory !== defaultChildLoggerFactory) { + validateLogger(child, true) // throw if the child is not a valid logger + } + + return child +} + +/** Default factory to create child logger instance + * + * @param {import('../fastify.js').FastifyBaseLogger} logger + * @param {import('../types/logger.js').Bindings} bindings + * @param {import('../types/logger.js').ChildLoggerOptions} opts + * + * @returns {import('../types/logger.js').FastifyBaseLogger} + */ +function defaultChildLoggerFactory (logger, bindings, opts) { + return logger.child(bindings, opts) +} + +/** + * Determines if a provided logger object meets the requirements + * of a Fastify compatible logger. + * + * @param {object} logger Object to validate. + * @param {boolean?} strict `true` if the object must be a logger (always throw if any methods missing) + * + * @returns {boolean} `true` when the logger meets the requirements. + * + * @throws {FST_ERR_LOG_INVALID_LOGGER} When the logger object is + * missing required methods. + */ +function validateLogger (logger, strict) { + const methods = ['info', 'error', 'debug', 'fatal', 'warn', 'trace', 'child'] + const missingMethods = logger + ? methods.filter(method => !logger[method] || typeof logger[method] !== 'function') + : methods + + if (!missingMethods.length) { + return true + } else if ((missingMethods.length === methods.length) && !strict) { + return false + } else { + throw FST_ERR_LOG_INVALID_LOGGER(missingMethods.join(',')) + } +} + +function createLogger (options) { + if (options.logger && options.loggerInstance) { + throw new FST_ERR_LOG_LOGGER_AND_LOGGER_INSTANCE_PROVIDED() + } + + if (!options.loggerInstance && !options.logger) { + const nullLogger = require('abstract-logging') + const logger = nullLogger + logger.child = () => logger + return { logger, hasLogger: false } + } + + const { createPinoLogger, serializers } = require('./logger-pino.js') + + // check if the logger instance has all required properties + if (validateLogger(options.loggerInstance)) { + const logger = createPinoLogger({ + logger: options.loggerInstance, + serializers: Object.assign({}, serializers, options.loggerInstance.serializers) + }) + return { logger, hasLogger: true } + } + + // if a logger instance is passed to logger, throw an exception + if (validateLogger(options.logger)) { + throw FST_ERR_LOG_INVALID_LOGGER_CONFIG() + } + + if (options.loggerInstance) { + throw FST_ERR_LOG_INVALID_LOGGER_INSTANCE() + } + + const localLoggerOptions = {} + if (Object.prototype.toString.call(options.logger) === '[object Object]') { + Reflect.ownKeys(options.logger).forEach(prop => { + Object.defineProperty(localLoggerOptions, prop, { + value: options.logger[prop], + writable: true, + enumerable: true, + configurable: true + }) + }) + } + localLoggerOptions.level = localLoggerOptions.level || 'info' + localLoggerOptions.serializers = Object.assign({}, serializers, localLoggerOptions.serializers) + options.logger = localLoggerOptions + const logger = createPinoLogger(options.logger) + return { logger, hasLogger: true } +} + +function now () { + const ts = process.hrtime() + return (ts[0] * 1e3) + (ts[1] / 1e6) +} + +module.exports = { + createChildLogger, + defaultChildLoggerFactory, + createLogger, + validateLogger, + now +} diff --git a/lib/logger-pino.js b/lib/logger-pino.js new file mode 100644 index 00000000000..19e92d22fdd --- /dev/null +++ b/lib/logger-pino.js @@ -0,0 +1,68 @@ +'use strict' + +/** + * Code imported from `pino-http` + * Repo: https://github.com/pinojs/pino-http + * License: MIT (https://raw.githubusercontent.com/pinojs/pino-http/master/LICENSE) + */ + +const pino = require('pino') +const { serializersSym } = pino.symbols +const { + FST_ERR_LOG_INVALID_DESTINATION +} = require('./errors') + +function createPinoLogger (opts) { + if (opts.stream && opts.file) { + throw new FST_ERR_LOG_INVALID_DESTINATION() + } else if (opts.file) { + // we do not have stream + opts.stream = pino.destination(opts.file) + delete opts.file + } + + const prevLogger = opts.logger + const prevGenReqId = opts.genReqId + let logger = null + + if (prevLogger) { + opts.logger = undefined + opts.genReqId = undefined + // we need to tap into pino internals because in v5 it supports + // adding serializers in child loggers + if (prevLogger[serializersSym]) { + opts.serializers = Object.assign({}, opts.serializers, prevLogger[serializersSym]) + } + logger = prevLogger.child({}, opts) + opts.logger = prevLogger + opts.genReqId = prevGenReqId + } else { + logger = pino(opts, opts.stream) + } + + return logger +} + +const serializers = { + req: function asReqValue (req) { + return { + method: req.method, + url: req.url, + version: req.headers && req.headers['accept-version'], + host: req.host, + remoteAddress: req.ip, + remotePort: req.socket ? req.socket.remotePort : undefined + } + }, + err: pino.stdSerializers.err, + res: function asResValue (reply) { + return { + statusCode: reply.statusCode + } + } +} + +module.exports = { + serializers, + createPinoLogger +} diff --git a/lib/logger.js b/lib/logger.js deleted file mode 100644 index 0a441505062..00000000000 --- a/lib/logger.js +++ /dev/null @@ -1,123 +0,0 @@ -'use strict' - -/** - * Code imported from `pino-http` - * Repo: https://github.com/pinojs/pino-http - * License: MIT (https://raw.githubusercontent.com/pinojs/pino-http/master/LICENSE) - */ - -const nullLogger = require('abstract-logging') -const pino = require('pino') -const { serializersSym } = pino.symbols -const { FST_ERR_LOG_INVALID_DESTINATION } = require('./errors') - -function createPinoLogger (opts, stream) { - stream = stream || opts.stream - delete opts.stream - - if (stream && opts.file) { - throw new FST_ERR_LOG_INVALID_DESTINATION() - } else if (opts.file) { - // we do not have stream - stream = pino.destination(opts.file) - delete opts.file - } - - const prevLogger = opts.logger - const prevGenReqId = opts.genReqId - let logger = null - - if (prevLogger) { - opts.logger = undefined - opts.genReqId = undefined - // we need to tap into pino internals because in v5 it supports - // adding serializers in child loggers - if (prevLogger[serializersSym]) { - opts.serializers = Object.assign({}, opts.serializers, prevLogger[serializersSym]) - } - logger = prevLogger.child({}, opts) - opts.logger = prevLogger - opts.genReqId = prevGenReqId - } else { - logger = pino(opts, stream) - } - - return logger -} - -const serializers = { - req: function asReqValue (req) { - return { - method: req.method, - url: req.url, - version: req.headers && req.headers['accept-version'], - hostname: req.hostname, - remoteAddress: req.ip, - remotePort: req.socket ? req.socket.remotePort : undefined - } - }, - err: pino.stdSerializers.err, - res: function asResValue (reply) { - return { - statusCode: reply.statusCode - } - } -} - -function now () { - const ts = process.hrtime() - return (ts[0] * 1e3) + (ts[1] / 1e6) -} - -function createLogger (options) { - if (isValidLogger(options.logger)) { - const logger = createPinoLogger({ - logger: options.logger, - serializers: Object.assign({}, serializers, options.logger.serializers) - }) - return { logger, hasLogger: true } - } else if (!options.logger) { - const logger = nullLogger - logger.child = () => logger - return { logger, hasLogger: false } - } else { - const localLoggerOptions = {} - if (Object.prototype.toString.call(options.logger) === '[object Object]') { - Reflect.ownKeys(options.logger).forEach(prop => { - Object.defineProperty(localLoggerOptions, prop, { - value: options.logger[prop], - writable: true, - enumerable: true, - configurable: true - }) - }) - } - localLoggerOptions.level = localLoggerOptions.level || 'info' - localLoggerOptions.serializers = Object.assign({}, serializers, localLoggerOptions.serializers) - options.logger = localLoggerOptions - const logger = createPinoLogger(options.logger) - return { logger, hasLogger: true } - } -} - -function isValidLogger (logger) { - if (!logger) { - return false - } - - let result = true - const methods = ['info', 'error', 'debug', 'fatal', 'warn', 'trace', 'child'] - for (let i = 0; i < methods.length; i += 1) { - if (!logger[methods[i]] || typeof logger[methods[i]] !== 'function') { - result = false - break - } - } - return result -} - -module.exports = { - createLogger, - serializers, - now -} diff --git a/lib/pluginOverride.js b/lib/plugin-override.js similarity index 74% rename from lib/pluginOverride.js rename to lib/plugin-override.js index d3d9d7b6e91..4eef32577ee 100644 --- a/lib/pluginOverride.js +++ b/lib/plugin-override.js @@ -12,24 +12,26 @@ const { kReply, kRequest, kFourOhFour, - kPluginNameChain + kPluginNameChain, + kErrorHandlerAlreadySet } = require('./symbols.js') const Reply = require('./reply') const Request = require('./request') const SchemaController = require('./schema-controller') -const ContentTypeParser = require('./contentTypeParser') +const ContentTypeParser = require('./content-type-parser.js') const { buildHooks } = require('./hooks') -const pluginUtils = require('./pluginUtils') +const pluginUtils = require('./plugin-utils.js') // Function that runs the encapsulation magic. // Everything that need to be encapsulated must be handled in this function. module.exports = function override (old, fn, opts) { const shouldSkipOverride = pluginUtils.registerPlugin.call(old, fn) + const fnName = pluginUtils.getPluginName(fn) || pluginUtils.getFuncPreview(fn) if (shouldSkipOverride) { // after every plugin registration we will enter a new name - old[kPluginNameChain].push(pluginUtils.getDisplayName(fn)) + old[kPluginNameChain].push(fnName) return old } @@ -48,8 +50,15 @@ module.exports = function override (old, fn, opts) { instance[kSchemaController] = SchemaController.buildSchemaController(old[kSchemaController]) instance.getSchema = instance[kSchemaController].getSchema.bind(instance[kSchemaController]) instance.getSchemas = instance[kSchemaController].getSchemas.bind(instance[kSchemaController]) - instance[pluginUtils.registeredPlugins] = Object.create(instance[pluginUtils.registeredPlugins]) - instance[kPluginNameChain] = [pluginUtils.getPluginName(fn) || pluginUtils.getFuncPreview(fn)] + + // Track the registered and loaded plugins since the root instance. + // It does not track the current encapsulated plugin. + instance[pluginUtils.kRegisteredPlugins] = Object.create(instance[pluginUtils.kRegisteredPlugins]) + + // Track the plugin chain since the root instance. + // When an non-encapsulated plugin is added, the chain will be updated. + instance[kPluginNameChain] = [fnName] + instance[kErrorHandlerAlreadySet] = false if (instance[kLogSerializers] || opts.logSerializers) { instance[kLogSerializers] = Object.assign(Object.create(instance[kLogSerializers]), opts.logSerializers) @@ -59,7 +68,7 @@ module.exports = function override (old, fn, opts) { instance[kFourOhFour].arrange404(instance) } - for (const hook of instance[kHooks].onRegister) hook.call(this, instance, opts) + for (const hook of instance[kHooks].onRegister) hook.call(old, instance, opts) return instance } diff --git a/lib/pluginUtils.js b/lib/plugin-utils.js similarity index 72% rename from lib/pluginUtils.js rename to lib/plugin-utils.js index 84f9b6a11a3..d18d94e58a5 100644 --- a/lib/pluginUtils.js +++ b/lib/plugin-utils.js @@ -1,13 +1,19 @@ 'use strict' const semver = require('semver') -const assert = require('assert') -const registeredPlugins = Symbol.for('registered-plugin') +const assert = require('node:assert') +const kRegisteredPlugins = Symbol.for('registered-plugin') const { kTestInternals } = require('./symbols.js') -const { exist, existReply, existRequest } = require('./decorate') -const { FST_ERR_PLUGIN_VERSION_MISMATCH } = require('./errors') +const { exist, existReply, existRequest } = require('./decorate.js') +const { + FST_ERR_PLUGIN_VERSION_MISMATCH, + FST_ERR_PLUGIN_NOT_PRESENT_IN_INSTANCE, + FST_ERR_PLUGIN_INVALID_ASYNC_HANDLER +} = require('./errors.js') + +const rcRegex = /-(?:rc|pre|alpha).+$/u function getMeta (fn) { return fn[Symbol.for('plugin-meta')] @@ -22,12 +28,15 @@ function getPluginName (func) { // let's see if this is a file, and in that case use that // this is common for plugins const cache = require.cache - const keys = Object.keys(cache) - - for (let i = 0; i < keys.length; i++) { - const key = keys[i] - if (cache[key].exports === func) { - return key + // cache is undefined inside SEA + if (cache) { + const keys = Object.keys(cache) + + for (let i = 0; i < keys.length; i++) { + const key = keys[i] + if (cache[key].exports === func) { + return key + } } } @@ -41,7 +50,7 @@ function getPluginName (func) { function getFuncPreview (func) { // takes the first two lines of the function if nothing else works - return func.toString().split('\n').slice(0, 2).map(s => s.trim()).join(' -- ') + return func.toString().split('\n', 2).map(s => s.trim()).join(' -- ') } function getDisplayName (fn) { @@ -62,7 +71,7 @@ function checkDependencies (fn) { dependencies.forEach(dependency => { assert( - this[registeredPlugins].indexOf(dependency) > -1, + this[kRegisteredPlugins].indexOf(dependency) > -1, `The dependency '${dependency}' of plugin '${meta.name}' is not registered` ) }) @@ -92,18 +101,18 @@ function _checkDecorators (that, instance, decorators, name) { decorators.forEach(decorator => { const withPluginName = typeof name === 'string' ? ` required by '${name}'` : '' if (!checks[instance].call(that, decorator)) { - throw new Error(`The decorator '${decorator}'${withPluginName} is not present in ${instance}`) + throw new FST_ERR_PLUGIN_NOT_PRESENT_IN_INSTANCE(decorator, withPluginName, instance) } }) } function checkVersion (fn) { const meta = getMeta(fn) - if (!meta) return + if (meta?.fastify == null) return const requiredVersion = meta.fastify - const fastifyRc = /-rc.+$/.test(this.version) + const fastifyRc = rcRegex.test(this.version) if (fastifyRc === true && semver.gt(this.version, semver.coerce(requiredVersion)) === true) { // A Fastify release candidate phase is taking place. In order to reduce // the effort needed to test plugins with the RC, we allow plugins targeting @@ -125,11 +134,19 @@ function registerPluginName (fn) { const name = meta.name if (!name) return - this[registeredPlugins].push(name) + this[kRegisteredPlugins].push(name) + return name +} + +function checkPluginHealthiness (fn, pluginName) { + if (fn.constructor.name === 'AsyncFunction' && fn.length === 3) { + throw new FST_ERR_PLUGIN_INVALID_ASYNC_HANDLER(pluginName) + } } function registerPlugin (fn) { - registerPluginName.call(this, fn) + const pluginName = registerPluginName.call(this, fn) || getPluginName(fn) + checkPluginHealthiness.call(this, fn, pluginName) checkVersion.call(this, fn) checkDecorators.call(this, fn) checkDependencies.call(this, fn) @@ -139,7 +156,7 @@ function registerPlugin (fn) { module.exports = { getPluginName, getFuncPreview, - registeredPlugins, + kRegisteredPlugins, getDisplayName, registerPlugin } diff --git a/lib/promise.js b/lib/promise.js new file mode 100644 index 00000000000..ec49ad6707a --- /dev/null +++ b/lib/promise.js @@ -0,0 +1,23 @@ +'use strict' + +const { kTestInternals } = require('./symbols') + +function withResolvers () { + let res, rej + const promise = new Promise((resolve, reject) => { + res = resolve + rej = reject + }) + return { promise, resolve: res, reject: rej } +} + +module.exports = { + // TODO(20.x): remove when node@20 is not supported + withResolvers: typeof Promise.withResolvers === 'function' + ? Promise.withResolvers.bind(Promise) // Promise.withResolvers must bind to itself + /* c8 ignore next */ + : withResolvers, // Tested using the kTestInternals + [kTestInternals]: { + withResolvers + } +} diff --git a/lib/reply.js b/lib/reply.js index 45db78b96e4..56f5e1eaeb5 100644 --- a/lib/reply.js +++ b/lib/reply.js @@ -1,6 +1,6 @@ 'use strict' -const eos = require('stream').finished +const eos = require('node:stream').finished const { kFourOhFourContext, @@ -16,12 +16,25 @@ const { kReplyHasStatusCode, kReplyIsRunningOnErrorHook, kReplyNextErrorHandler, - kDisableRequestLogging + kDisableRequestLogging, + kSchemaResponse, + kReplyCacheSerializeFns, + kSchemaController, + kOptions, + kRouteContext, + kTimeoutTimer, + kOnAbort, + kRequestSignal } = require('./symbols.js') -const { hookRunner, hookIterator, onSendHookRunner } = require('./hooks') - -const internals = require('./handleRequest')[Symbol.for('internals')] -const loggerUtils = require('./logger') +const { + onSendHookRunner, + onResponseHookRunner, + preHandlerHookRunner, + preSerializationHookRunner +} = require('./hooks') + +const internals = require('./handle-request.js')[Symbol.for('internals')] +const loggerUtils = require('./logger-factory') const now = loggerUtils.now const { handleError } = require('./error-handler') const { getSchemaSerializer } = require('./schemas') @@ -33,14 +46,20 @@ const CONTENT_TYPE = { } const { FST_ERR_REP_INVALID_PAYLOAD_TYPE, + FST_ERR_REP_RESPONSE_BODY_CONSUMED, + FST_ERR_REP_READABLE_STREAM_LOCKED, FST_ERR_REP_ALREADY_SENT, - FST_ERR_REP_SENT_VALUE, FST_ERR_SEND_INSIDE_ONERR, FST_ERR_BAD_STATUS_CODE, FST_ERR_BAD_TRAILER_NAME, - FST_ERR_BAD_TRAILER_VALUE + FST_ERR_BAD_TRAILER_VALUE, + FST_ERR_MISSING_SERIALIZATION_FN, + FST_ERR_MISSING_CONTENTTYPE_SERIALIZATION_FN, + FST_ERR_DEC_UNDECLARED } = require('./errors') -const warning = require('./warnings') +const decorators = require('./decorate') + +const toString = Object.prototype.toString function Reply (res, request, log) { this.raw = res @@ -58,14 +77,22 @@ function Reply (res, request, log) { Reply.props = [] Object.defineProperties(Reply.prototype, { - context: { + [kRouteContext]: { + get () { + return this.request[kRouteContext] + } + }, + elapsedTime: { get () { - return this.request.context + if (this[kReplyStartTime] === undefined) { + return 0 + } + return (this[kReplyEndTime] || now()) - this[kReplyStartTime] } }, server: { get () { - return this.request.context.server + return this.request[kRouteContext].server } }, sent: { @@ -73,20 +100,6 @@ Object.defineProperties(Reply.prototype, { get () { // We are checking whether reply was hijacked or the response has ended. return (this[kReplyHijacked] || this.raw.writableEnded) === true - }, - set (value) { - warning.emit('FSTDEP010') - - if (value !== true) { - throw new FST_ERR_REP_SENT_VALUE() - } - - // We throw only if sent was overwritten from Fastify - if (this.sent && this[kReplyHijacked]) { - throw new FST_ERR_REP_ALREADY_SENT() - } - - this[kReplyHijacked] = true } }, statusCode: { @@ -96,25 +109,44 @@ Object.defineProperties(Reply.prototype, { set (value) { this.code(value) } + }, + routeOptions: { + get () { + return this.request.routeOptions + } } }) +Reply.prototype.writeEarlyHints = function (hints, callback) { + this.raw.writeEarlyHints(hints, callback) + return this +} + Reply.prototype.hijack = function () { this[kReplyHijacked] = true + // Clear handler timeout and signal — hijacked replies manage their own lifecycle + if (this.request[kRequestSignal]) { + clearTimeout(this.request[kTimeoutTimer]) + this.request[kTimeoutTimer] = null + if (this.request[kOnAbort]) { + this.request.raw.removeListener('close', this.request[kOnAbort]) + this.request[kOnAbort] = null + } + } return this } Reply.prototype.send = function (payload) { - if (this[kReplyIsRunningOnErrorHook] === true) { + if (this[kReplyIsRunningOnErrorHook]) { throw new FST_ERR_SEND_INSIDE_ONERR() } - if (this.sent) { - this.log.warn({ err: new FST_ERR_REP_ALREADY_SENT() }, 'Reply already sent') + if (this.sent === true) { + this.log.warn({ err: new FST_ERR_REP_ALREADY_SENT(this.request.url, this.request.method) }) return this } - if (payload instanceof Error || this[kReplyIsError] === true) { + if (this[kReplyIsError] || payload instanceof Error) { this[kReplyIsError] = false onErrorHook(this, payload, onSendHook) return this @@ -129,20 +161,30 @@ Reply.prototype.send = function (payload) { const hasContentType = contentType !== undefined if (payload !== null) { - if (typeof payload.pipe === 'function') { + if ( + // node:stream + typeof payload.pipe === 'function' || + // node:stream/web + typeof payload.getReader === 'function' || + // Response + toString.call(payload) === '[object Response]' + ) { onSendHook(this, payload) return this } - if (Buffer.isBuffer(payload)) { - if (hasContentType === false) { + if (payload.buffer instanceof ArrayBuffer) { + if (!hasContentType) { this[kReplyHeaders]['content-type'] = CONTENT_TYPE.OCTET } - onSendHook(this, payload) + const payloadToSend = Buffer.isBuffer(payload) + ? payload + : Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength) + onSendHook(this, payloadToSend) return this } - if (hasContentType === false && typeof payload === 'string') { + if (!hasContentType && typeof payload === 'string') { this[kReplyHeaders]['content-type'] = CONTENT_TYPE.PLAIN onSendHook(this, payload) return this @@ -151,30 +193,28 @@ Reply.prototype.send = function (payload) { if (this[kReplySerializer] !== null) { if (typeof payload !== 'string') { - preserializeHook(this, payload) + preSerializationHook(this, payload) return this - } else { - payload = this[kReplySerializer](payload) } + payload = this[kReplySerializer](payload) - // The indexOf below also matches custom json mimetypes such as 'application/hal+json' or 'application/ld+json' - } else if (hasContentType === false || contentType.indexOf('json') > -1) { - if (hasContentType === false) { + // The indexOf below also matches custom json mimetypes such as 'application/hal+json' or 'application/ld+json' + } else if (!hasContentType || contentType.indexOf('json') !== -1) { + if (!hasContentType) { this[kReplyHeaders]['content-type'] = CONTENT_TYPE.JSON - } else { + } else if (contentType.indexOf('charset') === -1) { // If user doesn't set charset, we will set charset to utf-8 - if (contentType.indexOf('charset') === -1) { - const customContentType = contentType.trim() - if (customContentType.endsWith(';')) { - // custom content-type is ended with ';' - this[kReplyHeaders]['content-type'] = `${customContentType} charset=utf-8` - } else { - this[kReplyHeaders]['content-type'] = `${customContentType}; charset=utf-8` - } + const customContentType = contentType.trim() + if (customContentType.endsWith(';')) { + // custom content-type is ended with ';' + this[kReplyHeaders]['content-type'] = `${customContentType} charset=utf-8` + } else { + this[kReplyHeaders]['content-type'] = `${customContentType}; charset=utf-8` } } + if (typeof payload !== 'string') { - preserializeHook(this, payload) + preSerializationHook(this, payload) return this } } @@ -186,12 +226,8 @@ Reply.prototype.send = function (payload) { Reply.prototype.getHeader = function (key) { key = key.toLowerCase() - const res = this.raw - let value = this[kReplyHeaders][key] - if (value === undefined && res.hasHeader(key)) { - value = res.getHeader(key) - } - return value + const value = this[kReplyHeaders][key] + return value !== undefined ? value : this.raw.getHeader(key) } Reply.prototype.getHeaders = function () { @@ -218,13 +254,13 @@ Reply.prototype.header = function (key, value = '') { key = key.toLowerCase() if (this[kReplyHeaders][key] && key === 'set-cookie') { - // https://tools.ietf.org/html/rfc7230#section-3.2.2 + // https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.2 if (typeof this[kReplyHeaders][key] === 'string') { this[kReplyHeaders][key] = [this[kReplyHeaders][key]] } if (Array.isArray(value)) { - this[kReplyHeaders][key].push(...value) + Array.prototype.push.apply(this[kReplyHeaders][key], value) } else { this[kReplyHeaders][key].push(value) } @@ -237,8 +273,7 @@ Reply.prototype.header = function (key, value = '') { Reply.prototype.headers = function (headers) { const keys = Object.keys(headers) - /* eslint-disable no-var */ - for (var i = 0; i !== keys.length; ++i) { + for (let i = 0; i !== keys.length; ++i) { const key = keys[i] this.header(key, headers[key]) } @@ -247,7 +282,7 @@ Reply.prototype.headers = function (headers) { } // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Trailer#directives -// https://httpwg.org/specs/rfc7230.html#chunked.trailer.part +// https://datatracker.ietf.org/doc/html/rfc7230.html#chunked.trailer.part const INVALID_TRAILERS = new Set([ 'transfer-encoding', 'content-length', @@ -287,26 +322,116 @@ Reply.prototype.removeTrailer = function (key) { } Reply.prototype.code = function (code) { - const intValue = parseInt(code) - if (isNaN(intValue) || intValue < 100 || intValue > 599) { + const statusCode = +code + if (!(statusCode >= 100 && statusCode <= 599)) { throw new FST_ERR_BAD_STATUS_CODE(code || String(code)) } - this.raw.statusCode = intValue + this.raw.statusCode = statusCode this[kReplyHasStatusCode] = true return this } Reply.prototype.status = Reply.prototype.code +Reply.prototype.getSerializationFunction = function (schemaOrStatus, contentType) { + let serialize + + if (typeof schemaOrStatus === 'string' || typeof schemaOrStatus === 'number') { + if (typeof contentType === 'string') { + serialize = this[kRouteContext][kSchemaResponse]?.[schemaOrStatus]?.[contentType] + } else { + serialize = this[kRouteContext][kSchemaResponse]?.[schemaOrStatus] + } + } else if (typeof schemaOrStatus === 'object') { + serialize = this[kRouteContext][kReplyCacheSerializeFns]?.get(schemaOrStatus) + } + + return serialize +} + +Reply.prototype.compileSerializationSchema = function (schema, httpStatus = null, contentType = null) { + const { request } = this + const { method, url } = request + + // Check if serialize function already compiled + if (this[kRouteContext][kReplyCacheSerializeFns]?.has(schema)) { + return this[kRouteContext][kReplyCacheSerializeFns].get(schema) + } + + const serializerCompiler = this[kRouteContext].serializerCompiler || + this.server[kSchemaController].serializerCompiler || + ( + // We compile the schemas if no custom serializerCompiler is provided + // nor set + this.server[kSchemaController].setupSerializer(this.server[kOptions]) || + this.server[kSchemaController].serializerCompiler + ) + + const serializeFn = serializerCompiler({ + schema, + method, + url, + httpStatus, + contentType + }) + + // We create a WeakMap to compile the schema only once + // Its done lazily to avoid add overhead by creating the WeakMap + // if it is not used + // TODO: Explore a central cache for all the schemas shared across + // encapsulated contexts + if (this[kRouteContext][kReplyCacheSerializeFns] == null) { + this[kRouteContext][kReplyCacheSerializeFns] = new WeakMap() + } + + this[kRouteContext][kReplyCacheSerializeFns].set(schema, serializeFn) + + return serializeFn +} + +Reply.prototype.serializeInput = function (input, schema, httpStatus, contentType) { + const possibleContentType = httpStatus + let serialize + httpStatus = typeof schema === 'string' || typeof schema === 'number' + ? schema + : httpStatus + + contentType = httpStatus && possibleContentType !== httpStatus + ? possibleContentType + : contentType + + if (httpStatus != null) { + if (contentType != null) { + serialize = this[kRouteContext][kSchemaResponse]?.[httpStatus]?.[contentType] + } else { + serialize = this[kRouteContext][kSchemaResponse]?.[httpStatus] + } + + if (serialize == null) { + if (contentType) throw new FST_ERR_MISSING_CONTENTTYPE_SERIALIZATION_FN(httpStatus, contentType) + throw new FST_ERR_MISSING_SERIALIZATION_FN(httpStatus) + } + } else { + // Check if serialize function already compiled + if (this[kRouteContext][kReplyCacheSerializeFns]?.has(schema)) { + serialize = this[kRouteContext][kReplyCacheSerializeFns].get(schema) + } else { + serialize = this.compileSerializationSchema(schema, httpStatus, contentType) + } + } + + return serialize(input) +} + Reply.prototype.serialize = function (payload) { if (this[kReplySerializer] !== null) { return this[kReplySerializer](payload) } else { - if (this.context && this.context[kReplySerializerDefault]) { - return this.context[kReplySerializerDefault](payload, this.raw.statusCode) + if (this[kRouteContext] && this[kRouteContext][kReplySerializerDefault]) { + return this[kRouteContext][kReplySerializerDefault](payload, this.raw.statusCode) } else { - return serialize(this.context, payload, this.raw.statusCode) + return serialize(this[kRouteContext], payload, this.raw.statusCode) } } } @@ -321,9 +446,8 @@ Reply.prototype.type = function (type) { return this } -Reply.prototype.redirect = function (code, url) { - if (typeof code === 'string') { - url = code +Reply.prototype.redirect = function (url, code) { + if (!code) { code = this[kReplyHasStatusCode] ? this.raw.statusCode : 302 } @@ -335,20 +459,9 @@ Reply.prototype.callNotFound = function () { return this } -Reply.prototype.getResponseTime = function () { - let responseTime = 0 - - if (this[kReplyStartTime] !== undefined) { - responseTime = (this[kReplyEndTime] || now()) - this[kReplyStartTime] - } - - return responseTime -} - // Make reply a thenable, so it could be used with async/await. // See // - https://github.com/fastify/fastify/issues/1864 for the discussions -// - https://promisesaplus.com/ for the definition of thenable // - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/then for the signature Reply.prototype.then = function (fulfilled, rejected) { if (this.sent) { @@ -371,21 +484,34 @@ Reply.prototype.then = function (fulfilled, rejected) { }) } -function preserializeHook (reply, payload) { - if (reply.context.preSerialization !== null) { - onSendHookRunner( - reply.context.preSerialization, +Reply.prototype.getDecorator = function (name) { + if (!decorators.hasKey(this, name) && !decorators.exist(this, name)) { + throw new FST_ERR_DEC_UNDECLARED(name, 'reply') + } + + const decorator = this[name] + if (typeof decorator === 'function') { + return decorator.bind(this) + } + + return decorator +} + +function preSerializationHook (reply, payload) { + if (reply[kRouteContext].preSerialization !== null) { + preSerializationHookRunner( + reply[kRouteContext].preSerialization, reply.request, reply, payload, - preserializeHookEnd + preSerializationHookEnd ) } else { - preserializeHookEnd(null, reply.request, reply, payload) + preSerializationHookEnd(null, undefined, reply, payload) } } -function preserializeHookEnd (err, request, reply, payload) { +function preSerializationHookEnd (err, _request, reply, payload) { if (err != null) { onErrorHook(reply, err) return @@ -394,13 +520,13 @@ function preserializeHookEnd (err, request, reply, payload) { try { if (reply[kReplySerializer] !== null) { payload = reply[kReplySerializer](payload) - } else if (reply.context && reply.context[kReplySerializerDefault]) { - payload = reply.context[kReplySerializerDefault](payload, reply.raw.statusCode) + } else if (reply[kRouteContext] && reply[kRouteContext][kReplySerializerDefault]) { + payload = reply[kRouteContext][kReplySerializerDefault](payload, reply.raw.statusCode) } else { - payload = serialize(reply.context, payload, reply.raw.statusCode) + payload = serialize(reply[kRouteContext], payload, reply.raw.statusCode, reply[kReplyHeaders]['content-type']) } } catch (e) { - wrapSeralizationError(e, reply) + wrapSerializationError(e, reply) onErrorHook(reply, e) return } @@ -408,14 +534,14 @@ function preserializeHookEnd (err, request, reply, payload) { onSendHook(reply, payload) } -function wrapSeralizationError (error, reply) { - error.serialization = reply.context.config +function wrapSerializationError (error, reply) { + error.serialization = reply[kRouteContext].config } function onSendHook (reply, payload) { - if (reply.context.onSend !== null) { + if (reply[kRouteContext].onSend !== null) { onSendHookRunner( - reply.context.onSend, + reply[kRouteContext].onSend, reply.request, reply, payload, @@ -434,10 +560,21 @@ function wrapOnSendEnd (err, request, reply, payload) { } } +function safeWriteHead (reply, statusCode) { + const res = reply.raw + try { + res.writeHead(statusCode, reply[kReplyHeaders]) + } catch (err) { + if (err.code === 'ERR_HTTP_HEADERS_SENT') { + reply.log.warn(`Reply was already sent, did you forget to "return reply" in the "${reply.request.raw.url}" (${reply.request.raw.method}) route?`) + } + throw err + } +} + function onSendEnd (reply, payload) { const res = reply.raw const req = reply.request - const statusCode = res.statusCode // we check if we need to update the trailers header and set it if (reply[kReplyTrailers] !== null) { @@ -453,8 +590,35 @@ function onSendEnd (reply, payload) { reply.header('Trailer', header.trim()) } + // since Response contain status code, headers and body, + // we need to update the status, add the headers and use it's body as payload + // before continuing + if (toString.call(payload) === '[object Response]') { + // https://developer.mozilla.org/en-US/docs/Web/API/Response/status + if (typeof payload.status === 'number') { + reply.code(payload.status) + } + + // https://developer.mozilla.org/en-US/docs/Web/API/Response/headers + if (typeof payload.headers === 'object' && typeof payload.headers.forEach === 'function') { + for (const [headerName, headerValue] of payload.headers) { + reply.header(headerName, headerValue) + } + } + + // https://developer.mozilla.org/en-US/docs/Web/API/Response/body + if (payload.body !== null) { + if (payload.bodyUsed) { + throw new FST_ERR_REP_RESPONSE_BODY_CONSUMED() + } + } + // Keep going, body is either null or ReadableStream + payload = payload.body + } + const statusCode = res.statusCode + if (payload === undefined || payload === null) { - // according to https://tools.ietf.org/html/rfc7230#section-3.3.2 + // according to https://datatracker.ietf.org/doc/html/rfc7230#section-3.3.2 // we cannot send a content-length for 304 and 204, and all status code // < 200 // A sender MUST NOT send a Content-Length header field in any message @@ -464,18 +628,38 @@ function onSendEnd (reply, payload) { reply[kReplyHeaders]['content-length'] = '0' } - res.writeHead(statusCode, reply[kReplyHeaders]) + safeWriteHead(reply, statusCode) sendTrailer(payload, res, reply) - // avoid ArgumentsAdaptorTrampoline from V8 - res.end(null, null, null) return } + if ((statusCode >= 100 && statusCode < 200) || statusCode === 204) { + // Responses without a content body must not send content-type + // or content-length headers. + // See https://www.rfc-editor.org/rfc/rfc9110.html#section-8.6. + reply.removeHeader('content-type') + reply.removeHeader('content-length') + safeWriteHead(reply, statusCode) + sendTrailer(undefined, res, reply) + if (typeof payload.resume === 'function') { + payload.on('error', noop) + payload.resume() + } + return + } + + // node:stream if (typeof payload.pipe === 'function') { sendStream(payload, res, reply) return } + // node:stream/web + if (typeof payload.getReader === 'function') { + sendWebStream(payload, res, reply) + return + } + if (typeof payload !== 'string' && !Buffer.isBuffer(payload)) { throw new FST_ERR_REP_INVALID_PAYLOAD_TYPE(typeof payload) } @@ -483,21 +667,19 @@ function onSendEnd (reply, payload) { if (reply[kReplyTrailers] === null) { const contentLength = reply[kReplyHeaders]['content-length'] if (!contentLength || - (req.raw.method !== 'HEAD' && - parseInt(contentLength, 10) !== Buffer.byteLength(payload) - ) + (req.raw.method !== 'HEAD' && + Number(contentLength) !== Buffer.byteLength(payload) + ) ) { reply[kReplyHeaders]['content-length'] = '' + Buffer.byteLength(payload) } } - res.writeHead(statusCode, reply[kReplyHeaders]) + safeWriteHead(reply, statusCode) // write payload first res.write(payload) // then send trailers sendTrailer(payload, res, reply) - // avoid ArgumentsAdaptorTrampoline from V8 - res.end(null, null, null) } function logStreamError (logger, err, res) { @@ -510,6 +692,79 @@ function logStreamError (logger, err, res) { } } +function sendWebStream (payload, res, reply) { + if (payload.locked) { + throw new FST_ERR_REP_READABLE_STREAM_LOCKED() + } + + let sourceOpen = true + let errorLogged = false + let waitingDrain = false + const reader = payload.getReader() + + eos(res, function (err) { + if (sourceOpen) { + if (err != null && res.headersSent && !errorLogged) { + errorLogged = true + logStreamError(reply.log, err, res) + } + reader.cancel().catch(noop) + } + }) + + if (!res.headersSent) { + for (const key in reply[kReplyHeaders]) { + res.setHeader(key, reply[kReplyHeaders][key]) + } + } else { + reply.log.warn('response will send, but you shouldn\'t use res.writeHead in stream mode') + } + + function onRead (result) { + if (result.done) { + sourceOpen = false + sendTrailer(null, res, reply) + return + } + /* c8 ignore next 5 - race condition: eos handler typically fires first */ + if (res.destroyed) { + sourceOpen = false + reader.cancel().catch(noop) + return + } + const shouldContinue = res.write(result.value) + if (shouldContinue === false) { + waitingDrain = true + res.once('drain', onDrain) + return + } + reader.read().then(onRead, onReadError) + } + + function onDrain () { + if (!waitingDrain || !sourceOpen || res.destroyed) { + return + } + waitingDrain = false + reader.read().then(onRead, onReadError) + } + + function onReadError (err) { + sourceOpen = false + if (res.headersSent || reply.request.raw.aborted === true) { + if (!errorLogged) { + errorLogged = true + logStreamError(reply.log, err, reply) + } + res.destroy() + } else { + onErrorHook(reply, err) + } + } + + reader.read().then(onRead, onReadError) +} + function sendStream (payload, res, reply) { let sourceOpen = true let errorLogged = false @@ -523,7 +778,7 @@ function sendStream (payload, res, reply) { if (res.headersSent || reply.request.raw.aborted === true) { if (!errorLogged) { errorLogged = true - logStreamError(reply.log, err, res) + logStreamError(reply.log, err, reply) } res.destroy() } else { @@ -566,14 +821,57 @@ function sendStream (payload, res, reply) { } function sendTrailer (payload, res, reply) { - if (reply[kReplyTrailers] === null) return + if (reply[kReplyTrailers] === null) { + // when no trailer, we close the stream + res.end(null, null, null) // avoid ArgumentsAdaptorTrampoline from V8 + return + } const trailerHeaders = Object.keys(reply[kReplyTrailers]) const trailers = {} + let handled = 0 + let skipped = true + function send () { + // add trailers when all handler handled + /* istanbul ignore else */ + if (handled === 0) { + res.addTrailers(trailers) + // we need to properly close the stream + // after trailers sent + res.end(null, null, null) // avoid ArgumentsAdaptorTrampoline from V8 + } + } + for (const trailerName of trailerHeaders) { if (typeof reply[kReplyTrailers][trailerName] !== 'function') continue - trailers[trailerName] = reply[kReplyTrailers][trailerName](reply, payload) + skipped = false + handled-- + + function cb (err, value) { + // TODO: we may protect multiple callback calls + // or mixing async-await with callback + handled++ + + // we can safely ignore error for trailer + // since it does affect the client + // we log in here only for debug usage + if (err) reply.log.debug(err) + else trailers[trailerName] = value + + // we push the check to the end of event + // loop, so the registration continue to + // process. + process.nextTick(send) + } + + const result = reply[kReplyTrailers][trailerName](reply, payload, cb) + if (typeof result === 'object' && typeof result.then === 'function') { + result.then((v) => cb(null, v), cb) + } } - res.addTrailers(trailers) + + // when all trailers are skipped + // we need to close the stream + if (skipped) res.end(null, null, null) // avoid ArgumentsAdaptorTrampoline from V8 } function sendStreamTrailer (payload, res, reply) { @@ -582,10 +880,10 @@ function sendStreamTrailer (payload, res, reply) { } function onErrorHook (reply, error, cb) { - if (reply.context.onError !== null && !reply[kReplyNextErrorHandler]) { + if (reply[kRouteContext].onError !== null && !reply[kReplyNextErrorHandler]) { reply[kReplyIsRunningOnErrorHook] = true onSendHookRunner( - reply.context.onError, + reply[kRouteContext].onError, reply.request, reply, error, @@ -604,12 +902,21 @@ function setupResponseListeners (reply) { reply.raw.removeListener('finish', onResFinished) reply.raw.removeListener('error', onResFinished) - const ctx = reply.context + const ctx = reply[kRouteContext] + + // Clean up handler timeout / signal resources + if (reply.request[kRequestSignal]) { + clearTimeout(reply.request[kTimeoutTimer]) + reply.request[kTimeoutTimer] = null + if (reply.request[kOnAbort]) { + reply.request.raw.removeListener('close', reply.request[kOnAbort]) + reply.request[kOnAbort] = null + } + } if (ctx && ctx.onResponse !== null) { - hookRunner( + onResponseHookRunner( ctx.onResponse, - onResponseIterator, reply.request, reply, onResponseCallback @@ -623,16 +930,12 @@ function setupResponseListeners (reply) { reply.raw.on('error', onResFinished) } -function onResponseIterator (fn, request, reply, next) { - return fn(request, reply, next) -} - function onResponseCallback (err, request, reply) { if (reply.log[kDisableRequestLogging]) { return } - const responseTime = reply.getResponseTime() + const responseTime = reply.elapsedTime if (err != null) { reply.log.error({ @@ -650,7 +953,7 @@ function onResponseCallback (err, request, reply) { } function buildReply (R) { - const props = [...R.props] + const props = R.props.slice() function _Reply (res, request, log) { this.raw = res @@ -665,10 +968,9 @@ function buildReply (R) { this[kReplyEndTime] = undefined this.log = log - // eslint-disable-next-line no-var - var prop - // eslint-disable-next-line no-var - for (var i = 0; i < props.length; i++) { + let prop + + for (let i = 0; i < props.length; i++) { prop = props[i] this[prop.key] = prop.value } @@ -681,19 +983,18 @@ function buildReply (R) { } function notFound (reply) { - if (reply.context[kFourOhFourContext] === null) { + if (reply[kRouteContext][kFourOhFourContext] === null) { reply.log.warn('Trying to send a NotFound error inside a 404 handler. Sending basic 404 response.') reply.code(404).send('404 Not Found') return } - reply.request.context = reply.context[kFourOhFourContext] + reply.request[kRouteContext] = reply[kRouteContext][kFourOhFourContext] // preHandler hook - if (reply.context.preHandler !== null) { - hookRunner( - reply.context.preHandler, - hookIterator, + if (reply[kRouteContext].preHandler !== null) { + preHandlerHookRunner( + reply[kRouteContext].preHandler, reply.request, reply, internals.preHandlerCallback @@ -711,10 +1012,11 @@ function notFound (reply) { * @param {object} context the request context * @param {object} data the JSON payload to serialize * @param {number} statusCode the http status code + * @param {string} [contentType] the reply content type * @returns {string} the serialized payload */ -function serialize (context, data, statusCode) { - const fnSerialize = getSchemaSerializer(context, statusCode) +function serialize (context, data, statusCode, contentType) { + const fnSerialize = getSchemaSerializer(context, statusCode, contentType) if (fnSerialize) { return fnSerialize(data) } diff --git a/lib/req-id-gen-factory.js b/lib/req-id-gen-factory.js new file mode 100644 index 00000000000..23b96211bc8 --- /dev/null +++ b/lib/req-id-gen-factory.js @@ -0,0 +1,52 @@ +'use strict' + +/** + * @callback GenerateRequestId + * @param {Object} req + * @returns {string} + */ + +/** + * @param {string} [requestIdHeader] + * @param {GenerateRequestId} [optGenReqId] + * @returns {GenerateRequestId} + */ +function reqIdGenFactory (requestIdHeader, optGenReqId) { + const genReqId = optGenReqId || buildDefaultGenReqId() + + if (requestIdHeader) { + return buildOptionalHeaderReqId(requestIdHeader, genReqId) + } + + return genReqId +} + +function getGenReqId (contextServer, req) { + return contextServer.genReqId(req) +} + +function buildDefaultGenReqId () { + // 2,147,483,647 (2^31 − 1) stands for max SMI value (an internal optimization of V8). + // With this upper bound, if you'll be generating 1k ids/sec, you're going to hit it in ~25 days. + // This is very likely to happen in real-world applications, hence the limit is enforced. + // Growing beyond this value will make the id generation slower and cause a deopt. + // In the worst cases, it will become a float, losing accuracy. + const maxInt = 2147483647 + + let nextReqId = 0 + return function defaultGenReqId () { + nextReqId = (nextReqId + 1) & maxInt + return `req-${nextReqId.toString(36)}` + } +} + +function buildOptionalHeaderReqId (requestIdHeader, genReqId) { + return function (req) { + return req.headers[requestIdHeader] || genReqId(req) + } +} + +module.exports = { + getGenReqId, + reqIdGenFactory +} diff --git a/lib/reqIdGenFactory.js b/lib/reqIdGenFactory.js deleted file mode 100644 index fc7e35ecdb3..00000000000 --- a/lib/reqIdGenFactory.js +++ /dev/null @@ -1,15 +0,0 @@ -'use strict' - -module.exports = function () { - // 2,147,483,647 (2^31 − 1) stands for max SMI value (an internal optimization of V8). - // With this upper bound, if you'll be generating 1k ids/sec, you're going to hit it in ~25 days. - // This is very likely to happen in real-world applications, hence the limit is enforced. - // Growing beyond this value will make the id generation slower and cause a deopt. - // In the worst cases, it will become a float, losing accuracy. - const maxInt = 2147483647 - let nextReqId = 0 - return function genReqId (req) { - nextReqId = (nextReqId + 1) & maxInt - return `req-${nextReqId.toString(36)}` - } -} diff --git a/lib/request.js b/lib/request.js index c2f6850d746..f1f4a5e1338 100644 --- a/lib/request.js +++ b/lib/request.js @@ -1,15 +1,34 @@ 'use strict' -const proxyAddr = require('proxy-addr') -const semver = require('semver') -const warning = require('./warnings') +const proxyAddr = require('@fastify/proxy-addr') const { - kHasBeenDecorated + kHasBeenDecorated, + kSchemaBody, + kSchemaHeaders, + kSchemaParams, + kSchemaQuerystring, + kSchemaController, + kOptions, + kRequestCacheValidateFns, + kRouteContext, + kRequestOriginalUrl, + kRequestSignal, + kOnAbort } = require('./symbols') +const { FST_ERR_REQ_INVALID_VALIDATION_INVOCATION, FST_ERR_DEC_UNDECLARED } = require('./errors') +const decorators = require('./decorate') + +const HTTP_PART_SYMBOL_MAP = { + body: kSchemaBody, + headers: kSchemaHeaders, + params: kSchemaParams, + querystring: kSchemaQuerystring, + query: kSchemaQuerystring +} function Request (id, params, req, query, log, context) { this.id = id - this.context = context + this[kRouteContext] = context this.params = params this.raw = req this.query = query @@ -23,8 +42,8 @@ function getTrustProxyFn (tp) { return tp } if (tp === true) { - // Support plain true/false - return function () { return true } + // Support trusting everything + return null } if (typeof tp === 'number') { // Support trusting hop count @@ -32,8 +51,8 @@ function getTrustProxyFn (tp) { } if (typeof tp === 'string') { // Support comma-separated tps - const vals = tp.split(',').map(it => it.trim()) - return proxyAddr.compile(vals) + const values = tp.split(',').map(it => it.trim()) + return proxyAddr.compile(values) } return proxyAddr.compile(tp) } @@ -47,20 +66,18 @@ function buildRequest (R, trustProxy) { } function buildRegularRequest (R) { - const props = [...R.props] + const props = R.props.slice() function _Request (id, params, req, query, log, context) { this.id = id - this.context = context + this[kRouteContext] = context this.params = params this.raw = req this.query = query this.log = log this.body = undefined - // eslint-disable-next-line no-var - var prop - // eslint-disable-next-line no-var - for (var i = 0; i < props.length; i++) { + let prop + for (let i = 0; i < props.length; i++) { prop = props[i] this[prop.key] = prop.value } @@ -89,7 +106,8 @@ function buildRequestWithTrustProxy (R, trustProxy) { Object.defineProperties(_Request.prototype, { ip: { get () { - return proxyAddr(this.raw, proxyFn) + const addrs = proxyAddr.all(this.raw, proxyFn) + return addrs[addrs.length - 1] } }, ips: { @@ -97,12 +115,18 @@ function buildRequestWithTrustProxy (R, trustProxy) { return proxyAddr.all(this.raw, proxyFn) } }, - hostname: { + host: { get () { if (this.ip !== undefined && this.headers['x-forwarded-host']) { return getLastEntryInMultiHeaderValue(this.headers['x-forwarded-host']) } - return this.headers.host || this.headers[':authority'] + /** + * The last fallback supports the following cases: + * 1. http.requireHostHeader === false + * 2. HTTP/1.0 without a Host Header + * 3. Headers schema that may remove the Host Header + */ + return this.headers.host ?? this.headers[':authority'] ?? '' } }, protocol: { @@ -120,10 +144,16 @@ function buildRequestWithTrustProxy (R, trustProxy) { return _Request } +function assertsRequestDecoration (request, name) { + if (!decorators.hasKey(request, name) && !decorators.exist(request, name)) { + throw new FST_ERR_DEC_UNDECLARED(name, 'request') + } +} + Object.defineProperties(Request.prototype, { server: { get () { - return this.context.server + return this[kRouteContext].server } }, url: { @@ -131,38 +161,66 @@ Object.defineProperties(Request.prototype, { return this.raw.url } }, - method: { + originalUrl: { get () { - return this.raw.method + /* istanbul ignore else */ + if (!this[kRequestOriginalUrl]) { + this[kRequestOriginalUrl] = this.raw.originalUrl || this.raw.url + } + return this[kRequestOriginalUrl] } }, - routerPath: { + method: { get () { - return this.context.config.url + return this.raw.method } }, - routerMethod: { + routeOptions: { get () { - return this.context.config.method + const context = this[kRouteContext] + const routeLimit = context._parserOptions.limit + const serverLimit = context.server.initialConfig.bodyLimit + const version = context.server.hasConstraintStrategy('version') ? this.raw.headers['accept-version'] : undefined + const options = { + method: context.config?.method, + url: context.config?.url, + bodyLimit: (routeLimit || serverLimit), + handlerTimeout: context.handlerTimeout, + attachValidation: context.attachValidation, + logLevel: context.logLevel, + exposeHeadRoute: context.exposeHeadRoute, + prefixTrailingSlash: context.prefixTrailingSlash, + handler: context.handler, + config: context.config, + schema: context.schema, + version + } + + return options } }, is404: { get () { - return this.context.config.url === undefined + return this[kRouteContext].config?.url === undefined } }, - connection: { + socket: { get () { - /* istanbul ignore next */ - if (semver.gte(process.versions.node, '13.0.0')) { - warning.emit('FSTDEP005') - } - return this.raw.connection + return this.raw.socket } }, - socket: { + signal: { get () { - return this.raw.socket + let ac = this[kRequestSignal] + if (ac) return ac.signal + ac = new AbortController() + this[kRequestSignal] = ac + const onAbort = () => { + if (!ac.signal.aborted) ac.abort() + } + this.raw.on('close', onAbort) + this[kOnAbort] = onAbort + return ac.signal } }, ip: { @@ -172,9 +230,42 @@ Object.defineProperties(Request.prototype, { } } }, + host: { + get () { + /** + * The last fallback supports the following cases: + * 1. http.requireHostHeader === false + * 2. HTTP/1.0 without a Host Header + * 3. Headers schema that may remove the Host Header + */ + return this.raw.headers.host ?? this.raw.headers[':authority'] ?? '' + } + }, hostname: { get () { - return this.raw.headers.host || this.raw.headers[':authority'] + // Check for IPV6 Host + if (this.host[0] === '[') { + return this.host.slice(0, this.host.indexOf(']') + 1) + } + + return this.host.split(':', 1)[0] + } + }, + port: { + get () { + // first try taking port from host + const portFromHost = parseInt(this.host.split(':').slice(-1)[0]) + if (!isNaN(portFromHost)) { + return portFromHost + } + // now fall back to port from host/:authority header + const host = (this.headers.host ?? this.headers[':authority'] ?? '') + const portFromHeader = parseInt(host.split(':').slice(-1)[0]) + if (!isNaN(portFromHeader)) { + return portFromHeader + } + // fall back to null + return null } }, protocol: { @@ -194,6 +285,105 @@ Object.defineProperties(Request.prototype, { set (headers) { this.additionalHeaders = headers } + }, + getValidationFunction: { + value: function (httpPartOrSchema) { + if (typeof httpPartOrSchema === 'string') { + const symbol = HTTP_PART_SYMBOL_MAP[httpPartOrSchema] + return this[kRouteContext][symbol] + } else if (typeof httpPartOrSchema === 'object') { + return this[kRouteContext][kRequestCacheValidateFns]?.get(httpPartOrSchema) + } + } + }, + compileValidationSchema: { + value: function (schema, httpPart = null) { + const { method, url } = this + + if (this[kRouteContext][kRequestCacheValidateFns]?.has(schema)) { + return this[kRouteContext][kRequestCacheValidateFns].get(schema) + } + + const validatorCompiler = this[kRouteContext].validatorCompiler || + this.server[kSchemaController].validatorCompiler || + ( + // We compile the schemas if no custom validatorCompiler is provided + // nor set + this.server[kSchemaController].setupValidator(this.server[kOptions]) || + this.server[kSchemaController].validatorCompiler + ) + + const validateFn = validatorCompiler({ + schema, + method, + url, + httpPart + }) + + // We create a WeakMap to compile the schema only once + // Its done lazily to avoid add overhead by creating the WeakMap + // if it is not used + // TODO: Explore a central cache for all the schemas shared across + // encapsulated contexts + if (this[kRouteContext][kRequestCacheValidateFns] == null) { + this[kRouteContext][kRequestCacheValidateFns] = new WeakMap() + } + + this[kRouteContext][kRequestCacheValidateFns].set(schema, validateFn) + + return validateFn + } + }, + validateInput: { + value: function (input, schema, httpPart) { + httpPart = typeof schema === 'string' ? schema : httpPart + + const symbol = (httpPart != null && typeof httpPart === 'string') && HTTP_PART_SYMBOL_MAP[httpPart] + let validate + + if (symbol) { + // Validate using the HTTP Request Part schema + validate = this[kRouteContext][symbol] + } + + // We cannot compile if the schema is missed + if (validate == null && (schema == null || + typeof schema !== 'object' || + Array.isArray(schema)) + ) { + throw new FST_ERR_REQ_INVALID_VALIDATION_INVOCATION(httpPart) + } + + if (validate == null) { + if (this[kRouteContext][kRequestCacheValidateFns]?.has(schema)) { + validate = this[kRouteContext][kRequestCacheValidateFns].get(schema) + } else { + // We proceed to compile if there's no validate function yet + validate = this.compileValidationSchema(schema, httpPart) + } + } + + return validate(input) + } + }, + getDecorator: { + value: function (name) { + assertsRequestDecoration(this, name) + + const decorator = this[name] + if (typeof decorator === 'function') { + return decorator.bind(this) + } + + return decorator + } + }, + setDecorator: { + value: function (name, value) { + assertsRequestDecoration(this, name) + + this[name] = value + } } }) diff --git a/lib/route.js b/lib/route.js index fbc45a418c5..8e90bda061a 100644 --- a/lib/route.js +++ b/lib/route.js @@ -2,13 +2,10 @@ const FindMyWay = require('find-my-way') const Context = require('./context') -const handleRequest = require('./handleRequest') -const { hookRunner, hookIterator, lifecycleHooks } = require('./hooks') -const supportedMethods = ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT', 'OPTIONS'] +const handleRequest = require('./handle-request.js') +const { onRequestAbortHookRunner, lifecycleHooks, preParsingHookRunner, onTimeoutHookRunner, onRequestHookRunner } = require('./hooks') const { normalizeSchema } = require('./schemas') -const { parseHeadOnSendHandlers } = require('./headRoute') -const warning = require('./warnings') -const { kRequestAcceptVersion, kRouteByFastify } = require('./symbols') +const { parseHeadOnSendHandlers } = require('./head-route.js') const { compileSchemasForValidation, @@ -18,14 +15,25 @@ const { const { FST_ERR_SCH_VALIDATION_BUILD, FST_ERR_SCH_SERIALIZATION_BUILD, - FST_ERR_DEFAULT_ROUTE_INVALID_TYPE, FST_ERR_DUPLICATED_ROUTE, FST_ERR_INVALID_URL, - FST_ERR_SEND_UNDEFINED_ERR + FST_ERR_HOOK_INVALID_HANDLER, + FST_ERR_ROUTE_OPTIONS_NOT_OBJ, + FST_ERR_ROUTE_DUPLICATED_HANDLER, + FST_ERR_ROUTE_HANDLER_NOT_FN, + FST_ERR_ROUTE_MISSING_HANDLER, + FST_ERR_ROUTE_METHOD_NOT_SUPPORTED, + FST_ERR_ROUTE_METHOD_INVALID, + FST_ERR_ROUTE_BODY_VALIDATION_SCHEMA_NOT_SUPPORTED, + FST_ERR_ROUTE_BODY_LIMIT_OPTION_NOT_INT, + FST_ERR_ROUTE_HANDLER_TIMEOUT_OPTION_NOT_INT, + FST_ERR_HANDLER_TIMEOUT, + FST_ERR_HOOK_INVALID_ASYNC_HANDLER } = require('./errors') const { kRoutePrefix, + kSupportedHTTPMethods, kLogLevel, kLogSerializers, kHooks, @@ -37,74 +45,91 @@ const { kDisableRequestLogging, kSchemaErrorFormatter, kErrorHandler, - kHasBeenDecorated + kHasBeenDecorated, + kRequestAcceptVersion, + kRouteByFastify, + kRouteContext, + kRequestSignal, + kTimeoutTimer, + kOnAbort } = require('./symbols.js') const { buildErrorHandler } = require('./error-handler') +const { createChildLogger } = require('./logger-factory.js') +const { getGenReqId } = require('./req-id-gen-factory.js') +const { FSTDEP022 } = require('./warnings') + +const routerKeys = [ + 'allowUnsafeRegex', + 'buildPrettyMeta', + 'caseSensitive', + 'constraints', + 'defaultRoute', + 'ignoreDuplicateSlashes', + 'ignoreTrailingSlash', + 'maxParamLength', + 'onBadUrl', + 'querystringParser', + 'useSemicolonDelimiter' +] function buildRouting (options) { - const router = FindMyWay(options.config) + const router = FindMyWay(options) let avvio let fourOhFour - let requestIdHeader - let requestIdLogLabel let logger let hasLogger let setupResponseListeners let throwIfAlreadyStarted - let genReqId let disableRequestLogging + let disableRequestLoggingFn let ignoreTrailingSlash let ignoreDuplicateSlashes let return503OnClosing let globalExposeHeadRoutes - let validateHTTPVersion let keepAliveConnections let closing = false return { + /** + * @param {import('../fastify').FastifyServerOptions} options + * @param {*} fastifyArgs + */ setup (options, fastifyArgs) { avvio = fastifyArgs.avvio fourOhFour = fastifyArgs.fourOhFour - logger = fastifyArgs.logger + logger = options.logger hasLogger = fastifyArgs.hasLogger setupResponseListeners = fastifyArgs.setupResponseListeners throwIfAlreadyStarted = fastifyArgs.throwIfAlreadyStarted - validateHTTPVersion = fastifyArgs.validateHTTPVersion globalExposeHeadRoutes = options.exposeHeadRoutes - requestIdHeader = options.requestIdHeader - requestIdLogLabel = options.requestIdLogLabel - genReqId = options.genReqId disableRequestLogging = options.disableRequestLogging - ignoreTrailingSlash = options.ignoreTrailingSlash - ignoreDuplicateSlashes = options.ignoreDuplicateSlashes - return503OnClosing = Object.prototype.hasOwnProperty.call(options, 'return503OnClosing') ? options.return503OnClosing : true + if (typeof disableRequestLogging === 'function') { + disableRequestLoggingFn = options.disableRequestLogging + } + + ignoreTrailingSlash = options.routerOptions.ignoreTrailingSlash + ignoreDuplicateSlashes = options.routerOptions.ignoreDuplicateSlashes + return503OnClosing = Object.hasOwn(options, 'return503OnClosing') ? options.return503OnClosing : true keepAliveConnections = fastifyArgs.keepAliveConnections }, routing: router.lookup.bind(router), // router func to find the right handler to call route, // configure a route in the fastify instance + hasRoute, prepareRoute, - getDefaultRoute: function () { - return router.defaultRoute - }, - setDefaultRoute: function (defaultRoute) { - if (typeof defaultRoute !== 'function') { - throw new FST_ERR_DEFAULT_ROUTE_INVALID_TYPE() - } - - router.defaultRoute = defaultRoute - }, routeHandler, closeRoutes: () => { closing = true }, printRoutes: router.prettyPrint.bind(router), addConstraintStrategy, - hasConstraintStrategy + hasConstraintStrategy, + isAsyncConstraint, + findRoute } function addConstraintStrategy (strategy) { - throwIfAlreadyStarted('Cannot add constraint strategy when fastify instance is already started!') + throwIfAlreadyStarted('Cannot add constraint strategy!') return router.addConstraintStrategy(strategy) } @@ -112,6 +137,10 @@ function buildRouting (options) { return router.hasConstraintStrategy(strategyName) } + function isAsyncConstraint () { + return router.constrainer.asyncStrategiesInUse.size > 0 + } + // Convert shorthand to extended route declaration function prepareRoute ({ method, url, options, handler, isFastify }) { if (typeof url !== 'string') { @@ -123,12 +152,12 @@ function buildRouting (options) { options = {} } else if (handler && typeof handler === 'function') { if (Object.prototype.toString.call(options) !== '[object Object]') { - throw new Error(`Options for ${method}:${url} route must be an object`) + throw new FST_ERR_ROUTE_OPTIONS_NOT_OBJ(method, url) } else if (options.handler) { if (typeof options.handler === 'function') { - throw new Error(`Duplicate handler for ${method}:${url} route is not allowed!`) + throw new FST_ERR_ROUTE_DUPLICATED_HANDLER(method, url) } else { - throw new Error(`Handler for ${method}:${url} route must be a function`) + throw new FST_ERR_ROUTE_HANDLER_NOT_FN(method, url) } } } @@ -143,33 +172,81 @@ function buildRouting (options) { return route.call(this, { options, isFastify }) } - // Route management + function hasRoute ({ options }) { + const normalizedMethod = options.method?.toUpperCase() ?? '' + return router.hasRoute( + normalizedMethod, + options.url || '', + options.constraints + ) + } + + function findRoute (options) { + const route = router.find( + options.method, + options.url || '', + options.constraints + ) + if (route) { + // we must reduce the expose surface, otherwise + // we provide the ability for the user to modify + // all the route and server information in runtime + return { + handler: route.handler, + params: route.params, + searchParams: route.searchParams + } + } else { + return null + } + } + + /** + * Route management + * @param {{ options: import('../fastify').RouteOptions, isFastify: boolean }} + */ function route ({ options, isFastify }) { + throwIfAlreadyStarted('Cannot add route!') + // Since we are mutating/assigning only top level props, it is fine to have a shallow copy using the spread operator const opts = { ...options } - throwIfAlreadyStarted('Cannot add route when fastify instance is already started!') - const path = opts.url || opts.path || '' - if (Array.isArray(opts.method)) { - // eslint-disable-next-line no-var - for (var i = 0; i < opts.method.length; ++i) { - validateMethodAndSchemaBodyOption(opts.method[i], path, opts.schema) - } - } else { - validateMethodAndSchemaBodyOption(opts.method, path, opts.schema) - } - if (!opts.handler) { - throw new Error(`Missing handler function for ${opts.method}:${path} route.`) + throw new FST_ERR_ROUTE_MISSING_HANDLER(opts.method, path) } if (opts.errorHandler !== undefined && typeof opts.errorHandler !== 'function') { - throw new Error(`Error Handler for ${opts.method}:${path} route, if defined, must be a function`) + throw new FST_ERR_ROUTE_HANDLER_NOT_FN(opts.method, path) } validateBodyLimitOption(opts.bodyLimit) + validateHandlerTimeoutOption(opts.handlerTimeout) + + const shouldExposeHead = opts.exposeHeadRoute ?? globalExposeHeadRoutes + + let isGetRoute = false + let isHeadRoute = false + + if (Array.isArray(opts.method)) { + for (let i = 0; i < opts.method.length; ++i) { + opts.method[i] = normalizeAndValidateMethod.call(this, opts.method[i]) + validateSchemaBodyOption.call(this, opts.method[i], path, opts.schema) + + isGetRoute = opts.method.includes('GET') + isHeadRoute = opts.method.includes('HEAD') + } + } else { + opts.method = normalizeAndValidateMethod.call(this, opts.method) + validateSchemaBodyOption.call(this, opts.method, path, opts.schema) + + isGetRoute = opts.method === 'GET' + isHeadRoute = opts.method === 'HEAD' + } + + // we need to clone a set of initial options for HEAD route + const headOpts = shouldExposeHead && isGetRoute ? { ...options } : null const prefix = this[kRoutePrefix] @@ -223,6 +300,34 @@ function buildRouting (options) { } } + for (const hook of lifecycleHooks) { + if (opts && hook in opts) { + if (Array.isArray(opts[hook])) { + for (const func of opts[hook]) { + if (typeof func !== 'function') { + throw new FST_ERR_HOOK_INVALID_HANDLER(hook, Object.prototype.toString.call(func)) + } + + if (hook === 'onSend' || hook === 'preSerialization' || hook === 'onError' || hook === 'preParsing') { + if (func.constructor.name === 'AsyncFunction' && func.length === 4) { + throw new FST_ERR_HOOK_INVALID_ASYNC_HANDLER() + } + } else if (hook === 'onRequestAbort') { + if (func.constructor.name === 'AsyncFunction' && func.length !== 1) { + throw new FST_ERR_HOOK_INVALID_ASYNC_HANDLER() + } + } else { + if (func.constructor.name === 'AsyncFunction' && func.length === 3) { + throw new FST_ERR_HOOK_INVALID_ASYNC_HANDLER() + } + } + } + } else if (opts[hook] !== undefined && typeof opts[hook] !== 'function') { + throw new FST_ERR_HOOK_INVALID_HANDLER(hook, Object.prototype.toString.call(opts[hook])) + } + } + } + const constraints = opts.constraints || {} const config = { ...opts.config, @@ -235,28 +340,24 @@ function buildRouting (options) { handler: opts.handler.bind(this), config, errorHandler: opts.errorHandler, + childLoggerFactory: opts.childLoggerFactory, bodyLimit: opts.bodyLimit, logLevel: opts.logLevel, logSerializers: opts.logSerializers, attachValidation: opts.attachValidation, schemaErrorFormatter: opts.schemaErrorFormatter, replySerializer: this[kReplySerializerDefault], + validatorCompiler: opts.validatorCompiler, + serializerCompiler: opts.serializerCompiler, + exposeHeadRoute: shouldExposeHead, + prefixTrailingSlash: (opts.prefixTrailingSlash || 'both'), server: this, - isFastify + isFastify, + handlerTimeout: opts.handlerTimeout }) - if (opts.version) { - warning.emit('FSTDEP008') - constraints.version = opts.version - } - - const headHandler = router.find('HEAD', opts.url, constraints) - const hasHEADHandler = headHandler != null - - // remove the head route created by fastify - if (hasHEADHandler && !context[kRouteByFastify] && headHandler.store[kRouteByFastify]) { - router.off(opts.method, opts.url, { constraints }) - } + const headHandler = router.findRoute('HEAD', opts.url, constraints) + const hasHEADHandler = headHandler !== null try { router.on(opts.method, opts.url, { constraints }, routeHandler, context) @@ -264,7 +365,7 @@ function buildRouting (options) { // any route insertion error created by fastify can be safely ignore // because it only duplicate route for head if (!context[kRouteByFastify]) { - const isDuplicatedRoute = error.message.includes(`Method '${opts.method}' already declared for route '${opts.url}'`) + const isDuplicatedRoute = error.message.includes(`Method '${opts.method}' already declared for route`) if (isDuplicatedRoute) { throw new FST_ERR_DUPLICATED_ROUTE(opts.method, opts.url) } @@ -275,13 +376,16 @@ function buildRouting (options) { this.after((notHandledErr, done) => { // Send context async - context.errorHandler = opts.errorHandler ? buildErrorHandler(this[kErrorHandler], opts.errorHandler) : this[kErrorHandler] + context.errorHandler = opts.errorHandler + ? buildErrorHandler(this[kErrorHandler], opts.errorHandler) + : this[kErrorHandler] context._parserOptions.limit = opts.bodyLimit || null context.logLevel = opts.logLevel context.logSerializers = opts.logSerializers context.attachValidation = opts.attachValidation context[kReplySerializerDefault] = this[kReplySerializerDefault] - context.schemaErrorFormatter = opts.schemaErrorFormatter || this[kSchemaErrorFormatter] || context.schemaErrorFormatter + context.schemaErrorFormatter = + opts.schemaErrorFormatter || this[kSchemaErrorFormatter] || context.schemaErrorFormatter // Run hooks and more avvio.once('preReady', () => { @@ -308,11 +412,21 @@ function buildRouting (options) { context.schema = normalizeSchema(context.schema, this.initialConfig) const schemaController = this[kSchemaController] - if (!opts.validatorCompiler && (opts.schema.body || opts.schema.headers || opts.schema.querystring || opts.schema.params)) { + const hasValidationSchema = opts.schema.body || + opts.schema.headers || + opts.schema.querystring || + opts.schema.params + if (!opts.validatorCompiler && hasValidationSchema) { schemaController.setupValidator(this[kOptions]) } try { - compileSchemasForValidation(context, opts.validatorCompiler || schemaController.validatorCompiler) + const isCustom = typeof opts?.validatorCompiler === 'function' || + schemaController.isCustomValidatorCompiler + compileSchemasForValidation( + context, + opts.validatorCompiler || schemaController.validatorCompiler, + isCustom + ) } catch (error) { throw new FST_ERR_SCH_VALIDATION_BUILD(opts.method, url, error.message) } @@ -333,47 +447,48 @@ function buildRouting (options) { // register head route in sync // we must place it after the `this.after` - const { exposeHeadRoute } = opts - const hasRouteExposeHeadRouteFlag = exposeHeadRoute != null - const shouldExposeHead = hasRouteExposeHeadRouteFlag ? exposeHeadRoute : globalExposeHeadRoutes - - if (shouldExposeHead && options.method === 'GET' && !hasHEADHandler) { - const onSendHandlers = parseHeadOnSendHandlers(opts.onSend) - prepareRoute.call(this, { method: 'HEAD', url: path, options: { ...opts, onSend: onSendHandlers }, isFastify: true }) - } else if (hasHEADHandler && exposeHeadRoute) { - warning.emit('FSTDEP007') + + if (shouldExposeHead && isGetRoute && !isHeadRoute && !hasHEADHandler) { + const onSendHandlers = parseHeadOnSendHandlers(headOpts.onSend) + prepareRoute.call(this, { method: 'HEAD', url: path, options: { ...headOpts, onSend: onSendHandlers }, isFastify: true }) } } } // HTTP request entry point, the routing has already been executed function routeHandler (req, res, params, context, query) { - // TODO: The check here should be removed once https://github.com/nodejs/node/issues/43115 resolve in core. - if (!validateHTTPVersion(req.httpVersion)) { - const message = '{"error":"HTTP Version Not Supported","message":"HTTP Version Not Supported","statusCode":505}' - const headers = { - 'Content-Type': 'application/json', - 'Content-Length': message.length - } - res.writeHead(505, headers) - res.end(message) - return + const id = getGenReqId(context.server, req) + + const loggerOpts = { + level: context.logLevel + } + + if (context.logSerializers) { + loggerOpts.serializers = context.logSerializers } + const childLogger = createChildLogger(context, logger, req, id, loggerOpts) + // Set initial value; will be re-evaluated after FastifyRequest is constructed if it's a function + childLogger[kDisableRequestLogging] = disableRequestLoggingFn ? false : disableRequestLogging if (closing === true) { /* istanbul ignore next mac, windows */ if (req.httpVersionMajor !== 2) { - res.once('finish', () => req.destroy()) res.setHeader('Connection', 'close') } + // TODO remove return503OnClosing after Node v18 goes EOL + /* istanbul ignore else */ if (return503OnClosing) { + // On Node v19 we cannot test this behavior as it won't be necessary + // anymore. It will close all the idle connections before they reach this + // stage. const headers = { 'Content-Type': 'application/json', 'Content-Length': '80' } res.writeHead(503, headers) res.end('{"error":"Service Unavailable","message":"Service Unavailable","statusCode":503}') + childLogger.info({ res: { statusCode: 503 } }, 'request aborted - refusing to accept new requests as server is closing') return } } @@ -395,37 +510,52 @@ function buildRouting (options) { req.headers[kRequestAcceptVersion] = undefined } - const id = req.headers[requestIdHeader] || genReqId(req) - - const loggerBinding = { - [requestIdLogLabel]: id - } + const request = new context.Request(id, params, req, query, childLogger, context) + const reply = new context.Reply(res, request, childLogger) - const loggerOpts = { - level: context.logLevel - } + // Evaluate disableRequestLogging after FastifyRequest is constructed + // so the caller has access to decorations and customizations + const resolvedDisableRequestLogging = disableRequestLoggingFn + ? disableRequestLoggingFn(request) + : disableRequestLogging + childLogger[kDisableRequestLogging] = resolvedDisableRequestLogging - if (context.logSerializers) { - loggerOpts.serializers = context.logSerializers + if (resolvedDisableRequestLogging === false) { + childLogger.info({ req: request }, 'incoming request') } - const childLogger = logger.child(loggerBinding, loggerOpts) - childLogger[kDisableRequestLogging] = disableRequestLogging - const request = new context.Request(id, params, req, query, childLogger, context) - const reply = new context.Reply(res, request, childLogger) + // Handler timeout setup — only when configured (zero overhead otherwise) + const handlerTimeout = context.handlerTimeout + if (handlerTimeout > 0) { + const ac = new AbortController() + request[kRequestSignal] = ac + + request[kTimeoutTimer] = setTimeout(() => { + if (!reply.sent) { + const err = new FST_ERR_HANDLER_TIMEOUT(handlerTimeout, context.config?.url) + ac.abort(err) + reply[kReplyIsError] = true + reply.send(err) + } + }, handlerTimeout) - if (disableRequestLogging === false) { - childLogger.info({ req: request }, 'incoming request') + const onAbort = () => { + if (!ac.signal.aborted) { + ac.abort() + } + clearTimeout(request[kTimeoutTimer]) + } + req.on('close', onAbort) + request[kOnAbort] = onAbort } - if (hasLogger === true || context.onResponse !== null) { + if (hasLogger === true || context.onResponse !== null || handlerTimeout > 0) { setupResponseListeners(reply) } if (context.onRequest !== null) { - hookRunner( + onRequestHookRunner( context.onRequest, - hookIterator, request, reply, runPreParsing @@ -434,6 +564,19 @@ function buildRouting (options) { runPreParsing(null, request, reply) } + if (context.onRequestAbort !== null) { + req.on('close', () => { + /* istanbul ignore else */ + if (req.aborted) { + onRequestAbortHookRunner( + context.onRequestAbort, + request, + handleOnRequestAbortHooksErrors.bind(null, reply) + ) + } + }) + } + if (context.onTimeout !== null) { if (!request.raw.socket._meta) { request.raw.socket.on('timeout', handleTimeout) @@ -443,31 +586,52 @@ function buildRouting (options) { } } +function handleOnRequestAbortHooksErrors (reply, err) { + if (err) { + reply.log.error({ err }, 'onRequestAborted hook failed') + } +} + function handleTimeout () { const { context, request, reply } = this._meta - hookRunner( + onTimeoutHookRunner( context.onTimeout, - hookIterator, request, reply, noop ) } -function validateMethodAndSchemaBodyOption (method, path, schema) { - if (supportedMethods.indexOf(method) === -1) { - throw new Error(`${method} method is not supported!`) +function normalizeAndValidateMethod (method) { + if (typeof method !== 'string') { + throw new FST_ERR_ROUTE_METHOD_INVALID() + } + method = method.toUpperCase() + if (!this[kSupportedHTTPMethods].bodyless.has(method) && + !this[kSupportedHTTPMethods].bodywith.has(method)) { + throw new FST_ERR_ROUTE_METHOD_NOT_SUPPORTED(method) } - if ((method === 'GET' || method === 'HEAD') && schema && schema.body) { - throw new Error(`Body validation schema for ${method}:${path} route is not supported!`) + return method +} + +function validateSchemaBodyOption (method, path, schema) { + if (this[kSupportedHTTPMethods].bodyless.has(method) && schema?.body) { + throw new FST_ERR_ROUTE_BODY_VALIDATION_SCHEMA_NOT_SUPPORTED(method, path) } } function validateBodyLimitOption (bodyLimit) { if (bodyLimit === undefined) return if (!Number.isInteger(bodyLimit) || bodyLimit <= 0) { - throw new TypeError(`'bodyLimit' option must be an integer > 0. Got '${bodyLimit}'`) + throw new FST_ERR_ROUTE_BODY_LIMIT_OPTION_NOT_INT(bodyLimit) + } +} + +function validateHandlerTimeoutOption (handlerTimeout) { + if (handlerTimeout === undefined) return + if (!Number.isInteger(handlerTimeout) || handlerTimeout <= 0) { + throw new FST_ERR_ROUTE_HANDLER_TIMEOUT_OPTION_NOT_INT(handlerTimeout) } } @@ -481,57 +645,31 @@ function runPreParsing (err, request, reply) { request[kRequestPayloadStream] = request.raw - if (reply.context.preParsing !== null) { - preParsingHookRunner(reply.context.preParsing, request, reply, handleRequest) + if (request[kRouteContext].preParsing !== null) { + preParsingHookRunner(request[kRouteContext].preParsing, request, reply, handleRequest.bind(request.server)) } else { - handleRequest(null, request, reply) + handleRequest.call(request.server, null, request, reply) } } -function preParsingHookRunner (functions, request, reply, cb) { - let i = 0 +function buildRouterOptions (options, defaultOptions) { + const routerOptions = options.routerOptions == null + ? Object.create(null) + : Object.assign(Object.create(null), options.routerOptions) - function next (err, stream) { - if (reply.sent) { - return - } - - if (typeof stream !== 'undefined') { - request[kRequestPayloadStream] = stream - } + const usedDeprecatedOptions = routerKeys.filter(key => Object.hasOwn(options, key)) - if (err || i === functions.length) { - cb(err, request, reply) - return - } - - const fn = functions[i++] - let result - try { - result = fn(request, reply, request[kRequestPayloadStream], next) - } catch (error) { - next(error) - return - } - - if (result && typeof result.then === 'function') { - result.then(handleResolve, handleReject) - } + if (usedDeprecatedOptions.length > 0) { + FSTDEP022(usedDeprecatedOptions.join(', ')) } - function handleResolve (stream) { - next(null, stream) - } - - function handleReject (err) { - if (!err) { - err = new FST_ERR_SEND_UNDEFINED_ERR() + for (const key of routerKeys) { + if (!Object.hasOwn(routerOptions, key)) { + routerOptions[key] = options[key] ?? defaultOptions[key] } - - next(err) } - next(null, request[kRequestPayloadStream]) + return routerOptions } /** @@ -545,4 +683,4 @@ function removeTrackedSocket () { function noop () { } -module.exports = { buildRouting, validateBodyLimitOption } +module.exports = { buildRouting, validateBodyLimitOption, buildRouterOptions } diff --git a/lib/schema-controller.js b/lib/schema-controller.js index 1484019cb92..a805c85ce5d 100644 --- a/lib/schema-controller.js +++ b/lib/schema-controller.js @@ -1,8 +1,6 @@ 'use strict' const { buildSchemas } = require('./schemas') -const SerializerSelector = require('@fastify/fast-json-stringify-compiler') -const ValidatorSelector = require('@fastify/ajv-compiler') /** * Called at every fastify context that is being created. @@ -15,17 +13,25 @@ function buildSchemaController (parentSchemaCtrl, opts) { return new SchemaController(parentSchemaCtrl, opts) } - let compilersFactory = { - buildValidator: ValidatorSelector(), - buildSerializer: SerializerSelector() + const compilersFactory = Object.assign({ + buildValidator: null, + buildSerializer: null + }, opts?.compilersFactory) + + if (!compilersFactory.buildValidator) { + const ValidatorSelector = require('@fastify/ajv-compiler') + compilersFactory.buildValidator = ValidatorSelector() } - if (opts && opts.compilersFactory) { - compilersFactory = Object.assign(compilersFactory, opts.compilersFactory) + if (!compilersFactory.buildSerializer) { + const SerializerSelector = require('@fastify/fast-json-stringify-compiler') + compilersFactory.buildSerializer = SerializerSelector() } const option = { bucket: (opts && opts.bucket) || buildSchemas, - compilersFactory + compilersFactory, + isCustomValidatorCompiler: typeof opts?.compilersFactory?.buildValidator === 'function', + isCustomSerializerCompiler: typeof opts?.compilersFactory?.buildValidator === 'function' } return new SchemaController(undefined, option) @@ -33,7 +39,7 @@ function buildSchemaController (parentSchemaCtrl, opts) { class SchemaController { constructor (parent, options) { - this.opts = options || (parent && parent.opts) + this.opts = options || parent?.opts this.addedSchemas = false this.compilersFactory = this.opts.compilersFactory @@ -42,9 +48,13 @@ class SchemaController { this.schemaBucket = this.opts.bucket(parent.getSchemas()) this.validatorCompiler = parent.getValidatorCompiler() this.serializerCompiler = parent.getSerializerCompiler() + this.isCustomValidatorCompiler = parent.isCustomValidatorCompiler + this.isCustomSerializerCompiler = parent.isCustomSerializerCompiler this.parent = parent } else { this.schemaBucket = this.opts.bucket() + this.isCustomValidatorCompiler = this.opts.isCustomValidatorCompiler || false + this.isCustomSerializerCompiler = this.opts.isCustomSerializerCompiler || false } } @@ -62,13 +72,48 @@ class SchemaController { return this.schemaBucket.getSchemas() } - // Schema Controller compilers holder setValidatorCompiler (validatorCompiler) { + // Set up as if the fixed validator compiler had been provided + // by a custom 'options.compilersFactory.buildValidator' that + // always returns the same compiler object. This is required because: + // + // - setValidatorCompiler must immediately install a compiler to preserve + // legacy behavior + // - setupValidator will recreate compilers from builders in some + // circumstances, so we have to install this adapter to make it + // behave the same if the legacy API is used + // + // The cloning of the compilersFactory object is necessary because + // we are aliasing the parent compilersFactory if none was provided + // to us (see constructor.) + this.compilersFactory = Object.assign( + {}, + this.compilersFactory, + { buildValidator: () => validatorCompiler }) this.validatorCompiler = validatorCompiler + this.isCustomValidatorCompiler = true } setSerializerCompiler (serializerCompiler) { + // Set up as if the fixed serializer compiler had been provided + // by a custom 'options.compilersFactory.buildSerializer' that + // always returns the same compiler object. This is required because: + // + // - setSerializerCompiler must immediately install a compiler to preserve + // legacy behavior + // - setupSerializer will recreate compilers from builders in some + // circumstances, so we have to install this adapter to make it + // behave the same if the legacy API is used + // + // The cloning of the compilersFactory object is necessary because + // we are aliasing the parent compilersFactory if none was provided + // to us (see constructor.) + this.compilersFactory = Object.assign( + {}, + this.compilersFactory, + { buildSerializer: () => serializerCompiler }) this.serializerCompiler = serializerCompiler + this.isCustomSerializerCompiler = true } getValidatorCompiler () { @@ -90,28 +135,28 @@ class SchemaController { /** * This method will be called when a validator must be setup. * Do not setup the compiler more than once - * @param {object} serverOptions: the fastify server option + * @param {object} serverOptions the fastify server options */ - setupValidator (serverOption) { + setupValidator (serverOptions) { const isReady = this.validatorCompiler !== undefined && !this.addedSchemas if (isReady) { return } - this.validatorCompiler = this.getValidatorBuilder()(this.schemaBucket.getSchemas(), serverOption.ajv) + this.validatorCompiler = this.getValidatorBuilder()(this.schemaBucket.getSchemas(), serverOptions.ajv) } /** * This method will be called when a serializer must be setup. * Do not setup the compiler more than once - * @param {object} serverOptions: the fastify server option + * @param {object} serverOptions the fastify server options */ - setupSerializer (serverOption) { + setupSerializer (serverOptions) { const isReady = this.serializerCompiler !== undefined && !this.addedSchemas if (isReady) { return } - this.serializerCompiler = this.getSerializerBuilder()(this.schemaBucket.getSchemas(), serverOption.serializerOpts) + this.serializerCompiler = this.getSerializerBuilder()(this.schemaBucket.getSchemas(), serverOptions.serializerOpts) } } diff --git a/lib/schemas.js b/lib/schemas.js index a28d4e82c38..02524c311e6 100644 --- a/lib/schemas.js +++ b/lib/schemas.js @@ -7,7 +7,8 @@ const kFluentSchema = Symbol.for('fluent-schema-object') const { FST_ERR_SCH_MISSING_ID, FST_ERR_SCH_ALREADY_PRESENT, - FST_ERR_SCH_DUPLICATE + FST_ERR_SCH_DUPLICATE, + FST_ERR_SCH_CONTENT_MISSING_SCHEMA } = require('./errors') const SCHEMAS_SOURCE = ['params', 'body', 'querystring', 'query', 'headers'] @@ -22,7 +23,7 @@ Schemas.prototype.add = function (inputSchema) { : inputSchema ) - // devs can add schemas without $id, but with $def instead + // developers can add schemas without $id, but with $def instead const id = schema.$id if (!id) { throw new FST_ERR_SCH_MISSING_ID() @@ -43,6 +44,16 @@ Schemas.prototype.getSchema = function (schemaId) { return this.store[schemaId] } +/** + * Checks whether a schema is a non-plain object. + * + * @param {*} schema the schema to check + * @returns {boolean} true if schema has a custom prototype + */ +function isCustomSchemaPrototype (schema) { + return typeof schema === 'object' && Object.getPrototypeOf(schema) !== Object.prototype +} + function normalizeSchema (routeSchemas, serverOptions) { if (routeSchemas[kSchemaVisited]) { return routeSchemas @@ -59,33 +70,42 @@ function normalizeSchema (routeSchemas, serverOptions) { generateFluentSchema(routeSchemas) - // let's check if our schemas have a custom prototype - for (const key of ['headers', 'querystring', 'params', 'body']) { - if (typeof routeSchemas[key] === 'object' && Object.getPrototypeOf(routeSchemas[key]) !== Object.prototype) { - return routeSchemas + for (const key of SCHEMAS_SOURCE) { + const schema = routeSchemas[key] + if (schema && !isCustomSchemaPrototype(schema)) { + if (key === 'body' && schema.content) { + const contentProperty = schema.content + const keys = Object.keys(contentProperty) + for (let i = 0; i < keys.length; i++) { + const contentType = keys[i] + const contentSchema = contentProperty[contentType].schema + if (!contentSchema) { + throw new FST_ERR_SCH_CONTENT_MISSING_SCHEMA(contentType) + } + } + continue + } } } - if (routeSchemas.body) { - routeSchemas.body = getSchemaAnyway(routeSchemas.body, serverOptions.jsonShorthand) - } - - if (routeSchemas.headers) { - routeSchemas.headers = getSchemaAnyway(routeSchemas.headers, serverOptions.jsonShorthand) - } - - if (routeSchemas.querystring) { - routeSchemas.querystring = getSchemaAnyway(routeSchemas.querystring, serverOptions.jsonShorthand) - } - - if (routeSchemas.params) { - routeSchemas.params = getSchemaAnyway(routeSchemas.params, serverOptions.jsonShorthand) - } - if (routeSchemas.response) { const httpCodes = Object.keys(routeSchemas.response) for (const code of httpCodes) { - routeSchemas.response[code] = getSchemaAnyway(routeSchemas.response[code], serverOptions.jsonShorthand) + if (isCustomSchemaPrototype(routeSchemas.response[code])) { + continue + } + + const contentProperty = routeSchemas.response[code].content + + if (contentProperty) { + const keys = Object.keys(contentProperty) + for (let i = 0; i < keys.length; i++) { + const mediaName = keys[i] + if (!contentProperty[mediaName].schema) { + throw new FST_ERR_SCH_CONTENT_MISSING_SCHEMA(mediaName) + } + } + } } } @@ -110,17 +130,6 @@ function generateFluentSchema (schema) { } } -function getSchemaAnyway (schema, jsonShorthand) { - if (!jsonShorthand || schema.$ref || schema.oneOf || schema.allOf || schema.anyOf || schema.$merge || schema.$patch) return schema - if (!schema.type && !schema.properties) { - return { - type: 'object', - properties: schema - } - } - return schema -} - /** * Search for the right JSON schema compiled function in the request context * setup by the route configuration `schema.response`. @@ -128,22 +137,64 @@ function getSchemaAnyway (schema, jsonShorthand) { * * @param {object} context the request context * @param {number} statusCode the http status code - * @returns {function|boolean} the right JSON Schema function to serialize + * @param {string} [contentType] the reply content type + * @returns {function|false} the right JSON Schema function to serialize * the reply or false if it is not set */ -function getSchemaSerializer (context, statusCode) { +function getSchemaSerializer (context, statusCode, contentType) { const responseSchemaDef = context[kSchemaResponse] if (!responseSchemaDef) { return false } if (responseSchemaDef[statusCode]) { + if (responseSchemaDef[statusCode].constructor === Object && contentType) { + const mediaName = contentType.split(';', 1)[0] + if (responseSchemaDef[statusCode][mediaName]) { + return responseSchemaDef[statusCode][mediaName] + } + + // fallback to match all media-type + if (responseSchemaDef[statusCode]['*/*']) { + return responseSchemaDef[statusCode]['*/*'] + } + + return false + } return responseSchemaDef[statusCode] } const fallbackStatusCode = (statusCode + '')[0] + 'xx' if (responseSchemaDef[fallbackStatusCode]) { + if (responseSchemaDef[fallbackStatusCode].constructor === Object && contentType) { + const mediaName = contentType.split(';', 1)[0] + if (responseSchemaDef[fallbackStatusCode][mediaName]) { + return responseSchemaDef[fallbackStatusCode][mediaName] + } + + // fallback to match all media-type + if (responseSchemaDef[fallbackStatusCode]['*/*']) { + return responseSchemaDef[fallbackStatusCode]['*/*'] + } + + return false + } + return responseSchemaDef[fallbackStatusCode] } if (responseSchemaDef.default) { + if (responseSchemaDef.default.constructor === Object && contentType) { + const mediaName = contentType.split(';', 1)[0] + if (responseSchemaDef.default[mediaName]) { + return responseSchemaDef.default[mediaName] + } + + // fallback to match all media-type + if (responseSchemaDef.default['*/*']) { + return responseSchemaDef.default['*/*'] + } + + return false + } + return responseSchemaDef.default } return false diff --git a/lib/server.js b/lib/server.js index 46a2f76ba4f..2b8b93591f2 100644 --- a/lib/server.js +++ b/lib/server.js @@ -1,45 +1,62 @@ 'use strict' -const http = require('http') -const https = require('https') -const dns = require('dns') - -const warnings = require('./warnings') -const { kState, kOptions, kServerBindings } = require('./symbols') -const { FST_ERR_HTTP2_INVALID_VERSION, FST_ERR_REOPENED_CLOSE_SERVER, FST_ERR_REOPENED_SERVER } = require('./errors') +const http = require('node:http') +const https = require('node:https') +const http2 = require('node:http2') +const dns = require('node:dns') +const os = require('node:os') + +const { kState, kOptions, kServerBindings, kHttp2ServerSessions } = require('./symbols') +const { FSTWRN003 } = require('./warnings') +const { onListenHookRunner } = require('./hooks') +const { + FST_ERR_REOPENED_CLOSE_SERVER, + FST_ERR_REOPENED_SERVER, + FST_ERR_LISTEN_OPTIONS_INVALID, + FST_ERR_FORCE_CLOSE_CONNECTIONS_IDLE_NOT_AVAILABLE +} = require('./errors') +const noopSet = require('./noop-set') +const PonyPromise = require('./promise') module.exports.createServer = createServer -module.exports.compileValidateHTTPVersion = compileValidateHTTPVersion + +function defaultResolveServerListeningText (address) { + return `Server listening at ${address}` +} function createServer (options, httpHandler) { const server = getServerInstance(options, httpHandler) - return { server, listen } - // `this` is the Fastify object - function listen (listenOptions, ...args) { - let cb = args.slice(-1).pop() - // When the variadic signature deprecation is complete, the function - // declaration should become: - // function listen (listenOptions = { port: 0, host: 'localhost' }, cb = undefined) - // Upon doing so, the `normalizeListenArgs` function is no longer needed, - // and all of this preamble to feed it correctly also no longer needed. - const firstArgType = Object.prototype.toString.call(arguments[0]) - if (arguments.length === 0) { - listenOptions = normalizeListenArgs([]) - } else if (arguments.length > 0 && (firstArgType !== '[object Object]' && firstArgType !== '[object Function]')) { - warnings.emit('FSTDEP011') - listenOptions = normalizeListenArgs(Array.from(arguments)) - cb = listenOptions.cb - } else if (args.length > 1) { - // `.listen(obj, a, ..., n, callback )` - warnings.emit('FSTDEP011') - // Deal with `.listen(port, host, backlog, [cb])` - const hostPath = listenOptions.path ? [listenOptions.path] : [listenOptions.port ?? 0, listenOptions.host ?? 'localhost'] - Object.assign(listenOptions, normalizeListenArgs([...hostPath, ...args])) - } else { + function listen ( + listenOptions = { port: 0, host: 'localhost' }, + cb = undefined + ) { + if (typeof cb === 'function') { + if (cb.constructor.name === 'AsyncFunction') { + FSTWRN003('listen method') + } + listenOptions.cb = cb } + if (listenOptions.signal) { + if (typeof listenOptions.signal.on !== 'function' && typeof listenOptions.signal.addEventListener !== 'function') { + throw new FST_ERR_LISTEN_OPTIONS_INVALID('Invalid options.signal') + } + + // copy the current signal state + this[kState].aborted = listenOptions.signal.aborted + + if (this[kState].aborted) { + return this.close() + } else { + const onAborted = () => { + this[kState].aborted = true + this.close() + } + listenOptions.signal.addEventListener('abort', onAborted, { once: true }) + } + } // If we have a path specified, don't default host to 'localhost' so we don't end up listening // on both path and host @@ -50,10 +67,10 @@ function createServer (options, httpHandler) { } else { host = listenOptions.host } - if (Object.prototype.hasOwnProperty.call(listenOptions, 'host') === false) { + if (!Object.hasOwn(listenOptions, 'host') || + listenOptions.host == null) { listenOptions.host = host } - if (host === 'localhost') { listenOptions.cb = (err, address) => { if (err) { @@ -65,8 +82,20 @@ function createServer (options, httpHandler) { multipleBindings.call(this, server, httpHandler, options, listenOptions, () => { this[kState].listening = true cb(null, address) + onListenHookRunner(this) }) } + } else { + listenOptions.cb = (err, address) => { + // the server did not start + if (err) { + cb(err, address) + return + } + this[kState].listening = true + cb(null, address) + onListenHookRunner(this) + } } // https://github.com/nodejs/node/issues/9390 @@ -76,22 +105,47 @@ function createServer (options, httpHandler) { if (cb === undefined) { const listening = listenPromise.call(this, server, listenOptions) - /* istanbul ignore else */ - if (host === 'localhost') { - return listening.then(address => { - return new Promise((resolve, reject) => { - multipleBindings.call(this, server, httpHandler, options, listenOptions, () => { - this[kState].listening = true - resolve(address) - }) + return listening.then(address => { + const { promise, resolve } = PonyPromise.withResolvers() + if (host === 'localhost') { + multipleBindings.call(this, server, httpHandler, options, listenOptions, () => { + this[kState].listening = true + resolve(address) + onListenHookRunner(this) }) - }) - } - return listening + } else { + resolve(address) + onListenHookRunner(this) + } + return promise + }) } this.ready(listenCallback.call(this, server, listenOptions)) } + + const serverHasCloseAllConnections = typeof server.closeAllConnections === 'function' + const serverHasCloseIdleConnections = typeof server.closeIdleConnections === 'function' + const serverHasCloseHttp2Sessions = typeof server.closeHttp2Sessions === 'function' + + let forceCloseConnections = options.forceCloseConnections + if (forceCloseConnections === 'idle' && !serverHasCloseIdleConnections) { + throw new FST_ERR_FORCE_CLOSE_CONNECTIONS_IDLE_NOT_AVAILABLE() + } else if (typeof forceCloseConnections !== 'boolean') { + /* istanbul ignore next: only one branch can be valid in a given Node.js version */ + forceCloseConnections = serverHasCloseIdleConnections ? 'idle' : false + } + + const keepAliveConnections = !serverHasCloseAllConnections && forceCloseConnections === true ? new Set() : noopSet() + + return { + server, + listen, + forceCloseConnections, + serverHasCloseAllConnections, + serverHasCloseHttp2Sessions, + keepAliveConnections + } } function multipleBindings (mainServer, httpHandler, serverOpts, listenOptions, onListen) { @@ -100,45 +154,64 @@ function multipleBindings (mainServer, httpHandler, serverOpts, listenOptions, o // let's check if we need to bind additional addresses dns.lookup(listenOptions.host, { all: true }, (dnsErr, addresses) => { - if (dnsErr) { + if (dnsErr || this[kState].aborted) { // not blocking the main server listening // this.log.warn('dns.lookup error:', dnsErr) onListen() return } + const isMainServerListening = mainServer.listening && serverOpts.serverFactory + let binding = 0 - let binded = 0 - const primaryAddress = mainServer.address() - for (const adr of addresses) { - if (adr.address !== primaryAddress.address) { - binding++ - const secondaryOpts = Object.assign({}, listenOptions, { - host: adr.address, - port: primaryAddress.port, - cb: (_ignoreErr) => { - binded++ - - if (!_ignoreErr) { - this[kServerBindings].push(secondaryServer) + let bound = 0 + if (!isMainServerListening) { + const primaryAddress = mainServer.address() + for (const adr of addresses) { + if (adr.address !== primaryAddress.address) { + binding++ + const secondaryOpts = Object.assign({}, listenOptions, { + host: adr.address, + port: primaryAddress.port, + cb: (_ignoreErr) => { + bound++ + + if (!_ignoreErr) { + this[kServerBindings].push(secondaryServer) + } + + if (bound === binding) { + // regardless of the error, we are done + onListen() + } } + }) - if (binded === binding) { - // regardless of the error, we are done - onListen() + const secondaryServer = getServerInstance(serverOpts, httpHandler) + const closeSecondary = () => { + // To avoid falling into situations where the close of the + // secondary server is triggered before the preClose hook + // is done running, we better wait until the main server is closed. + // No new TCP connections are accepted + // We swallow any error from the secondary server + secondaryServer.close(() => {}) + if (typeof secondaryServer.closeAllConnections === 'function' && serverOpts.forceCloseConnections === true) { + secondaryServer.closeAllConnections() + } + if (typeof secondaryServer.closeHttp2Sessions === 'function') { + secondaryServer.closeHttp2Sessions() } } - }) - const secondaryServer = getServerInstance(serverOpts, httpHandler) - const closeSecondary = () => { secondaryServer.close(() => {}) } - mainServer.on('unref', closeSecondary) - mainServer.on('close', closeSecondary) - mainServer.on('error', closeSecondary) - listenCallback.call(this, secondaryServer, secondaryOpts)() + secondaryServer.on('upgrade', mainServer.emit.bind(mainServer, 'upgrade')) + mainServer.on('unref', closeSecondary) + mainServer.on('close', closeSecondary) + mainServer.on('error', closeSecondary) + this[kState].listening = false + listenCallback.call(this, secondaryServer, secondaryOpts)() + } } } - // no extra bindings are necessary if (binding === 0) { onListen() @@ -149,7 +222,6 @@ function multipleBindings (mainServer, httpHandler, serverOpts, listenOptions, o // to the secondary servers. It is valid only when the user is // listening on localhost const originUnref = mainServer.unref - /* istanbul ignore next */ mainServer.unref = function () { originUnref.call(mainServer) mainServer.emit('unref') @@ -160,8 +232,13 @@ function multipleBindings (mainServer, httpHandler, serverOpts, listenOptions, o function listenCallback (server, listenOptions) { const wrap = (err) => { server.removeListener('error', wrap) + server.removeListener('listening', wrap) if (!err) { - const address = logServerAddress.call(this, server) + const address = logServerAddress.call( + this, + server, + listenOptions.listenTextResolver || defaultResolveServerListeningText + ) listenOptions.cb(null, address) } else { this[kState].listening = false @@ -174,193 +251,191 @@ function listenCallback (server, listenOptions) { if (this[kState].listening && this[kState].closing) { return listenOptions.cb(new FST_ERR_REOPENED_CLOSE_SERVER(), null) - } else if (this[kState].listening) { + } + if (this[kState].listening) { return listenOptions.cb(new FST_ERR_REOPENED_SERVER(), null) } server.once('error', wrap) - server.listen(listenOptions, wrap) - - this[kState].listening = true + if (!this[kState].closing) { + server.once('listening', wrap) + server.listen(listenOptions) + this[kState].listening = true + } } } function listenPromise (server, listenOptions) { if (this[kState].listening && this[kState].closing) { return Promise.reject(new FST_ERR_REOPENED_CLOSE_SERVER()) - } else if (this[kState].listening) { + } + if (this[kState].listening) { return Promise.reject(new FST_ERR_REOPENED_SERVER()) } return this.ready().then(() => { - let errEventHandler - const errEvent = new Promise((resolve, reject) => { - errEventHandler = (err) => { - this[kState].listening = false - reject(err) - } - server.once('error', errEventHandler) - }) - const listen = new Promise((resolve, reject) => { - server.listen(listenOptions, () => { - server.removeListener('error', errEventHandler) - resolve(logServerAddress.call(this, server)) - }) - // we set it afterwards because listen can throw + // skip listen when aborted during ready + if (this[kState].aborted) return + + const { promise, resolve, reject } = PonyPromise.withResolvers() + + const errEventHandler = (err) => { + cleanup() + this[kState].listening = false + reject(err) + } + const listeningEventHandler = () => { + cleanup() this[kState].listening = true - }) + resolve(logServerAddress.call( + this, + server, + listenOptions.listenTextResolver || defaultResolveServerListeningText + )) + } + function cleanup () { + server.removeListener('error', errEventHandler) + server.removeListener('listening', listeningEventHandler) + } + server.once('error', errEventHandler) + server.once('listening', listeningEventHandler) + + server.listen(listenOptions) - return Promise.race([ - errEvent, // e.g invalid port range error is always emitted before the server listening - listen - ]) + return promise }) } -/** - * Creates a function that, based upon initial configuration, will - * verify that every incoming request conforms to allowed - * HTTP versions for the Fastify instance, e.g. a Fastify HTTP/1.1 - * server will not serve HTTP/2 requests upon the result of the - * verification function. - * - * @param {object} options fastify option - * @param {function} [options.serverFactory] If present, the - * validator function will skip all checks. - * @param {boolean} [options.http2 = false] If true, the validator - * function will allow HTTP/2 requests. - * @param {object} [options.https = null] https server options - * @param {boolean} [options.https.allowHTTP1] If true and use - * with options.http2 the validator function will allow HTTP/1 - * request to http2 server. - * - * @returns {function} HTTP version validator function. - */ -function compileValidateHTTPVersion (options) { - let bypass = false - // key-value map to store valid http version - const map = new Map() +function getServerInstance (options, httpHandler) { if (options.serverFactory) { - // When serverFactory is passed, we cannot identify how to check http version reliably - // So, we should skip the http version check - bypass = true - } - if (options.http2) { - // HTTP2 must serve HTTP/2.0 - map.set('2.0', true) - if (options.https && options.https.allowHTTP1 === true) { - // HTTP2 with HTTPS.allowHTTP1 allow fallback to HTTP/1.1 and HTTP/1.0 - map.set('1.1', true) - map.set('1.0', true) - } - } else { - // HTTP must server HTTP/1.1 and HTTP/1.0 - map.set('1.1', true) - map.set('1.0', true) - } - // The compiled function here placed in one of the hottest path inside fastify - // the implementation here must be as performant as possible - return function validateHTTPVersion (httpVersion) { - // `bypass` skip the check when custom server factory provided - // `httpVersion in obj` check for the valid http version we should support - return bypass || map.has(httpVersion) + // User provided server instance + return options.serverFactory(httpHandler, options) } -} -function getServerInstance (options, httpHandler) { - let server = null - if (options.serverFactory) { - server = options.serverFactory(httpHandler, options) - } else if (options.http2) { - if (options.https) { - server = http2().createSecureServer(options.https, httpHandler) - } else { - server = http2().createServer(httpHandler) - } - server.on('session', sessionTimeout(options.http2SessionTimeout)) - } else { - // this is http1 - if (options.https) { - server = https.createServer(options.https, httpHandler) - } else { - server = http.createServer(httpHandler) - } - server.keepAliveTimeout = options.keepAliveTimeout - server.requestTimeout = options.requestTimeout - // we treat zero as null - // and null is the default setting from nodejs - // so we do not pass the option to server - if (options.maxRequestsPerSocket > 0) { - server.maxRequestsPerSocket = options.maxRequestsPerSocket + // We have accepted true as a valid way to init https but node requires an options obj + const httpsOptions = options.https === true ? {} : options.https + + if (options.http2) { + const server = typeof httpsOptions === 'object' ? http2.createSecureServer(httpsOptions, httpHandler) : http2.createServer(options.http, httpHandler) + server.on('session', (session) => session.setTimeout(options.http2SessionTimeout, () => { + session.close() + })) + + // This is only needed for Node.js versions < 24.0.0 since Node.js added native + // closeAllSessions() on server.close() support for HTTP/2 servers in v24.0.0 + if (options.forceCloseConnections === true) { + server.closeHttp2Sessions = createCloseHttp2SessionsByHttp2Server(server) } - } - if (!options.serverFactory) { server.setTimeout(options.connectionTimeout) + + return server } - return server -} -function normalizeListenArgs (args) { - if (args.length === 0) { - return { port: 0, host: 'localhost' } + // HTTP1 server instance + const server = httpsOptions + ? https.createServer(httpsOptions, httpHandler) + : http.createServer(options.http, httpHandler) + server.keepAliveTimeout = options.keepAliveTimeout + server.requestTimeout = options.requestTimeout + server.setTimeout(options.connectionTimeout) + // We treat zero as null(node default) so we do not pass zero to the server instance + if (options.maxRequestsPerSocket > 0) { + server.maxRequestsPerSocket = options.maxRequestsPerSocket } - const cb = typeof args[args.length - 1] === 'function' ? args.pop() : undefined - const options = { cb } + return server +} - const firstArg = args[0] - const argsLength = args.length - const lastArg = args[argsLength - 1] - if (typeof firstArg === 'string' && isNaN(firstArg)) { - /* Deal with listen (pipe[, backlog]) */ - options.path = firstArg - options.backlog = argsLength > 1 ? lastArg : undefined - } else { - /* Deal with listen ([port[, host[, backlog]]]) */ - options.port = argsLength >= 1 && Number.isInteger(firstArg) ? firstArg : 0 - // This will listen to what localhost is. - // It can be 127.0.0.1 or ::1, depending on the operating system. - // Fixes https://github.com/fastify/fastify/issues/1022. - options.host = argsLength >= 2 && args[1] ? args[1] : 'localhost' - options.backlog = argsLength >= 3 ? args[2] : undefined +/** + * Inspects the provided `server.address` object and returns a + * normalized list of IP address strings. Normalization in this + * case refers to mapping wildcard `0.0.0.0` to the list of IP + * addresses the wildcard refers to. + * + * @see https://nodejs.org/docs/latest/api/net.html#serveraddress + * + * @param {object} A server address object as described in the + * linked docs. + * + * @returns {string[]} + */ +function getAddresses (address) { + if (address.address === '0.0.0.0') { + return Object.values(os.networkInterfaces()).flatMap((iface) => { + return iface.filter((iface) => iface.family === 'IPv4') + }).sort((iface) => { + /* c8 ignore next 2 */ + // Order the interfaces so that internal ones come first + return iface.internal ? -1 : 1 + }).map((iface) => { return iface.address }) } - - return options + return [address.address] } -function logServerAddress (server) { - let address = server.address() - const isUnixSocket = typeof address === 'string' - /* istanbul ignore next */ +function logServerAddress (server, listenTextResolver) { + let addresses + const isUnixSocket = typeof server.address() === 'string' if (!isUnixSocket) { - if (address.address.indexOf(':') === -1) { - address = address.address + ':' + address.port + if (server.address().address.indexOf(':') === -1) { + // IPv4 + addresses = getAddresses(server.address()).map((address) => address + ':' + server.address().port) } else { - address = '[' + address.address + ']:' + address.port + // IPv6 + addresses = ['[' + server.address().address + ']:' + server.address().port] } - } - /* istanbul ignore next */ - address = (isUnixSocket ? '' : ('http' + (this[kOptions].https ? 's' : '') + '://')) + address - - this.log.info('Server listening at ' + address) - return address -} -function http2 () { - try { - return require('http2') - } catch (err) { - throw new FST_ERR_HTTP2_INVALID_VERSION() + addresses = addresses.map((address) => ('http' + (this[kOptions].https ? 's' : '') + '://') + address) + } else { + addresses = [server.address()] } -} -function sessionTimeout (timeout) { - return function (session) { - session.setTimeout(timeout, close) + for (const address of addresses) { + this.log.info(listenTextResolver(address)) } + return addresses[0] } -function close () { - this.close() +/** + * @param {http2.Http2Server} http2Server + * @returns {() => void} + */ +function createCloseHttp2SessionsByHttp2Server (http2Server) { + /** + * @type {Set} + */ + http2Server[kHttp2ServerSessions] = new Set() + + http2Server.on('session', function (session) { + session.once('connect', function () { + http2Server[kHttp2ServerSessions].add(session) + }) + + session.once('close', function () { + http2Server[kHttp2ServerSessions].delete(session) + }) + + session.once('frameError', function (type, code, streamId) { + if (streamId === 0) { + // The stream ID is 0, which means that the error is related to the session itself. + // If the event is not associated with a stream, the Http2Session will be shut down immediately + http2Server[kHttp2ServerSessions].delete(session) + } + }) + + session.once('goaway', function () { + // The Http2Session instance will be shut down automatically when the 'goaway' event is emitted. + http2Server[kHttp2ServerSessions].delete(session) + }) + }) + + return function closeHttp2Sessions () { + if (http2Server[kHttp2ServerSessions].size === 0) { + return + } + + for (const session of http2Server[kHttp2ServerSessions]) { + session.close() + } + } } diff --git a/lib/symbols.js b/lib/symbols.js index de534e29eb5..a3ad9d88a14 100644 --- a/lib/symbols.js +++ b/lib/symbols.js @@ -5,10 +5,20 @@ const keys = { kChildren: Symbol('fastify.children'), kServerBindings: Symbol('fastify.serverBindings'), kBodyLimit: Symbol('fastify.bodyLimit'), + kSupportedHTTPMethods: Symbol('fastify.acceptedHTTPMethods'), kRoutePrefix: Symbol('fastify.routePrefix'), kLogLevel: Symbol('fastify.logLevel'), kLogSerializers: Symbol('fastify.logSerializers'), kHooks: Symbol('fastify.hooks'), + kContentTypeParser: Symbol('fastify.contentTypeParser'), + kState: Symbol('fastify.state'), + kOptions: Symbol('fastify.options'), + kDisableRequestLogging: Symbol('fastify.disableRequestLogging'), + kPluginNameChain: Symbol('fastify.pluginNameChain'), + kRouteContext: Symbol('fastify.context'), + kGenReqId: Symbol('fastify.genReqId'), + kHttp2ServerSessions: Symbol('fastify.http2ServerSessions'), + // Schema kSchemaController: Symbol('fastify.schemaController'), kSchemaHeaders: Symbol('headers-schema'), kSchemaParams: Symbol('params-schema'), @@ -16,17 +26,25 @@ const keys = { kSchemaBody: Symbol('body-schema'), kSchemaResponse: Symbol('response-schema'), kSchemaErrorFormatter: Symbol('fastify.schemaErrorFormatter'), - kReplySerializerDefault: Symbol('fastify.replySerializerDefault'), - kContentTypeParser: Symbol('fastify.contentTypeParser'), - kReply: Symbol('fastify.Reply'), + kSchemaVisited: Symbol('fastify.schemas.visited'), + // Request kRequest: Symbol('fastify.Request'), kRequestPayloadStream: Symbol('fastify.RequestPayloadStream'), kRequestAcceptVersion: Symbol('fastify.RequestAcceptVersion'), - kCanSetNotFoundHandler: Symbol('fastify.canSetNotFoundHandler'), + kRequestCacheValidateFns: Symbol('fastify.request.cache.validateFns'), + kRequestOriginalUrl: Symbol('fastify.request.originalUrl'), + kRequestSignal: Symbol('fastify.request.signal'), + kHandlerTimeout: Symbol('fastify.handlerTimeout'), + kTimeoutTimer: Symbol('fastify.request.timeoutTimer'), + kOnAbort: Symbol('fastify.request.onAbort'), + // 404 kFourOhFour: Symbol('fastify.404'), + kCanSetNotFoundHandler: Symbol('fastify.canSetNotFoundHandler'), kFourOhFourLevelInstance: Symbol('fastify.404LogLevelInstance'), kFourOhFourContext: Symbol('fastify.404ContextKey'), kDefaultJsonParse: Symbol('fastify.defaultJSONParse'), + // Reply + kReply: Symbol('fastify.Reply'), kReplySerializer: Symbol('fastify.reply.serializer'), kReplyIsError: Symbol('fastify.reply.isError'), kReplyHeaders: Symbol('fastify.reply.headers'), @@ -38,14 +56,13 @@ const keys = { kReplyEndTime: Symbol('fastify.reply.endTime'), kReplyErrorHandlerCalled: Symbol('fastify.reply.errorHandlerCalled'), kReplyIsRunningOnErrorHook: Symbol('fastify.reply.isRunningOnErrorHook'), - kSchemaVisited: Symbol('fastify.schemas.visited'), - kState: Symbol('fastify.state'), - kOptions: Symbol('fastify.options'), - kDisableRequestLogging: Symbol('fastify.disableRequestLogging'), - kPluginNameChain: Symbol('fastify.pluginNameChain'), + kReplySerializerDefault: Symbol('fastify.replySerializerDefault'), + kReplyCacheSerializeFns: Symbol('fastify.reply.cache.serializeFns'), // This symbol is only meant to be used for fastify tests and should not be used for any other purpose kTestInternals: Symbol('fastify.testInternals'), kErrorHandler: Symbol('fastify.errorHandler'), + kErrorHandlerAlreadySet: Symbol('fastify.errorHandlerAlreadySet'), + kChildLoggerFactory: Symbol('fastify.childLoggerFactory'), kHasBeenDecorated: Symbol('fastify.hasBeenDecorated'), kKeepAliveConnections: Symbol('fastify.keepAliveConnections'), kRouteByFastify: Symbol('fastify.routeByFastify') diff --git a/lib/validation.js b/lib/validation.js index 46ed81c8043..a7ac2aeadea 100644 --- a/lib/validation.js +++ b/lib/validation.js @@ -7,7 +7,13 @@ const { kSchemaBody: bodySchema, kSchemaResponse: responseSchema } = require('./symbols') -const scChecker = /^[1-5]{1}[0-9]{2}$|^[1-5]xx$|^default$/ +const scChecker = /^[1-5](?:\d{2}|xx)$|^default$/ + +const { + FST_ERR_SCH_RESPONSE_SCHEMA_NOT_NESTED_2XX +} = require('./errors') + +const { FSTWRN001 } = require('./warnings') function compileSchemasForSerialization (context, compile) { if (!context.schema || !context.schema.response) { @@ -18,21 +24,37 @@ function compileSchemasForSerialization (context, compile) { .reduce(function (acc, statusCode) { const schema = context.schema.response[statusCode] statusCode = statusCode.toLowerCase() - if (!scChecker.exec(statusCode)) { - throw new Error('response schemas should be nested under a valid status code, e.g { 2xx: { type: "object" } }') + if (!scChecker.test(statusCode)) { + throw new FST_ERR_SCH_RESPONSE_SCHEMA_NOT_NESTED_2XX() + } + + if (schema.content) { + const contentTypesSchemas = {} + for (const mediaName of Object.keys(schema.content)) { + const contentSchema = schema.content[mediaName].schema + contentTypesSchemas[mediaName] = compile({ + schema: contentSchema, + url, + method, + httpStatus: statusCode, + contentType: mediaName + }) + } + acc[statusCode] = contentTypesSchemas + } else { + acc[statusCode] = compile({ + schema, + url, + method, + httpStatus: statusCode + }) } - acc[statusCode] = compile({ - schema, - url, - method, - httpStatus: statusCode - }) return acc }, {}) } -function compileSchemasForValidation (context, compile) { +function compileSchemasForValidation (context, compile, isCustom) { const { schema } = context if (!schema) { return @@ -41,12 +63,13 @@ function compileSchemasForValidation (context, compile) { const { method, url } = context.config || {} const headers = schema.headers - if (headers && Object.getPrototypeOf(headers) !== Object.prototype) { - // do not mess with non-literals, e.g. Joi schemas + // the or part is used for backward compatibility + if (headers && (isCustom || Object.getPrototypeOf(headers) !== Object.prototype)) { + // do not mess with schema when custom validator applied, e.g. Joi, Typebox context[headersSchema] = compile({ schema: headers, method, url, httpPart: 'headers' }) } else if (headers) { // The header keys are case insensitive - // https://tools.ietf.org/html/rfc2616#section-4.2 + // https://datatracker.ietf.org/doc/html/rfc2616#section-4.2 const headersSchemaLowerCase = {} Object.keys(headers).forEach(k => { headersSchemaLowerCase[k] = headers[k] }) if (headersSchemaLowerCase.required instanceof Array) { @@ -59,65 +82,196 @@ function compileSchemasForValidation (context, compile) { }) } context[headersSchema] = compile({ schema: headersSchemaLowerCase, method, url, httpPart: 'headers' }) + } else if (Object.hasOwn(schema, 'headers')) { + FSTWRN001('headers', method, url) } if (schema.body) { - context[bodySchema] = compile({ schema: schema.body, method, url, httpPart: 'body' }) + const contentProperty = schema.body.content + if (contentProperty) { + const contentTypeSchemas = {} + for (const contentType of Object.keys(contentProperty)) { + const contentSchema = contentProperty[contentType].schema + contentTypeSchemas[contentType] = compile({ schema: contentSchema, method, url, httpPart: 'body', contentType }) + } + context[bodySchema] = contentTypeSchemas + } else { + context[bodySchema] = compile({ schema: schema.body, method, url, httpPart: 'body' }) + } + } else if (Object.hasOwn(schema, 'body')) { + FSTWRN001('body', method, url) } if (schema.querystring) { context[querystringSchema] = compile({ schema: schema.querystring, method, url, httpPart: 'querystring' }) + } else if (Object.hasOwn(schema, 'querystring')) { + FSTWRN001('querystring', method, url) } if (schema.params) { context[paramsSchema] = compile({ schema: schema.params, method, url, httpPart: 'params' }) + } else if (Object.hasOwn(schema, 'params')) { + FSTWRN001('params', method, url) } } function validateParam (validatorFunction, request, paramName) { const isUndefined = request[paramName] === undefined - const ret = validatorFunction && validatorFunction(isUndefined ? null : request[paramName]) - if (ret === false) return validatorFunction.errors - if (ret && ret.error) return ret.error - if (ret && ret.value) request[paramName] = ret.value - return false + let ret + + try { + ret = validatorFunction?.(isUndefined ? null : request[paramName]) + } catch (err) { + // If validator throws synchronously, ensure it propagates as an internal error + err.statusCode = 500 + return err + } + + if (ret && typeof ret.then === 'function') { + return ret + .then((res) => { return answer(res) }) + .catch(err => { return err }) // return as simple error (not throw) + } + + return answer(ret) + + function answer (ret) { + if (ret === false) return validatorFunction.errors + if (ret && ret.error) return ret.error + if (ret && ret.value) request[paramName] = ret.value + return false + } } -function validate (context, request) { - const params = validateParam(context[paramsSchema], request, 'params') +function validate (context, request, execution) { + const runExecution = execution === undefined - if (params) { - return wrapValidationError(params, 'params', context.schemaErrorFormatter) + if (runExecution || !execution.skipParams) { + const params = validateParam(context[paramsSchema], request, 'params') + if (params) { + if (typeof params.then !== 'function') { + return wrapValidationError(params, 'params', context.schemaErrorFormatter) + } else { + return validateAsyncParams(params, context, request) + } + } } - const body = validateParam(context[bodySchema], request, 'body') - if (body) { - return wrapValidationError(body, 'body', context.schemaErrorFormatter) + + if (runExecution || !execution.skipBody) { + let validatorFunction = null + if (typeof context[bodySchema] === 'function') { + validatorFunction = context[bodySchema] + } else if (context[bodySchema]) { + // TODO: add request.contentType and reuse it here + const contentType = getEssenceMediaType(request.headers['content-type']) + const contentSchema = context[bodySchema][contentType] + if (contentSchema) { + validatorFunction = contentSchema + } + } + const body = validateParam(validatorFunction, request, 'body') + if (body) { + if (typeof body.then !== 'function') { + return wrapValidationError(body, 'body', context.schemaErrorFormatter) + } else { + return validateAsyncBody(body, context, request) + } + } } - const query = validateParam(context[querystringSchema], request, 'query') - if (query) { - return wrapValidationError(query, 'querystring', context.schemaErrorFormatter) + + if (runExecution || !execution.skipQuery) { + const query = validateParam(context[querystringSchema], request, 'query') + if (query) { + if (typeof query.then !== 'function') { + return wrapValidationError(query, 'querystring', context.schemaErrorFormatter) + } else { + return validateAsyncQuery(query, context, request) + } + } } + const headers = validateParam(context[headersSchema], request, 'headers') if (headers) { - return wrapValidationError(headers, 'headers', context.schemaErrorFormatter) + if (typeof headers.then !== 'function') { + return wrapValidationError(headers, 'headers', context.schemaErrorFormatter) + } else { + return validateAsyncHeaders(headers, context, request) + } } - return null + + return false +} + +function validateAsyncParams (validatePromise, context, request) { + return validatePromise + .then((paramsResult) => { + if (paramsResult) { + return wrapValidationError(paramsResult, 'params', context.schemaErrorFormatter) + } + + return validate(context, request, { skipParams: true }) + }) +} + +function validateAsyncBody (validatePromise, context, request) { + return validatePromise + .then((bodyResult) => { + if (bodyResult) { + return wrapValidationError(bodyResult, 'body', context.schemaErrorFormatter) + } + + return validate(context, request, { skipParams: true, skipBody: true }) + }) +} + +function validateAsyncQuery (validatePromise, context, request) { + return validatePromise + .then((queryResult) => { + if (queryResult) { + return wrapValidationError(queryResult, 'querystring', context.schemaErrorFormatter) + } + + return validate(context, request, { skipParams: true, skipBody: true, skipQuery: true }) + }) +} + +function validateAsyncHeaders (validatePromise, context, request) { + return validatePromise + .then((headersResult) => { + if (headersResult) { + return wrapValidationError(headersResult, 'headers', context.schemaErrorFormatter) + } + + return false + }) } function wrapValidationError (result, dataVar, schemaErrorFormatter) { if (result instanceof Error) { result.statusCode = result.statusCode || 400 + result.code = result.code || 'FST_ERR_VALIDATION' result.validationContext = result.validationContext || dataVar return result } const error = schemaErrorFormatter(result, dataVar) error.statusCode = error.statusCode || 400 + error.code = error.code || 'FST_ERR_VALIDATION' error.validation = result error.validationContext = dataVar return error } +/** + * simple function to retrieve the essence media type + * @param {string} header + * @returns {string} Mimetype string. + */ +function getEssenceMediaType (header) { + if (!header) return '' + return header.split(/[ ;]/, 1)[0].trim().toLowerCase() +} + module.exports = { symbols: { bodySchema, querystringSchema, responseSchema, paramsSchema, headersSchema }, compileSchemasForValidation, diff --git a/lib/warnings.js b/lib/warnings.js index f6bbbcafbdc..1a5092e90a5 100644 --- a/lib/warnings.js +++ b/lib/warnings.js @@ -1,24 +1,57 @@ 'use strict' -const warning = require('process-warning')() +const { createWarning } = require('process-warning') /** * Deprecation codes: - * - FSTDEP005 + * - FSTWRN001 + * - FSTSEC001 + * - FSTDEP022 + * + * Deprecation Codes FSTDEP001 - FSTDEP021 were used by v4 and MUST NOT not be reused. + * - FSTDEP022 is used by v5 and MUST NOT be reused. + * Warning Codes FSTWRN001 - FSTWRN002 were used by v4 and MUST NOT not be reused. */ -warning.create('FastifyDeprecation', 'FSTDEP005', 'You are accessing the deprecated "request.connection" property. Use "request.socket" instead.') - -warning.create('FastifyDeprecation', 'FSTDEP006', 'You are decorating Request/Reply with a reference type. This reference is shared amongst all requests. Use onRequest hook instead. Property: %s') - -warning.create('FastifyDeprecation', 'FSTDEP007', 'You are trying to set a HEAD route using "exposeHeadRoute" route flag when a sibling route is already set. See documentation for more info.') - -warning.create('FastifyDeprecation', 'FSTDEP008', 'You are using route constraints via the route { version: "..." } option, use { constraints: { version: "..." } } option instead.') - -warning.create('FastifyDeprecation', 'FSTDEP009', 'You are using a custom route versioning strategy via the server { versioning: "..." } option, use { constraints: { version: "..." } } option instead.') - -warning.create('FastifyDeprecation', 'FSTDEP010', 'Modifying the "reply.sent" property is deprecated. Use the "reply.hijack()" method instead.') - -warning.create('FastifyDeprecation', 'FSTDEP011', 'Variadic listen method is deprecated. Please use ".listen(optionsObject)" instead. The variadic signature will be removed in `fastify@5`.') - -module.exports = warning +const FSTWRN001 = createWarning({ + name: 'FastifyWarning', + code: 'FSTWRN001', + message: 'The %s schema for %s: %s is missing. This may indicate the schema is not well specified.', + unlimited: true +}) + +const FSTWRN003 = createWarning({ + name: 'FastifyWarning', + code: 'FSTWRN003', + message: 'The %s mixes async and callback styles that may lead to unhandled rejections. Please use only one of them.', + unlimited: true +}) + +const FSTWRN004 = createWarning({ + name: 'FastifyWarning', + code: 'FSTWRN004', + message: 'It seems that you are overriding an errorHandler in the same scope, which can lead to subtle bugs.', + unlimited: true +}) + +const FSTSEC001 = createWarning({ + name: 'FastifySecurity', + code: 'FSTSEC001', + message: 'You are using /%s/ Content-Type which may be vulnerable to CORS attack. Please make sure your RegExp start with "^" or include ";?" to proper detection of the essence MIME type.', + unlimited: true +}) + +const FSTDEP022 = createWarning({ + name: 'FastifyWarning', + code: 'FSTDEP022', + message: 'The router options for %s property access is deprecated. Please use "options.routerOptions" instead for accessing router options. The router options will be removed in `fastify@6`.', + unlimited: true +}) + +module.exports = { + FSTWRN001, + FSTWRN003, + FSTWRN004, + FSTSEC001, + FSTDEP022 +} diff --git a/lib/wrap-thenable.js b/lib/wrap-thenable.js new file mode 100644 index 00000000000..6ff5091d189 --- /dev/null +++ b/lib/wrap-thenable.js @@ -0,0 +1,84 @@ +'use strict' + +const { + kReplyIsError, + kReplyHijacked +} = require('./symbols') +const { setErrorStatusCode } = require('./error-status') + +const diagnostics = require('node:diagnostics_channel') +const channels = diagnostics.tracingChannel('fastify.request.handler') + +function wrapThenable (thenable, reply, store) { + if (store) store.async = true + thenable.then(function (payload) { + if (reply[kReplyHijacked] === true) { + return + } + + if (store) { + channels.asyncStart.publish(store) + } + + try { + // this is for async functions that are using reply.send directly + // + // since wrap-thenable will be called when using reply.send directly + // without actual return. the response can be sent already or + // the request may be terminated during the reply. in this situation, + // it require an extra checking of request.aborted to see whether + // the request is killed by client. + if (payload !== undefined || // + (reply.sent === false && // + reply.raw.headersSent === false && + reply.request.raw.aborted === false && + reply.request.socket && + !reply.request.socket.destroyed + ) + ) { + // we use a try-catch internally to avoid adding a catch to another + // promise, increase promise perf by 10% + try { + reply.send(payload) + } catch (err) { + reply[kReplyIsError] = true + reply.send(err) + } + } + } finally { + if (store) { + channels.asyncEnd.publish(store) + } + } + }, function (err) { + if (store) { + store.error = err + // Set status code before publishing so subscribers see the correct value + setErrorStatusCode(reply, err) + channels.error.publish(store) // note that error happens before asyncStart + channels.asyncStart.publish(store) + } + + try { + if (reply.sent === true) { + reply.log.error({ err }, 'Promise errored, but reply.sent = true was set') + return + } + + reply[kReplyIsError] = true + + reply.send(err) + // The following should not happen + /* c8 ignore next 3 */ + } catch (err) { + // try-catch allow to re-throw error in error handler for async handler + reply.send(err) + } finally { + if (store) { + channels.asyncEnd.publish(store) + } + } + }) +} + +module.exports = wrapThenable diff --git a/lib/wrapThenable.js b/lib/wrapThenable.js deleted file mode 100644 index 7cda5d59942..00000000000 --- a/lib/wrapThenable.js +++ /dev/null @@ -1,42 +0,0 @@ -'use strict' - -const { - kReplyIsError, - kReplyHijacked -} = require('./symbols') - -function wrapThenable (thenable, reply) { - thenable.then(function (payload) { - if (reply[kReplyHijacked] === true) { - return - } - - // this is for async functions that are using reply.send directly - // - // since wrap-thenable will be called when using reply.send directly - // without actual return. the response can be sent already or - // the request may be terminated during the reply. in this situation, - // it require an extra checking of request.aborted to see whether - // the request is killed by client. - if (payload !== undefined || (reply.sent === false && reply.raw.headersSent === false && reply.request.raw.aborted === false)) { - // we use a try-catch internally to avoid adding a catch to another - // promise, increase promise perf by 10% - try { - reply.send(payload) - } catch (err) { - reply[kReplyIsError] = true - reply.send(err) - } - } - }, function (err) { - if (reply.sent === true) { - reply.log.error({ err }, 'Promise errored, but reply.sent = true was set') - return - } - - reply[kReplyIsError] = true - reply.send(err) - }) -} - -module.exports = wrapThenable diff --git a/package.json b/package.json index 5073f2e11a0..d0cae6c0257 100644 --- a/package.json +++ b/package.json @@ -1,31 +1,33 @@ { "name": "fastify", - "version": "4.2.0", + "version": "5.8.2", "description": "Fast and low overhead web framework, for Node.js", "main": "fastify.js", "type": "commonjs", "types": "fastify.d.ts", "scripts": { "bench": "branchcmp -r 2 -g -s \"npm run benchmark\"", - "benchmark": "npx concurrently -k -s first \"node ./examples/benchmark/simple.js\" \"npx autocannon -c 100 -d 30 -p 10 localhost:3000/\"", - "coverage": "npm run unit -- --cov --coverage-report=html", - "coverage:ci": "npm run unit -- --cov --coverage-report=html --no-browser --no-check-coverage -R terse", - "coverage:ci-check-coverage": "nyc check-coverage --branches 100 --functions 100 --lines 100 --statements 100", - "license-checker": "license-checker --production --onlyAllow=\"MIT;ISC;BSD-3-Clause;BSD-2-Clause\"", - "lint": "npm run lint:standard && npm run lint:typescript && npm run lint:markdown", - "lint:fix": "standard --fix", + "benchmark": "concurrently -k -s first \"node ./examples/benchmark/simple.js\" \"autocannon -c 100 -d 30 -p 10 localhost:3000/\"", + "benchmark:parser": "concurrently -k -s first \"node ./examples/benchmark/parser.js\" \"autocannon -c 100 -d 30 -p 10 -i ./examples/benchmark/body.json -H \"content-type:application/jsoff\" -m POST localhost:3000/\"", + "benchmark:parser:error": "concurrently -k -s first \"node ./examples/benchmark/parser.js\" \"autocannon -c 100 -d 30 -p 10 -i ./examples/benchmark/body.json -H \"content-type:application/jsoff\" -H \"content-length:123\" -m POST localhost:3000/\"", + "build:validation": "node build/build-error-serializer.js && node build/build-validation.js", + "build:sync-version": "node build/sync-version.js", + "coverage": "c8 --reporter html borp --reporter=@jsumners/line-reporter", + "coverage:ci-check-coverage": "borp --reporter=@jsumners/line-reporter --coverage --check-coverage --lines 100", + "lint": "npm run lint:eslint", + "lint:fix": "eslint --fix", "lint:markdown": "markdownlint-cli2", - "lint:standard": "standard | snazzy", - "lint:typescript": "eslint -c types/.eslintrc.json types/**/*.d.ts test/types/**/*.test-d.ts", - "prepublishOnly": "tap --no-check-coverage test/build/**.test.js", + "lint:eslint": "eslint", + "prepublishOnly": "cross-env PREPUBLISH=true borp --reporter=@jsumners/line-reporter && npm run test:validator:integrity && npm run build:sync-version", "test": "npm run lint && npm run unit && npm run test:typescript", - "test:ci": "npm run unit -- -R terse --cov --coverage-report=lcovonly && npm run test:typescript", + "test:ci": "npm run unit && npm run test:typescript", "test:report": "npm run lint && npm run unit:report && npm run test:typescript", - "test:typescript": "tsc test/types/import.ts && tsd", - "test:watch": "npm run unit -- -w --no-coverage-report -R terse", - "unit": "tap", - "unit:junit": "tap-mocha-reporter xunit < out.tap > test/junit-testresults.xml", - "unit:report": "tap --cov --coverage-report=html --coverage-report=cobertura | tee out.tap" + "test:validator:integrity": "npm run build:validation && git diff --quiet --ignore-all-space --ignore-blank-lines --ignore-cr-at-eol lib/error-serializer.js && git diff --quiet --ignore-all-space --ignore-blank-lines --ignore-cr-at-eol lib/config-validator.js", + "test:typescript": "tsc test/types/import.ts --target es2022 --moduleResolution node16 --module node16 --noEmit && tsd", + "test:watch": "npm run unit -- --watch --coverage-report=none --reporter=terse", + "unit": "borp", + "unit:report": "c8 --reporter html borp --reporter=@jsumners/line-reporter", + "citgm": "borp --reporter=@jsumners/line-reporter --coverage --check-coverage --concurrency=1" }, "repository": { "type": "git", @@ -117,90 +119,105 @@ "name": "Luis Orbaiceta", "email": "luisorbaiceta@gmail.com", "url": "https://luisorbaiceta.com" + }, + { + "name": "Carlos Fuentes", + "email": "me@metcoder.dev", + "url": "https://metcoder.dev" + }, + { + "name": "Gürgün Dayıoğlu", + "email": "hey@gurgun.day", + "url": "https://heyhey.to/G" + }, + { + "name": "Aras Abbasi", + "email": "aras.abbasi@gmail.com" + }, + { + "name": "Frazer Smith", + "email": "frazer.dev@icloud.com", + "url": "https://github.com/fdawgs" + }, + { + "name": "KaKa Ng", + "email": "kaka@kakang.dev", + "url": "https://github.com/climba03003" + }, + { + "name": "Jean Michelet", + "email": "jean.antoine.michelet@gmail.com", + "url": "https://github.com/jean-michelet" } ], "license": "MIT", "bugs": { "url": "https://github.com/fastify/fastify/issues" }, - "homepage": "https://www.fastify.io/", + "homepage": "https://fastify.dev/", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "devDependencies": { - "@fastify/pre-commit": "^2.0.2", - "@sinclair/typebox": "^0.24.9", - "@sinonjs/fake-timers": "^9.1.2", - "@types/node": "^18.0.0", - "@typescript-eslint/eslint-plugin": "^5.27.0", - "@typescript-eslint/parser": "^5.27.0", - "ajv": "^8.11.0", + "@jsumners/line-reporter": "^1.0.1", + "@sinonjs/fake-timers": "^11.2.2", + "@stylistic/eslint-plugin": "^5.1.0", + "@stylistic/eslint-plugin-js": "^4.1.0", + "@types/node": "^25.0.3", + "ajv": "^8.12.0", "ajv-errors": "^3.0.0", - "ajv-formats": "^2.1.1", + "ajv-formats": "^3.0.1", "ajv-i18n": "^4.2.0", "ajv-merge-patch": "^5.0.1", + "autocannon": "^8.0.0", + "borp": "^0.21.0", "branch-comparer": "^1.1.0", - "cors": "^2.8.5", - "dns-prefetch-control": "^0.3.0", - "eslint": "^8.16.0", - "eslint-config-standard": "^17.0.0-1", - "eslint-import-resolver-node": "^0.3.6", - "eslint-plugin-import": "^2.26.0", - "eslint-plugin-n": "^15.2.0", - "eslint-plugin-promise": "^6.0.0", + "concurrently": "^9.1.2", + "cross-env": "^10.0.0", + "eslint": "^9.0.0", "fast-json-body": "^1.1.0", - "fast-json-stringify": "^5.0.0", - "fastify-plugin": "^3.0.1", - "fluent-json-schema": "^3.1.0", - "form-data": "^4.0.0", - "frameguard": "^4.0.0", + "fastify-plugin": "^5.0.0", + "fluent-json-schema": "^6.0.0", "h2url": "^0.2.0", - "helmet": "^5.1.0", - "hide-powered-by": "^1.1.0", "http-errors": "^2.0.0", - "joi": "^17.6.0", - "json-schema-to-ts": "^2.5.3", + "joi": "^18.0.1", + "json-schema-to-ts": "^3.0.1", "JSONStream": "^1.3.5", - "license-checker": "^25.0.1", - "markdownlint-cli2": "^0.4.0", + "markdownlint-cli2": "^0.21.0", + "neostandard": "^0.12.0", + "node-forge": "^1.3.1", "proxyquire": "^2.1.3", - "pump": "^3.0.0", - "self-cert": "^2.0.0", - "send": "^0.18.0", - "serve-static": "^1.15.0", - "simple-get": "^4.0.1", - "snazzy": "^9.0.0", - "split2": "^4.1.0", - "standard": "^17.0.0-2", - "tap": "^16.2.0", - "tsd": "^0.22.0", - "typescript": "^4.7.2", - "undici": "^5.4.0", - "x-xss-protection": "^2.0.0", - "yup": "^0.32.11" + "split2": "^4.2.0", + "tsd": "^0.33.0", + "typebox": "^1.0.81", + "typescript": "~5.9.2", + "undici": "^7.11.0", + "vary": "^1.1.2", + "yup": "^1.4.0" }, "dependencies": { - "@fastify/ajv-compiler": "^3.1.1", - "@fastify/error": "^3.0.0", - "@fastify/fast-json-stringify-compiler": "^4.0.0", + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", - "avvio": "^8.1.3", - "find-my-way": "^7.0.0", - "light-my-request": "^5.0.0", - "pino": "^8.0.0", - "process-warning": "^2.0.0", - "proxy-addr": "^2.0.7", - "rfdc": "^1.3.0", - "secure-json-parse": "^2.4.0", - "semver": "^7.3.7", - "tiny-lru": "^8.0.2" - }, - "standard": { - "ignore": [ - "lib/configValidator.js", - "lib/error-serializer.js", - "fastify.d.ts", - "types/*", - "test/types/*", - "test/same-shape.test.js" - ] + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" }, "tsd": { "directory": "test/types" diff --git a/scripts/validate-ecosystem-links.js b/scripts/validate-ecosystem-links.js new file mode 100644 index 00000000000..d99e1533622 --- /dev/null +++ b/scripts/validate-ecosystem-links.js @@ -0,0 +1,179 @@ +#!/usr/bin/env node + +'use strict' +/** + * Script to validate GitHub links in the Ecosystem.md file + * Checks if repositories are accessible or return 404 + * + * Usage: + * node validate-ecosystem-links.js + * + * Environment variables: + * GITHUB_TOKEN - Optional GitHub token for higher rate limits + */ + +const fs = require('node:fs') +const path = require('node:path') + +const ECOSYSTEM_FILE = path.join(__dirname, '../docs/Guides/Ecosystem.md') +const GITHUB_OWNER_REGEX = /^[a-z\d](?:[a-z\d-]{0,38})$/i +const GITHUB_REPO_REGEX = /^[a-z\d._-]+$/i + +function getGitHubToken () { + return process.env.GITHUB_TOKEN +} + +function isValidGitHubReference (owner, repo) { + return GITHUB_OWNER_REGEX.test(owner) && GITHUB_REPO_REGEX.test(repo) +} + +function extractGitHubLinks (content) { + const regex = /\[([^\]]+)\]\((https:\/\/github\.com\/([^/]+)\/([^/)]+)[^)]*)\)/g + const links = [] + let match + + while ((match = regex.exec(content)) !== null) { + links.push({ + name: match[1], + url: match[2], + owner: match[3], + repo: match[4].replace(/[#?].*$/, '') + }) + } + + return links +} + +async function checkGitHubRepo (owner, repo, retries = 3) { + if (!isValidGitHubReference(owner, repo)) { + return { + owner, + repo, + status: 'invalid', + exists: false, + error: 'Invalid GitHub repository identifier' + } + } + + const headers = { + 'User-Agent': 'fastify-ecosystem-validator' + } + + const githubToken = getGitHubToken() + if (githubToken) { + headers.Authorization = `token ${githubToken}` + } + + try { + const response = await fetch(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, { + method: 'HEAD', + headers + }) + + // Retry on rate limit (403) with exponential backoff + if (response.status === 403 && retries > 0) { + const delay = (4 - retries) * 2000 // 2s, 4s, 6s + await new Promise(resolve => setTimeout(resolve, delay)) + return checkGitHubRepo(owner, repo, retries - 1) + } + + return { + owner, + repo, + status: response.status, + exists: response.status === 200 + } + } catch (err) { + return { + owner, + repo, + status: 'error', + exists: false, + error: err.message + } + } +} + +async function validateAllLinks () { + console.log('Reading Ecosystem.md...\n') + const content = fs.readFileSync(ECOSYSTEM_FILE, 'utf8') + const links = extractGitHubLinks(content) + + // Deduplicate by owner/repo + const seen = new Set() + const uniqueLinks = links.filter(link => { + const key = `${link.owner}/${link.repo}`.toLowerCase() + if (seen.has(key)) return false + seen.add(key) + return true + }) + + console.log(`Found ${uniqueLinks.length} unique GitHub links to check:\n`) + + const results = [] + let checked = 0 + + for (const link of uniqueLinks) { + checked++ + process.stdout.write(`\r[${checked}/${uniqueLinks.length}] Checking: ${link.owner}/${link.repo}...`.padEnd(80)) + const result = await checkGitHubRepo(link.owner, link.repo) + results.push({ ...link, ...result }) + + // Rate limiting - wait a bit between requests + await new Promise(resolve => setTimeout(resolve, 200)) + } + + console.log('\n\n========== VALIDATION RESULTS ==========\n') + + const notFound = results.filter(r => !r.exists) + const found = results.filter(r => r.exists) + + if (notFound.length > 0) { + console.log('INACCESSIBLE (should be removed):') + console.log('-'.repeat(50)) + notFound.forEach(r => { + console.log(` [${r.status}] ${r.owner}/${r.repo}`) + console.log(` ${r.url}`) + }) + console.log() + } + + if (found.length > 0) { + console.log('ACCESSIBLE (kept):') + console.log('-'.repeat(50)) + found.forEach(r => { + console.log(` [${r.status}] ${r.owner}/${r.repo}`) + }) + console.log() + } + + console.log('========== SUMMARY ==========') + console.log(`Total links checked: ${results.length}`) + console.log(`Inaccessible: ${notFound.length}`) + console.log(`Accessible: ${found.length}`) + + return { notFound, found } +} + +// Export functions for testing +module.exports = { + extractGitHubLinks, + checkGitHubRepo, + validateAllLinks +} + +// Run if executed directly +/* c8 ignore start */ +if (require.main === module) { + validateAllLinks() + .then(({ notFound }) => { + if (notFound.length > 0) { + process.exit(1) + } + }) + .catch((err) => { + console.error(err) + process.exit(1) + }) +} +/* c8 ignore stop */ diff --git a/test/404s.test.js b/test/404s.test.js index cb235f4367d..f8bc8cefe7f 100644 --- a/test/404s.test.js +++ b/test/404s.test.js @@ -1,86 +1,75 @@ 'use strict' -const t = require('tap') -const test = t.test +const { test } = require('node:test') const fp = require('fastify-plugin') -const sget = require('simple-get').concat const errors = require('http-errors') const split = require('split2') const Fastify = require('..') +const { getServerUrl } = require('./helper') -function getUrl (app) { - const { address, port } = app.server.address() - if (address === '::1') { - return `http://[${address}]:${port}` - } else { - return `http://${address}:${port}` - } -} - -test('default 404', t => { +test('default 404', async t => { t.plan(4) - const test = t.test const fastify = Fastify() fastify.get('/', function (req, reply) { reply.send({ hello: 'world' }) }) - t.teardown(fastify.close.bind(fastify)) + t.after(() => { fastify.close() }) - fastify.listen({ port: 0 }, err => { - t.error(err) - - test('unsupported method', t => { - t.plan(3) - sget({ - method: 'PUT', - url: getUrl(fastify), - body: {}, - json: true - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 404) - t.equal(response.headers['content-type'], 'application/json; charset=utf-8') - }) + await fastify.listen({ port: 0 }) + + await t.test('unsupported method', async (t) => { + t.plan(3) + const result = await fetch(getServerUrl(fastify), { + method: 'PUT' }) - // Return 404 instead of 405 see https://github.com/fastify/fastify/pull/862 for discussion - test('framework-unsupported method', t => { - t.plan(3) - sget({ - method: 'PROPFIND', - url: getUrl(fastify), - body: {}, - json: true - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 404) - t.equal(response.headers['content-type'], 'application/json; charset=utf-8') - }) + t.assert.ok(!result.ok) + t.assert.strictEqual(result.status, 404) + t.assert.strictEqual(result.headers.get('content-type'), 'application/json; charset=utf-8') + }) + + // Return 404 instead of 405 see https://github.com/fastify/fastify/pull/862 for discussion + await t.test('framework-unsupported method', async (t) => { + t.plan(3) + const result = await fetch(getServerUrl(fastify), { + method: 'PROPFIND' }) - test('unsupported route', t => { - t.plan(3) - sget({ - method: 'GET', - url: getUrl(fastify) + '/notSupported', - body: {}, - json: true - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 404) - t.equal(response.headers['content-type'], 'application/json; charset=utf-8') - }) + t.assert.ok(!result.ok) + t.assert.strictEqual(result.status, 404) + t.assert.strictEqual(result.headers.get('content-type'), 'application/json; charset=utf-8') + }) + + await t.test('unsupported route', async (t) => { + t.plan(3) + const response = await fetch(getServerUrl(fastify) + '/notSupported') + + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 404) + t.assert.strictEqual(response.headers.get('content-type'), 'application/json; charset=utf-8') + }) + + await t.test('using post method and multipart/formdata', async t => { + t.plan(3) + const form = new FormData() + form.set('test-field', 'just some field') + + const response = await fetch(getServerUrl(fastify) + '/notSupported', { + method: 'POST', + body: form }) + t.assert.strictEqual(response.status, 404) + t.assert.strictEqual(response.statusText, 'Not Found') + t.assert.strictEqual(response.headers.get('content-type'), 'application/json; charset=utf-8') }) }) -test('customized 404', t => { - t.plan(6) +test('customized 404', async t => { + t.plan(5) - const test = t.test const fastify = Fastify() fastify.get('/', function (req, reply) { @@ -101,120 +90,101 @@ test('customized 404', t => { reply.code(404).send('this was not found') }) - t.teardown(fastify.close.bind(fastify)) + t.after(() => { fastify.close() }) - fastify.listen({ port: 0 }, err => { - t.error(err) - - test('unsupported method', t => { - t.plan(3) - sget({ - method: 'PUT', - url: getUrl(fastify), - body: JSON.stringify({ hello: 'world' }), - headers: { 'Content-Type': 'application/json' } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 404) - t.equal(body.toString(), 'this was not found') - }) - }) + await fastify.listen({ port: 0 }) - test('framework-unsupported method', t => { - t.plan(3) - sget({ - method: 'PROPFIND', - url: getUrl(fastify), - body: JSON.stringify({ hello: 'world' }), - headers: { 'Content-Type': 'application/json' } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 404) - t.equal(body.toString(), 'this was not found') - }) + await t.test('unsupported method', async (t) => { + t.plan(3) + const response = await fetch(getServerUrl(fastify), { + method: 'PUT', + body: JSON.stringify({ hello: 'world' }), + headers: { 'Content-Type': 'application/json' } }) - test('unsupported route', t => { - t.plan(3) - sget({ - method: 'GET', - url: getUrl(fastify) + '/notSupported' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 404) - t.equal(body.toString(), 'this was not found') - }) + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 404) + t.assert.strictEqual(await response.text(), 'this was not found') + }) + + await t.test('framework-unsupported method', async (t) => { + t.plan(3) + const response = await fetch(getServerUrl(fastify), { + method: 'PROPFIND', + body: JSON.stringify({ hello: 'world' }), + headers: { 'Content-Type': 'application/json' } }) - test('with error object', t => { - t.plan(3) - sget({ - method: 'GET', - url: getUrl(fastify) + '/with-error' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 404) - t.same(JSON.parse(body), { - error: 'Not Found', - message: 'Not Found', - statusCode: 404 - }) - }) + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 404) + t.assert.strictEqual(await response.text(), 'this was not found') + }) + + await t.test('unsupported route', async (t) => { + t.plan(3) + const response = await fetch(getServerUrl(fastify) + '/notSupported') + + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 404) + t.assert.strictEqual(await response.text(), 'this was not found') + }) + + await t.test('with error object', async (t) => { + t.plan(3) + const response = await fetch(getServerUrl(fastify) + '/with-error') + + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 404) + t.assert.deepStrictEqual(await response.json(), { + error: 'Not Found', + message: 'Not Found', + statusCode: 404 }) + }) - test('error object with headers property', t => { - t.plan(4) - sget({ - method: 'GET', - url: getUrl(fastify) + '/with-error-custom-header' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 404) - t.equal(response.headers['x-foo'], 'bar') - t.same(JSON.parse(body), { - error: 'Not Found', - message: 'Not Found', - statusCode: 404 - }) - }) + await t.test('error object with headers property', async (t) => { + t.plan(4) + const response = await fetch(getServerUrl(fastify) + '/with-error-custom-header') + + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 404) + t.assert.strictEqual(response.headers.get('x-foo'), 'bar') + t.assert.deepStrictEqual(await response.json(), { + error: 'Not Found', + message: 'Not Found', + statusCode: 404 }) }) }) -test('custom header in notFound handler', t => { - t.plan(2) +test('custom header in notFound handler', async t => { + t.plan(1) - const test = t.test const fastify = Fastify() fastify.setNotFoundHandler(function (req, reply) { reply.code(404).header('x-foo', 'bar').send('this was not found') }) - t.teardown(fastify.close.bind(fastify)) + t.after(() => { fastify.close() }) - fastify.listen({ port: 0 }, err => { - t.error(err) - - test('not found with custom header', t => { - t.plan(4) - sget({ - method: 'GET', - url: getUrl(fastify) + '/notSupported' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 404) - t.equal(response.headers['x-foo'], 'bar') - t.equal(body.toString(), 'this was not found') - }) - }) + await fastify.listen({ port: 0 }) + + await t.test('not found with custom header', async (t) => { + t.plan(4) + const response = await fetch(getServerUrl(fastify) + '/notSupported') + + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 404) + t.assert.strictEqual(response.headers.get('x-foo'), 'bar') + t.assert.strictEqual(await response.text(), 'this was not found') }) }) -test('setting a custom 404 handler multiple times is an error', t => { +test('setting a custom 404 handler multiple times is an error', async t => { t.plan(5) - t.test('at the root level', t => { + await t.test('at the root level', t => { t.plan(2) const fastify = Fastify() @@ -223,14 +193,14 @@ test('setting a custom 404 handler multiple times is an error', t => { try { fastify.setNotFoundHandler(() => {}) - t.fail('setting multiple 404 handlers at the same prefix encapsulation level should throw') + t.assert.fail('setting multiple 404 handlers at the same prefix encapsulation level should throw') } catch (err) { - t.type(err, Error) - t.equal(err.message, 'Not found handler already set for Fastify instance with prefix: \'/\'') + t.assert.ok(err instanceof Error) + t.assert.strictEqual(err.message, 'Not found handler already set for Fastify instance with prefix: \'/\'') } }) - t.test('at the plugin level', t => { + await t.test('at the plugin level', (t, done) => { t.plan(3) const fastify = Fastify() @@ -240,22 +210,23 @@ test('setting a custom 404 handler multiple times is an error', t => { try { instance.setNotFoundHandler(() => {}) - t.fail('setting multiple 404 handlers at the same prefix encapsulation level should throw') + t.assert.fail('setting multiple 404 handlers at the same prefix encapsulation level should throw') } catch (err) { - t.type(err, Error) - t.equal(err.message, 'Not found handler already set for Fastify instance with prefix: \'/prefix\'') + t.assert.ok(err instanceof Error) + t.assert.strictEqual(err.message, 'Not found handler already set for Fastify instance with prefix: \'/prefix\'') } done() }, { prefix: '/prefix' }) fastify.listen({ port: 0 }, err => { - t.error(err) + t.assert.ifError(err) fastify.close() + done() }) }) - t.test('at multiple levels', t => { + await t.test('at multiple levels', (t, done) => { t.plan(3) const fastify = Fastify() @@ -263,10 +234,10 @@ test('setting a custom 404 handler multiple times is an error', t => { fastify.register((instance, options, done) => { try { instance.setNotFoundHandler(() => {}) - t.fail('setting multiple 404 handlers at the same prefix encapsulation level should throw') + t.assert.fail('setting multiple 404 handlers at the same prefix encapsulation level should throw') } catch (err) { - t.type(err, Error) - t.equal(err.message, 'Not found handler already set for Fastify instance with prefix: \'/\'') + t.assert.ok(err instanceof Error) + t.assert.strictEqual(err.message, 'Not found handler already set for Fastify instance with prefix: \'/\'') } done() }) @@ -274,12 +245,13 @@ test('setting a custom 404 handler multiple times is an error', t => { fastify.setNotFoundHandler(() => {}) fastify.listen({ port: 0 }, err => { - t.error(err) + t.assert.ifError(err) fastify.close() + done() }) }) - t.test('at multiple levels / 2', t => { + await t.test('at multiple levels / 2', (t, done) => { t.plan(3) const fastify = Fastify() @@ -290,10 +262,10 @@ test('setting a custom 404 handler multiple times is an error', t => { instance.register((instance2, options, done) => { try { instance2.setNotFoundHandler(() => {}) - t.fail('setting multiple 404 handlers at the same prefix encapsulation level should throw') + t.assert.fail('setting multiple 404 handlers at the same prefix encapsulation level should throw') } catch (err) { - t.type(err, Error) - t.equal(err.message, 'Not found handler already set for Fastify instance with prefix: \'/prefix\'') + t.assert.ok(err instanceof Error) + t.assert.strictEqual(err.message, 'Not found handler already set for Fastify instance with prefix: \'/prefix\'') } done() }) @@ -304,12 +276,13 @@ test('setting a custom 404 handler multiple times is an error', t => { fastify.setNotFoundHandler(() => {}) fastify.listen({ port: 0 }, err => { - t.error(err) + t.assert.ifError(err) fastify.close() + done() }) }) - t.test('in separate plugins at the same level', t => { + await t.test('in separate plugins at the same level', (t, done) => { t.plan(3) const fastify = Fastify() @@ -323,10 +296,10 @@ test('setting a custom 404 handler multiple times is an error', t => { instance.register((instance2B, options, done) => { try { instance2B.setNotFoundHandler(() => {}) - t.fail('setting multiple 404 handlers at the same prefix encapsulation level should throw') + t.assert.fail('setting multiple 404 handlers at the same prefix encapsulation level should throw') } catch (err) { - t.type(err, Error) - t.equal(err.message, 'Not found handler already set for Fastify instance with prefix: \'/prefix\'') + t.assert.ok(err instanceof Error) + t.assert.strictEqual(err.message, 'Not found handler already set for Fastify instance with prefix: \'/prefix\'') } done() }) @@ -337,16 +310,16 @@ test('setting a custom 404 handler multiple times is an error', t => { fastify.setNotFoundHandler(() => {}) fastify.listen({ port: 0 }, err => { - t.error(err) + t.assert.ifError(err) fastify.close() + done() }) }) }) -test('encapsulated 404', t => { - t.plan(13) +test('encapsulated 404', async t => { + t.plan(12) - const test = t.test const fastify = Fastify() fastify.get('/', function (req, reply) { @@ -378,199 +351,177 @@ test('encapsulated 404', t => { done() }, { prefix: '/test3/' }) - t.teardown(fastify.close.bind(fastify)) + t.after(() => { fastify.close() }) - fastify.listen({ port: 0 }, err => { - t.error(err) - - test('root unsupported method', t => { - t.plan(3) - sget({ - method: 'PUT', - url: getUrl(fastify), - body: JSON.stringify({ hello: 'world' }), - headers: { 'Content-Type': 'application/json' } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 404) - t.equal(body.toString(), 'this was not found') - }) - }) + await fastify.listen({ port: 0 }) - test('root framework-unsupported method', t => { - t.plan(3) - sget({ - method: 'PROPFIND', - url: getUrl(fastify), - body: JSON.stringify({ hello: 'world' }), - headers: { 'Content-Type': 'application/json' } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 404) - t.equal(body.toString(), 'this was not found') - }) + await t.test('root unsupported method', async (t) => { + t.plan(3) + const response = await fetch(getServerUrl(fastify), { + method: 'PUT', + body: JSON.stringify({ hello: 'world' }), + headers: { 'Content-Type': 'application/json' } }) - test('root unsupported route', t => { - t.plan(3) - sget({ - method: 'GET', - url: getUrl(fastify) + '/notSupported' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 404) - t.equal(body.toString(), 'this was not found') - }) - }) + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 404) + t.assert.strictEqual(await response.text(), 'this was not found') + }) - test('unsupported method', t => { - t.plan(3) - sget({ - method: 'PUT', - url: getUrl(fastify) + '/test', - body: JSON.stringify({ hello: 'world' }), - headers: { 'Content-Type': 'application/json' } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 404) - t.equal(body.toString(), 'this was not found 2') - }) + await t.test('root framework-unsupported method', async (t) => { + t.plan(3) + const response = await fetch(getServerUrl(fastify), { + method: 'PROPFIND', + body: JSON.stringify({ hello: 'world' }), + headers: { 'Content-Type': 'application/json' } }) - test('framework-unsupported method', t => { - t.plan(3) - sget({ - method: 'PROPFIND', - url: getUrl(fastify) + '/test', - body: JSON.stringify({ hello: 'world' }), - headers: { 'Content-Type': 'application/json' } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 404) - t.equal(body.toString(), 'this was not found 2') - }) - }) + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 404) + t.assert.strictEqual(await response.text(), 'this was not found') + }) - test('unsupported route', t => { - t.plan(3) - sget({ - method: 'GET', - url: getUrl(fastify) + '/test/notSupported' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 404) - t.equal(body.toString(), 'this was not found 2') - }) - }) + await t.test('root unsupported route', async (t) => { + t.plan(3) + const response = await fetch(getServerUrl(fastify) + '/notSupported') - test('unsupported method 2', t => { - t.plan(3) - sget({ - method: 'PUT', - url: getUrl(fastify) + '/test2', - body: JSON.stringify({ hello: 'world' }), - headers: { 'Content-Type': 'application/json' } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 404) - t.equal(body.toString(), 'this was not found 3') - }) + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 404) + t.assert.strictEqual(await response.text(), 'this was not found') + }) + + await t.test('unsupported method', async (t) => { + t.plan(3) + const response = await fetch(getServerUrl(fastify) + '/test', { + method: 'PUT', + body: JSON.stringify({ hello: 'world' }), + headers: { 'Content-Type': 'application/json' } }) - test('framework-unsupported method 2', t => { - t.plan(3) - sget({ - method: 'PROPFIND', - url: getUrl(fastify) + '/test2', - body: JSON.stringify({ hello: 'world' }), - headers: { 'Content-Type': 'application/json' } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 404) - t.equal(body.toString(), 'this was not found 3') - }) + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 404) + t.assert.strictEqual(await response.text(), 'this was not found 2') + }) + + await t.test('framework-unsupported method', async (t) => { + t.plan(3) + const response = await fetch(getServerUrl(fastify) + '/test', { + method: 'PROPFIND', + body: JSON.stringify({ hello: 'world' }), + headers: { 'Content-Type': 'application/json' } }) - test('unsupported route 2', t => { - t.plan(3) - sget({ - method: 'GET', - url: getUrl(fastify) + '/test2/notSupported' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 404) - t.equal(body.toString(), 'this was not found 3') - }) + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 404) + t.assert.strictEqual(await response.text(), 'this was not found 2') + }) + + await t.test('unsupported route', async (t) => { + t.plan(3) + const response = await fetch(getServerUrl(fastify) + '/test/notSupported') + + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 404) + t.assert.strictEqual(await response.text(), 'this was not found 2') + }) + + await t.test('unsupported method 2', async (t) => { + t.plan(3) + const response = await fetch(getServerUrl(fastify) + '/test2', { + method: 'PUT', + body: JSON.stringify({ hello: 'world' }), + headers: { 'Content-Type': 'application/json' } }) - test('unsupported method 3', t => { - t.plan(3) - sget({ - method: 'PUT', - url: getUrl(fastify) + '/test3/', - body: JSON.stringify({ hello: 'world' }), - headers: { 'Content-Type': 'application/json' } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 404) - t.equal(body.toString(), 'this was not found 4') - }) + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 404) + t.assert.strictEqual(await response.text(), 'this was not found 3') + }) + + await t.test('framework-unsupported method 2', async (t) => { + t.plan(3) + const response = await fetch(getServerUrl(fastify) + '/test2', { + method: 'PROPFIND', + body: JSON.stringify({ hello: 'world' }), + headers: { 'Content-Type': 'application/json' } }) - test('framework-unsupported method 3', t => { - t.plan(3) - sget({ - method: 'PROPFIND', - url: getUrl(fastify) + '/test3/', - body: JSON.stringify({ hello: 'world' }), - headers: { 'Content-Type': 'application/json' } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 404) - t.equal(body.toString(), 'this was not found 4') - }) + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 404) + t.assert.strictEqual(await response.text(), 'this was not found 3') + }) + + await t.test('unsupported route 2', async (t) => { + t.plan(3) + const response = await fetch(getServerUrl(fastify) + '/test2/notSupported') + + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 404) + t.assert.strictEqual(await response.text(), 'this was not found 3') + }) + + await t.test('unsupported method 3', async (t) => { + t.plan(3) + const response = await fetch(getServerUrl(fastify) + '/test3/', { + method: 'PUT', + body: JSON.stringify({ hello: 'world' }), + headers: { 'Content-Type': 'application/json' } }) - test('unsupported route 3', t => { - t.plan(3) - sget({ - method: 'GET', - url: getUrl(fastify) + '/test3/notSupported' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 404) - t.equal(body.toString(), 'this was not found 4') - }) + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 404) + t.assert.strictEqual(await response.text(), 'this was not found 4') + }) + + await t.test('framework-unsupported method 3', async (t) => { + t.plan(3) + const response = await fetch(getServerUrl(fastify) + '/test3/', { + method: 'PROPFIND', + body: JSON.stringify({ hello: 'world' }), + headers: { 'Content-Type': 'application/json' } }) + + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 404) + t.assert.strictEqual(await response.text(), 'this was not found 4') + }) + + await t.test('unsupported route 3', async (t) => { + t.plan(3) + const response = await fetch(getServerUrl(fastify) + '/test3/notSupported') + + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 404) + t.assert.strictEqual(await response.text(), 'this was not found 4') }) }) -test('custom 404 hook and handler context', t => { - t.plan(21) +test('custom 404 hook and handler context', async t => { + t.plan(19) const fastify = Fastify() fastify.decorate('foo', 42) fastify.addHook('onRequest', function (req, res, done) { - t.equal(this.foo, 42) + t.assert.strictEqual(this.foo, 42) done() }) fastify.addHook('preHandler', function (request, reply, done) { - t.equal(this.foo, 42) + t.assert.strictEqual(this.foo, 42) done() }) fastify.addHook('onSend', function (request, reply, payload, done) { - t.equal(this.foo, 42) + t.assert.strictEqual(this.foo, 42) done() }) fastify.addHook('onResponse', function (request, reply, done) { - t.equal(this.foo, 42) + t.assert.strictEqual(this.foo, 42) done() }) fastify.setNotFoundHandler(function (req, reply) { - t.equal(this.foo, 42) + t.assert.strictEqual(this.foo, 42) reply.code(404).send('this was not found') }) @@ -578,45 +529,45 @@ test('custom 404 hook and handler context', t => { instance.decorate('bar', 84) instance.addHook('onRequest', function (req, res, done) { - t.equal(this.bar, 84) + t.assert.strictEqual(this.bar, 84) done() }) instance.addHook('preHandler', function (request, reply, done) { - t.equal(this.bar, 84) + t.assert.strictEqual(this.bar, 84) done() }) instance.addHook('onSend', function (request, reply, payload, done) { - t.equal(this.bar, 84) + t.assert.strictEqual(this.bar, 84) done() }) instance.addHook('onResponse', function (request, reply, done) { - t.equal(this.bar, 84) + t.assert.strictEqual(this.bar, 84) done() }) instance.setNotFoundHandler(function (req, reply) { - t.equal(this.foo, 42) - t.equal(this.bar, 84) + t.assert.strictEqual(this.foo, 42) + t.assert.strictEqual(this.bar, 84) reply.code(404).send('encapsulated was not found') }) done() }, { prefix: '/encapsulated' }) - fastify.inject('/not-found', (err, res) => { - t.error(err) - t.equal(res.statusCode, 404) - t.equal(res.payload, 'this was not found') - }) + { + const res = await fastify.inject('/not-found') + t.assert.strictEqual(res.statusCode, 404) + t.assert.strictEqual(res.payload, 'this was not found') + } - fastify.inject('/encapsulated/not-found', (err, res) => { - t.error(err) - t.equal(res.statusCode, 404) - t.equal(res.payload, 'encapsulated was not found') - }) + { + const res = await fastify.inject('/encapsulated/not-found') + t.assert.strictEqual(res.statusCode, 404) + t.assert.strictEqual(res.payload, 'encapsulated was not found') + } }) -test('encapsulated custom 404 without - prefix hook and handler context', t => { +test('encapsulated custom 404 without - prefix hook and handler context', (t, done) => { t.plan(13) const fastify = Fastify() @@ -627,29 +578,29 @@ test('encapsulated custom 404 without - prefix hook and handler context', t => { instance.decorate('bar', 84) instance.addHook('onRequest', function (req, res, done) { - t.equal(this.foo, 42) - t.equal(this.bar, 84) + t.assert.strictEqual(this.foo, 42) + t.assert.strictEqual(this.bar, 84) done() }) instance.addHook('preHandler', function (request, reply, done) { - t.equal(this.foo, 42) - t.equal(this.bar, 84) + t.assert.strictEqual(this.foo, 42) + t.assert.strictEqual(this.bar, 84) done() }) instance.addHook('onSend', function (request, reply, payload, done) { - t.equal(this.foo, 42) - t.equal(this.bar, 84) + t.assert.strictEqual(this.foo, 42) + t.assert.strictEqual(this.bar, 84) done() }) instance.addHook('onResponse', function (request, reply, done) { - t.equal(this.foo, 42) - t.equal(this.bar, 84) + t.assert.strictEqual(this.foo, 42) + t.assert.strictEqual(this.bar, 84) done() }) instance.setNotFoundHandler(function (request, reply) { - t.equal(this.foo, 42) - t.equal(this.bar, 84) + t.assert.strictEqual(this.foo, 42) + t.assert.strictEqual(this.bar, 84) reply.code(404).send('custom not found') }) @@ -657,34 +608,35 @@ test('encapsulated custom 404 without - prefix hook and handler context', t => { }) fastify.inject('/not-found', (err, res) => { - t.error(err) - t.equal(res.statusCode, 404) - t.equal(res.payload, 'custom not found') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 404) + t.assert.strictEqual(res.payload, 'custom not found') + done() }) }) -test('run hooks on default 404', t => { - t.plan(7) +test('run hooks on default 404', async t => { + t.plan(6) const fastify = Fastify() fastify.addHook('onRequest', function (req, res, done) { - t.pass('onRequest called') + t.assert.ok(true, 'onRequest called') done() }) fastify.addHook('preHandler', function (request, reply, done) { - t.pass('preHandler called') + t.assert.ok(true, 'preHandler called') done() }) fastify.addHook('onSend', function (request, reply, payload, done) { - t.pass('onSend called') + t.assert.ok(true, 'onSend called') done() }) fastify.addHook('onResponse', function (request, reply, done) { - t.pass('onResponse called') + t.assert.ok(true, 'onResponse called') done() }) @@ -692,46 +644,43 @@ test('run hooks on default 404', t => { reply.send({ hello: 'world' }) }) - t.teardown(fastify.close.bind(fastify)) + t.after(() => { fastify.close() }) - fastify.listen({ port: 0 }, err => { - t.error(err) + await fastify.listen({ port: 0 }) - sget({ - method: 'PUT', - url: getUrl(fastify), - body: JSON.stringify({ hello: 'world' }), - headers: { 'Content-Type': 'application/json' } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 404) - }) + const response = await fetch(getServerUrl(fastify), { + method: 'PUT', + body: JSON.stringify({ hello: 'world' }), + headers: { 'Content-Type': 'application/json' } }) + + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 404) }) -test('run non-encapsulated plugin hooks on default 404', t => { +test('run non-encapsulated plugin hooks on default 404', (t, done) => { t.plan(6) const fastify = Fastify() fastify.register(fp(function (instance, options, done) { instance.addHook('onRequest', function (req, res, done) { - t.pass('onRequest called') + t.assert.ok(true, 'onRequest called') done() }) instance.addHook('preHandler', function (request, reply, done) { - t.pass('preHandler called') + t.assert.ok(true, 'preHandler called') done() }) instance.addHook('onSend', function (request, reply, payload, done) { - t.pass('onSend called') + t.assert.ok(true, 'onSend called') done() }) instance.addHook('onResponse', function (request, reply, done) { - t.pass('onResponse called') + t.assert.ok(true, 'onResponse called') done() }) @@ -747,34 +696,35 @@ test('run non-encapsulated plugin hooks on default 404', t => { url: '/', payload: { hello: 'world' } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 404) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 404) + done() }) }) -test('run non-encapsulated plugin hooks on custom 404', t => { +test('run non-encapsulated plugin hooks on custom 404', (t, done) => { t.plan(11) const fastify = Fastify() const plugin = fp((instance, opts, done) => { instance.addHook('onRequest', function (req, res, done) { - t.pass('onRequest called') + t.assert.ok(true, 'onRequest called') done() }) instance.addHook('preHandler', function (request, reply, done) { - t.pass('preHandler called') + t.assert.ok(true, 'preHandler called') done() }) instance.addHook('onSend', function (request, reply, payload, done) { - t.pass('onSend called') + t.assert.ok(true, 'onSend called') done() }) instance.addHook('onResponse', function (request, reply, done) { - t.pass('onResponse called') + t.assert.ok(true, 'onResponse called') done() }) @@ -794,34 +744,35 @@ test('run non-encapsulated plugin hooks on custom 404', t => { fastify.register(plugin) // Registering plugin after handler also works fastify.inject({ url: '/not-found' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 404) - t.equal(res.payload, 'this was not found') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 404) + t.assert.strictEqual(res.payload, 'this was not found') + done() }) }) -test('run hook with encapsulated 404', t => { - t.plan(11) +test('run hook with encapsulated 404', async t => { + t.plan(10) const fastify = Fastify() fastify.addHook('onRequest', function (req, res, done) { - t.pass('onRequest called') + t.assert.ok(true, 'onRequest called') done() }) fastify.addHook('preHandler', function (request, reply, done) { - t.pass('preHandler called') + t.assert.ok(true, 'preHandler called') done() }) fastify.addHook('onSend', function (request, reply, payload, done) { - t.pass('onSend called') + t.assert.ok(true, 'onSend called') done() }) fastify.addHook('onResponse', function (request, reply, done) { - t.pass('onResponse called') + t.assert.ok(true, 'onResponse called') done() }) @@ -831,67 +782,64 @@ test('run hook with encapsulated 404', t => { }) f.addHook('onRequest', function (req, res, done) { - t.pass('onRequest 2 called') + t.assert.ok(true, 'onRequest 2 called') done() }) f.addHook('preHandler', function (request, reply, done) { - t.pass('preHandler 2 called') + t.assert.ok(true, 'preHandler 2 called') done() }) f.addHook('onSend', function (request, reply, payload, done) { - t.pass('onSend 2 called') + t.assert.ok(true, 'onSend 2 called') done() }) f.addHook('onResponse', function (request, reply, done) { - t.pass('onResponse 2 called') + t.assert.ok(true, 'onResponse 2 called') done() }) done() }, { prefix: '/test' }) - t.teardown(fastify.close.bind(fastify)) + t.after(() => { fastify.close() }) - fastify.listen({ port: 0 }, err => { - t.error(err) + await fastify.listen({ port: 0 }) - sget({ - method: 'PUT', - url: getUrl(fastify) + '/test', - body: JSON.stringify({ hello: 'world' }), - headers: { 'Content-Type': 'application/json' } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 404) - }) + const response = await fetch(getServerUrl(fastify) + '/test', { + method: 'PUT', + body: JSON.stringify({ hello: 'world' }), + headers: { 'Content-Type': 'application/json' } }) + + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 404) }) -test('run hook with encapsulated 404 and framework-unsupported method', t => { - t.plan(11) +test('run hook with encapsulated 404 and framework-unsupported method', async t => { + t.plan(10) const fastify = Fastify() fastify.addHook('onRequest', function (req, res, done) { - t.pass('onRequest called') + t.assert.ok(true, 'onRequest called') done() }) fastify.addHook('preHandler', function (request, reply, done) { - t.pass('preHandler called') + t.assert.ok(true, 'preHandler called') done() }) fastify.addHook('onSend', function (request, reply, payload, done) { - t.pass('onSend called') + t.assert.ok(true, 'onSend called') done() }) fastify.addHook('onResponse', function (request, reply, done) { - t.pass('onResponse called') + t.assert.ok(true, 'onResponse called') done() }) @@ -901,47 +849,44 @@ test('run hook with encapsulated 404 and framework-unsupported method', t => { }) f.addHook('onRequest', function (req, res, done) { - t.pass('onRequest 2 called') + t.assert.ok(true, 'onRequest 2 called') done() }) f.addHook('preHandler', function (request, reply, done) { - t.pass('preHandler 2 called') + t.assert.ok(true, 'preHandler 2 called') done() }) f.addHook('onSend', function (request, reply, payload, done) { - t.pass('onSend 2 called') + t.assert.ok(true, 'onSend 2 called') done() }) f.addHook('onResponse', function (request, reply, done) { - t.pass('onResponse 2 called') + t.assert.ok(true, 'onResponse 2 called') done() }) done() }, { prefix: '/test' }) - t.teardown(fastify.close.bind(fastify)) + t.after(() => { fastify.close() }) - fastify.listen({ port: 0 }, err => { - t.error(err) + await fastify.listen({ port: 0 }) - sget({ - method: 'PROPFIND', - url: getUrl(fastify) + '/test', - body: JSON.stringify({ hello: 'world' }), - headers: { 'Content-Type': 'application/json' } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 404) - }) + const response = await fetch(getServerUrl(fastify) + '/test', { + method: 'PROPFIND', + body: JSON.stringify({ hello: 'world' }), + headers: { 'Content-Type': 'application/json' } }) + + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 404) }) -test('hooks check 404', t => { - t.plan(13) +test('hooks check 404', async t => { + t.plan(12) const fastify = Fastify() @@ -950,42 +895,36 @@ test('hooks check 404', t => { }) fastify.addHook('onSend', (req, reply, payload, done) => { - t.same(req.query, { foo: 'asd' }) - t.ok('called', 'onSend') + t.assert.deepStrictEqual(req.query, { foo: 'asd' }) + t.assert.ok(true, 'called onSend') done() }) fastify.addHook('onRequest', (req, res, done) => { - t.ok('called', 'onRequest') + t.assert.ok(true, 'called onRequest') done() }) fastify.addHook('onResponse', (request, reply, done) => { - t.ok('called', 'onResponse') + t.assert.ok(true, 'calledonResponse') done() }) - t.teardown(fastify.close.bind(fastify)) + t.after(() => { fastify.close() }) - fastify.listen({ port: 0 }, err => { - t.error(err) + await fastify.listen({ port: 0 }) - sget({ - method: 'PUT', - url: getUrl(fastify) + '?foo=asd', - body: JSON.stringify({ hello: 'world' }), - headers: { 'Content-Type': 'application/json' } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 404) - }) - - sget({ - method: 'GET', - url: getUrl(fastify) + '/notSupported?foo=asd' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 404) - }) + const response1 = await fetch(getServerUrl(fastify) + '?foo=asd', { + method: 'PUT', + body: JSON.stringify({ hello: 'world' }), + headers: { 'Content-Type': 'application/json' } }) + + t.assert.ok(!response1.ok) + t.assert.strictEqual(response1.status, 404) + + const response2 = await fetch(getServerUrl(fastify) + '/notSupported?foo=asd') + + t.assert.ok(!response2.ok) + t.assert.strictEqual(response2.status, 404) }) test('setNotFoundHandler should not suppress duplicated routes checking', t => { @@ -1004,16 +943,16 @@ test('setNotFoundHandler should not suppress duplicated routes checking', t => { reply.code(404).send('this was not found') }) - t.fail('setNotFoundHandler should not interfere duplicated route error') + t.assert.fail('setNotFoundHandler should not interfere duplicated route error') } catch (error) { - t.ok(error) + t.assert.ok(error) } }) -test('log debug for 404', t => { +test('log debug for 404', async t => { t.plan(1) - const Writable = require('stream').Writable + const Writable = require('node:stream').Writable const logStream = new Writable() logStream.logs = [] @@ -1033,29 +972,30 @@ test('log debug for 404', t => { reply.send({ hello: 'world' }) }) - t.teardown(fastify.close.bind(fastify)) + t.after(() => { fastify.close() }) - t.test('log debug', t => { + await t.test('log debug', (t, done) => { t.plan(7) fastify.inject({ method: 'GET', url: '/not-found' }, (err, response) => { - t.error(err) - t.equal(response.statusCode, 404) + t.assert.ifError(err) + t.assert.strictEqual(response.statusCode, 404) const INFO_LEVEL = 30 - t.equal(JSON.parse(logStream.logs[0]).msg, 'incoming request') - t.equal(JSON.parse(logStream.logs[1]).msg, 'Route GET:/not-found not found') - t.equal(JSON.parse(logStream.logs[1]).level, INFO_LEVEL) - t.equal(JSON.parse(logStream.logs[2]).msg, 'request completed') - t.equal(logStream.logs.length, 3) + t.assert.strictEqual(JSON.parse(logStream.logs[0]).msg, 'incoming request') + t.assert.strictEqual(JSON.parse(logStream.logs[1]).msg, 'Route GET:/not-found not found') + t.assert.strictEqual(JSON.parse(logStream.logs[1]).level, INFO_LEVEL) + t.assert.strictEqual(JSON.parse(logStream.logs[2]).msg, 'request completed') + t.assert.strictEqual(logStream.logs.length, 3) + done() }) }) }) -test('Unknown method', t => { - t.plan(5) +test('Unknown method', async t => { + t.plan(4) const fastify = Fastify() @@ -1063,35 +1003,33 @@ test('Unknown method', t => { reply.send({ hello: 'world' }) }) - t.teardown(fastify.close.bind(fastify)) + t.after(() => { fastify.close() }) - fastify.listen({ port: 0 }, err => { - t.error(err) - - const handler = () => {} - // See https://github.com/fastify/light-my-request/pull/20 - t.throws(() => fastify.inject({ - method: 'UNKNWON_METHOD', - url: '/' - }, handler), Error) - - sget({ - method: 'UNKNWON_METHOD', - url: getUrl(fastify) - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 400) - t.strictSame(JSON.parse(body), { - error: 'Bad Request', - message: 'Client Error', - statusCode: 400 - }) - }) + await fastify.listen({ port: 0 }) + + const handler = () => {} + // See https://github.com/fastify/light-my-request/pull/20 + t.assert.throws(() => fastify.inject({ + method: 'UNKNOWN_METHOD', + url: '/' + }, handler), Error) + + const response = await fetch(getServerUrl(fastify), { + method: 'UNKNOWN_METHOD' + }) + + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 400) + + t.assert.deepStrictEqual(await response.json(), { + error: 'Bad Request', + message: 'Client Error', + statusCode: 400 }) }) -test('recognizes errors from the http-errors module', t => { - t.plan(5) +test('recognizes errors from the http-errors module', async t => { + t.plan(4) const fastify = Fastify() @@ -1099,32 +1037,29 @@ test('recognizes errors from the http-errors module', t => { reply.send(new errors.NotFound()) }) - t.teardown(fastify.close.bind(fastify)) + t.after(() => { fastify.close() }) - fastify.listen({ port: 0 }, err => { - t.error(err) + await fastify.listen({ port: 0 }) - fastify.inject({ - method: 'GET', - url: '/' - }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 404) - - sget(getUrl(fastify), (err, response, body) => { - t.error(err) - const obj = JSON.parse(body.toString()) - t.strictSame(obj, { - error: 'Not Found', - message: 'Not Found', - statusCode: 404 - }) - }) - }) + fastify.inject({ + method: 'GET', + url: '/' + }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 404) + }) + + const response = await fetch(getServerUrl(fastify)) + + t.assert.ok(!response.ok) + t.assert.deepStrictEqual(await response.json(), { + error: 'Not Found', + message: 'Not Found', + statusCode: 404 }) }) -test('the default 404 handler can be invoked inside a prefixed plugin', t => { +test('the default 404 handler can be invoked inside a prefixed plugin', (t, done) => { t.plan(3) const fastify = Fastify() @@ -1138,17 +1073,18 @@ test('the default 404 handler can be invoked inside a prefixed plugin', t => { }, { prefix: '/v1' }) fastify.inject('/v1/path', (err, res) => { - t.error(err) - t.equal(res.statusCode, 404) - t.strictSame(JSON.parse(res.payload), { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 404) + t.assert.deepStrictEqual(JSON.parse(res.payload), { error: 'Not Found', message: 'Not Found', statusCode: 404 }) + done() }) }) -test('an inherited custom 404 handler can be invoked inside a prefixed plugin', t => { +test('an inherited custom 404 handler can be invoked inside a prefixed plugin', (t, done) => { t.plan(3) const fastify = Fastify() @@ -1166,18 +1102,19 @@ test('an inherited custom 404 handler can be invoked inside a prefixed plugin', }, { prefix: '/v1' }) fastify.inject('/v1/path', (err, res) => { - t.error(err) - t.equal(res.statusCode, 404) - t.same(JSON.parse(res.payload), { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 404) + t.assert.deepStrictEqual(JSON.parse(res.payload), { error: 'Not Found', message: 'Not Found', statusCode: 404 }) + done() }) }) -test('encapsulated custom 404 handler without a prefix is the handler for the entire 404 level', t => { - t.plan(6) +test('encapsulated custom 404 handler without a prefix is the handler for the entire 404 level', async t => { + t.plan(4) const fastify = Fastify() @@ -1200,39 +1137,40 @@ test('encapsulated custom 404 handler without a prefix is the handler for the en done() }, { prefix: 'prefixed' }) - fastify.inject('/not-found', (err, res) => { - t.error(err) - t.equal(res.statusCode, 404) - t.equal(res.payload, 'custom handler') - }) + { + const res = await fastify.inject('/not-found') + t.assert.strictEqual(res.statusCode, 404) + t.assert.strictEqual(res.payload, 'custom handler') + } - fastify.inject('/prefixed/not-found', (err, res) => { - t.error(err) - t.equal(res.statusCode, 404) - t.equal(res.payload, 'custom handler 2') - }) + { + const res = await fastify.inject('/prefixed/not-found') + t.assert.strictEqual(res.statusCode, 404) + t.assert.strictEqual(res.payload, 'custom handler 2') + } }) -test('cannot set notFoundHandler after binding', t => { +test('cannot set notFoundHandler after binding', (t, done) => { t.plan(2) const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) + t.after(() => { fastify.close() }) fastify.listen({ port: 0 }, err => { - t.error(err) + t.assert.ifError(err) try { fastify.setNotFoundHandler(() => { }) - t.fail() + t.assert.fail() } catch (e) { - t.pass() + t.assert.ok(true) + done() } }) }) -test('404 inside onSend', t => { - t.plan(3) +test('404 inside onSend', async t => { + t.plan(2) const fastify = Fastify() @@ -1251,29 +1189,24 @@ test('404 inside onSend', t => { } }) - t.teardown(fastify.close.bind(fastify)) + t.after(() => { fastify.close() }) - fastify.listen({ port: 0 }, err => { - t.error(err) + await fastify.listen({ port: 0 }) - sget({ - method: 'GET', - url: getUrl(fastify) - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 404) - }) - }) + const response = await fetch(getServerUrl(fastify)) + + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 404) }) // https://github.com/fastify/fastify/issues/868 -test('onSend hooks run when an encapsulated route invokes the notFound handler', t => { +test('onSend hooks run when an encapsulated route invokes the notFound handler', (t, done) => { t.plan(3) const fastify = Fastify() fastify.register((instance, options, done) => { instance.addHook('onSend', (request, reply, payload, done) => { - t.pass('onSend hook called') + t.assert.ok(true, 'onSend hook called') done() }) @@ -1285,16 +1218,17 @@ test('onSend hooks run when an encapsulated route invokes the notFound handler', }) fastify.inject('/', (err, res) => { - t.error(err) - t.equal(res.statusCode, 404) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 404) + done() }) }) // https://github.com/fastify/fastify/issues/713 -test('preHandler option for setNotFoundHandler', t => { +test('preHandler option for setNotFoundHandler', async t => { t.plan(10) - t.test('preHandler option', t => { + await t.test('preHandler option', (t, done) => { t.plan(2) const fastify = Fastify() @@ -1312,14 +1246,15 @@ test('preHandler option for setNotFoundHandler', t => { url: '/not-found', payload: { hello: 'world' } }, (err, res) => { - t.error(err) + t.assert.ifError(err) const payload = JSON.parse(res.payload) - t.same(payload, { preHandler: true, hello: 'world' }) + t.assert.deepStrictEqual(payload, { preHandler: true, hello: 'world' }) + done() }) }) // https://github.com/fastify/fastify/issues/2229 - t.test('preHandler hook in setNotFoundHandler should be called when callNotFound', { timeout: 40000 }, t => { + await t.test('preHandler hook in setNotFoundHandler should be called when callNotFound', { timeout: 40000 }, (t, done) => { t.plan(3) const fastify = Fastify() @@ -1333,7 +1268,7 @@ test('preHandler option for setNotFoundHandler', t => { }) fastify.post('/', function (req, reply) { - t.equal(reply.callNotFound(), reply) + t.assert.strictEqual(reply.callNotFound(), reply) }) fastify.inject({ @@ -1341,13 +1276,14 @@ test('preHandler option for setNotFoundHandler', t => { url: '/', payload: { hello: 'world' } }, (err, res) => { - t.error(err) + t.assert.ifError(err) const payload = JSON.parse(res.payload) - t.same(payload, { preHandler: true, hello: 'world' }) + t.assert.deepStrictEqual(payload, { preHandler: true, hello: 'world' }) + done() }) }) - t.test('preHandler hook in setNotFoundHandler should accept an array of functions and be called when callNotFound', t => { + await t.test('preHandler hook in setNotFoundHandler should accept an array of functions and be called when callNotFound', (t, done) => { t.plan(2) const fastify = Fastify() @@ -1375,13 +1311,14 @@ test('preHandler option for setNotFoundHandler', t => { url: '/', payload: { hello: 'world' } }, (err, res) => { - t.error(err) + t.assert.ifError(err) const payload = JSON.parse(res.payload) - t.same(payload, { preHandler1: true, preHandler2: true, hello: 'world' }) + t.assert.deepStrictEqual(payload, { preHandler1: true, preHandler2: true, hello: 'world' }) + done() }) }) - t.test('preHandler option should be called after preHandler hook', t => { + await t.test('preHandler option should be called after preHandler hook', (t, done) => { t.plan(2) const fastify = Fastify() @@ -1404,14 +1341,15 @@ test('preHandler option for setNotFoundHandler', t => { url: '/', payload: { hello: 'world' } }, (err, res) => { - t.error(err) + t.assert.ifError(err) const payload = JSON.parse(res.payload) - t.same(payload, { check: 'ab', hello: 'world' }) + t.assert.deepStrictEqual(payload, { check: 'ab', hello: 'world' }) + done() }) }) - t.test('preHandler option should be unique per prefix', t => { - t.plan(4) + await t.test('preHandler option should be unique per prefix', async t => { + t.plan(2) const fastify = Fastify() fastify.setNotFoundHandler({ @@ -1431,28 +1369,30 @@ test('preHandler option for setNotFoundHandler', t => { n() }, { prefix: '/no' }) - fastify.inject({ - method: 'POST', - url: '/not-found', - payload: { hello: 'world' } - }, (err, res) => { - t.error(err) + { + const res = await fastify.inject({ + method: 'POST', + url: '/not-found', + payload: { hello: 'world' } + }) + const payload = JSON.parse(res.payload) - t.same(payload, { hello: 'earth' }) - }) + t.assert.deepStrictEqual(payload, { hello: 'earth' }) + } + + { + const res = await fastify.inject({ + method: 'POST', + url: '/no/not-found', + payload: { hello: 'world' } + }) - fastify.inject({ - method: 'POST', - url: '/no/not-found', - payload: { hello: 'world' } - }, (err, res) => { - t.error(err) const payload = JSON.parse(res.payload) - t.same(payload, { hello: 'world' }) - }) + t.assert.deepStrictEqual(payload, { hello: 'world' }) + } }) - t.test('preHandler option should handle errors', t => { + await t.test('preHandler option should handle errors', (t, done) => { t.plan(3) const fastify = Fastify() @@ -1469,18 +1409,19 @@ test('preHandler option for setNotFoundHandler', t => { url: '/not-found', payload: { hello: 'world' } }, (err, res) => { - t.error(err) + t.assert.ifError(err) const payload = JSON.parse(res.payload) - t.equal(res.statusCode, 500) - t.same(payload, { + t.assert.strictEqual(res.statusCode, 500) + t.assert.deepStrictEqual(payload, { message: 'kaboom', error: 'Internal Server Error', statusCode: 500 }) + done() }) }) - t.test('preHandler option should handle errors with custom status code', t => { + await t.test('preHandler option should handle errors with custom status code', (t, done) => { t.plan(3) const fastify = Fastify() @@ -1498,18 +1439,19 @@ test('preHandler option for setNotFoundHandler', t => { url: '/not-found', payload: { hello: 'world' } }, (err, res) => { - t.error(err) + t.assert.ifError(err) const payload = JSON.parse(res.payload) - t.equal(res.statusCode, 401) - t.same(payload, { + t.assert.strictEqual(res.statusCode, 401) + t.assert.deepStrictEqual(payload, { message: 'go away', error: 'Unauthorized', statusCode: 401 }) + done() }) }) - t.test('preHandler option could accept an array of functions', t => { + await t.test('preHandler option could accept an array of functions', (t, done) => { t.plan(2) const fastify = Fastify() @@ -1533,14 +1475,15 @@ test('preHandler option for setNotFoundHandler', t => { url: '/not-found', payload: { hello: 'world' } }, (err, res) => { - t.error(err) + t.assert.ifError(err) const payload = JSON.parse(res.payload) - t.same(payload, { preHandler: 'ab', hello: 'world' }) + t.assert.deepStrictEqual(payload, { preHandler: 'ab', hello: 'world' }) + done() }) }) - t.test('preHandler option does not interfere with preHandler', t => { - t.plan(4) + await t.test('preHandler option does not interfere with preHandler', async t => { + t.plan(2) const fastify = Fastify() fastify.addHook('preHandler', (req, reply, done) => { @@ -1565,28 +1508,30 @@ test('preHandler option for setNotFoundHandler', t => { n() }, { prefix: '/no' }) - fastify.inject({ - method: 'post', - url: '/not-found', - payload: { hello: 'world' } - }, (err, res) => { - t.error(err) + { + const res = await fastify.inject({ + method: 'post', + url: '/not-found', + payload: { hello: 'world' } + }) + const payload = JSON.parse(res.payload) - t.same(payload, { check: 'ab', hello: 'world' }) - }) + t.assert.deepStrictEqual(payload, { check: 'ab', hello: 'world' }) + } + + { + const res = await fastify.inject({ + method: 'post', + url: '/no/not-found', + payload: { hello: 'world' } + }) - fastify.inject({ - method: 'post', - url: '/no/not-found', - payload: { hello: 'world' } - }, (err, res) => { - t.error(err) const payload = JSON.parse(res.payload) - t.same(payload, { check: 'a', hello: 'world' }) - }) + t.assert.deepStrictEqual(payload, { check: 'a', hello: 'world' }) + } }) - t.test('preHandler option should keep the context', t => { + await t.test('preHandler option should keep the context', (t, done) => { t.plan(3) const fastify = Fastify() @@ -1594,7 +1539,7 @@ test('preHandler option for setNotFoundHandler', t => { fastify.setNotFoundHandler({ preHandler: function (req, reply, done) { - t.equal(this.foo, 42) + t.assert.strictEqual(this.foo, 42) this.foo += 1 req.body.foo = this.foo done() @@ -1608,14 +1553,15 @@ test('preHandler option for setNotFoundHandler', t => { url: '/not-found', payload: { hello: 'world' } }, (err, res) => { - t.error(err) + t.assert.ifError(err) const payload = JSON.parse(res.payload) - t.same(payload, { foo: 43, hello: 'world' }) + t.assert.deepStrictEqual(payload, { foo: 43, hello: 'world' }) + done() }) }) }) -test('reply.notFound invoked the notFound handler', t => { +test('reply.notFound invoked the notFound handler', (t, done) => { t.plan(3) const fastify = Fastify() @@ -1632,30 +1578,31 @@ test('reply.notFound invoked the notFound handler', t => { url: '/', method: 'GET' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 404) - t.same(JSON.parse(res.payload), { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 404) + t.assert.deepStrictEqual(JSON.parse(res.payload), { error: 'Not Found', message: 'kaboom', statusCode: 404 }) + done() }) }) -test('The custom error handler should be invoked after the custom not found handler', t => { +test('The custom error handler should be invoked after the custom not found handler', (t, done) => { t.plan(6) const fastify = Fastify() const order = [1, 2] fastify.setErrorHandler((err, req, reply) => { - t.equal(order.shift(), 2) - t.type(err, Error) + t.assert.strictEqual(order.shift(), 2) + t.assert.ok(err instanceof Error) reply.send(err) }) fastify.setNotFoundHandler((req, reply) => { - t.equal(order.shift(), 1) + t.assert.strictEqual(order.shift(), 1) reply.code(404).send(new Error('kaboom')) }) @@ -1667,23 +1614,24 @@ test('The custom error handler should be invoked after the custom not found hand url: '/', method: 'GET' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 404) - t.same(JSON.parse(res.payload), { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 404) + t.assert.deepStrictEqual(JSON.parse(res.payload), { error: 'Not Found', message: 'kaboom', statusCode: 404 }) + done() }) }) -test('If the custom not found handler does not use an Error, the custom error handler should not be called', t => { +test('If the custom not found handler does not use an Error, the custom error handler should not be called', (t, done) => { t.plan(3) const fastify = Fastify() fastify.setErrorHandler((_err, req, reply) => { - t.fail('Should not be called') + t.assert.fail('Should not be called') }) fastify.setNotFoundHandler((req, reply) => { @@ -1698,13 +1646,14 @@ test('If the custom not found handler does not use an Error, the custom error ha url: '/', method: 'GET' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 404) - t.equal(res.payload, 'kaboom') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 404) + t.assert.strictEqual(res.payload, 'kaboom') + done() }) }) -test('preValidation option', t => { +test('preValidation option', (t, done) => { t.plan(3) const fastify = Fastify() @@ -1712,7 +1661,7 @@ test('preValidation option', t => { fastify.setNotFoundHandler({ preValidation: function (req, reply, done) { - t.ok(this.foo) + t.assert.ok(this.foo) done() } }, function (req, reply) { @@ -1724,24 +1673,25 @@ test('preValidation option', t => { url: '/not-found', payload: { hello: 'world' } }, (err, res) => { - t.error(err) + t.assert.ifError(err) const payload = JSON.parse(res.payload) - t.same(payload, { hello: 'world' }) + t.assert.deepStrictEqual(payload, { hello: 'world' }) + done() }) }) -t.test('preValidation option could accept an array of functions', t => { +test('preValidation option could accept an array of functions', (t, done) => { t.plan(4) const fastify = Fastify() fastify.setNotFoundHandler({ preValidation: [ (req, reply, done) => { - t.ok('called') + t.assert.ok('called') done() }, (req, reply, done) => { - t.ok('called') + t.assert.ok('called') done() } ] @@ -1754,13 +1704,14 @@ t.test('preValidation option could accept an array of functions', t => { url: '/not-found', payload: { hello: 'world' } }, (err, res) => { - t.error(err) + t.assert.ifError(err) const payload = JSON.parse(res.payload) - t.same(payload, { hello: 'world' }) + t.assert.deepStrictEqual(payload, { hello: 'world' }) + done() }) }) -test('Should fail to invoke callNotFound inside a 404 handler', t => { +test('Should fail to invoke callNotFound inside a 404 handler', (t, done) => { t.plan(5) let fastify = null @@ -1773,7 +1724,7 @@ test('Should fail to invoke callNotFound inside a 404 handler', t => { } }) } catch (e) { - t.fail() + t.assert.fail() } fastify.setNotFoundHandler((req, reply) => { @@ -1785,93 +1736,100 @@ test('Should fail to invoke callNotFound inside a 404 handler', t => { }) logStream.once('data', line => { - t.equal(line.msg, 'Trying to send a NotFound error inside a 404 handler. Sending basic 404 response.') - t.equal(line.level, 40) + t.assert.strictEqual(line.msg, 'Trying to send a NotFound error inside a 404 handler. Sending basic 404 response.') + t.assert.strictEqual(line.level, 40) }) fastify.inject({ url: '/', method: 'GET' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 404) - t.equal(res.payload, '404 Not Found') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 404) + t.assert.strictEqual(res.payload, '404 Not Found') + done() }) }) -test('400 in case of bad url (pre find-my-way v2.2.0 was a 404)', t => { - t.test('Dynamic route', t => { +test('400 in case of bad url (pre find-my-way v2.2.0 was a 404)', async t => { + await t.test('Dynamic route', (t, done) => { t.plan(3) const fastify = Fastify() - fastify.get('/hello/:id', () => t.fail('we should not be here')) + fastify.get('/hello/:id', () => t.assert.fail('we should not be here')) fastify.inject({ url: '/hello/%world', method: 'GET' }, (err, response) => { - t.error(err) - t.equal(response.statusCode, 400) - t.same(JSON.parse(response.payload), { + t.assert.ifError(err) + t.assert.strictEqual(response.statusCode, 400) + t.assert.deepStrictEqual(JSON.parse(response.payload), { error: 'Bad Request', message: "'/hello/%world' is not a valid url component", - statusCode: 400 + statusCode: 400, + code: 'FST_ERR_BAD_URL' }) + done() }) }) - t.test('Wildcard', t => { + await t.test('Wildcard', (t, done) => { t.plan(3) const fastify = Fastify() - fastify.get('*', () => t.fail('we should not be here')) + fastify.get('*', () => t.assert.fail('we should not be here')) fastify.inject({ url: '/hello/%world', method: 'GET' }, (err, response) => { - t.error(err) - t.equal(response.statusCode, 400) - t.same(JSON.parse(response.payload), { + t.assert.ifError(err) + t.assert.strictEqual(response.statusCode, 400) + t.assert.deepStrictEqual(JSON.parse(response.payload), { error: 'Bad Request', message: "'/hello/%world' is not a valid url component", - statusCode: 400 + statusCode: 400, + code: 'FST_ERR_BAD_URL' }) + done() }) }) - t.test('No route registered', t => { + await t.test('No route registered', (t, done) => { t.plan(3) const fastify = Fastify() fastify.inject({ url: '/%c0', method: 'GET' }, (err, response) => { - t.error(err) - t.equal(response.statusCode, 404) - t.same(JSON.parse(response.payload), { + t.assert.ifError(err) + t.assert.strictEqual(response.statusCode, 404) + t.assert.deepStrictEqual(JSON.parse(response.payload), { error: 'Not Found', message: 'Route GET:/%c0 not found', statusCode: 404 }) + done() }) }) - t.test('Only / is registered', t => { + await t.test('Only / is registered', (t, done) => { t.plan(3) const fastify = Fastify() - fastify.get('/', () => t.fail('we should not be here')) + fastify.get('/', () => t.assert.fail('we should not be here')) fastify.inject({ url: '/non-existing', method: 'GET' }, (err, response) => { - t.error(err) - t.equal(response.statusCode, 404) - t.same(JSON.parse(response.payload), { + t.assert.ifError(err) + t.assert.strictEqual(response.statusCode, 404) + t.assert.deepStrictEqual(JSON.parse(response.payload), { error: 'Not Found', message: 'Route GET:/non-existing not found', statusCode: 404 }) + done() }) }) - t.test('customized 404', t => { + await t.test('customized 404', (t, done) => { t.plan(3) const fastify = Fastify({ logger: true }) fastify.setNotFoundHandler(function (req, reply) { @@ -1881,18 +1839,37 @@ test('400 in case of bad url (pre find-my-way v2.2.0 was a 404)', t => { url: '/%c0', method: 'GET' }, (err, response) => { - t.error(err) - t.equal(response.statusCode, 404) - t.same(response.payload, 'this was not found') + t.assert.ifError(err) + t.assert.strictEqual(response.statusCode, 404) + t.assert.deepStrictEqual(response.payload, 'this was not found') + done() }) }) - t.end() + await t.test('Bad URL with special characters should be properly JSON escaped', (t, done) => { + t.plan(3) + const fastify = Fastify() + fastify.get('/hello/:id', () => t.assert.fail('we should not be here')) + fastify.inject({ + url: '/hello/%world%22test', + method: 'GET' + }, (err, response) => { + t.assert.ifError(err) + t.assert.strictEqual(response.statusCode, 400) + t.assert.deepStrictEqual(JSON.parse(response.payload), { + error: 'Bad Request', + message: '\'/hello/%world%22test\' is not a valid url component', + statusCode: 400, + code: 'FST_ERR_BAD_URL' + }) + done() + }) + }) }) -test('setNotFoundHandler should be chaining fastify instance', t => { - t.test('Register route after setNotFoundHandler', t => { - t.plan(6) +test('setNotFoundHandler should be chaining fastify instance', async t => { + await t.test('Register route after setNotFoundHandler', async t => { + t.plan(4) const fastify = Fastify() fastify.setNotFoundHandler(function (_req, reply) { reply.code(404).send('this was not found') @@ -1900,47 +1877,159 @@ test('setNotFoundHandler should be chaining fastify instance', t => { reply.send('valid route') }) - fastify.inject({ - url: '/invalid-route', - method: 'GET' - }, (err, response) => { - t.error(err) - t.equal(response.statusCode, 404) - t.equal(response.payload, 'this was not found') - }) + { + const response = await fastify.inject({ + url: '/invalid-route', + method: 'GET' + }) + t.assert.strictEqual(response.statusCode, 404) + t.assert.strictEqual(response.payload, 'this was not found') + } - fastify.inject({ - url: '/valid-route', - method: 'GET' - }, (err, response) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.payload, 'valid route') - }) - }) + { + const response = await fastify.inject({ + url: '/valid-route', + method: 'GET' + }) - t.end() + t.assert.strictEqual(response.statusCode, 200) + t.assert.strictEqual(response.payload, 'valid route') + } + }) }) -test('Send 404 when frameworkError calls reply.callNotFound', t => { - t.test('Dynamic route', t => { +test('Send 404 when frameworkError calls reply.callNotFound', async t => { + await t.test('Dynamic route', (t, done) => { t.plan(4) const fastify = Fastify({ frameworkErrors: (error, req, reply) => { - t.equal(error.message, "'/hello/%world' is not a valid url component") + t.assert.strictEqual(error.message, "'/hello/%world' is not a valid url component") return reply.callNotFound() } }) - fastify.get('/hello/:id', () => t.fail('we should not be here')) + fastify.get('/hello/:id', () => t.assert.fail('we should not be here')) fastify.inject({ url: '/hello/%world', method: 'GET' }, (err, response) => { - t.error(err) - t.equal(response.statusCode, 404) - t.equal(response.payload, '404 Not Found') + t.assert.ifError(err) + t.assert.strictEqual(response.statusCode, 404) + t.assert.strictEqual(response.payload, '404 Not Found') + done() }) }) +}) + +test('hooks are applied to not found handlers /1', async t => { + const fastify = Fastify() + + // adding await here is fundamental for this test + await fastify.register(async function (fastify) { + }) + + fastify.setErrorHandler(function (_, request, reply) { + return reply.code(401).send({ error: 'Unauthorized' }) + }) + + fastify.addHook('preValidation', async function (request, reply) { + throw new Error('kaboom') + }) + + const { statusCode } = await fastify.inject('/') + t.assert.strictEqual(statusCode, 401) +}) + +test('hooks are applied to not found handlers /2', async t => { + const fastify = Fastify() + + async function plugin (fastify) { + fastify.setErrorHandler(function (_, request, reply) { + return reply.code(401).send({ error: 'Unauthorized' }) + }) + } + + plugin[Symbol.for('skip-override')] = true + + fastify.register(plugin) + + fastify.addHook('preValidation', async function (request, reply) { + throw new Error('kaboom') + }) + + const { statusCode } = await fastify.inject('/') + t.assert.strictEqual(statusCode, 401) +}) + +test('hooks are applied to not found handlers /3', async t => { + const fastify = Fastify() + + async function plugin (fastify) { + fastify.setNotFoundHandler({ errorHandler }, async () => { + t.assert.fail('this should never be called') + }) + + function errorHandler (_, request, reply) { + return reply.code(401).send({ error: 'Unauthorized' }) + } + } + + plugin[Symbol.for('skip-override')] = true + + fastify.register(plugin) + + fastify.addHook('preValidation', async function (request, reply) { + throw new Error('kaboom') + }) + + const { statusCode } = await fastify.inject('/') + t.assert.strictEqual(statusCode, 401) +}) + +test('should honor disableRequestLogging function for 404', async t => { + t.plan(3) + + const Writable = require('node:stream').Writable + + const logStream = new Writable() + logStream.logs = [] + logStream._write = function (chunk, encoding, callback) { + this.logs.push(JSON.parse(chunk.toString())) + callback() + } + + const fastify = Fastify({ + logger: { + level: 'info', + stream: logStream + }, + disableRequestLogging: (req) => { + // Disable logging for URLs containing 'silent' + return req.url.includes('silent') + } + }) + + fastify.get('/', function (req, reply) { + reply.send({ hello: 'world' }) + }) + + t.after(() => { fastify.close() }) + + // First request to a non-existent route (no 'silent' in URL) - should log + const response1 = await fastify.inject({ + method: 'GET', + url: '/not-found' + }) + t.assert.strictEqual(response1.statusCode, 404) + + // Second request to a non-existent route with 'silent' in URL - should not log + const response2 = await fastify.inject({ + method: 'GET', + url: '/silent-route' + }) + t.assert.strictEqual(response2.statusCode, 404) - t.end() + // Check logs: first request should have logged, second should not + // We expect: incoming request, Route not found info, request completed (for first request only) + const infoLogs = logStream.logs.filter(log => log.msg && log.msg.includes('Route GET:/not-found not found')) + t.assert.strictEqual(infoLogs.length, 1, 'Should log 404 info only for non-silent route') }) diff --git a/test/500s.test.js b/test/500s.test.js index 857e29607aa..846eefbab9b 100644 --- a/test/500s.test.js +++ b/test/500s.test.js @@ -1,14 +1,15 @@ 'use strict' -const t = require('tap') -const test = t.test +const { test } = require('node:test') const Fastify = require('..') const symbols = require('../lib/symbols.js') +const { FastifyError } = require('@fastify/error') -test('default 500', t => { +test('default 500', (t, done) => { t.plan(4) const fastify = Fastify() + t.after(() => fastify.close()) fastify.get('/', function (req, reply) { reply.send(new Error('kaboom')) @@ -18,29 +19,119 @@ test('default 500', t => { method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 500) - t.equal(res.headers['content-type'], 'application/json; charset=utf-8') - t.same(JSON.parse(res.payload), { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 500) + t.assert.strictEqual(res.headers['content-type'], 'application/json; charset=utf-8') + t.assert.deepStrictEqual(JSON.parse(res.payload), { error: 'Internal Server Error', message: 'kaboom', statusCode: 500 }) + done() + }) +}) + +test('default 500 with non-error string', (t, done) => { + t.plan(4) + + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.get('/', function (req, reply) { + throw 'kaboom' // eslint-disable-line no-throw-literal + }) + + fastify.inject({ + method: 'GET', + url: '/' + }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 500) + t.assert.strictEqual(res.headers['content-type'], 'text/plain; charset=utf-8') + t.assert.deepStrictEqual(res.payload, 'kaboom') + done() + }) +}) + +test('default 500 with non-error symbol', (t, done) => { + t.plan(4) + + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.get('/', function (req, reply) { + throw Symbol('error') + }) + + fastify.inject({ + method: 'GET', + url: '/' + }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 500) + t.assert.strictEqual(res.headers['content-type'], 'application/json; charset=utf-8') + t.assert.deepStrictEqual(res.payload, '') + done() }) }) -test('custom 500', t => { +test('default 500 with non-error false', (t, done) => { + t.plan(4) + + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.get('/', function (req, reply) { + throw false // eslint-disable-line no-throw-literal + }) + + fastify.inject({ + method: 'GET', + url: '/' + }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 500) + t.assert.strictEqual(res.headers['content-type'], 'application/json; charset=utf-8') + t.assert.deepStrictEqual(res.payload, 'false') + done() + }) +}) + +test('default 500 with non-error null', (t, done) => { + t.plan(4) + + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.get('/', function (req, reply) { + throw null // eslint-disable-line no-throw-literal + }) + + fastify.inject({ + method: 'GET', + url: '/' + }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 500) + t.assert.strictEqual(res.headers['content-type'], 'application/json; charset=utf-8') + t.assert.deepStrictEqual(res.payload, 'null') + done() + }) +}) + +test('custom 500', (t, done) => { t.plan(6) const fastify = Fastify() + t.after(() => fastify.close()) fastify.get('/', function (req, reply) { reply.send(new Error('kaboom')) }) fastify.setErrorHandler(function (err, request, reply) { - t.type(request, 'object') - t.type(request, fastify[symbols.kRequest].parent) + t.assert.ok(typeof request === 'object') + t.assert.ok(request instanceof fastify[symbols.kRequest].parent) reply .code(500) .type('text/plain') @@ -51,17 +142,19 @@ test('custom 500', t => { method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 500) - t.equal(res.headers['content-type'], 'text/plain') - t.same(res.payload.toString(), 'an error happened: kaboom') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 500) + t.assert.strictEqual(res.headers['content-type'], 'text/plain') + t.assert.deepStrictEqual(res.payload.toString(), 'an error happened: kaboom') + done() }) }) -test('encapsulated 500', t => { - t.plan(10) +test('encapsulated 500', async t => { + t.plan(8) const fastify = Fastify() + t.after(() => fastify.close()) fastify.get('/', function (req, reply) { reply.send(new Error('kaboom')) @@ -73,8 +166,8 @@ test('encapsulated 500', t => { }) f.setErrorHandler(function (err, request, reply) { - t.type(request, 'object') - t.type(request, fastify[symbols.kRequest].parent) + t.assert.ok(typeof request === 'object') + t.assert.ok(request instanceof fastify[symbols.kRequest].parent) reply .code(500) .type('text/plain') @@ -84,35 +177,38 @@ test('encapsulated 500', t => { done() }, { prefix: 'test' }) - fastify.inject({ - method: 'GET', - url: '/test' - }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 500) - t.equal(res.headers['content-type'], 'text/plain') - t.same(res.payload.toString(), 'an error happened: kaboom') - }) + { + const response = await fastify.inject({ + method: 'GET', + url: '/test' + }) - fastify.inject({ - method: 'GET', - url: '/' - }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 500) - t.equal(res.headers['content-type'], 'application/json; charset=utf-8') - t.same(JSON.parse(res.payload), { + t.assert.strictEqual(response.statusCode, 500) + t.assert.strictEqual(response.headers['content-type'], 'text/plain') + t.assert.deepStrictEqual(response.payload.toString(), 'an error happened: kaboom') + } + + { + const response = await fastify.inject({ + method: 'GET', + url: '/' + }) + + t.assert.strictEqual(response.statusCode, 500) + t.assert.strictEqual(response.headers['content-type'], 'application/json; charset=utf-8') + t.assert.deepStrictEqual(JSON.parse(response.payload), { error: 'Internal Server Error', message: 'kaboom', statusCode: 500 }) - }) + } }) -test('custom 500 with hooks', t => { +test('custom 500 with hooks', (t, done) => { t.plan(7) const fastify = Fastify() + t.after(() => fastify.close()) fastify.get('/', function (req, reply) { reply.send(new Error('kaboom')) @@ -126,15 +222,15 @@ test('custom 500 with hooks', t => { }) fastify.addHook('onSend', (req, res, payload, done) => { - t.ok('called', 'onSend') + t.assert.ok('called', 'onSend') done() }) fastify.addHook('onRequest', (req, res, done) => { - t.ok('called', 'onRequest') + t.assert.ok('called', 'onRequest') done() }) fastify.addHook('onResponse', (request, reply, done) => { - t.ok('called', 'onResponse') + t.assert.ok('called', 'onResponse') done() }) @@ -142,27 +238,185 @@ test('custom 500 with hooks', t => { method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 500) - t.equal(res.headers['content-type'], 'text/plain') - t.same(res.payload.toString(), 'an error happened: kaboom') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 500) + t.assert.strictEqual(res.headers['content-type'], 'text/plain') + t.assert.deepStrictEqual(res.payload.toString(), 'an error happened: kaboom') + done() }) }) -test('cannot set errorHandler after binding', t => { +test('cannot set errorHandler after binding', (t, done) => { t.plan(2) const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) + t.after(() => fastify.close()) fastify.listen({ port: 0 }, err => { - t.error(err) + t.assert.ifError(err) try { fastify.setErrorHandler(() => { }) - t.fail() + t.assert.fail() + } catch (e) { + t.assert.ok(true) + } finally { + done() + } + }) +}) + +test('cannot set childLoggerFactory after binding', (t, done) => { + t.plan(2) + + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + + try { + fastify.setChildLoggerFactory(() => { }) + t.assert.fail() } catch (e) { - t.pass() + t.assert.ok(true) + } finally { + done() } }) }) + +test('catch synchronous errors', (t, done) => { + t.plan(3) + + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.setErrorHandler((_, req, reply) => { + throw new Error('kaboom2') + }) + + fastify.post('/', function (req, reply) { + reply.send(new Error('kaboom')) + }) + + fastify.inject({ + method: 'POST', + url: '/', + headers: { + 'Content-Type': 'application/json' + }, + payload: JSON.stringify({ hello: 'world' }).substring(0, 5) + }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 500) + t.assert.deepStrictEqual(res.json(), { + error: 'Internal Server Error', + message: 'kaboom2', + statusCode: 500 + }) + done() + }) +}) + +test('custom 500 with non-error and custom errorHandler', (t, done) => { + t.plan(6) + + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.get('/', function (req, reply) { + throw 'kaboom' // eslint-disable-line no-throw-literal + }) + + fastify.setErrorHandler(function (err, request, reply) { + t.assert.ok(typeof request === 'object') + t.assert.ok(request instanceof fastify[symbols.kRequest].parent) + reply + .code(500) + .type('text/plain') + .send('an error happened: ' + err.message) + }) + + fastify.inject({ + method: 'GET', + url: '/' + }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 500) + t.assert.strictEqual(res.headers['content-type'], 'text/plain') + t.assert.deepStrictEqual(res.payload, 'an error happened: undefined') + done() + }) +}) + +test('custom 500 with FastifyError detection', (t, done) => { + t.plan(18) + + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.get('/string', function (req, reply) { + throw 'kaboom' // eslint-disable-line no-throw-literal + }) + + fastify.get('/native-error', function (req, reply) { + throw new Error('kaboom') + }) + + fastify.get('/fastify-error', function (req, reply) { + throw new FastifyError('kaboom') + }) + + fastify.setErrorHandler(function (err, request, reply) { + t.assert.ok(typeof request === 'object') + t.assert.ok(request instanceof fastify[symbols.kRequest].parent) + if (err instanceof FastifyError) { + reply + .code(500) + .type('text/plain') + .send('FastifyError thrown') + } else if (err instanceof Error) { + reply + .code(500) + .type('text/plain') + .send('Error thrown') + } else { + reply + .code(500) + .type('text/plain') + .send('Primitive thrown') + } + }) + + fastify.inject({ + method: 'GET', + url: '/string' + }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 500) + t.assert.strictEqual(res.headers['content-type'], 'text/plain') + t.assert.deepStrictEqual(res.payload, 'Primitive thrown') + + fastify.inject({ + method: 'GET', + url: '/native-error' + }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 500) + t.assert.strictEqual(res.headers['content-type'], 'text/plain') + t.assert.deepStrictEqual(res.payload, 'Error thrown') + + fastify.inject({ + method: 'GET', + url: '/fastify-error' + }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 500) + t.assert.strictEqual(res.headers['content-type'], 'text/plain') + t.assert.deepStrictEqual(res.payload, 'FastifyError thrown') + done() + }) + }) + }) +}) diff --git a/test/allow-unsafe-regex.test.js b/test/allow-unsafe-regex.test.js new file mode 100644 index 00000000000..50e2b4ffb99 --- /dev/null +++ b/test/allow-unsafe-regex.test.js @@ -0,0 +1,92 @@ +'use strict' + +const { test } = require('node:test') +const Fastify = require('..') + +test('allow unsafe regex', async t => { + t.plan(2) + + const fastify = Fastify({ + allowUnsafeRegex: false + }) + t.after(() => fastify.close()) + + fastify.get('/:foo(^[0-9]*$)', (req, reply) => { + reply.send({ foo: req.params.foo }) + }) + + await fastify.listen({ port: 0 }) + + const result = await fetch(`http://localhost:${fastify.server.address().port}/1234`) + t.assert.strictEqual(result.status, 200) + t.assert.deepStrictEqual(await result.json(), { foo: '1234' }) +}) + +test('allow unsafe regex not match', async t => { + t.plan(1) + + const fastify = Fastify({ + allowUnsafeRegex: false + }) + t.after(() => fastify.close()) + + fastify.get('/:foo(^[0-9]*$)', (req, reply) => { + reply.send({ foo: req.params.foo }) + }) + + await fastify.listen({ port: 0 }) + + const result = await fetch(`http://localhost:${fastify.server.address().port}/a1234`) + t.assert.strictEqual(result.status, 404) +}) + +test('allow unsafe regex not safe', (t, done) => { + t.plan(1) + + const fastify = Fastify({ + allowUnsafeRegex: false + }) + t.after(() => fastify.close()) + + t.assert.throws(() => { + fastify.get('/:foo(^([0-9]+){4}$)', (req, reply) => { + reply.send({ foo: req.params.foo }) + }) + }) + done() +}) + +test('allow unsafe regex not safe by default', (t, done) => { + t.plan(1) + + const fastify = Fastify() + t.after(() => fastify.close()) + + t.assert.throws(() => { + fastify.get('/:foo(^([0-9]+){4}$)', (req, reply) => { + reply.send({ foo: req.params.foo }) + }) + }) + done() +}) + +test('allow unsafe regex allow unsafe', async t => { + t.plan(3) + + const fastify = Fastify({ + allowUnsafeRegex: true + }) + t.after(() => fastify.close()) + + t.assert.doesNotThrow(() => { + fastify.get('/:foo(^([0-9]+){4}$)', (req, reply) => { + reply.send({ foo: req.params.foo }) + }) + }) + + await fastify.listen({ port: 0 }) + + const result = await fetch(`http://localhost:${fastify.server.address().port}/1234`) + t.assert.strictEqual(result.status, 200) + t.assert.deepStrictEqual(await result.json(), { foo: '1234' }) +}) diff --git a/test/allowUnsafeRegex.test.js b/test/allowUnsafeRegex.test.js deleted file mode 100644 index 6adba0a068a..00000000000 --- a/test/allowUnsafeRegex.test.js +++ /dev/null @@ -1,117 +0,0 @@ -'use strict' - -const t = require('tap') -const test = t.test -const Fastify = require('..') -const sget = require('simple-get').concat - -test('allow unsafe regex', t => { - t.plan(4) - - const fastify = Fastify({ - allowUnsafeRegex: false - }) - t.teardown(fastify.close.bind(fastify)) - - fastify.get('/:foo(^[0-9]*$)', (req, reply) => { - reply.send({ foo: req.params.foo }) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/1234' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(JSON.parse(body), { - foo: '1234' - }) - }) - }) -}) - -test('allow unsafe regex not match', t => { - t.plan(3) - - const fastify = Fastify({ - allowUnsafeRegex: false - }) - t.teardown(fastify.close.bind(fastify)) - - fastify.get('/:foo(^[0-9]*$)', (req, reply) => { - reply.send({ foo: req.params.foo }) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/a1234' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 404) - }) - }) -}) - -test('allow unsafe regex not safe', t => { - t.plan(1) - - const fastify = Fastify({ - allowUnsafeRegex: false - }) - t.teardown(fastify.close.bind(fastify)) - - t.throws(() => { - fastify.get('/:foo(^([0-9]+){4}$)', (req, reply) => { - reply.send({ foo: req.params.foo }) - }) - }) -}) - -test('allow unsafe regex not safe by default', t => { - t.plan(1) - - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - - t.throws(() => { - fastify.get('/:foo(^([0-9]+){4}$)', (req, reply) => { - reply.send({ foo: req.params.foo }) - }) - }) -}) - -test('allow unsafe regex allow unsafe', t => { - t.plan(5) - - const fastify = Fastify({ - allowUnsafeRegex: true - }) - t.teardown(fastify.close.bind(fastify)) - - t.doesNotThrow(() => { - fastify.get('/:foo(^([0-9]+){4}$)', (req, reply) => { - reply.send({ foo: req.params.foo }) - }) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/1234' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(JSON.parse(body), { - foo: '1234' - }) - }) - }) -}) diff --git a/test/als.test.js b/test/als.test.js index 7bc9ba06185..861a8d5ce1f 100644 --- a/test/als.test.js +++ b/test/als.test.js @@ -1,74 +1,65 @@ 'use strict' -const { AsyncLocalStorage } = require('async_hooks') -const t = require('tap') +const { AsyncLocalStorage } = require('node:async_hooks') +const { test } = require('node:test') const Fastify = require('..') -const sget = require('simple-get').concat -if (!AsyncLocalStorage) { - t.skip('AsyncLocalStorage not available, skipping test') - process.exit(0) -} +test('Async Local Storage test', async (t) => { + t.plan(12) + if (!AsyncLocalStorage) { + t.skip('AsyncLocalStorage not available, skipping test') + process.exit(0) + } -const storage = new AsyncLocalStorage() -const app = Fastify({ logger: false }) + const storage = new AsyncLocalStorage() + const app = Fastify({ logger: false }) -let counter = 0 -app.addHook('onRequest', (req, reply, next) => { - const id = counter++ - storage.run({ id }, next) -}) + t.after(() => app.close()) -app.get('/', function (request, reply) { - t.ok(storage.getStore()) - const id = storage.getStore().id - reply.send({ id }) -}) + let counter = 0 + app.addHook('onRequest', (req, reply, next) => { + const id = counter++ + storage.run({ id }, next) + }) -app.post('/', function (request, reply) { - t.ok(storage.getStore()) - const id = storage.getStore().id - reply.send({ id }) -}) + app.get('/', function (request, reply) { + t.assert.ok(storage.getStore()) + const id = storage.getStore().id + reply.send({ id }) + }) -app.listen({ port: 0 }, function (err, address) { - t.error(err) + app.post('/', function (request, reply) { + t.assert.ok(storage.getStore()) + const id = storage.getStore().id + reply.send({ id }) + }) + + const fastifyServer = await app.listen({ port: 0 }) - sget({ + // First POST request + const result1 = await fetch(fastifyServer, { method: 'POST', - url: 'http://localhost:' + app.server.address().port, - body: { - hello: 'world' - }, - json: true - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body, { id: 0 }) + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hello: 'world' }) + }) + t.assert.ok(result1.ok) + t.assert.strictEqual(result1.status, 200) + t.assert.deepStrictEqual(await result1.json(), { id: 0 }) - sget({ - method: 'POST', - url: 'http://localhost:' + app.server.address().port, - body: { - hello: 'world' - }, - json: true - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body, { id: 1 }) + const result2 = await fetch(fastifyServer, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hello: 'world' }) + }) + t.assert.ok(result2.ok) + t.assert.strictEqual(result2.status, 200) + t.assert.deepStrictEqual(await result2.json(), { id: 1 }) - sget({ - method: 'GET', - url: 'http://localhost:' + app.server.address().port, - json: true - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body, { id: 2 }) - app.close() - t.end() - }) - }) + // GET request + const result3 = await fetch(fastifyServer, { + method: 'GET' }) + t.assert.ok(result3.ok) + t.assert.strictEqual(result3.status, 200) + t.assert.deepStrictEqual(await result3.json(), { id: 2 }) }) diff --git a/test/async-await.test.js b/test/async-await.test.js index a946f5777d5..b7f62053f2d 100644 --- a/test/async-await.test.js +++ b/test/async-await.test.js @@ -1,13 +1,11 @@ 'use strict' -const t = require('tap') -const test = t.test -const sget = require('simple-get').concat +const { test } = require('node:test') const Fastify = require('..') const split = require('split2') const pino = require('pino') -const statusCodes = require('http').STATUS_CODES -const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) +const { sleep } = require('./helper') +const statusCodes = require('node:http').STATUS_CODES const opts = { schema: { @@ -24,56 +22,89 @@ const opts = { } } -test('async await', t => { - t.plan(11) +const optsWithHostnameAndPort = { + schema: { + response: { + '2xx': { + type: 'object', + properties: { + hello: { + type: 'string' + }, + hostname: { + type: 'string' + }, + port: { + type: 'string' + } + } + } + } + } +} +test('async await', async t => { + t.plan(15) const fastify = Fastify() try { fastify.get('/', opts, async function awaitMyFunc (req, reply) { await sleep(200) return { hello: 'world' } }) - t.pass() + t.assert.ok(true) } catch (e) { - t.fail() + t.assert.fail() } try { fastify.get('/no-await', opts, async function (req, reply) { return { hello: 'world' } }) - t.pass() + t.assert.ok(true) } catch (e) { - t.fail() + t.assert.fail() } - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) + try { + fastify.get('/await/hostname_port', optsWithHostnameAndPort, async function awaitMyFunc (req, reply) { + await sleep(200) + return { hello: 'world', hostname: req.hostname, port: req.port } }) + t.assert.ok(true) + } catch (e) { + t.assert.fail() + } - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/no-await' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) - }) + const fastifyServer = await fastify.listen({ port: 0 }) + + t.after(() => { fastify.close() }) + + const result = await fetch(fastifyServer) + + const body = await result.text() + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { hello: 'world' }) + + const result1 = await fetch(`${fastifyServer}/no-await`) + + const body1 = await result1.text() + t.assert.ok(result1.ok) + t.assert.strictEqual(result1.status, 200) + t.assert.strictEqual(result1.headers.get('content-length'), '' + body1.length) + t.assert.deepStrictEqual(JSON.parse(body1), { hello: 'world' }) + + const result2 = await fetch(`http://localhost:${fastify.server.address().port}/await/hostname_port`) + + const parsedBody = await result2.json() + t.assert.ok(result2.ok) + t.assert.strictEqual(result2.status, 200) + t.assert.strictEqual(parsedBody.hostname, 'localhost') + t.assert.strictEqual(parseInt(parsedBody.port), fastify.server.address().port) }) -test('ignore the result of the promise if reply.send is called beforehand (undefined)', t => { - t.plan(4) +test('ignore the result of the promise if reply.send is called beforehand (undefined)', async (t) => { + t.plan(3) const server = Fastify() const payload = { hello: 'world' } @@ -82,23 +113,19 @@ test('ignore the result of the promise if reply.send is called beforehand (undef await reply.send(payload) }) - t.teardown(server.close.bind(server)) + t.after(() => { server.close() }) - server.listen({ port: 0 }, (err) => { - t.error(err) - sget({ - method: 'GET', - url: 'http://localhost:' + server.server.address().port + '/' - }, (err, res, body) => { - t.error(err) - t.same(payload, JSON.parse(body)) - t.equal(res.statusCode, 200) - }) - }) + const fastifyServer = await server.listen({ port: 0 }) + + const result = await fetch(fastifyServer) + + t.assert.ok(result.ok) + t.assert.deepStrictEqual(payload, await result.json()) + t.assert.strictEqual(result.status, 200) }) -test('ignore the result of the promise if reply.send is called beforehand (object)', t => { - t.plan(4) +test('ignore the result of the promise if reply.send is called beforehand (object)', async (t) => { + t.plan(3) const server = Fastify() const payload = { hello: 'world2' } @@ -108,34 +135,30 @@ test('ignore the result of the promise if reply.send is called beforehand (objec return { hello: 'world' } }) - t.teardown(server.close.bind(server)) + t.after(() => { server.close() }) - server.listen({ port: 0 }, (err) => { - t.error(err) - sget({ - method: 'GET', - url: 'http://localhost:' + server.server.address().port + '/' - }, (err, res, body) => { - t.error(err) - t.same(payload, JSON.parse(body)) - t.equal(res.statusCode, 200) - }) - }) + const fastifyServer = await server.listen({ port: 0 }) + + const result = await fetch(fastifyServer) + + t.assert.ok(result.ok) + t.assert.deepStrictEqual(payload, await result.json()) + t.assert.strictEqual(result.status, 200) }) -test('server logs an error if reply.send is called and a value is returned via async/await', t => { - const lines = ['incoming request', 'request completed', 'Reply already sent'] +test('server logs an error if reply.send is called and a value is returned via async/await', (t, done) => { + const lines = ['incoming request', 'request completed', 'Reply was already sent, did you forget to "return reply" in "/" (GET)?'] t.plan(lines.length + 2) const splitStream = split(JSON.parse) splitStream.on('data', (line) => { - t.equal(line.msg, lines.shift()) + t.assert.strictEqual(line.msg, lines.shift()) }) const logger = pino(splitStream) const fastify = Fastify({ - logger + loggerInstance: logger }) fastify.get('/', async (req, reply) => { @@ -147,14 +170,15 @@ test('server logs an error if reply.send is called and a value is returned via a method: 'GET', url: '/' }, (err, res) => { - t.error(err) + t.assert.ifError(err) const payload = JSON.parse(res.payload) - t.same(payload, { hello: 'world' }) + t.assert.deepStrictEqual(payload, { hello: 'world' }) + done() }) }) -test('ignore the result of the promise if reply.send is called beforehand (undefined)', t => { - t.plan(4) +test('ignore the result of the promise if reply.send is called beforehand (undefined)', async (t) => { + t.plan(3) const server = Fastify() const payload = { hello: 'world' } @@ -163,23 +187,19 @@ test('ignore the result of the promise if reply.send is called beforehand (undef await reply.send(payload) }) - t.teardown(server.close.bind(server)) + t.after(() => { server.close() }) - server.listen({ port: 0 }, (err) => { - t.error(err) - sget({ - method: 'GET', - url: 'http://localhost:' + server.server.address().port + '/' - }, (err, res, body) => { - t.error(err) - t.same(payload, JSON.parse(body)) - t.equal(res.statusCode, 200) - }) - }) + const fastifyServer = await server.listen({ port: 0 }) + + const result = await fetch(fastifyServer) + + t.assert.ok(result.ok) + t.assert.deepStrictEqual(payload, await result.json()) + t.assert.strictEqual(result.status, 200) }) -test('ignore the result of the promise if reply.send is called beforehand (object)', t => { - t.plan(4) +test('ignore the result of the promise if reply.send is called beforehand (object)', async (t) => { + t.plan(3) const server = Fastify() const payload = { hello: 'world2' } @@ -189,28 +209,24 @@ test('ignore the result of the promise if reply.send is called beforehand (objec return { hello: 'world' } }) - t.teardown(server.close.bind(server)) + t.after(() => { server.close() }) - server.listen({ port: 0 }, (err) => { - t.error(err) - sget({ - method: 'GET', - url: 'http://localhost:' + server.server.address().port + '/' - }, (err, res, body) => { - t.error(err) - t.same(payload, JSON.parse(body)) - t.equal(res.statusCode, 200) - }) - }) + const fastifyServer = await server.listen({ port: 0 }) + + const result = await fetch(fastifyServer) + + t.assert.ok(result.ok) + t.assert.deepStrictEqual(payload, await result.json()) + t.assert.strictEqual(result.status, 200) }) -test('await reply if we will be calling reply.send in the future', t => { +test('await reply if we will be calling reply.send in the future', (t, done) => { const lines = ['incoming request', 'request completed'] t.plan(lines.length + 2) const splitStream = split(JSON.parse) splitStream.on('data', (line) => { - t.equal(line.msg, lines.shift()) + t.assert.strictEqual(line.msg, lines.shift()) }) const server = Fastify({ @@ -232,19 +248,20 @@ test('await reply if we will be calling reply.send in the future', t => { method: 'GET', url: '/' }, (err, res) => { - t.error(err) + t.assert.ifError(err) const payload = JSON.parse(res.payload) - t.same(payload, { hello: 'world' }) + t.assert.deepStrictEqual(payload, { hello: 'world' }) + done() }) }) -test('await reply if we will be calling reply.send in the future (error case)', t => { +test('await reply if we will be calling reply.send in the future (error case)', (t, done) => { const lines = ['incoming request', 'kaboom', 'request completed'] t.plan(lines.length + 2) const splitStream = split(JSON.parse) splitStream.on('data', (line) => { - t.equal(line.msg, lines.shift()) + t.assert.strictEqual(line.msg, lines.shift()) }) const server = Fastify({ @@ -265,12 +282,13 @@ test('await reply if we will be calling reply.send in the future (error case)', method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 500) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 500) + done() }) }) -test('support reply decorators with await', t => { +test('support reply decorators with await', (t, done) => { t.plan(2) const fastify = Fastify() @@ -292,9 +310,10 @@ test('support reply decorators with await', t => { method: 'GET', url: '/' }, (err, res) => { - t.error(err) + t.assert.ifError(err) const payload = JSON.parse(res.payload) - t.same(payload, { hello: 'world' }) + t.assert.deepStrictEqual(payload, { hello: 'world' }) + done() }) }) @@ -309,9 +328,9 @@ test('inject async await', async t => { try { const res = await fastify.inject({ method: 'GET', url: '/' }) - t.same({ hello: 'world' }, JSON.parse(res.payload)) + t.assert.deepStrictEqual({ hello: 'world' }, JSON.parse(res.payload)) } catch (err) { - t.fail(err) + t.assert.fail(err) } }) @@ -326,18 +345,18 @@ test('inject async await - when the server equal up', async t => { try { const res = await fastify.inject({ method: 'GET', url: '/' }) - t.same({ hello: 'world' }, JSON.parse(res.payload)) + t.assert.deepStrictEqual({ hello: 'world' }, JSON.parse(res.payload)) } catch (err) { - t.fail(err) + t.assert.fail(err) } await sleep(200) try { const res2 = await fastify.inject({ method: 'GET', url: '/' }) - t.same({ hello: 'world' }, JSON.parse(res2.payload)) + t.assert.deepStrictEqual({ hello: 'world' }, JSON.parse(res2.payload)) } catch (err) { - t.fail(err) + t.assert.fail(err) } }) @@ -356,13 +375,13 @@ test('async await plugin', async t => { try { const res = await fastify.inject({ method: 'GET', url: '/' }) - t.same({ hello: 'world' }, JSON.parse(res.payload)) + t.assert.deepStrictEqual({ hello: 'world' }, JSON.parse(res.payload)) } catch (err) { - t.fail(err) + t.assert.fail(err) } }) -test('does not call reply.send() twice if 204 response equal already sent', t => { +test('does not call reply.send() twice if 204 response equal already sent', (t, done) => { t.plan(2) const fastify = Fastify() @@ -378,13 +397,14 @@ test('does not call reply.send() twice if 204 response equal already sent', t => method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 204) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 204) + done() }) }) -test('promise was fulfilled with undefined', t => { - t.plan(4) +test('promise was fulfilled with undefined', async (t) => { + t.plan(3) let fastify = null const stream = split(JSON.parse) @@ -396,35 +416,51 @@ test('promise was fulfilled with undefined', t => { } }) } catch (e) { - t.fail() + t.assert.fail() } - t.teardown(fastify.close.bind(fastify)) + t.after(() => { fastify.close() }) fastify.get('/', async (req, reply) => { }) stream.once('data', line => { - t.fail('should not log an error') + t.assert.fail('should not log an error') }) - fastify.listen({ port: 0 }, (err) => { - t.error(err) - t.teardown(() => { fastify.close() }) + const fastifyServer = await fastify.listen({ port: 0 }) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/' - }, (err, res, body) => { - t.error(err) - t.equal(res.body, undefined) - t.equal(res.statusCode, 200) - }) + const result = await fetch(fastifyServer) + + t.assert.ok(result.ok) + t.assert.strictEqual(await result.text(), '') + t.assert.strictEqual(result.status, 200) +}) + +test('promise was fulfilled with undefined using inject', async (t) => { + const stream = split(JSON.parse) + const fastify = Fastify({ + logger: { + stream, + level: 'error' + } }) + + fastify.get('/', async (req, reply) => { + }) + + stream.once('data', line => { + t.assert.fail('should not log an error') + }) + + const res = await fastify.inject('/') + + t.assert.strictEqual(res.body, '') + t.assert.strictEqual(res.statusCode, 200) }) -test('error is not logged because promise was fulfilled with undefined but response was sent before promise resolution', t => { - t.plan(4) +test('error is not logged because promise was fulfilled with undefined but response was sent before promise resolution', async (t) => { + t.plan(3) let fastify = null const stream = split(JSON.parse) @@ -437,38 +473,32 @@ test('error is not logged because promise was fulfilled with undefined but respo } }) } catch (e) { - t.fail() + t.assert.fail() } - t.teardown(fastify.close.bind(fastify)) + t.after(() => { fastify.close() }) fastify.get('/', async (req, reply) => { reply.send(payload) }) stream.once('data', line => { - t.fail('should not log an error') - }) - - fastify.listen({ port: 0 }, (err) => { - t.error(err) - t.teardown(() => { fastify.close() }) - - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/' - }, (err, res, body) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same( - payload, - JSON.parse(body) - ) - }) + t.assert.fail('should not log an error') }) + + const fastifyServer = await fastify.listen({ port: 0 }) + + const result = await fetch(fastifyServer) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.deepStrictEqual( + payload, + await result.json() + ) }) -test('Thrown Error instance sets HTTP status code', t => { +test('Thrown Error instance sets HTTP status code', (t, done) => { t.plan(3) const fastify = Fastify() @@ -484,9 +514,9 @@ test('Thrown Error instance sets HTTP status code', t => { method: 'GET', url: '/' }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 418) - t.same( + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 418) + t.assert.deepStrictEqual( { error: statusCodes['418'], message: err.message, @@ -494,10 +524,11 @@ test('Thrown Error instance sets HTTP status code', t => { }, JSON.parse(res.payload) ) + done() }) }) -test('customErrorHandler support', t => { +test('customErrorHandler support', (t, done) => { t.plan(4) const fastify = Fastify() @@ -509,7 +540,7 @@ test('customErrorHandler support', t => { }) fastify.setErrorHandler(async err => { - t.equal(err.message, 'ouch') + t.assert.strictEqual(err.message, 'ouch') const error = new Error('kaboom') error.statusCode = 401 throw error @@ -519,9 +550,9 @@ test('customErrorHandler support', t => { method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 401) - t.same( + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 401) + t.assert.deepStrictEqual( { error: statusCodes['401'], message: 'kaboom', @@ -529,10 +560,11 @@ test('customErrorHandler support', t => { }, JSON.parse(res.payload) ) + done() }) }) -test('customErrorHandler support without throwing', t => { +test('customErrorHandler support without throwing', (t, done) => { t.plan(4) const fastify = Fastify() @@ -544,26 +576,27 @@ test('customErrorHandler support without throwing', t => { }) fastify.setErrorHandler(async (err, req, reply) => { - t.equal(err.message, 'ouch') + t.assert.strictEqual(err.message, 'ouch') await reply.code(401).send('kaboom') - reply.send = t.fail.bind(t, 'should not be called') + reply.send = t.assert.fail.bind(t, 'should not be called') }) fastify.inject({ method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 401) - t.same( + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 401) + t.assert.deepStrictEqual( 'kaboom', res.payload ) + done() }) }) // See https://github.com/fastify/fastify/issues/2653 -test('customErrorHandler only called if reply not already sent', t => { +test('customErrorHandler only called if reply not already sent', (t, done) => { t.plan(3) const fastify = Fastify() @@ -575,23 +608,24 @@ test('customErrorHandler only called if reply not already sent', t => { throw error }) - fastify.setErrorHandler(t.fail.bind(t, 'should not be called')) + fastify.setErrorHandler(t.assert.fail.bind(t, 'should not be called')) fastify.inject({ method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same( + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual( 'success', res.payload ) + done() }) }) // See https://github.com/fastify/fastify/issues/3209 -test('setNotFoundHandler should accept return value', t => { +test('setNotFoundHandler should accept return value', (t, done) => { t.plan(3) const fastify = Fastify() @@ -611,9 +645,9 @@ test('setNotFoundHandler should accept return value', t => { method: 'GET', url: '/elsewhere' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 404) - t.same( + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 404) + t.assert.deepStrictEqual( { error: statusCodes['404'], message: 'lost', @@ -621,11 +655,12 @@ test('setNotFoundHandler should accept return value', t => { }, JSON.parse(res.payload) ) + done() }) }) // See https://github.com/fastify/fastify/issues/3209 -test('customErrorHandler should accept return value', t => { +test('customErrorHandler should accept return value', (t, done) => { t.plan(4) const fastify = Fastify() @@ -637,7 +672,7 @@ test('customErrorHandler should accept return value', t => { }) fastify.setErrorHandler((err, req, reply) => { - t.equal(err.message, 'ouch') + t.assert.strictEqual(err.message, 'ouch') reply.code(401) return { error: statusCodes['401'], @@ -650,9 +685,9 @@ test('customErrorHandler should accept return value', t => { method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 401) - t.same( + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 401) + t.assert.deepStrictEqual( { error: statusCodes['401'], message: 'kaboom', @@ -660,10 +695,11 @@ test('customErrorHandler should accept return value', t => { }, JSON.parse(res.payload) ) + done() }) }) test('await self', async t => { const app = Fastify() - t.equal(await app, app) + t.assert.strictEqual(await app, app) }) diff --git a/test/async-dispose.test.js b/test/async-dispose.test.js new file mode 100644 index 00000000000..f6a726692cd --- /dev/null +++ b/test/async-dispose.test.js @@ -0,0 +1,20 @@ +'use strict' + +const { test } = require('node:test') +const Fastify = require('../fastify') + +// asyncDispose doesn't exist in node <= 16 +test('async dispose should close fastify', { skip: !('asyncDispose' in Symbol) }, async t => { + t.plan(2) + + const fastify = Fastify() + + await fastify.listen({ port: 0 }) + + t.assert.strictEqual(fastify.server.listening, true) + + // the same as syntax sugar for + // await using app = fastify() + await fastify[Symbol.asyncDispose]() + t.assert.strictEqual(fastify.server.listening, false) +}) diff --git a/test/async_hooks.test.js b/test/async_hooks.test.js new file mode 100644 index 00000000000..b99dda953c9 --- /dev/null +++ b/test/async_hooks.test.js @@ -0,0 +1,52 @@ +'use strict' + +const { createHook } = require('node:async_hooks') +const { test } = require('node:test') +const Fastify = require('..') + +const remainingIds = new Set() + +createHook({ + init (asyncId, type, triggerAsyncId, resource) { + if (type === 'content-type-parser:run') { + remainingIds.add(asyncId) + } + }, + destroy (asyncId) { + remainingIds.delete(asyncId) + } +}) + +const app = Fastify({ logger: false }) + +test('test async hooks', async (t) => { + app.get('/', function (request, reply) { + reply.send({ id: 42 }) + }) + + app.post('/', function (request, reply) { + reply.send({ id: 42 }) + }) + + t.after(() => app.close()) + + const fastifyServer = await app.listen({ port: 0 }) + + const result1 = await fetch(fastifyServer, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hello: 'world' }) + }) + t.assert.strictEqual(result1.status, 200) + + const result2 = await fetch(fastifyServer, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hello: 'world' }) + }) + t.assert.strictEqual(result2.status, 200) + + const result3 = await fetch(fastifyServer) + t.assert.strictEqual(result3.status, 200) + t.assert.strictEqual(remainingIds.size, 0) +}) diff --git a/test/body-limit.test.js b/test/body-limit.test.js new file mode 100644 index 00000000000..8d9c282322d --- /dev/null +++ b/test/body-limit.test.js @@ -0,0 +1,224 @@ +'use strict' + +const Fastify = require('../fastify') +const zlib = require('node:zlib') +const { test } = require('node:test') + +test('bodyLimit', async t => { + t.plan(4) + + try { + Fastify({ bodyLimit: 1.3 }) + t.assert.fail('option must be an integer') + } catch (err) { + t.assert.ok(err) + } + + try { + Fastify({ bodyLimit: [] }) + t.assert.fail('option must be an integer') + } catch (err) { + t.assert.ok(err) + } + + const fastify = Fastify({ bodyLimit: 1 }) + + fastify.post('/', (request, reply) => { + reply.send({ error: 'handler should not be called' }) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) + + const result = await fetch(fastifyServer, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify([]) + }) + + t.assert.ok(!result.ok) + t.assert.strictEqual(result.status, 413) +}) + +test('bodyLimit is applied to decoded content', async (t) => { + t.plan(6) + + const body = { x: 'x'.repeat(30000) } + const json = JSON.stringify(body) + const encoded = zlib.gzipSync(json) + + const fastify = Fastify() + + fastify.addHook('preParsing', async (req, reply, payload) => { + t.assert.strictEqual(req.headers['content-length'], `${encoded.length}`) + const unzip = zlib.createGunzip() + Object.defineProperty(unzip, 'receivedEncodedLength', { + get () { + return unzip.bytesWritten + } + }) + payload.pipe(unzip) + return unzip + }) + + fastify.post('/body-limit-40k', { + bodyLimit: 40000, + onError: async (req, res, err) => { + t.fail('should not be called') + } + }, (request, reply) => { + reply.send({ x: request.body.x }) + }) + + fastify.post('/body-limit-20k', { + bodyLimit: 20000, + onError: async (req, res, err) => { + t.assert.strictEqual(err.code, 'FST_ERR_CTP_BODY_TOO_LARGE') + t.assert.strictEqual(err.statusCode, 413) + } + }, (request, reply) => { + reply.send({ x: 'handler should not be called' }) + }) + + await t.test('bodyLimit 40k', async (t) => { + const result = await fastify.inject({ + method: 'POST', + url: '/body-limit-40k', + headers: { + 'content-encoding': 'gzip', + 'content-type': 'application/json' + }, + payload: encoded + }) + t.assert.strictEqual(result.statusCode, 200) + t.assert.deepStrictEqual(result.json(), body) + }) + + await t.test('bodyLimit 20k', async (t) => { + const result = await fastify.inject({ + method: 'POST', + url: '/body-limit-20k', + headers: { + 'content-encoding': 'gzip', + 'content-type': 'application/json' + }, + payload: encoded + }) + t.assert.strictEqual(result.statusCode, 413) + }) +}) + +test('default request.routeOptions.bodyLimit should be 1048576', async t => { + t.plan(3) + const fastify = Fastify() + fastify.post('/default-bodylimit', { + handler (request, reply) { + t.assert.strictEqual(1048576, request.routeOptions.bodyLimit) + reply.send({ }) + } + }) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) + + const result = await fetch(fastifyServer + '/default-bodylimit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify([]) + }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) +}) + +test('request.routeOptions.bodyLimit should be equal to route limit', async t => { + t.plan(3) + const fastify = Fastify({ bodyLimit: 1 }) + fastify.post('/route-limit', { + bodyLimit: 1000, + handler (request, reply) { + t.assert.strictEqual(1000, request.routeOptions.bodyLimit) + reply.send({}) + } + }) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) + + const result = await fetch(fastifyServer + '/route-limit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify([]) + }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) +}) + +test('request.routeOptions.bodyLimit should be equal to server limit', async t => { + t.plan(3) + const fastify = Fastify({ bodyLimit: 100 }) + fastify.post('/server-limit', { + handler (request, reply) { + t.assert.strictEqual(100, request.routeOptions.bodyLimit) + reply.send({}) + } + }) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) + + const result = await fetch(fastifyServer + '/server-limit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify([]) + }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) +}) + +test('bodyLimit should use byte length for UTF-8 strings, not character length', async t => { + t.plan(4) + + // Create a string with multi-byte UTF-8 characters + // Use Japanese characters that are 3 bytes each in UTF-8 + const multiByteString = 'あああ' // 3 characters, 9 bytes in UTF-8 + t.assert.strictEqual(multiByteString.length, 3) // 3 characters + t.assert.strictEqual(Buffer.byteLength(multiByteString, 'utf8'), 9) // 9 bytes + + const fastify = Fastify() + + // Add a custom text parser that returns the string as-is + fastify.addContentTypeParser('text/plain', { parseAs: 'string' }, (req, body, done) => { + done(null, body) + }) + + // Set body limit to 7 bytes - this should reject the string (9 bytes) + // even though string.length (3) would be under any reasonable limit + fastify.post('/test-utf8', { + bodyLimit: 7 + }, (request, reply) => { + reply.send({ body: request.body, length: request.body.length }) + }) + + await t.test('should reject body when byte length exceeds limit', async (t) => { + const result = await fastify.inject({ + method: 'POST', + url: '/test-utf8', + headers: { 'Content-Type': 'text/plain', 'Content-Length': null }, + payload: multiByteString + }) + + t.assert.strictEqual(result.statusCode, 413) + }) + + await t.test('should accept body when byte length is within limit', async (t) => { + const smallString = 'あ' // 1 character, 3 bytes, under the 7 byte limit + + const result = await fastify.inject({ + method: 'POST', + url: '/test-utf8', + headers: { 'Content-Type': 'text/plain' }, + payload: smallString + }) + + t.assert.strictEqual(result.statusCode, 200) + t.assert.strictEqual(result.json().body, smallString) + t.assert.strictEqual(result.json().length, 1) // 1 character + }) +}) diff --git a/test/bodyLimit.test.js b/test/bodyLimit.test.js deleted file mode 100644 index f1c50b8a37f..00000000000 --- a/test/bodyLimit.test.js +++ /dev/null @@ -1,46 +0,0 @@ -'use strict' - -const Fastify = require('..') -const sget = require('simple-get').concat -const t = require('tap') -const test = t.test - -test('bodyLimit', t => { - t.plan(5) - - try { - Fastify({ bodyLimit: 1.3 }) - t.fail('option must be an integer') - } catch (err) { - t.ok(err) - } - - try { - Fastify({ bodyLimit: [] }) - t.fail('option must be an integer') - } catch (err) { - t.ok(err) - } - - const fastify = Fastify({ bodyLimit: 1 }) - - fastify.post('/', (request, reply) => { - reply.send({ error: 'handler should not be called' }) - }) - - fastify.listen({ port: 0 }, function (err) { - t.error(err) - t.teardown(() => { fastify.close() }) - - sget({ - method: 'POST', - url: 'http://localhost:' + fastify.server.address().port, - headers: { 'Content-Type': 'application/json' }, - body: [], - json: true - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 413) - }) - }) -}) diff --git a/test/buffer.test.js b/test/buffer.test.js new file mode 100644 index 00000000000..61c37dfc925 --- /dev/null +++ b/test/buffer.test.js @@ -0,0 +1,74 @@ +'use strict' + +const { test } = require('node:test') +const Fastify = require('..') + +test('Buffer test', async t => { + const fastify = Fastify() + fastify.addContentTypeParser('application/json', { parseAs: 'buffer' }, fastify.getDefaultJsonParser('error', 'ignore')) + + fastify.delete('/', async (request) => { + return request.body + }) + + await test('should return 200 if the body is not empty', async t => { + t.plan(3) + + const response = await fastify.inject({ + method: 'DELETE', + url: '/', + payload: Buffer.from('{"hello":"world"}'), + headers: { + 'content-type': 'application/json' + } + }) + + t.assert.ifError(response.error) + t.assert.strictEqual(response.statusCode, 200) + t.assert.deepStrictEqual(response.payload.toString(), '{"hello":"world"}') + }) + + await test('should return 400 if the body is empty', async t => { + t.plan(3) + + const response = await fastify.inject({ + method: 'DELETE', + url: '/', + payload: Buffer.alloc(0), + headers: { + 'content-type': 'application/json' + } + }) + + t.assert.ifError(response.error) + t.assert.strictEqual(response.statusCode, 400) + t.assert.deepStrictEqual(JSON.parse(response.payload.toString()), { + error: 'Bad Request', + code: 'FST_ERR_CTP_EMPTY_JSON_BODY', + message: 'Body cannot be empty when content-type is set to \'application/json\'', + statusCode: 400 + }) + }) + + await test('should return 400 if the body is invalid json', async t => { + t.plan(3) + + const response = await fastify.inject({ + method: 'DELETE', + url: '/', + payload: Buffer.from(']'), + headers: { + 'content-type': 'application/json' + } + }) + + t.assert.ifError(response.error) + t.assert.strictEqual(response.statusCode, 400) + t.assert.deepStrictEqual(JSON.parse(response.payload.toString()), { + error: 'Bad Request', + code: 'FST_ERR_CTP_INVALID_JSON_BODY', + message: 'Body is not valid JSON but content-type is set to \'application/json\'', + statusCode: 400 + }) + }) +}) diff --git a/test/build-certificate.js b/test/build-certificate.js index 913701f3842..73a29729be3 100644 --- a/test/build-certificate.js +++ b/test/build-certificate.js @@ -1,8 +1,97 @@ 'use strict' -const selfCert = require('self-cert') +const os = require('node:os') +const forge = require('node-forge') -async function buildCertificate () { +// from self-cert module +function selfCert (opts) { + const options = opts || {} + const log = opts.logger || require('abstract-logging') + const now = new Date() + + if (!options.attrs) options.attrs = {} + if (!options.expires) { + options.expires = new Date( + now.getFullYear() + 5, now.getMonth() + 1, now.getDate() + ) + } + + log.debug('generating key pair') + const keys = forge.pki.rsa.generateKeyPair(options.bits || 2048) + log.debug('key pair generated') + + log.debug('generating self-signed certificate') + const cert = forge.pki.createCertificate() + cert.publicKey = keys.publicKey + cert.serialNumber = '01' + cert.validity.notBefore = now + cert.validity.notAfter = options.expires + + const attrs = [ + { name: 'commonName', value: options.attrs.commonName || os.hostname() }, + { name: 'countryName', value: options.attrs.countryName || 'US' }, + { name: 'stateOrProvinceName', value: options.attrs.stateName || 'Georgia' }, + { name: 'localityName', value: options.attrs.locality || 'Atlanta' }, + { name: 'organizationName', value: options.attrs.orgName || 'None' }, + { shortName: 'OU', value: options.attrs.shortName || 'example' } + ] + cert.setSubject(attrs) + cert.setIssuer(attrs) + + cert.setExtensions([ + { name: 'basicConstraints', cA: true }, + { + name: 'keyUsage', + keyCertSign: true, + digitalSignature: true, + nonRepudiation: true, + keyEncipherment: true, + dataEncipherment: true + }, + { + name: 'extKeyUsage', + serverAuth: true, + clientAuth: true, + codeSigning: true, + emailProtection: true, + timeStamping: true + }, + { + name: 'nsCertType', + client: true, + server: true, + email: true, + objsign: true, + sslCA: true, + emailCA: true, + objCA: true + }, + { name: 'subjectKeyIdentifier' }, + { + name: 'subjectAltName', + altNames: [{ type: 6 /* URI */, value: 'DNS: ' + attrs[0].value }].concat((function () { + const interfaces = os.networkInterfaces() + + // fix citgm: skip invalid ips (aix72-ppc64) + const ips = Object.values(interfaces).flat() + .filter(i => !!forge.util.bytesFromIP(i.address)) + .map(i => ({ type: 7 /* IP */, ip: i.address })) + + return ips + }())) + } + ]) + + cert.sign(keys.privateKey) + log.debug('certificate generated') + return { + privateKey: forge.pki.privateKeyToPem(keys.privateKey), + publicKey: forge.pki.publicKeyToPem(keys.publicKey), + certificate: forge.pki.certificateToPem(cert) + } +} + +function buildCertificate () { // "global" is used in here because "t.context" is only supported by "t.beforeEach" and "t.afterEach" // For the test case which execute this code which will be using `t.before` and it can reduce the // number of times executing it. diff --git a/test/build/error-serializer.test.js b/test/build/error-serializer.test.js index d2ca8db6d08..a865452b75d 100644 --- a/test/build/error-serializer.test.js +++ b/test/build/error-serializer.test.js @@ -1,9 +1,9 @@ 'use strict' -const t = require('tap') -const test = t.test -const fs = require('fs') -const path = require('path') +const { test } = require('node:test') +const fs = require('node:fs') +const path = require('node:path') +const { loadESLint } = require('eslint') const { code } = require('../../build/build-error-serializer') @@ -15,19 +15,22 @@ test('check generated code syntax', async (t) => { t.plan(1) // standard is a esm, we import it like this - const { default: standard } = await import('standard') - const result = await standard.lintText(code) + const Eslint = await loadESLint({ useFlatConfig: true }) + const eslint = new Eslint() + const result = await eslint.lintText(code) // if there are any invalid syntax // fatal count will be greater than 0 - t.equal(result[0].fatalErrorCount, 0) + t.assert.strictEqual(result[0].fatalErrorCount, 0) }) -test('ensure the current error serializer is latest', async (t) => { +const isPrepublish = !!process.env.PREPUBLISH + +test('ensure the current error serializer is latest', { skip: !isPrepublish }, async (t) => { t.plan(1) const current = await fs.promises.readFile(path.resolve('lib/error-serializer.js')) // line break should not be a problem depends on system - t.equal(unifyLineBreak(current), unifyLineBreak(code)) + t.assert.strictEqual(unifyLineBreak(current), unifyLineBreak(code)) }) diff --git a/test/build/version.test.js b/test/build/version.test.js index 84b474ca1fc..d9b6479d9ea 100644 --- a/test/build/version.test.js +++ b/test/build/version.test.js @@ -1,9 +1,8 @@ 'use strict' -const fs = require('fs') -const path = require('path') -const t = require('tap') -const test = t.test +const fs = require('node:fs') +const path = require('node:path') +const { test } = require('node:test') const fastify = require('../../fastify')() test('should be the same as package.json', t => { @@ -11,5 +10,5 @@ test('should be the same as package.json', t => { const json = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json')).toString('utf8')) - t.equal(fastify.version, json.version) + t.assert.strictEqual(fastify.version, json.version) }) diff --git a/test/bundler/README.md b/test/bundler/README.md index e4040bfb8b7..d0f7c46eee4 100644 --- a/test/bundler/README.md +++ b/test/bundler/README.md @@ -1,12 +1,12 @@ # Bundlers test stack -In some cases, developers bundle their apps for several targets such as serverless applications. -Even if it's not recommended by Fastify team; we need to ensure we do not break the build process. +In some cases, developers bundle their apps for several targets such as serverless applications. +Even if it's not recommended by Fastify team; we need to ensure we do not break the build process. Please note this might result in features behaving differently, like the version handling check for plugins. ## Test bundlers -The bundler test stack has been defined separately from the rest of the Unit testing stack because it's not a +The bundler test stack has been defined separately from the rest of the Unit testing stack because it's not a part of the fastify lib itself. Note that the tests run in CI only on NodeJs LTS version. Developers do not need to install every bundler to run unit tests. @@ -23,7 +23,7 @@ stack dependencies. See: ## Bundler test development -To not break the fastify unit testing stack please name test files like this `*-test.js` and not `*.test.js`, +To not break the fastify unit testing stack please name test files like this `*-test.js` and not `*.test.js`, otherwise it will be targeted by the regular expression used for unit tests for fastify. -Tests need to ensure the build process works and the fastify application can be run, +Tests need to ensure the build process works and the fastify application can be run, no need to go in deep testing unless an issue is raised. diff --git a/test/bundler/esbuild/bundler-test.js b/test/bundler/esbuild/bundler-test.js index 12df3cd567c..8958694504c 100644 --- a/test/bundler/esbuild/bundler-test.js +++ b/test/bundler/esbuild/bundler-test.js @@ -1,31 +1,32 @@ 'use strict' -const t = require('tap') -const test = t.test +const { test } = require('node:test') const fastifySuccess = require('./dist/success') const fastifyFailPlugin = require('./dist/failPlugin') -test('Bundled package should work', (t) => { +test('Bundled package should work', (t, done) => { t.plan(4) fastifySuccess.ready((err) => { - t.error(err) + t.assert.ifError(err) fastifySuccess.inject( { method: 'GET', url: '/' }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 200) - t.same(res.json(), { hello: 'world' }) + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.json(), { hello: 'world' }) + done() } ) }) }) -test('Bundled package should not work with bad plugin version', (t) => { +test('Bundled package should not work with bad plugin version', (t, done) => { t.plan(1) fastifyFailPlugin.ready((err) => { - t.match(err.message, /expected '9.x' fastify version/i) + t.assert.match(err.message, /expected '9.x' fastify version/i) + done() }) }) diff --git a/test/bundler/esbuild/package.json b/test/bundler/esbuild/package.json index 26b94c6adfd..b86ae19679f 100644 --- a/test/bundler/esbuild/package.json +++ b/test/bundler/esbuild/package.json @@ -5,6 +5,6 @@ "test": "npm run bundle && node bundler-test.js" }, "devDependencies": { - "esbuild": "^0.14.11" + "esbuild": "^0.25.0" } } diff --git a/test/bundler/esbuild/src/fail-plugin-version.js b/test/bundler/esbuild/src/fail-plugin-version.js index d78e216f1fc..f3f850dffcd 100644 --- a/test/bundler/esbuild/src/fail-plugin-version.js +++ b/test/bundler/esbuild/src/fail-plugin-version.js @@ -1,3 +1,5 @@ +'use strict' + const fp = require('fastify-plugin') const fastify = require('../../../../')() diff --git a/test/bundler/esbuild/src/index.js b/test/bundler/esbuild/src/index.js index c80b41bc108..818dfe106d4 100644 --- a/test/bundler/esbuild/src/index.js +++ b/test/bundler/esbuild/src/index.js @@ -1,3 +1,5 @@ +'use strict' + const fastify = require('../../../../')() // Declare a route fastify.get('/', function (request, reply) { diff --git a/test/bundler/webpack/bundler-test.js b/test/bundler/webpack/bundler-test.js index 12df3cd567c..8958694504c 100644 --- a/test/bundler/webpack/bundler-test.js +++ b/test/bundler/webpack/bundler-test.js @@ -1,31 +1,32 @@ 'use strict' -const t = require('tap') -const test = t.test +const { test } = require('node:test') const fastifySuccess = require('./dist/success') const fastifyFailPlugin = require('./dist/failPlugin') -test('Bundled package should work', (t) => { +test('Bundled package should work', (t, done) => { t.plan(4) fastifySuccess.ready((err) => { - t.error(err) + t.assert.ifError(err) fastifySuccess.inject( { method: 'GET', url: '/' }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 200) - t.same(res.json(), { hello: 'world' }) + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.json(), { hello: 'world' }) + done() } ) }) }) -test('Bundled package should not work with bad plugin version', (t) => { +test('Bundled package should not work with bad plugin version', (t, done) => { t.plan(1) fastifyFailPlugin.ready((err) => { - t.match(err.message, /expected '9.x' fastify version/i) + t.assert.match(err.message, /expected '9.x' fastify version/i) + done() }) }) diff --git a/test/bundler/webpack/src/fail-plugin-version.js b/test/bundler/webpack/src/fail-plugin-version.js index d78e216f1fc..f3f850dffcd 100644 --- a/test/bundler/webpack/src/fail-plugin-version.js +++ b/test/bundler/webpack/src/fail-plugin-version.js @@ -1,3 +1,5 @@ +'use strict' + const fp = require('fastify-plugin') const fastify = require('../../../../')() diff --git a/test/bundler/webpack/src/index.js b/test/bundler/webpack/src/index.js index c80b41bc108..818dfe106d4 100644 --- a/test/bundler/webpack/src/index.js +++ b/test/bundler/webpack/src/index.js @@ -1,3 +1,5 @@ +'use strict' + const fastify = require('../../../../')() // Declare a route fastify.get('/', function (request, reply) { diff --git a/test/bundler/webpack/webpack.config.js b/test/bundler/webpack/webpack.config.js index e1470690a0b..e86edd3f489 100644 --- a/test/bundler/webpack/webpack.config.js +++ b/test/bundler/webpack/webpack.config.js @@ -1,4 +1,6 @@ -const path = require('path') +'use strict' + +const path = require('node:path') module.exports = { entry: { success: './src/index.js', failPlugin: './src/fail-plugin-version.js' }, diff --git a/test/case-insensitive.test.js b/test/case-insensitive.test.js index d9980acd6bb..a9bce3b4f9a 100644 --- a/test/case-insensitive.test.js +++ b/test/case-insensitive.test.js @@ -1,120 +1,102 @@ 'use strict' -const t = require('tap') -const test = t.test +const { test } = require('node:test') const Fastify = require('..') -const sget = require('simple-get').concat -test('case insensitive', t => { - t.plan(4) +test('case insensitive', async (t) => { + t.plan(3) const fastify = Fastify({ caseSensitive: false }) - t.teardown(fastify.close.bind(fastify)) + t.after(() => fastify.close()) fastify.get('/foo', (req, reply) => { reply.send({ hello: 'world' }) }) - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/FOO' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(JSON.parse(body), { - hello: 'world' - }) - }) + const fastifyServer = await fastify.listen({ port: 0 }) + + const result = await fetch(`${fastifyServer}/FOO`) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.deepStrictEqual(await result.json(), { + hello: 'world' }) }) -test('case insensitive inject', t => { - t.plan(4) +test('case insensitive inject', async t => { + t.plan(2) const fastify = Fastify({ caseSensitive: false }) - t.teardown(fastify.close.bind(fastify)) + t.after(() => fastify.close()) fastify.get('/foo', (req, reply) => { reply.send({ hello: 'world' }) }) - fastify.listen({ port: 0 }, err => { - t.error(err) - - fastify.inject({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/FOO' - }, (err, response) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(JSON.parse(response.payload), { - hello: 'world' - }) - }) + const fastifyServer = await fastify.listen({ port: 0 }) + + const result = await fastify.inject({ + method: 'GET', + url: fastifyServer + '/FOO' + }) + + t.assert.strictEqual(result.statusCode, 200) + t.assert.deepStrictEqual(result.json(), { + hello: 'world' }) }) -test('case insensitive (parametric)', t => { - t.plan(5) +test('case insensitive (parametric)', async (t) => { + t.plan(4) const fastify = Fastify({ caseSensitive: false }) - t.teardown(fastify.close.bind(fastify)) + t.after(() => fastify.close()) fastify.get('/foo/:param', (req, reply) => { - t.equal(req.params.param, 'bAr') + t.assert.strictEqual(req.params.param, 'bAr') reply.send({ hello: 'world' }) }) - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/FoO/bAr' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(JSON.parse(body), { - hello: 'world' - }) - }) + const fastifyServer = await fastify.listen({ port: 0 }) + + const result = await fetch(`${fastifyServer}/FoO/bAr`, { + method: 'GET' + }) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.deepStrictEqual(await result.json(), { + hello: 'world' }) }) -test('case insensitive (wildcard)', t => { - t.plan(5) +test('case insensitive (wildcard)', async (t) => { + t.plan(4) const fastify = Fastify({ caseSensitive: false }) - t.teardown(fastify.close.bind(fastify)) + t.after(() => fastify.close()) fastify.get('/foo/*', (req, reply) => { - t.equal(req.params['*'], 'bAr/baZ') + t.assert.strictEqual(req.params['*'], 'bAr/baZ') reply.send({ hello: 'world' }) }) - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/FoO/bAr/baZ' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(JSON.parse(body), { - hello: 'world' - }) - }) + const fastifyServer = await fastify.listen({ port: 0 }) + + const result = await fetch(`${fastifyServer}/FoO/bAr/baZ`) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.deepStrictEqual(await result.json(), { + hello: 'world' }) }) diff --git a/test/chainable.test.js b/test/chainable.test.js index 276083ef3fc..f73709c3ed6 100644 --- a/test/chainable.test.js +++ b/test/chainable.test.js @@ -1,7 +1,6 @@ 'use strict' -const t = require('tap') -const test = t.test +const { test } = require('node:test') const fastify = require('..')() const noop = () => {} @@ -22,17 +21,17 @@ const opts = { test('chainable - get', t => { t.plan(1) - t.type(fastify.get('/', opts, noop), fastify) + t.assert.strictEqual(fastify.get('/', opts, noop), fastify) }) test('chainable - post', t => { t.plan(1) - t.type(fastify.post('/', opts, noop), fastify) + t.assert.strictEqual(fastify.post('/', opts, noop), fastify) }) test('chainable - route', t => { t.plan(1) - t.type(fastify.route({ + t.assert.strictEqual(fastify.route({ method: 'GET', url: '/other', schema: opts.schema, diff --git a/test/child-logger-factory.test.js b/test/child-logger-factory.test.js new file mode 100644 index 00000000000..f458757bae5 --- /dev/null +++ b/test/child-logger-factory.test.js @@ -0,0 +1,128 @@ +'use strict' + +const { test } = require('node:test') +const Fastify = require('..') + +test('Should accept a custom childLoggerFactory function', (t, done) => { + t.plan(4) + + const fastify = Fastify() + fastify.setChildLoggerFactory(function (logger, bindings, opts) { + t.assert.ok(bindings.reqId) + t.assert.ok(opts) + this.log.debug(bindings, 'created child logger') + return logger.child(bindings, opts) + }) + + fastify.get('/', (req, reply) => { + req.log.info('log message') + reply.send() + }) + + t.after(() => fastify.close()) + + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + fastify.inject({ + method: 'GET', + url: 'http://localhost:' + fastify.server.address().port + }, (err, res) => { + t.assert.ifError(err) + done() + }) + }) +}) + +test('Should accept a custom childLoggerFactory function as option', (t, done) => { + t.plan(4) + + const fastify = Fastify({ + childLoggerFactory: function (logger, bindings, opts) { + t.assert.ok(bindings.reqId) + t.assert.ok(opts) + this.log.debug(bindings, 'created child logger') + return logger.child(bindings, opts) + } + }) + + fastify.get('/', (req, reply) => { + req.log.info('log message') + reply.send() + }) + + t.after(() => fastify.close()) + + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + fastify.inject({ + method: 'GET', + url: 'http://localhost:' + fastify.server.address().port + }, (err, res) => { + t.assert.ifError(err) + done() + }) + }) +}) + +test('req.log should be the instance returned by the factory', (t, done) => { + t.plan(3) + + const fastify = Fastify() + fastify.setChildLoggerFactory(function (logger, bindings, opts) { + this.log.debug('using root logger') + return this.log + }) + + fastify.get('/', (req, reply) => { + t.assert.strictEqual(req.log, fastify.log) + req.log.info('log message') + reply.send() + }) + + t.after(() => fastify.close()) + + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + fastify.inject({ + method: 'GET', + url: 'http://localhost:' + fastify.server.address().port + }, (err, res) => { + t.assert.ifError(err) + done() + }) + }) +}) + +test('should throw error if invalid logger is returned', (t, done) => { + t.plan(2) + + const fastify = Fastify() + fastify.setChildLoggerFactory(function () { + this.log.debug('returning an invalid logger, expect error') + return undefined + }) + + fastify.get('/', (req, reply) => { + reply.send() + }) + + t.after(() => fastify.close()) + + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + t.assert.throws(() => { + try { + fastify.inject({ + method: 'GET', + url: 'http://localhost:' + fastify.server.address().port + }, (err) => { + t.assert.fail('request should have failed but did not') + t.assert.ifError(err) + done() + }) + } finally { + done() + } + }, { code: 'FST_ERR_LOG_INVALID_LOGGER' }) + }) +}) diff --git a/test/client-timeout.test.js b/test/client-timeout.test.js new file mode 100644 index 00000000000..1b858bc99a1 --- /dev/null +++ b/test/client-timeout.test.js @@ -0,0 +1,38 @@ +'use strict' + +const { test } = require('node:test') +const fastify = require('..')({ requestTimeout: 5, http: { connectionsCheckingInterval: 1000 } }) +const { connect } = require('node:net') + +test('requestTimeout should return 408', (t, done) => { + t.plan(1) + + t.after(() => { + fastify.close() + }) + + fastify.post('/', async function (req, reply) { + await new Promise(resolve => setTimeout(resolve, 100)) + return reply.send({ hello: 'world' }) + }) + + fastify.listen({ port: 0 }, err => { + if (err) { + throw err + } + + let data = Buffer.alloc(0) + const socket = connect(fastify.server.address().port) + + socket.write('POST / HTTP/1.1\r\nHost: fastify.test\r\nConnection-Length: 1\r\n') + + socket.on('data', c => (data = Buffer.concat([data, c]))) + socket.on('end', () => { + t.assert.strictEqual( + data.toString('utf-8'), + 'HTTP/1.1 408 Request Timeout\r\nContent-Length: 71\r\nContent-Type: application/json\r\n\r\n{"error":"Request Timeout","message":"Client Timeout","statusCode":408}' + ) + done() + }) + }) +}) diff --git a/test/close-pipelining.test.js b/test/close-pipelining.test.js index 1733674a6b1..81e6325ddcf 100644 --- a/test/close-pipelining.test.js +++ b/test/close-pipelining.test.js @@ -1,74 +1,78 @@ 'use strict' -const t = require('tap') -const test = t.test +const { test } = require('node:test') const Fastify = require('..') const { Client } = require('undici') -test('Should return 503 while closing - pipelining', t => { +test('Should return 503 while closing - pipelining', async t => { const fastify = Fastify({ return503OnClosing: true, forceCloseConnections: false }) - fastify.get('/', (req, reply) => { - fastify.close() + fastify.get('/', async (req, reply) => { + // Simulate a delay to allow pipelining to kick in + await new Promise(resolve => setTimeout(resolve, 5)) reply.send({ hello: 'world' }) + fastify.close() }) - fastify.listen({ port: 0 }, async err => { - t.error(err) - - const instance = new Client('http://localhost:' + fastify.server.address().port, { - pipelining: 1 - }) - - const codes = [200, 503] - for (const code of codes) { - instance.request( - { path: '/', method: 'GET' } - ).then(data => { - t.equal(data.statusCode, code) - }).catch((e) => { - t.fail(e) - }) - } - instance.close(() => { - t.end('Done') - }) + await fastify.listen({ port: 0 }) + + const instance = new Client('http://localhost:' + fastify.server.address().port, { + pipelining: 2 }) + + const [firstRequest, secondRequest, thirdRequest] = await Promise.allSettled([ + instance.request({ path: '/', method: 'GET', blocking: false }), + instance.request({ path: '/', method: 'GET', blocking: false }), + instance.request({ path: '/', method: 'GET', blocking: false }) + ]) + t.assert.strictEqual(firstRequest.status, 'fulfilled') + t.assert.strictEqual(secondRequest.status, 'fulfilled') + + t.assert.strictEqual(firstRequest.value.statusCode, 200) + t.assert.strictEqual(secondRequest.value.statusCode, 200) + + t.assert.strictEqual(thirdRequest.status, 'fulfilled') + t.assert.strictEqual(thirdRequest.value.statusCode, 503) + + await instance.close() }) -test('Should not return 503 while closing - pipelining - return503OnClosing', t => { +test('Should close the socket abruptly - pipelining - return503OnClosing: false', async t => { + // Since Node v20, we will always invoke server.closeIdleConnections() + // therefore our socket will be closed const fastify = Fastify({ return503OnClosing: false, forceCloseConnections: false }) - fastify.get('/', (req, reply) => { - fastify.close() + fastify.get('/', async (req, reply) => { + // Simulate a delay to allow pipelining to kick in + await new Promise(resolve => setTimeout(resolve, 5)) reply.send({ hello: 'world' }) + fastify.close() }) - fastify.listen({ port: 0 }, err => { - t.error(err) - - const instance = new Client('http://localhost:' + fastify.server.address().port, { - pipelining: 1 - }) - - const codes = [200, 200] - for (const code of codes) { - instance.request( - { path: '/', method: 'GET' } - ).then(data => { - t.equal(data.statusCode, code) - }).catch((e) => { - t.fail(e) - }) - } - instance.close(() => { - t.end('Done') - }) + await fastify.listen({ port: 0 }) + + const instance = new Client('http://localhost:' + fastify.server.address().port, { + pipelining: 1 }) + + const responses = await Promise.allSettled([ + instance.request({ path: '/', method: 'GET', blocking: false }), + instance.request({ path: '/', method: 'GET', blocking: false }), + instance.request({ path: '/', method: 'GET', blocking: false }), + instance.request({ path: '/', method: 'GET', blocking: false }) + ]) + + const fulfilled = responses.filter(r => r.status === 'fulfilled') + const rejected = responses.filter(r => r.status === 'rejected') + + t.assert.strictEqual(fulfilled.length, 1) + t.assert.strictEqual(rejected.length, 3) + + await instance.close() }) diff --git a/test/close.test.js b/test/close.test.js index 90dd53934b4..810ffe4f3f7 100644 --- a/test/close.test.js +++ b/test/close.test.js @@ -1,39 +1,44 @@ 'use strict' -const net = require('net') -const http = require('http') -const t = require('tap') -const test = t.test +const net = require('node:net') +const http = require('node:http') +const { test } = require('node:test') const Fastify = require('..') const { Client } = require('undici') +const split = require('split2') +const { sleep } = require('./helper') -test('close callback', t => { - t.plan(4) +test('close callback', (t, testDone) => { + t.plan(7) const fastify = Fastify() fastify.addHook('onClose', onClose) function onClose (instance, done) { - t.type(fastify, instance) + t.assert.ok(typeof fastify === typeof this) + t.assert.ok(typeof fastify === typeof instance) + t.assert.strictEqual(fastify, this) + t.assert.strictEqual(fastify, instance) done() } fastify.listen({ port: 0 }, err => { - t.error(err) + t.assert.ifError(err) fastify.close((err) => { - t.error(err) - t.ok('close callback') + t.assert.ifError(err) + t.assert.ok('close callback') + testDone() }) }) }) -test('inside register', t => { +test('inside register', (t, done) => { t.plan(5) const fastify = Fastify() fastify.register(function (f, opts, done) { f.addHook('onClose', onClose) function onClose (instance, done) { - t.ok(instance.prototype === fastify.prototype) - t.equal(instance, f) + t.assert.ok(instance.prototype === fastify.prototype) + t.assert.strictEqual(instance, f) done() } @@ -41,23 +46,24 @@ test('inside register', t => { }) fastify.listen({ port: 0 }, err => { - t.error(err) + t.assert.ifError(err) fastify.close((err) => { - t.error(err) - t.ok('close callback') + t.assert.ifError(err) + t.assert.ok('close callback') + done() }) }) }) -test('close order', t => { +test('close order', (t, done) => { t.plan(5) const fastify = Fastify() const order = [1, 2, 3] fastify.register(function (f, opts, done) { f.addHook('onClose', (instance, done) => { - t.equal(order.shift(), 1) + t.assert.strictEqual(order.shift(), 1) done() }) @@ -65,16 +71,17 @@ test('close order', t => { }) fastify.addHook('onClose', (instance, done) => { - t.equal(order.shift(), 2) + t.assert.strictEqual(order.shift(), 2) done() }) fastify.listen({ port: 0 }, err => { - t.error(err) + t.assert.ifError(err) fastify.close((err) => { - t.error(err) - t.equal(order.shift(), 3) + t.assert.ifError(err) + t.assert.strictEqual(order.shift(), 3) + done() }) }) }) @@ -86,37 +93,38 @@ test('close order - async', async t => { fastify.register(function (f, opts, done) { f.addHook('onClose', async instance => { - t.equal(order.shift(), 1) + t.assert.strictEqual(order.shift(), 1) }) done() }) fastify.addHook('onClose', () => { - t.equal(order.shift(), 2) + t.assert.strictEqual(order.shift(), 2) }) await fastify.listen({ port: 0 }) await fastify.close() - t.equal(order.shift(), 3) + t.assert.strictEqual(order.shift(), 3) }) -test('should not throw an error if the server is not listening', t => { +test('should not throw an error if the server is not listening', (t, done) => { t.plan(2) const fastify = Fastify() fastify.addHook('onClose', onClose) function onClose (instance, done) { - t.type(fastify, instance) + t.assert.ok(instance.prototype === fastify.prototype) done() } fastify.close((err) => { - t.error(err) + t.assert.ifError(err) + done() }) }) -test('onClose should keep the context', t => { +test('onClose should keep the context', (t, done) => { t.plan(4) const fastify = Fastify() fastify.register(plugin) @@ -124,11 +132,11 @@ test('onClose should keep the context', t => { function plugin (instance, opts, done) { instance.decorate('test', true) instance.addHook('onClose', onClose) - t.ok(instance.prototype === fastify.prototype) + t.assert.ok(instance.prototype === fastify.prototype) function onClose (i, done) { - t.ok(i.test) - t.equal(i, instance) + t.assert.ok(i.test) + t.assert.strictEqual(i, instance) done() } @@ -136,11 +144,12 @@ test('onClose should keep the context', t => { } fastify.close((err) => { - t.error(err) + t.assert.ifError(err) + done() }) }) -test('Should return error while closing (promise) - injection', t => { +test('Should return error while closing (promise) - injection', (t, done) => { t.plan(4) const fastify = Fastify() @@ -154,8 +163,8 @@ test('Should return error while closing (promise) - injection', t => { method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) fastify.close() process.nextTick(() => { @@ -163,14 +172,15 @@ test('Should return error while closing (promise) - injection', t => { method: 'GET', url: '/' }).catch(err => { - t.ok(err) - t.equal(err.message, 'Server is closed') + t.assert.ok(err) + t.assert.strictEqual(err.code, 'FST_ERR_REOPENED_CLOSE_SERVER') + done() }) }, 100) }) }) -test('Should return error while closing (callback) - injection', t => { +test('Should return error while closing (callback) - injection', (t, done) => { t.plan(4) const fastify = Fastify() @@ -186,8 +196,8 @@ test('Should return error while closing (callback) - injection', t => { method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) fastify.close() setTimeout(() => { @@ -195,14 +205,16 @@ test('Should return error while closing (callback) - injection', t => { method: 'GET', url: '/' }, (err, res) => { - t.ok(err) - t.equal(err.message, 'Server is closed') + t.assert.ok(err) + t.assert.strictEqual(err.code, 'FST_ERR_REOPENED_CLOSE_SERVER') + done() }) }, 100) }) }) -t.test('Current opened connection should continue to work after closing and return "connection: close" header - return503OnClosing: false', t => { +test('Current opened connection should NOT continue to work after closing and return "connection: close" header - return503OnClosing: false', (t, done) => { + t.plan(4) const fastify = Fastify({ return503OnClosing: false, forceCloseConnections: false @@ -214,33 +226,34 @@ t.test('Current opened connection should continue to work after closing and retu }) fastify.listen({ port: 0 }, err => { - t.error(err) + t.assert.ifError(err) const port = fastify.server.address().port const client = net.createConnection({ port }, () => { - client.write('GET / HTTP/1.1\r\n\r\n') + client.write('GET / HTTP/1.1\r\nHost: fastify.test\r\n\r\n') - client.once('data', data => { - t.match(data.toString(), /Connection:\s*keep-alive/i) - t.match(data.toString(), /200 OK/i) + client.on('error', function () { + // Depending on the Operating System + // the socket could error or not. + // However, it will always be closed. + }) - client.write('GET / HTTP/1.1\r\n\r\n') + client.on('close', function () { + t.assert.ok(true) + done() + }) - client.once('data', data => { - t.match(data.toString(), /Connection:\s*close/i) - t.match(data.toString(), /200 OK/i) + client.once('data', data => { + t.assert.match(data.toString(), /Connection:\s*keep-alive/i) + t.assert.match(data.toString(), /200 OK/i) - // Test that fastify closes the TCP connection - client.once('close', () => { - t.end() - }) - }) + client.write('GET / HTTP/1.1\r\nHost: fastify.test\r\n\r\n') }) }) }) }) -t.test('Current opened connection should not accept new incoming connections', t => { +test('Current opened connection should not accept new incoming connections', (t, done) => { t.plan(3) const fastify = Fastify({ forceCloseConnections: false }) fastify.get('/', (req, reply) => { @@ -250,14 +263,50 @@ t.test('Current opened connection should not accept new incoming connections', t }, 250) }) + fastify.listen({ port: 0 }, async err => { + t.assert.ifError(err) + const instance = new Client('http://localhost:' + fastify.server.address().port) + let response = await instance.request({ path: '/', method: 'GET' }) + t.assert.strictEqual(response.statusCode, 200) + + response = await instance.request({ path: '/', method: 'GET' }) + t.assert.strictEqual(response.statusCode, 503) + + done() + }) +}) + +test('rejected incoming connections should be logged', (t, done) => { + t.plan(2) + const stream = split(JSON.parse) + const fastify = Fastify({ + forceCloseConnections: false, + logger: { + stream, + level: 'info' + } + }) + + const messages = [] + stream.on('data', message => { + messages.push(message) + }) + fastify.get('/', (req, reply) => { + fastify.close() + setTimeout(() => { + reply.send({ hello: 'world' }) + }, 250) + }) + fastify.listen({ port: 0 }, err => { - t.error(err) + t.assert.ifError(err) const instance = new Client('http://localhost:' + fastify.server.address().port) - instance.request({ path: '/', method: 'GET' }).then(data => { - t.equal(data.statusCode, 200) - }) - instance.request({ path: '/', method: 'GET' }).then(data => { - t.equal(data.statusCode, 503) + // initial request to trigger close + instance.request({ path: '/', method: 'GET' }) + // subsequent request should be rejected + instance.request({ path: '/', method: 'GET' }).then(() => { + t.assert.ok(messages.find(message => message.msg.includes('request aborted'))) + done() }) }) }) @@ -272,8 +321,8 @@ test('Cannot be reopened the closed server without listen callback', async t => try { await fastify.listen({ port: 0 }) } catch (err) { - t.ok(err) - t.equal(err.code, 'FST_ERR_REOPENED_CLOSE_SERVER') + t.assert.ok(err) + t.assert.strictEqual(err.code, 'FST_ERR_REOPENED_CLOSE_SERVER') } }) @@ -289,15 +338,15 @@ test('Cannot be reopened the closed server has listen callback', async t => { reject(err) }) }).catch(err => { - t.equal(err.code, 'FST_ERR_REOPENED_CLOSE_SERVER') - t.ok(err) + t.assert.strictEqual(err.code, 'FST_ERR_REOPENED_CLOSE_SERVER') + t.assert.ok(err) }) }) const server = http.createServer() const noSupport = typeof server.closeAllConnections !== 'function' -test('shutsdown while keep-alive connections are active (non-async, native)', { skip: noSupport }, t => { +test('shutsdown while keep-alive connections are active (non-async, native)', { skip: noSupport }, (t, done) => { t.plan(5) const timeoutTime = 2 * 60 * 1000 @@ -311,31 +360,32 @@ test('shutsdown while keep-alive connections are active (non-async, native)', { }) fastify.listen({ port: 0 }, (err, address) => { - t.error(err) + t.assert.ifError(err) const client = new Client( 'http://localhost:' + fastify.server.address().port, { keepAliveTimeout: 1 * 60 * 1000 } ) client.request({ path: '/', method: 'GET' }, (err, response) => { - t.error(err) - t.equal(client.closed, false) + t.assert.ifError(err) + t.assert.strictEqual(client.closed, false) fastify.close((err) => { - t.error(err) + t.assert.ifError(err) // Due to the nature of the way we reap these keep-alive connections, // there hasn't been enough time before the server fully closed in order // for the client to have seen the socket get destroyed. The mere fact // that we have reached this callback is enough indication that the // feature being tested works as designed. - t.equal(client.closed, false) + t.assert.strictEqual(client.closed, false) + done() }) }) }) }) -test('shutsdown while keep-alive connections are active (non-async, idle, native)', { skip: noSupport }, t => { +test('shutsdown while keep-alive connections are active (non-async, idle, native)', { skip: noSupport }, (t, done) => { t.plan(5) const timeoutTime = 2 * 60 * 1000 @@ -349,31 +399,176 @@ test('shutsdown while keep-alive connections are active (non-async, idle, native }) fastify.listen({ port: 0 }, (err, address) => { - t.error(err) + t.assert.ifError(err) const client = new Client( 'http://localhost:' + fastify.server.address().port, { keepAliveTimeout: 1 * 60 * 1000 } ) client.request({ path: '/', method: 'GET' }, (err, response) => { - t.error(err) - t.equal(client.closed, false) + t.assert.ifError(err) + t.assert.strictEqual(client.closed, false) fastify.close((err) => { - t.error(err) + t.assert.ifError(err) // Due to the nature of the way we reap these keep-alive connections, // there hasn't been enough time before the server fully closed in order // for the client to have seen the socket get destroyed. The mere fact // that we have reached this callback is enough indication that the // feature being tested works as designed. - t.equal(client.closed, false) + t.assert.strictEqual(client.closed, false) + + done() + }) + }) + }) +}) + +test('triggers on-close hook in the right order with multiple bindings', async t => { + const expectedOrder = [1, 2, 3] + const order = [] + const fastify = Fastify() + + t.plan(1) + + // Follows LIFO + fastify.addHook('onClose', () => { + order.push(2) + }) + + fastify.addHook('onClose', () => { + order.push(1) + }) + + await fastify.listen({ port: 0 }) + + await new Promise((resolve, reject) => { + setTimeout(() => { + fastify.close(err => { + order.push(3) + t.assert.deepEqual(order, expectedOrder) + + if (err) t.assert.ifError(err) + else resolve() + }) + }, 2000) + }) +}) + +test('triggers on-close hook in the right order with multiple bindings (forceCloseConnections - idle)', { skip: noSupport }, async t => { + const expectedPayload = { hello: 'world' } + const timeoutTime = 2 * 60 * 1000 + const expectedOrder = [1, 2] + const order = [] + const fastify = Fastify({ forceCloseConnections: 'idle' }) + + fastify.server.setTimeout(timeoutTime) + fastify.server.keepAliveTimeout = timeoutTime + + fastify.get('/', async (req, reply) => { + await new Promise((resolve) => { + setTimeout(resolve, 1000) + }) + + return expectedPayload + }) + + fastify.addHook('onClose', () => { + order.push(1) + }) + + await fastify.listen({ port: 0 }) + const addresses = fastify.addresses() + const testPlan = (addresses.length * 2) + 1 + + t.plan(testPlan) + + for (const addr of addresses) { + const { family, address, port } = addr + const host = family === 'IPv6' ? `[${address}]` : address + const client = new Client(`http://${host}:${port}`, { + keepAliveTimeout: 1 * 60 * 1000 + }) + + client.request({ path: '/', method: 'GET' }) + .then((res) => res.body.json(), err => t.assert.ifError(err)) + .then(json => { + t.assert.deepEqual(json, expectedPayload, 'should payload match') + t.assert.ok(!client.closed, 'should client not be closed') + }, err => t.assert.ifError(err)) + } + + await new Promise((resolve, reject) => { + setTimeout(() => { + fastify.close(err => { + order.push(2) + t.assert.deepEqual(order, expectedOrder) + + if (err) t.assert.ifError(err) + else resolve() }) + }, 2000) + }) +}) + +test('triggers on-close hook in the right order with multiple bindings (forceCloseConnections - true)', { skip: noSupport }, async t => { + const expectedPayload = { hello: 'world' } + const timeoutTime = 2 * 60 * 1000 + const expectedOrder = [1, 2] + const order = [] + const fastify = Fastify({ forceCloseConnections: true }) + + fastify.server.setTimeout(timeoutTime) + fastify.server.keepAliveTimeout = timeoutTime + + fastify.get('/', async (req, reply) => { + await new Promise((resolve) => { + setTimeout(resolve, 1000) + }) + + return expectedPayload + }) + + fastify.addHook('onClose', () => { + order.push(1) + }) + + await fastify.listen({ port: 0 }) + const addresses = fastify.addresses() + const testPlan = (addresses.length * 2) + 1 + + t.plan(testPlan) + + for (const addr of addresses) { + const { family, address, port } = addr + const host = family === 'IPv6' ? `[${address}]` : address + const client = new Client(`http://${host}:${port}`, { + keepAliveTimeout: 1 * 60 * 1000 }) + + client.request({ path: '/', method: 'GET' }) + .then((res) => res.body.json(), err => t.assert.ifError(err)) + .then(json => { + t.assert.deepEqual(json, expectedPayload, 'should payload match') + t.assert.ok(!client.closed, 'should client not be closed') + }, err => t.assert.ifError(err)) + } + + await new Promise((resolve, reject) => { + setTimeout(() => { + fastify.close(err => { + order.push(2) + t.assert.deepEqual(order, expectedOrder) + + if (err) t.assert.ifError(err) + else resolve() + }) + }, 2000) }) }) -test('shutsdown while keep-alive connections are active (non-async, custom)', t => { +test('shutsdown while keep-alive connections are active (non-async, custom)', (t, done) => { t.plan(5) const timeoutTime = 2 * 60 * 1000 @@ -396,26 +591,116 @@ test('shutsdown while keep-alive connections are active (non-async, custom)', t }) fastify.listen({ port: 0 }, (err, address) => { - t.error(err) + t.assert.ifError(err) const client = new Client( 'http://localhost:' + fastify.server.address().port, { keepAliveTimeout: 1 * 60 * 1000 } ) client.request({ path: '/', method: 'GET' }, (err, response) => { - t.error(err) - t.equal(client.closed, false) + t.assert.ifError(err) + t.assert.strictEqual(client.closed, false) fastify.close((err) => { - t.error(err) + t.assert.ifError(err) // Due to the nature of the way we reap these keep-alive connections, // there hasn't been enough time before the server fully closed in order // for the client to have seen the socket get destroyed. The mere fact // that we have reached this callback is enough indication that the // feature being tested works as designed. - t.equal(client.closed, false) + t.assert.strictEqual(client.closed, false) + + done() }) }) }) }) + +test('preClose callback', (t, done) => { + t.plan(5) + const fastify = Fastify() + fastify.addHook('onClose', onClose) + let preCloseCalled = false + function onClose (instance, done) { + t.assert.strictEqual(preCloseCalled, true) + done() + } + fastify.addHook('preClose', preClose) + + function preClose (done) { + t.assert.ok(typeof this === typeof fastify) + preCloseCalled = true + done() + } + + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + + fastify.close((err) => { + t.assert.ifError(err) + t.assert.ok('close callback') + done() + }) + }) +}) + +test('preClose async', async t => { + t.plan(2) + const fastify = Fastify() + fastify.addHook('onClose', onClose) + let preCloseCalled = false + async function onClose () { + t.assert.strictEqual(preCloseCalled, true) + } + fastify.addHook('preClose', preClose) + + async function preClose () { + preCloseCalled = true + t.assert.ok(typeof this === typeof fastify) + } + + await fastify.listen({ port: 0 }) + + await fastify.close() +}) + +test('preClose execution order', (t, done) => { + t.plan(4) + const fastify = Fastify() + const order = [] + fastify.addHook('onClose', onClose) + function onClose (instance, done) { + t.assert.deepStrictEqual(order, [1, 2, 3]) + done() + } + + fastify.addHook('preClose', (done) => { + setTimeout(function () { + order.push(1) + done() + }, 200) + }) + + fastify.addHook('preClose', async () => { + await sleep(100) + order.push(2) + }) + + fastify.addHook('preClose', (done) => { + setTimeout(function () { + order.push(3) + done() + }, 100) + }) + + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + + fastify.close((err) => { + t.assert.ifError(err) + t.assert.ok('close callback') + done() + }) + }) +}) diff --git a/test/conditional-pino.test.js b/test/conditional-pino.test.js new file mode 100644 index 00000000000..4bce8eb9c83 --- /dev/null +++ b/test/conditional-pino.test.js @@ -0,0 +1,47 @@ +'use strict' + +const { test } = require('node:test') + +test("pino is not require'd if logger is not passed", t => { + t.plan(1) + + const fastify = require('..') + + fastify() + + t.assert.strictEqual(require.cache[require.resolve('pino')], undefined) +}) + +test("pino is require'd if logger is passed", t => { + t.plan(1) + + const fastify = require('..') + + fastify({ + logger: true + }) + + t.assert.notStrictEqual(require.cache[require.resolve('pino')], undefined) +}) + +test("pino is require'd if loggerInstance is passed", t => { + t.plan(1) + + const fastify = require('..') + + const loggerInstance = { + fatal: (msg) => { }, + error: (msg) => { }, + warn: (msg) => { }, + info: (msg) => { }, + debug: (msg) => { }, + trace: (msg) => { }, + child: () => loggerInstance + } + + fastify({ + loggerInstance + }) + + t.assert.notStrictEqual(require.cache[require.resolve('pino')], undefined) +}) diff --git a/test/connectionTimeout.test.js b/test/connection-timeout.test.js similarity index 60% rename from test/connectionTimeout.test.js rename to test/connection-timeout.test.js index ad1ea44a410..fc30e7dd3aa 100644 --- a/test/connectionTimeout.test.js +++ b/test/connection-timeout.test.js @@ -1,35 +1,34 @@ 'use strict' const Fastify = require('..') -const http = require('http') -const t = require('tap') -const test = t.test +const http = require('node:http') +const { test } = require('node:test') -test('connectionTimeout', t => { +test('connectionTimeout', async t => { t.plan(6) try { Fastify({ connectionTimeout: 1.3 }) - t.fail('option must be an integer') + t.assert.fail('option must be an integer') } catch (err) { - t.ok(err) + t.assert.ok(err) } try { Fastify({ connectionTimeout: [] }) - t.fail('option must be an integer') + t.assert.fail('option must be an integer') } catch (err) { - t.ok(err) + t.assert.ok(err) } const httpServer = Fastify({ connectionTimeout: 1 }).server - t.equal(httpServer.timeout, 1) + t.assert.strictEqual(httpServer.timeout, 1) const httpsServer = Fastify({ connectionTimeout: 2, https: {} }).server - t.equal(httpsServer.timeout, 2) + t.assert.strictEqual(httpsServer.timeout, 2) const http2Server = Fastify({ connectionTimeout: 3, http2: true }).server - t.equal(http2Server.timeout, 3) + t.assert.strictEqual(http2Server.timeout, 3) const serverFactory = (handler, _) => { const server = http.createServer((req, res) => { @@ -39,5 +38,5 @@ test('connectionTimeout', t => { return server } const customServer = Fastify({ connectionTimeout: 4, serverFactory }).server - t.equal(customServer.timeout, 5) + t.assert.strictEqual(customServer.timeout, 5) }) diff --git a/test/constrained-routes.test.js b/test/constrained-routes.test.js index 11fd919a111..c025cfd506b 100644 --- a/test/constrained-routes.test.js +++ b/test/constrained-routes.test.js @@ -1,114 +1,115 @@ 'use strict' -const t = require('tap') -const test = t.test +const { test } = require('node:test') const Fastify = require('../fastify') -test('Should register a host constrained route', t => { - t.plan(7) +test('Should register a host constrained route', async t => { + t.plan(4) const fastify = Fastify() fastify.route({ method: 'GET', url: '/', - constraints: { host: 'fastify.io' }, + constraints: { host: 'fastify.dev' }, handler: (req, reply) => { reply.send({ hello: 'world' }) } }) - fastify.inject({ - method: 'GET', - url: '/', - headers: { - host: 'fastify.io' - } - }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) - t.equal(res.statusCode, 200) - }) + { + const res = await fastify.inject({ + method: 'GET', + url: '/', + headers: { + host: 'fastify.dev' + } + }) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + t.assert.strictEqual(res.statusCode, 200) + } - fastify.inject({ - method: 'GET', - url: '/', - headers: { - host: 'example.com' - } - }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 404) - }) + { + const res = await fastify.inject({ + method: 'GET', + url: '/', + headers: { + host: 'fastify.test' + } + }) - fastify.inject({ - method: 'GET', - url: '/' - }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 404) - }) + t.assert.strictEqual(res.statusCode, 404) + } + + { + const res = await fastify.inject({ + method: 'GET', + url: '/' + }) + t.assert.strictEqual(res.statusCode, 404) + } }) -test('Should register the same route with host constraints', t => { - t.plan(8) +test('Should register the same route with host constraints', async t => { + t.plan(5) const fastify = Fastify() fastify.route({ method: 'GET', url: '/', - constraints: { host: 'fastify.io' }, + constraints: { host: 'fastify.dev' }, handler: (req, reply) => { - reply.send('fastify.io') + reply.send('fastify.dev') } }) fastify.route({ method: 'GET', url: '/', - constraints: { host: 'example.com' }, + constraints: { host: 'fastify.test' }, handler: (req, reply) => { - reply.send('example.com') + reply.send('fastify.test') } }) - fastify.inject({ - method: 'GET', - url: '/', - headers: { - host: 'fastify.io' - } - }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.equal(res.payload, 'fastify.io') - }) + { + const res = await fastify.inject({ + method: 'GET', + url: '/', + headers: { + host: 'fastify.dev' + } + }) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload, 'fastify.dev') + } - fastify.inject({ - method: 'GET', - url: '/', - headers: { - host: 'example.com' - } - }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.equal(res.payload, 'example.com') - }) + { + const res = await fastify.inject({ + method: 'GET', + url: '/', + headers: { + host: 'fastify.test' + } + }) - fastify.inject({ - method: 'GET', - url: '/', - headers: { - host: 'fancy.ca' - } - }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 404) - }) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload, 'fastify.test') + } + + { + const res = await fastify.inject({ + method: 'GET', + url: '/', + headers: { + host: 'fancy.ca' + } + }) + t.assert.strictEqual(res.statusCode, 404) + } }) -test('Should allow registering custom constrained routes', t => { - t.plan(8) +test('Should allow registering custom constrained routes', async t => { + t.plan(5) const constraint = { name: 'secret', @@ -145,44 +146,44 @@ test('Should allow registering custom constrained routes', t => { } }) - fastify.inject({ - method: 'GET', - url: '/', - headers: { - 'X-Secret': 'alpha' - } - }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'from alpha' }) - t.equal(res.statusCode, 200) - }) + { + const res = await fastify.inject({ + method: 'GET', + url: '/', + headers: { + 'X-Secret': 'alpha' + } + }) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'from alpha' }) + t.assert.strictEqual(res.statusCode, 200) + } - fastify.inject({ - method: 'GET', - url: '/', - headers: { - 'X-Secret': 'beta' - } - }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'from beta' }) - t.equal(res.statusCode, 200) - }) + { + const res = await fastify.inject({ + method: 'GET', + url: '/', + headers: { + 'X-Secret': 'beta' + } + }) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'from beta' }) + t.assert.strictEqual(res.statusCode, 200) + } - fastify.inject({ - method: 'GET', - url: '/', - headers: { - 'X-Secret': 'gamma' - } - }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 404) - }) + { + const res = await fastify.inject({ + method: 'GET', + url: '/', + headers: { + 'X-Secret': 'gamma' + } + }) + t.assert.strictEqual(res.statusCode, 404) + } }) -test('Should allow registering custom constrained routes outside constructor', t => { - t.plan(8) +test('Should allow registering custom constrained routes outside constructor', async t => { + t.plan(5) const constraint = { name: 'secret', @@ -220,43 +221,131 @@ test('Should allow registering custom constrained routes outside constructor', t } }) - fastify.inject({ + { + const res = await fastify.inject({ + method: 'GET', + url: '/', + headers: { + 'X-Secret': 'alpha' + } + }) + + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'from alpha' }) + t.assert.strictEqual(res.statusCode, 200) + } + + { + const res = await fastify.inject({ + method: 'GET', + url: '/', + headers: { + 'X-Secret': 'beta' + } + }) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'from beta' }) + t.assert.strictEqual(res.statusCode, 200) + } + + { + const res = await fastify.inject({ + method: 'GET', + url: '/', + headers: { + 'X-Secret': 'gamma' + } + }) + t.assert.strictEqual(res.statusCode, 404) + } +}) + +test('Custom constrained routes registered also for HEAD method generated by fastify', (t, done) => { + t.plan(3) + + const constraint = { + name: 'secret', + storage: function () { + const secrets = {} + return { + get: (secret) => { return secrets[secret] || null }, + set: (secret, store) => { secrets[secret] = store } + } + }, + deriveConstraint: (req, ctx) => { + return req.headers['x-secret'] + }, + validate () { return true } + } + + const fastify = Fastify({ constraints: { secret: constraint } }) + + fastify.route({ method: 'GET', url: '/', - headers: { - 'X-Secret': 'alpha' + constraints: { secret: 'mySecret' }, + handler: (req, reply) => { + reply.send('from mySecret - my length is 31') } - }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'from alpha' }) - t.equal(res.statusCode, 200) }) fastify.inject({ - method: 'GET', + method: 'HEAD', url: '/', headers: { - 'X-Secret': 'beta' + 'X-Secret': 'mySecret' } }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'from beta' }) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.deepStrictEqual(res.headers['content-length'], '31') + t.assert.strictEqual(res.statusCode, 200) + done() }) +}) - fastify.inject({ +test('Custom constrained routes registered with addConstraintStrategy apply also for HEAD method generated by fastify', (t, done) => { + t.plan(3) + + const constraint = { + name: 'secret', + storage: function () { + const secrets = {} + return { + get: (secret) => { return secrets[secret] || null }, + set: (secret, store) => { secrets[secret] = store } + } + }, + deriveConstraint: (req, ctx) => { + return req.headers['x-secret'] + }, + validate () { return true } + } + + const fastify = Fastify() + fastify.addConstraintStrategy(constraint) + + fastify.route({ method: 'GET', url: '/', + constraints: { secret: 'mySecret' }, + handler: (req, reply) => { + reply.send('from mySecret - my length is 31') + } + }) + + fastify.inject({ + method: 'HEAD', + url: '/', headers: { - 'X-Secret': 'gamma' + 'X-Secret': 'mySecret' } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 404) + t.assert.ifError(err) + t.assert.deepStrictEqual(res.headers['content-length'], '31') + t.assert.strictEqual(res.statusCode, 200) + done() }) }) -test('Add a constraint strategy after fastify instance was started', t => { +test('Add a constraint strategy after fastify instance was started', (t, done) => { t.plan(4) const constraint = { @@ -286,14 +375,14 @@ test('Add a constraint strategy after fastify instance was started', t => { method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.same(res.payload, 'ok') - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.deepStrictEqual(res.payload, 'ok') + t.assert.strictEqual(res.statusCode, 200) - t.throws( - () => fastify.addConstraintStrategy(constraint), - 'Cannot add constraint strategy when fastify instance is already started!' + t.assert.throws( + () => fastify.addConstraintStrategy(constraint) ) + done() }) }) @@ -318,9 +407,9 @@ test('Add a constraint strategy should throw an error if there already exist cus const fastify = Fastify() fastify.addConstraintStrategy(constraint) - t.throws( + t.assert.throws( () => fastify.addConstraintStrategy(constraint), - 'There already exists a custom constraint with the name secret.' + /^Error: There already exists a custom constraint with the name secret.$/ ) }) @@ -345,7 +434,7 @@ test('Add a constraint strategy shouldn\'t throw an error if default constraint const fastify = Fastify() fastify.addConstraintStrategy(constraint) - t.pass() + t.assert.ok(true) }) test('Add a constraint strategy should throw an error if default constraint with the same name is used', t => { @@ -377,9 +466,9 @@ test('Add a constraint strategy should throw an error if default constraint with } }) - t.throws( + t.assert.throws( () => fastify.addConstraintStrategy(constraint), - 'There already exists a route with version constraint.' + /^Error: There already exists a route with version constraint.$/ ) }) @@ -388,20 +477,20 @@ test('The hasConstraintStrategy should return false for default constraints unti const fastify = Fastify() - t.equal(fastify.hasConstraintStrategy('version'), false) - t.equal(fastify.hasConstraintStrategy('host'), false) + t.assert.strictEqual(fastify.hasConstraintStrategy('version'), false) + t.assert.strictEqual(fastify.hasConstraintStrategy('host'), false) fastify.route({ method: 'GET', url: '/', - constraints: { host: 'fastify.io' }, + constraints: { host: 'fastify.dev' }, handler: (req, reply) => { reply.send({ hello: 'from any other domain' }) } }) - t.equal(fastify.hasConstraintStrategy('version'), false) - t.equal(fastify.hasConstraintStrategy('host'), true) + t.assert.strictEqual(fastify.hasConstraintStrategy('version'), false) + t.assert.strictEqual(fastify.hasConstraintStrategy('host'), true) fastify.route({ method: 'GET', @@ -412,8 +501,8 @@ test('The hasConstraintStrategy should return false for default constraints unti } }) - t.equal(fastify.hasConstraintStrategy('version'), true) - t.equal(fastify.hasConstraintStrategy('host'), true) + t.assert.strictEqual(fastify.hasConstraintStrategy('version'), true) + t.assert.strictEqual(fastify.hasConstraintStrategy('host'), true) }) test('The hasConstraintStrategy should return true if there already exist a custom constraint with the same name', t => { @@ -436,21 +525,21 @@ test('The hasConstraintStrategy should return true if there already exist a cust const fastify = Fastify() - t.equal(fastify.hasConstraintStrategy('secret'), false) + t.assert.strictEqual(fastify.hasConstraintStrategy('secret'), false) fastify.addConstraintStrategy(constraint) - t.equal(fastify.hasConstraintStrategy('secret'), true) + t.assert.strictEqual(fastify.hasConstraintStrategy('secret'), true) }) -test('Should allow registering an unconstrained route after a constrained route', t => { - t.plan(6) +test('Should allow registering an unconstrained route after a constrained route', async t => { + t.plan(4) const fastify = Fastify() fastify.route({ method: 'GET', url: '/', - constraints: { host: 'fastify.io' }, + constraints: { host: 'fastify.dev' }, handler: (req, reply) => { - reply.send({ hello: 'from fastify.io' }) + reply.send({ hello: 'from fastify.dev' }) } }) @@ -462,32 +551,32 @@ test('Should allow registering an unconstrained route after a constrained route' } }) - fastify.inject({ - method: 'GET', - url: '/', - headers: { - host: 'fastify.io' - } - }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'from fastify.io' }) - t.equal(res.statusCode, 200) - }) + { + const res = await fastify.inject({ + method: 'GET', + url: '/', + headers: { + host: 'fastify.dev' + } + }) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'from fastify.dev' }) + t.assert.strictEqual(res.statusCode, 200) + } - fastify.inject({ - method: 'GET', - url: '/', - headers: { - host: 'example.com' - } - }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'from any other domain' }) - t.equal(res.statusCode, 200) - }) + { + const res = await fastify.inject({ + method: 'GET', + url: '/', + headers: { + host: 'fastify.test' + } + }) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'from any other domain' }) + t.assert.strictEqual(res.statusCode, 200) + } }) -test('Should allow registering constrained routes in a prefixed plugin', t => { +test('Should allow registering constrained routes in a prefixed plugin', (t, done) => { t.plan(3) const fastify = Fastify() @@ -495,7 +584,7 @@ test('Should allow registering constrained routes in a prefixed plugin', t => { fastify.register(async (scope, opts) => { scope.route({ method: 'GET', - constraints: { host: 'fastify.io' }, + constraints: { host: 'fastify.dev' }, path: '/route', handler: (req, reply) => { reply.send({ ok: true }) @@ -507,23 +596,24 @@ test('Should allow registering constrained routes in a prefixed plugin', t => { method: 'GET', url: '/prefix/route', headers: { - host: 'fastify.io' + host: 'fastify.dev' } }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { ok: true }) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { ok: true }) + t.assert.strictEqual(res.statusCode, 200) + done() }) }) -test('Should allow registering a constrained GET route after a constrained HEAD route', t => { +test('Should allow registering a constrained GET route after a constrained HEAD route', (t, done) => { t.plan(3) const fastify = Fastify() fastify.route({ method: 'HEAD', url: '/', - constraints: { host: 'fastify.io' }, + constraints: { host: 'fastify.dev' }, handler: (req, reply) => { reply.header('content-type', 'text/plain') reply.send('custom HEAD response') @@ -533,7 +623,7 @@ test('Should allow registering a constrained GET route after a constrained HEAD fastify.route({ method: 'GET', url: '/', - constraints: { host: 'fastify.io' }, + constraints: { host: 'fastify.dev' }, handler: (req, reply) => { reply.send({ hello: 'from any other domain' }) } @@ -543,16 +633,17 @@ test('Should allow registering a constrained GET route after a constrained HEAD method: 'HEAD', url: '/', headers: { - host: 'fastify.io' + host: 'fastify.dev' } }, (err, res) => { - t.error(err) - t.same(res.payload, 'custom HEAD response') - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.deepStrictEqual(res.payload, 'custom HEAD response') + t.assert.strictEqual(res.statusCode, 200) + done() }) }) -test('Should allow registering a constrained GET route after an unconstrained HEAD route', t => { +test('Should allow registering a constrained GET route after an unconstrained HEAD route', (t, done) => { t.plan(3) const fastify = Fastify() @@ -561,16 +652,17 @@ test('Should allow registering a constrained GET route after an unconstrained HE url: '/', handler: (req, reply) => { reply.header('content-type', 'text/plain') - reply.send('custom HEAD response') + reply.send('HEAD response: length is about 33') } }) fastify.route({ method: 'GET', url: '/', - constraints: { host: 'fastify.io' }, + constraints: { host: 'fastify.dev' }, handler: (req, reply) => { - reply.send({ hello: 'from any other domain' }) + reply.header('content-type', 'text/plain') + reply.send('Hello from constrains: length is about 41') } }) @@ -578,12 +670,13 @@ test('Should allow registering a constrained GET route after an unconstrained HE method: 'HEAD', url: '/', headers: { - host: 'fastify.io' + host: 'fastify.dev' } }, (err, res) => { - t.error(err) - t.same(res.payload, 'custom HEAD response') - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.deepStrictEqual(res.headers['content-length'], '41') + t.assert.strictEqual(res.statusCode, 200) + done() }) }) @@ -596,7 +689,7 @@ test('Will not try to re-createprefixed HEAD route if it already exists and expo scope.route({ method: 'HEAD', path: '/route', - constraints: { host: 'fastify.io' }, + constraints: { host: 'fastify.dev' }, handler: (req, reply) => { reply.header('content-type', 'text/plain') reply.send('custom HEAD response') @@ -605,7 +698,7 @@ test('Will not try to re-createprefixed HEAD route if it already exists and expo scope.route({ method: 'GET', path: '/route', - constraints: { host: 'fastify.io' }, + constraints: { host: 'fastify.dev' }, handler: (req, reply) => { reply.send({ ok: true }) } @@ -616,7 +709,7 @@ test('Will not try to re-createprefixed HEAD route if it already exists and expo await fastify.ready() - t.ok(true) + t.assert.ok(true) }) test('allows separate constrained and unconstrained HEAD routes', async (t) => { @@ -637,7 +730,7 @@ test('allows separate constrained and unconstrained HEAD routes', async (t) => { scope.route({ method: 'HEAD', path: '/route', - constraints: { host: 'fastify.io' }, + constraints: { host: 'fastify.dev' }, handler: (req, reply) => { reply.header('content-type', 'text/plain') reply.send('constrained HEAD response') @@ -647,7 +740,7 @@ test('allows separate constrained and unconstrained HEAD routes', async (t) => { scope.route({ method: 'GET', path: '/route', - constraints: { host: 'fastify.io' }, + constraints: { host: 'fastify.dev' }, handler: (req, reply) => { reply.send({ ok: true }) } @@ -658,5 +751,388 @@ test('allows separate constrained and unconstrained HEAD routes', async (t) => { await fastify.ready() - t.ok(true) + t.assert.ok(true) +}) + +test('allow async constraints', async (t) => { + t.plan(5) + + const constraint = { + name: 'secret', + storage: function () { + const secrets = {} + return { + get: (secret) => { return secrets[secret] || null }, + set: (secret, store) => { secrets[secret] = store } + } + }, + deriveConstraint: (req, ctx, done) => { + done(null, req.headers['x-secret']) + }, + validate () { return true } + } + + const fastify = Fastify({ constraints: { secret: constraint } }) + + fastify.route({ + method: 'GET', + url: '/', + constraints: { secret: 'alpha' }, + handler: (req, reply) => { + reply.send({ hello: 'from alpha' }) + } + }) + + fastify.route({ + method: 'GET', + url: '/', + constraints: { secret: 'beta' }, + handler: (req, reply) => { + reply.send({ hello: 'from beta' }) + } + }) + + { + const { statusCode, payload } = await fastify.inject({ method: 'GET', path: '/', headers: { 'X-Secret': 'alpha' } }) + t.assert.deepStrictEqual(JSON.parse(payload), { hello: 'from alpha' }) + t.assert.strictEqual(statusCode, 200) + } + { + const { statusCode, payload } = await fastify.inject({ method: 'GET', path: '/', headers: { 'X-Secret': 'beta' } }) + t.assert.deepStrictEqual(JSON.parse(payload), { hello: 'from beta' }) + t.assert.strictEqual(statusCode, 200) + } + { + const { statusCode } = await fastify.inject({ method: 'GET', path: '/', headers: { 'X-Secret': 'gamma' } }) + t.assert.strictEqual(statusCode, 404) + } +}) + +test('error in async constraints', async (t) => { + t.plan(8) + + const constraint = { + name: 'secret', + storage: function () { + const secrets = {} + return { + get: (secret) => { return secrets[secret] || null }, + set: (secret, store) => { secrets[secret] = store } + } + }, + deriveConstraint: (req, ctx, done) => { + done(Error('kaboom')) + }, + validate () { return true } + } + + const fastify = Fastify({ constraints: { secret: constraint } }) + + fastify.route({ + method: 'GET', + url: '/', + constraints: { secret: 'alpha' }, + handler: (req, reply) => { + reply.send({ hello: 'from alpha' }) + } + }) + + fastify.route({ + method: 'GET', + url: '/', + constraints: { secret: 'beta' }, + handler: (req, reply) => { + reply.send({ hello: 'from beta' }) + } + }) + + { + const { statusCode, payload } = await fastify.inject({ method: 'GET', path: '/', headers: { 'X-Secret': 'alpha' } }) + t.assert.deepStrictEqual(JSON.parse(payload), { error: 'Internal Server Error', message: 'Unexpected error from async constraint', statusCode: 500 }) + t.assert.strictEqual(statusCode, 500) + } + { + const { statusCode, payload } = await fastify.inject({ method: 'GET', path: '/', headers: { 'X-Secret': 'beta' } }) + t.assert.deepStrictEqual(JSON.parse(payload), { error: 'Internal Server Error', message: 'Unexpected error from async constraint', statusCode: 500 }) + t.assert.strictEqual(statusCode, 500) + } + { + const { statusCode, payload } = await fastify.inject({ method: 'GET', path: '/', headers: { 'X-Secret': 'gamma' } }) + t.assert.deepStrictEqual(JSON.parse(payload), { error: 'Internal Server Error', message: 'Unexpected error from async constraint', statusCode: 500 }) + t.assert.strictEqual(statusCode, 500) + } + { + const { statusCode, payload } = await fastify.inject({ method: 'GET', path: '/' }) + t.assert.deepStrictEqual(JSON.parse(payload), { error: 'Internal Server Error', message: 'Unexpected error from async constraint', statusCode: 500 }) + t.assert.strictEqual(statusCode, 500) + } +}) + +test('Allow regex constraints in routes', async t => { + t.plan(3) + + const fastify = Fastify() + + fastify.route({ + method: 'GET', + url: '/', + constraints: { host: /.*\.fastify\.dev$/ }, + handler: (req, reply) => { + reply.send({ hello: 'from fastify dev domain' }) + } + }) + + { + const res = await fastify.inject({ + method: 'GET', + url: '/', + headers: { + host: 'dev.fastify.dev' + } + }) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'from fastify dev domain' }) + t.assert.strictEqual(res.statusCode, 200) + } + + { + const res = await fastify.inject({ + method: 'GET', + url: '/', + headers: { + host: 'google.com' + } + }) + t.assert.strictEqual(res.statusCode, 404) + } +}) + +test('Should allow registering custom rotuerOptions constrained routes', async t => { + t.plan(5) + + const constraint = { + name: 'secret', + storage: function () { + const secrets = {} + return { + get: (secret) => { return secrets[secret] || null }, + set: (secret, store) => { secrets[secret] = store } + } + }, + deriveConstraint: (req, ctx) => { + return req.headers['x-secret'] + }, + validate () { return true } + } + + const fastify = Fastify({ routerOptions: { constraints: { secret: constraint } } }) + + fastify.route({ + method: 'GET', + url: '/', + constraints: { secret: 'alpha' }, + handler: (req, reply) => { + reply.send({ hello: 'from alpha' }) + } + }) + + fastify.route({ + method: 'GET', + url: '/', + constraints: { secret: 'beta' }, + handler: (req, reply) => { + reply.send({ hello: 'from beta' }) + } + }) + + { + const res = await fastify.inject({ + method: 'GET', + url: '/', + headers: { + 'X-Secret': 'alpha' + } + }) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'from alpha' }) + t.assert.strictEqual(res.statusCode, 200) + } + + { + const res = await fastify.inject({ + method: 'GET', + url: '/', + headers: { + 'X-Secret': 'beta' + } + }) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'from beta' }) + t.assert.strictEqual(res.statusCode, 200) + } + + { + const res = await fastify.inject({ + method: 'GET', + url: '/', + headers: { + 'X-Secret': 'gamma' + } + }) + t.assert.strictEqual(res.statusCode, 404) + } +}) + +test('Custom rotuerOptions constrained routes registered also for HEAD method generated by fastify', (t, done) => { + t.plan(3) + + const constraint = { + name: 'secret', + storage: function () { + const secrets = {} + return { + get: (secret) => { return secrets[secret] || null }, + set: (secret, store) => { secrets[secret] = store } + } + }, + deriveConstraint: (req, ctx) => { + return req.headers['x-secret'] + }, + validate () { return true } + } + + const fastify = Fastify({ routerOptions: { constraints: { secret: constraint } } }) + + fastify.route({ + method: 'GET', + url: '/', + constraints: { secret: 'mySecret' }, + handler: (req, reply) => { + reply.send('from mySecret - my length is 31') + } + }) + + fastify.inject({ + method: 'HEAD', + url: '/', + headers: { + 'X-Secret': 'mySecret' + } + }, (err, res) => { + t.assert.ifError(err) + t.assert.deepStrictEqual(res.headers['content-length'], '31') + t.assert.strictEqual(res.statusCode, 200) + done() + }) +}) + +test('allow async rotuerOptions constraints', async (t) => { + t.plan(5) + + const constraint = { + name: 'secret', + storage: function () { + const secrets = {} + return { + get: (secret) => { return secrets[secret] || null }, + set: (secret, store) => { secrets[secret] = store } + } + }, + deriveConstraint: (req, ctx, done) => { + done(null, req.headers['x-secret']) + }, + validate () { return true } + } + + const fastify = Fastify({ routerOptions: { constraints: { secret: constraint } } }) + + fastify.route({ + method: 'GET', + url: '/', + constraints: { secret: 'alpha' }, + handler: (req, reply) => { + reply.send({ hello: 'from alpha' }) + } + }) + + fastify.route({ + method: 'GET', + url: '/', + constraints: { secret: 'beta' }, + handler: (req, reply) => { + reply.send({ hello: 'from beta' }) + } + }) + + { + const { statusCode, payload } = await fastify.inject({ method: 'GET', path: '/', headers: { 'X-Secret': 'alpha' } }) + t.assert.deepStrictEqual(JSON.parse(payload), { hello: 'from alpha' }) + t.assert.strictEqual(statusCode, 200) + } + { + const { statusCode, payload } = await fastify.inject({ method: 'GET', path: '/', headers: { 'X-Secret': 'beta' } }) + t.assert.deepStrictEqual(JSON.parse(payload), { hello: 'from beta' }) + t.assert.strictEqual(statusCode, 200) + } + { + const { statusCode } = await fastify.inject({ method: 'GET', path: '/', headers: { 'X-Secret': 'gamma' } }) + t.assert.strictEqual(statusCode, 404) + } +}) + +test('error in async rotuerOptions constraints', async (t) => { + t.plan(8) + + const constraint = { + name: 'secret', + storage: function () { + const secrets = {} + return { + get: (secret) => { return secrets[secret] || null }, + set: (secret, store) => { secrets[secret] = store } + } + }, + deriveConstraint: (req, ctx, done) => { + done(Error('kaboom')) + }, + validate () { return true } + } + + const fastify = Fastify({ routerOptions: { constraints: { secret: constraint } } }) + + fastify.route({ + method: 'GET', + url: '/', + constraints: { secret: 'alpha' }, + handler: (req, reply) => { + reply.send({ hello: 'from alpha' }) + } + }) + + fastify.route({ + method: 'GET', + url: '/', + constraints: { secret: 'beta' }, + handler: (req, reply) => { + reply.send({ hello: 'from beta' }) + } + }) + + { + const { statusCode, payload } = await fastify.inject({ method: 'GET', path: '/', headers: { 'X-Secret': 'alpha' } }) + t.assert.deepStrictEqual(JSON.parse(payload), { error: 'Internal Server Error', message: 'Unexpected error from async constraint', statusCode: 500 }) + t.assert.strictEqual(statusCode, 500) + } + { + const { statusCode, payload } = await fastify.inject({ method: 'GET', path: '/', headers: { 'X-Secret': 'beta' } }) + t.assert.deepStrictEqual(JSON.parse(payload), { error: 'Internal Server Error', message: 'Unexpected error from async constraint', statusCode: 500 }) + t.assert.strictEqual(statusCode, 500) + } + { + const { statusCode, payload } = await fastify.inject({ method: 'GET', path: '/', headers: { 'X-Secret': 'gamma' } }) + t.assert.deepStrictEqual(JSON.parse(payload), { error: 'Internal Server Error', message: 'Unexpected error from async constraint', statusCode: 500 }) + t.assert.strictEqual(statusCode, 500) + } + { + const { statusCode, payload } = await fastify.inject({ method: 'GET', path: '/' }) + t.assert.deepStrictEqual(JSON.parse(payload), { error: 'Internal Server Error', message: 'Unexpected error from async constraint', statusCode: 500 }) + t.assert.strictEqual(statusCode, 500) + } }) diff --git a/test/content-length.test.js b/test/content-length.test.js index c59293495d6..0f62138af89 100644 --- a/test/content-length.test.js +++ b/test/content-length.test.js @@ -1,11 +1,10 @@ 'use strict' -const t = require('tap') -const test = t.test +const { test } = require('node:test') const Fastify = require('..') -test('default 413 with bodyLimit option', t => { - t.plan(4) +test('default 413 with bodyLimit option', async (t) => { + t.plan(3) const fastify = Fastify({ bodyLimit: 10 @@ -15,27 +14,25 @@ test('default 413 with bodyLimit option', t => { reply.send({ hello: 'world' }) }) - fastify.inject({ + const response = await fastify.inject({ method: 'POST', url: '/', body: { text: '12345678901234567890123456789012345678901234567890' } - }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 413) - t.equal(res.headers['content-type'], 'application/json; charset=utf-8') - t.same(JSON.parse(res.payload), { - error: 'Payload Too Large', - code: 'FST_ERR_CTP_BODY_TOO_LARGE', - message: 'Request body is too large', - statusCode: 413 - }) + }) + t.assert.strictEqual(response.statusCode, 413) + t.assert.strictEqual(response.headers['content-type'], 'application/json; charset=utf-8') + t.assert.deepStrictEqual(JSON.parse(response.payload), { + error: 'Payload Too Large', + code: 'FST_ERR_CTP_BODY_TOO_LARGE', + message: 'Request body is too large', + statusCode: 413 }) }) -test('default 400 with wrong content-length', t => { - t.plan(4) +test('default 400 with wrong content-length', async (t) => { + t.plan(3) const fastify = Fastify() @@ -43,7 +40,7 @@ test('default 400 with wrong content-length', t => { reply.send({ hello: 'world' }) }) - fastify.inject({ + const response = await fastify.inject({ method: 'POST', url: '/', headers: { @@ -52,21 +49,19 @@ test('default 400 with wrong content-length', t => { body: { text: '12345678901234567890123456789012345678901234567890' } - }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) - t.equal(res.headers['content-type'], 'application/json; charset=utf-8') - t.same(JSON.parse(res.payload), { - error: 'Bad Request', - code: 'FST_ERR_CTP_INVALID_CONTENT_LENGTH', - message: 'Request body size did not match Content-Length', - statusCode: 400 - }) + }) + t.assert.strictEqual(response.statusCode, 400) + t.assert.strictEqual(response.headers['content-type'], 'application/json; charset=utf-8') + t.assert.deepStrictEqual(JSON.parse(response.payload), { + error: 'Bad Request', + code: 'FST_ERR_CTP_INVALID_CONTENT_LENGTH', + message: 'Request body size did not match Content-Length', + statusCode: 400 }) }) -test('custom 413 with bodyLimit option', t => { - t.plan(4) +test('custom 413 with bodyLimit option', async (t) => { + t.plan(3) const fastify = Fastify({ bodyLimit: 10 @@ -83,27 +78,25 @@ test('custom 413 with bodyLimit option', t => { .send(err) }) - fastify.inject({ + const response = await fastify.inject({ method: 'POST', url: '/', body: { text: '12345678901234567890123456789012345678901234567890' } - }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 413) - t.equal(res.headers['content-type'], 'application/json; charset=utf-8') - t.same(JSON.parse(res.payload), { - error: 'Payload Too Large', - code: 'FST_ERR_CTP_BODY_TOO_LARGE', - message: 'Request body is too large', - statusCode: 413 - }) + }) + t.assert.strictEqual(response.statusCode, 413) + t.assert.strictEqual(response.headers['content-type'], 'application/json; charset=utf-8') + t.assert.deepStrictEqual(JSON.parse(response.payload), { + error: 'Payload Too Large', + code: 'FST_ERR_CTP_BODY_TOO_LARGE', + message: 'Request body is too large', + statusCode: 413 }) }) -test('custom 400 with wrong content-length', t => { - t.plan(4) +test('custom 400 with wrong content-length', async (t) => { + t.plan(3) const fastify = Fastify() @@ -118,7 +111,7 @@ test('custom 400 with wrong content-length', t => { .send(err) }) - fastify.inject({ + const response = await fastify.inject({ method: 'POST', url: '/', headers: { @@ -127,20 +120,18 @@ test('custom 400 with wrong content-length', t => { body: { text: '12345678901234567890123456789012345678901234567890' } - }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) - t.equal(res.headers['content-type'], 'application/json; charset=utf-8') - t.same(JSON.parse(res.payload), { - error: 'Bad Request', - code: 'FST_ERR_CTP_INVALID_CONTENT_LENGTH', - message: 'Request body size did not match Content-Length', - statusCode: 400 - }) + }) + t.assert.strictEqual(response.statusCode, 400) + t.assert.strictEqual(response.headers['content-type'], 'application/json; charset=utf-8') + t.assert.deepStrictEqual(JSON.parse(response.payload), { + error: 'Bad Request', + code: 'FST_ERR_CTP_INVALID_CONTENT_LENGTH', + message: 'Request body size did not match Content-Length', + statusCode: 400 }) }) -test('#2214 - wrong content-length', t => { +test('#2214 - wrong content-length', async (t) => { const fastify = Fastify() fastify.get('/', async () => { @@ -151,17 +142,14 @@ test('#2214 - wrong content-length', t => { throw error }) - fastify.inject({ + const response = await fastify.inject({ method: 'GET', path: '/' }) - .then(response => { - t.equal(response.headers['content-length'], '' + response.rawPayload.length) - t.end() - }) + t.assert.strictEqual(response.headers['content-length'], '' + response.rawPayload.length) }) -test('#2543 - wrong content-length with errorHandler', t => { +test('#2543 - wrong content-length with errorHandler', async (t) => { const fastify = Fastify() fastify.setErrorHandler((_error, _request, reply) => { @@ -176,14 +164,11 @@ test('#2543 - wrong content-length with errorHandler', t => { throw error }) - fastify.inject({ + const response = await fastify.inject({ method: 'GET', path: '/' }) - .then(res => { - t.equal(res.statusCode, 500) - t.equal(res.headers['content-length'], '' + res.rawPayload.length) - t.same(JSON.parse(res.payload), { message: 'longer than 2 bytes' }) - t.end() - }) + t.assert.strictEqual(response.statusCode, 500) + t.assert.strictEqual(response.headers['content-length'], '' + response.rawPayload.length) + t.assert.deepStrictEqual(JSON.parse(response.payload), { message: 'longer than 2 bytes' }) }) diff --git a/test/content-parser.test.js b/test/content-parser.test.js index 32ea5847133..4c361cd2ca3 100644 --- a/test/content-parser.test.js +++ b/test/content-parser.test.js @@ -1,7 +1,6 @@ 'use strict' -const t = require('tap') -const test = t.test +const { test } = require('node:test') const Fastify = require('..') const keys = require('../lib/symbols') const { FST_ERR_CTP_ALREADY_PRESENT, FST_ERR_CTP_INVALID_TYPE, FST_ERR_CTP_INVALID_MEDIA_TYPE } = require('../lib/errors') @@ -10,55 +9,113 @@ const first = function (req, payload, done) {} const second = function (req, payload, done) {} const third = function (req, payload, done) {} -test('hasContentTypeParser', t => { - test('should know about internal parsers', t => { - t.plan(4) +test('hasContentTypeParser', async t => { + await t.test('should know about internal parsers', (t, done) => { + t.plan(5) const fastify = Fastify() fastify.ready(err => { - t.error(err) - t.ok(fastify.hasContentTypeParser('application/json')) - t.ok(fastify.hasContentTypeParser('text/plain')) - t.notOk(fastify.hasContentTypeParser('application/jsoff')) + t.assert.ifError(err) + t.assert.ok(fastify.hasContentTypeParser('application/json')) + t.assert.ok(fastify.hasContentTypeParser('text/plain')) + t.assert.ok(fastify.hasContentTypeParser(' text/plain ')) + t.assert.ok(!fastify.hasContentTypeParser('application/jsoff')) + done() }) }) - test('should work with string and RegExp', t => { - t.plan(7) + await t.test('should only work with string and RegExp', t => { + t.plan(8) const fastify = Fastify() fastify.addContentTypeParser(/^image\/.*/, first) fastify.addContentTypeParser(/^application\/.+\+xml/, first) fastify.addContentTypeParser('image/gif', first) - t.ok(fastify.hasContentTypeParser('application/json')) - t.ok(fastify.hasContentTypeParser(/^image\/.*/)) - t.ok(fastify.hasContentTypeParser(/^application\/.+\+xml/)) - t.ok(fastify.hasContentTypeParser('image/gif')) - t.notOk(fastify.hasContentTypeParser(/^image\/.+\+xml/)) - t.notOk(fastify.hasContentTypeParser('image/png')) - t.notOk(fastify.hasContentTypeParser('*')) + t.assert.ok(fastify.hasContentTypeParser('application/json')) + t.assert.ok(fastify.hasContentTypeParser(/^image\/.*/)) + t.assert.ok(fastify.hasContentTypeParser(/^application\/.+\+xml/)) + t.assert.ok(fastify.hasContentTypeParser('image/gif')) + t.assert.ok(!fastify.hasContentTypeParser(/^image\/.+\+xml/)) + t.assert.ok(!fastify.hasContentTypeParser('image/png')) + t.assert.ok(!fastify.hasContentTypeParser('*')) + t.assert.throws( + () => fastify.hasContentTypeParser(123), + FST_ERR_CTP_INVALID_TYPE + ) }) - - t.end() }) -test('getParser', t => { - test('should return matching parser', t => { - t.plan(3) +test('getParser', async t => { + await t.test('should return matching parser', t => { + t.plan(7) const fastify = Fastify() fastify.addContentTypeParser(/^image\/.*/, first) fastify.addContentTypeParser(/^application\/.+\+xml/, second) fastify.addContentTypeParser('text/html', third) + fastify.addContentTypeParser('text/html; charset=utf-8', third) + + t.assert.strictEqual(fastify[keys.kContentTypeParser].getParser('application/t+xml').fn, second) + t.assert.strictEqual(fastify[keys.kContentTypeParser].getParser('image/png').fn, first) + t.assert.strictEqual(fastify[keys.kContentTypeParser].getParser('text/html').fn, third) + t.assert.strictEqual(fastify[keys.kContentTypeParser].getParser('text/html; charset=utf-8').fn, third) + t.assert.strictEqual(fastify[keys.kContentTypeParser].getParser('text/html ; charset=utf-8').fn, third) + t.assert.strictEqual(fastify[keys.kContentTypeParser].getParser('text/html\t; charset=utf-8').fn, third) + t.assert.strictEqual(fastify[keys.kContentTypeParser].getParser('text/htmlINVALID')?.fn, undefined) + }) + + await t.test('should return matching parser with caching /1', t => { + t.plan(7) + + const fastify = Fastify() + + fastify.addContentTypeParser('text/html', first) + + t.assert.strictEqual(fastify[keys.kContentTypeParser].cache.size, 0) + t.assert.strictEqual(fastify[keys.kContentTypeParser].getParser('text/html').fn, first) + t.assert.strictEqual(fastify[keys.kContentTypeParser].cache.size, 1) + t.assert.strictEqual(fastify[keys.kContentTypeParser].getParser('text/html ').fn, first) + t.assert.strictEqual(fastify[keys.kContentTypeParser].cache.size, 1) + t.assert.strictEqual(fastify[keys.kContentTypeParser].getParser('text/html ').fn, first) + t.assert.strictEqual(fastify[keys.kContentTypeParser].cache.size, 1) + }) + + await t.test('should return matching parser with caching /2', t => { + t.plan(9) + + const fastify = Fastify() + + fastify.addContentTypeParser('text/html', first) + + t.assert.strictEqual(fastify[keys.kContentTypeParser].cache.size, 0) + t.assert.strictEqual(fastify[keys.kContentTypeParser].getParser('text/html').fn, first) + t.assert.strictEqual(fastify[keys.kContentTypeParser].cache.size, 1) + t.assert.strictEqual(fastify[keys.kContentTypeParser].getParser('text/HTML').fn, first) + t.assert.strictEqual(fastify[keys.kContentTypeParser].cache.size, 1) + t.assert.strictEqual(fastify[keys.kContentTypeParser].getParser('TEXT/html').fn, first) + t.assert.strictEqual(fastify[keys.kContentTypeParser].cache.size, 1) + t.assert.strictEqual(fastify[keys.kContentTypeParser].getParser('TEXT/html').fn, first) + t.assert.strictEqual(fastify[keys.kContentTypeParser].cache.size, 1) + }) + + await t.test('should return matching parser with caching /3', t => { + t.plan(6) + + const fastify = Fastify() + + fastify.addContentTypeParser(/^text\/html(;\s*charset=[^;]+)?$/, first) - t.equal(fastify[keys.kContentTypeParser].getParser('application/t+xml').fn, second) - t.equal(fastify[keys.kContentTypeParser].getParser('image/png').fn, first) - t.equal(fastify[keys.kContentTypeParser].getParser('text/html').fn, third) + t.assert.strictEqual(fastify[keys.kContentTypeParser].getParser('text/html').fn, first) + t.assert.strictEqual(fastify[keys.kContentTypeParser].cache.size, 1) + t.assert.strictEqual(fastify[keys.kContentTypeParser].getParser('text/html;charset=utf-8').fn, first) + t.assert.strictEqual(fastify[keys.kContentTypeParser].cache.size, 2) + t.assert.strictEqual(fastify[keys.kContentTypeParser].getParser('text/html;charset=utf-8').fn, first) + t.assert.strictEqual(fastify[keys.kContentTypeParser].cache.size, 2) }) - test('should prefer content type parser with string value', t => { + await t.test('should prefer content type parser with string value', t => { t.plan(2) const fastify = Fastify() @@ -66,24 +123,23 @@ test('getParser', t => { fastify.addContentTypeParser(/^image\/.*/, first) fastify.addContentTypeParser('image/gif', second) - t.equal(fastify[keys.kContentTypeParser].getParser('image/gif').fn, second) - t.equal(fastify[keys.kContentTypeParser].getParser('image/png').fn, first) + t.assert.strictEqual(fastify[keys.kContentTypeParser].getParser('image/gif').fn, second) + t.assert.strictEqual(fastify[keys.kContentTypeParser].getParser('image/png').fn, first) }) - test('should return parser that catches all if no other is set', t => { - t.plan(3) + await t.test('should return parser that catches all if no other is set', t => { + t.plan(2) const fastify = Fastify() fastify.addContentTypeParser('*', first) fastify.addContentTypeParser(/^text\/.*/, second) - t.equal(fastify[keys.kContentTypeParser].getParser('image/gif').fn, first) - t.equal(fastify[keys.kContentTypeParser].getParser('text/html').fn, second) - t.equal(fastify[keys.kContentTypeParser].getParser('text').fn, first) + t.assert.strictEqual(fastify[keys.kContentTypeParser].getParser('image/gif').fn, first) + t.assert.strictEqual(fastify[keys.kContentTypeParser].getParser('text/html').fn, second) }) - test('should return undefined if no matching parser exist', t => { + await t.test('should return undefined if no matching parser exist', t => { t.plan(2) const fastify = Fastify() @@ -91,15 +147,13 @@ test('getParser', t => { fastify.addContentTypeParser(/^weirdType\/.+/, first) fastify.addContentTypeParser('application/javascript', first) - t.notOk(fastify[keys.kContentTypeParser].getParser('application/xml')) - t.notOk(fastify[keys.kContentTypeParser].getParser('weirdType/')) + t.assert.ok(!fastify[keys.kContentTypeParser].getParser('application/xml')) + t.assert.ok(!fastify[keys.kContentTypeParser].getParser('weirdType/')) }) - - t.end() }) -test('existingParser', t => { - test('returns always false for "*"', t => { +test('existingParser', async t => { + await t.test('returns always false for "*"', t => { t.plan(2) const fastify = Fastify() @@ -108,14 +162,14 @@ test('existingParser', t => { fastify.addContentTypeParser(/^application\/.+\+xml/, first) fastify.addContentTypeParser('text/html', first) - t.notOk(fastify[keys.kContentTypeParser].existingParser('*')) + t.assert.ok(!fastify[keys.kContentTypeParser].existingParser('*')) fastify.addContentTypeParser('*', first) - t.notOk(fastify[keys.kContentTypeParser].existingParser('*')) + t.assert.ok(!fastify[keys.kContentTypeParser].existingParser('*')) }) - test('let you override the default parser once', t => { + await t.test('let you override the default parser once', t => { t.plan(2) const fastify = Fastify() @@ -123,15 +177,13 @@ test('existingParser', t => { fastify.addContentTypeParser('application/json', first) fastify.addContentTypeParser('text/plain', first) - t.throws( + t.assert.throws( () => fastify.addContentTypeParser('application/json', first), - FST_ERR_CTP_ALREADY_PRESENT, - "Content type parser 'application/json' already present" + FST_ERR_CTP_ALREADY_PRESENT ) - t.throws( + t.assert.throws( () => fastify.addContentTypeParser('text/plain', first), - FST_ERR_CTP_ALREADY_PRESENT, - "Content type parser 'text/plain' already present" + FST_ERR_CTP_ALREADY_PRESENT ) }) @@ -142,52 +194,78 @@ test('existingParser', t => { fastify.addContentTypeParser(/^application\/.+\+xml/, first) fastify.addContentTypeParser('text/html', first) - t.ok(contentTypeParser.existingParser(/^image\/.*/)) - t.ok(contentTypeParser.existingParser('text/html')) - t.ok(contentTypeParser.existingParser(/^application\/.+\+xml/)) - t.notOk(contentTypeParser.existingParser('application/json')) - t.notOk(contentTypeParser.existingParser('text/plain')) - t.notOk(contentTypeParser.existingParser('image/png')) - t.notOk(contentTypeParser.existingParser(/^application\/.+\+json/)) - - t.end() + t.assert.ok(contentTypeParser.existingParser(/^image\/.*/)) + t.assert.ok(contentTypeParser.existingParser('text/html')) + t.assert.ok(contentTypeParser.existingParser(/^application\/.+\+xml/)) + t.assert.ok(!contentTypeParser.existingParser('application/json')) + t.assert.ok(!contentTypeParser.existingParser('text/plain')) + t.assert.ok(!contentTypeParser.existingParser('image/png')) + t.assert.ok(!contentTypeParser.existingParser(/^application\/.+\+json/)) }) -test('add', t => { - test('should only accept string and RegExp', t => { +test('add', async t => { + await t.test('should only accept string and RegExp', t => { t.plan(4) const fastify = Fastify() const contentTypeParser = fastify[keys.kContentTypeParser] - t.error(contentTypeParser.add('test', {}, first)) - t.error(contentTypeParser.add(/test/, {}, first)) - t.throws( + t.assert.ifError(contentTypeParser.add('test/type', {}, first)) + t.assert.ifError(contentTypeParser.add(/test/, {}, first)) + t.assert.throws( () => contentTypeParser.add({}, {}, first), FST_ERR_CTP_INVALID_TYPE, 'The content type should be a string or a RegExp' ) - t.throws( + t.assert.throws( () => contentTypeParser.add(1, {}, first), FST_ERR_CTP_INVALID_TYPE, 'The content type should be a string or a RegExp' ) }) - test('should set "*" as parser that catches all', t => { + await t.test('should set "*" as parser that catches all', t => { t.plan(1) const fastify = Fastify() const contentTypeParser = fastify[keys.kContentTypeParser] contentTypeParser.add('*', {}, first) - t.equal(contentTypeParser.customParsers[''].fn, first) + t.assert.strictEqual(contentTypeParser.customParsers.get('').fn, first) }) - t.end() + await t.test('should lowercase contentTypeParser name', async t => { + t.plan(1) + const fastify = Fastify() + fastify.addContentTypeParser('text/html', function (req, done) { + done() + }) + try { + fastify.addContentTypeParser('TEXT/html', function (req, done) { + done() + }) + } catch (err) { + t.assert.strictEqual(err.message, FST_ERR_CTP_ALREADY_PRESENT('text/html').message) + } + }) + + await t.test('should trim contentTypeParser name', async t => { + t.plan(1) + const fastify = Fastify() + fastify.addContentTypeParser('text/html', function (req, done) { + done() + }) + try { + fastify.addContentTypeParser(' text/html', function (req, done) { + done() + }) + } catch (err) { + t.assert.strictEqual(err.message, FST_ERR_CTP_ALREADY_PRESENT('text/html').message) + } + }) }) -test('non-Error thrown from content parser is properly handled', t => { +test('non-Error thrown from content parser is properly handled', (t, done) => { t.plan(3) const fastify = Fastify() @@ -203,7 +281,7 @@ test('non-Error thrown from content parser is properly handled', t => { }) fastify.setErrorHandler((err, req, res) => { - t.equal(err, throwable) + t.assert.strictEqual(err, throwable) res.send(payload) }) @@ -214,16 +292,17 @@ test('non-Error thrown from content parser is properly handled', t => { headers: { 'Content-Type': 'text/test' }, body: 'some text' }, (err, res) => { - t.error(err) - t.equal(res.payload, payload) + t.assert.ifError(err) + t.assert.strictEqual(res.payload, payload) + done() }) }) -test('Error thrown 415 from content type is null and make post request to server', t => { +test('Error thrown 415 from content type is null and make post request to server', (t, done) => { t.plan(3) const fastify = Fastify() - const errMsg = new FST_ERR_CTP_INVALID_MEDIA_TYPE(undefined).message + const errMsg = new FST_ERR_CTP_INVALID_MEDIA_TYPE().message fastify.post('/', (req, reply) => { }) @@ -233,68 +312,67 @@ test('Error thrown 415 from content type is null and make post request to server url: '/', body: 'some text' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 415) - t.equal(JSON.parse(res.body).message, errMsg) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 415) + t.assert.strictEqual(JSON.parse(res.body).message, errMsg) + done() }) }) -test('remove', t => { - test('should remove default parser', t => { - t.plan(2) +test('remove', async t => { + await t.test('should remove default parser', t => { + t.plan(6) const fastify = Fastify() const contentTypeParser = fastify[keys.kContentTypeParser] - contentTypeParser.remove('application/json') - - t.notOk(contentTypeParser.customParsers['application/json']) - t.notOk(contentTypeParser.parserList.find(parser => parser === 'application/json')) + t.assert.ok(contentTypeParser.remove('application/json')) + t.assert.ok(!contentTypeParser.customParsers['application/json']) + t.assert.ok(!contentTypeParser.parserList.find(parser => parser === 'application/json')) + t.assert.ok(contentTypeParser.remove(' text/plain ')) + t.assert.ok(!contentTypeParser.customParsers['text/plain']) + t.assert.ok(!contentTypeParser.parserList.find(parser => parser === 'text/plain')) }) - test('should remove RegExp parser', t => { - t.plan(2) + await t.test('should remove RegExp parser', t => { + t.plan(3) const fastify = Fastify() fastify.addContentTypeParser(/^text\/*/, first) const contentTypeParser = fastify[keys.kContentTypeParser] - contentTypeParser.remove(/^text\/*/) - - t.notOk(contentTypeParser.customParsers[/^text\/*/]) - t.notOk(contentTypeParser.parserRegExpList.find(parser => parser.toString() === /^text\/*/.toString())) + t.assert.ok(contentTypeParser.remove(/^text\/*/)) + t.assert.ok(!contentTypeParser.customParsers[/^text\/*/]) + t.assert.ok(!contentTypeParser.parserRegExpList.find(parser => parser.toString() === /^text\/*/.toString())) }) - test('should throw an error if content type is neither string nor RegExp', t => { + await t.test('should throw an error if content type is neither string nor RegExp', t => { t.plan(1) const fastify = Fastify() - t.throws(() => fastify[keys.kContentTypeParser].remove(12), FST_ERR_CTP_INVALID_TYPE) + t.assert.throws(() => fastify[keys.kContentTypeParser].remove(12), FST_ERR_CTP_INVALID_TYPE) }) - test('should not throw error if content type does not exist', t => { + await t.test('should return false if content type does not exist', t => { t.plan(1) const fastify = Fastify() - t.doesNotThrow(() => fastify[keys.kContentTypeParser].remove('image/png')) + t.assert.ok(!fastify[keys.kContentTypeParser].remove('image/png')) }) - test('should not remove any content type parser if content type does not exist', t => { - t.plan(1) + await t.test('should not remove any content type parser if content type does not exist', t => { + t.plan(2) const fastify = Fastify() const contentTypeParser = fastify[keys.kContentTypeParser] - contentTypeParser.remove('image/png') - - t.same(Object.keys(contentTypeParser.customParsers).length, 2) + t.assert.ok(!contentTypeParser.remove('image/png')) + t.assert.strictEqual(contentTypeParser.customParsers.size, 2) }) - - t.end() }) test('remove all should remove all existing parsers and reset cache', t => { @@ -309,8 +387,353 @@ test('remove all should remove all existing parsers and reset cache', t => { contentTypeParser.getParser('application/xml') // fill cache with one entry contentTypeParser.removeAll() - t.same(contentTypeParser.cache.size, 0) - t.same(contentTypeParser.parserList.length, 0) - t.same(contentTypeParser.parserRegExpList.length, 0) - t.same(Object.keys(contentTypeParser.customParsers).length, 0) + t.assert.strictEqual(contentTypeParser.cache.size, 0) + t.assert.strictEqual(contentTypeParser.parserList.length, 0) + t.assert.strictEqual(contentTypeParser.parserRegExpList.length, 0) + t.assert.strictEqual(Object.keys(contentTypeParser.customParsers).length, 0) +}) + +test('Safeguard against malicious content-type / 1', async t => { + const badNames = Object.getOwnPropertyNames({}.__proto__) // eslint-disable-line + t.plan(badNames.length) + + const fastify = Fastify() + + fastify.post('/', async () => { + return 'ok' + }) + + for (const prop of badNames) { + const response = await fastify.inject({ + method: 'POST', + path: '/', + headers: { + 'content-type': prop + }, + body: '' + }) + + t.assert.strictEqual(response.statusCode, 415) + } +}) + +test('Safeguard against malicious content-type / 2', async t => { + t.plan(1) + + const fastify = Fastify() + + fastify.post('/', async () => { + return 'ok' + }) + + const response = await fastify.inject({ + method: 'POST', + path: '/', + headers: { + 'content-type': '\\u0063\\u006fnstructor' + }, + body: '' + }) + + t.assert.strictEqual(response.statusCode, 415) +}) + +test('Safeguard against malicious content-type / 3', async t => { + t.plan(1) + + const fastify = Fastify() + + fastify.post('/', async () => { + return 'ok' + }) + + const response = await fastify.inject({ + method: 'POST', + path: '/', + headers: { + 'content-type': 'constructor; charset=utf-8' + }, + body: '' + }) + + t.assert.strictEqual(response.statusCode, 415) +}) + +test('Safeguard against content-type spoofing - string', async t => { + t.plan(1) + + const fastify = Fastify() + fastify.removeAllContentTypeParsers() + fastify.addContentTypeParser('text/plain', function (request, body, done) { + t.assert.ok('should be called') + done(null, body) + }) + fastify.addContentTypeParser('application/json', function (request, body, done) { + t.assert.fail('shouldn\'t be called') + done(null, body) + }) + + fastify.post('/', async () => { + return 'ok' + }) + + await fastify.inject({ + method: 'POST', + path: '/', + headers: { + 'content-type': 'text/plain; content-type="application/json"' + }, + body: '' + }) +}) + +test('Warning against improper content-type - regexp', async t => { + await t.test('improper regex - text plain', (t, done) => { + t.plan(2) + const fastify = Fastify() + + process.on('warning', onWarning) + function onWarning (warning) { + t.assert.strictEqual(warning.name, 'FastifySecurity') + t.assert.strictEqual(warning.code, 'FSTSEC001') + done() + } + t.after(() => process.removeListener('warning', onWarning)) + + fastify.removeAllContentTypeParsers() + fastify.addContentTypeParser(/text\/plain/, function (request, body, done) { + done(null, body) + }) + }) + + await t.test('improper regex - application json', (t, done) => { + t.plan(2) + const fastify = Fastify() + + process.on('warning', onWarning) + function onWarning (warning) { + t.assert.strictEqual(warning.name, 'FastifySecurity') + t.assert.strictEqual(warning.code, 'FSTSEC001') + done() + } + t.after(() => process.removeListener('warning', onWarning)) + + fastify.removeAllContentTypeParsers() + + fastify.addContentTypeParser(/application\/json/, function (request, body, done) { + done(null, body) + }) + }) +}) + +test('content-type match parameters - string 1', async t => { + t.plan(1) + + const fastify = Fastify() + fastify.removeAllContentTypeParsers() + fastify.addContentTypeParser('text/plain; charset=utf8', function (request, body, done) { + t.assert.fail('shouldn\'t be called') + done(null, body) + }) + fastify.addContentTypeParser('application/json; charset=utf8', function (request, body, done) { + t.assert.ok('should be called') + done(null, body) + }) + + fastify.post('/', async () => { + return 'ok' + }) + + await fastify.inject({ + method: 'POST', + path: '/', + headers: { + 'content-type': 'application/json; charset=utf8' + }, + body: '' + }) +}) + +test('content-type match parameters - regexp', async t => { + t.plan(1) + + const fastify = Fastify() + fastify.removeAllContentTypeParsers() + fastify.addContentTypeParser(/application\/json; charset="utf8"/, function (request, body, done) { + t.assert.ok('should be called') + done(null, body) + }) + + fastify.post('/', async () => { + return 'ok' + }) + + await fastify.inject({ + method: 'POST', + path: '/', + headers: { + 'content-type': 'application/json; charset=utf8' + }, + body: '' + }) +}) + +test('content-type fail when parameters not match - string 1', async t => { + t.plan(1) + + const fastify = Fastify() + fastify.removeAllContentTypeParsers() + fastify.addContentTypeParser('application/json; charset=utf8; foo=bar', function (request, body, done) { + t.assert.fail('shouldn\'t be called') + done(null, body) + }) + + fastify.post('/', async () => { + return 'ok' + }) + + const response = await fastify.inject({ + method: 'POST', + path: '/', + headers: { + 'content-type': 'application/json; charset=utf8' + }, + body: '' + }) + + t.assert.strictEqual(response.statusCode, 415) +}) + +test('content-type fail when parameters not match - string 2', async t => { + t.plan(1) + + const fastify = Fastify() + fastify.removeAllContentTypeParsers() + fastify.addContentTypeParser('application/json; charset=utf8; foo=bar', function (request, body, done) { + t.assert.fail('shouldn\'t be called') + done(null, body) + }) + + fastify.post('/', async () => { + return 'ok' + }) + + const response = await fastify.inject({ + method: 'POST', + path: '/', + headers: { + 'content-type': 'application/json; charset=utf8; foo=baz' + }, + body: '' + }) + + t.assert.strictEqual(response.statusCode, 415) +}) + +test('content-type fail when parameters not match - regexp', async t => { + t.plan(1) + + const fastify = Fastify() + fastify.removeAllContentTypeParsers() + fastify.addContentTypeParser(/application\/json; charset=utf8; foo=bar/, function (request, body, done) { + t.assert.fail('shouldn\'t be called') + done(null, body) + }) + + fastify.post('/', async () => { + return 'ok' + }) + + const response = await fastify.inject({ + method: 'POST', + path: '/', + headers: { + 'content-type': 'application/json; charset=utf8' + }, + body: '' + }) + + t.assert.strictEqual(response.statusCode, 415) +}) + +// Refs: https://github.com/fastify/fastify/issues/4495 +test('content-type regexp list should be cloned when plugin override', async t => { + t.plan(6) + + const fastify = Fastify() + + fastify.addContentTypeParser(/^image\/.*/, { parseAs: 'buffer' }, (req, payload, done) => { + done(null, payload) + }) + + fastify.register(function plugin (fastify, options, done) { + fastify.post('/', function (request, reply) { + reply.type(request.headers['content-type']).send(request.body) + }) + + done() + }) + + { + const { payload, headers, statusCode } = await fastify.inject({ + method: 'POST', + path: '/', + payload: 'jpeg', + headers: { 'content-type': 'image/jpeg' } + }) + t.assert.strictEqual(statusCode, 200) + t.assert.strictEqual(headers['content-type'], 'image/jpeg') + t.assert.strictEqual(payload, 'jpeg') + } + + { + const { payload, headers, statusCode } = await fastify.inject({ + method: 'POST', + path: '/', + payload: 'png', + headers: { 'content-type': 'image/png' } + }) + t.assert.strictEqual(statusCode, 200) + t.assert.strictEqual(headers['content-type'], 'image/png') + t.assert.strictEqual(payload, 'png') + } +}) + +test('invalid content-type error message should not contain format placeholder', (t, done) => { + t.plan(4) + + const fastify = Fastify() + + fastify.post('/', (req, reply) => { + reply.send('ok') + }) + + fastify.inject({ + method: 'POST', + url: '/', + headers: { 'Content-Type': 'invalid-content-type' }, + body: 'test' + }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 415) + const body = JSON.parse(res.body) + t.assert.strictEqual(body.code, 'FST_ERR_CTP_INVALID_MEDIA_TYPE') + t.assert.strictEqual(body.message, 'Unsupported Media Type') + done() + }) +}) + +test('content-type fail when not a valid type', async t => { + t.plan(1) + + const fastify = Fastify() + fastify.removeAllContentTypeParsers() + try { + fastify.addContentTypeParser('type-only', function (request, body, done) { + t.assert.fail('shouldn\'t be called') + done(null, body) + }) + } catch (error) { + t.assert.equal(error.message, 'The content type should be a string or a RegExp') + } }) diff --git a/test/content-type.test.js b/test/content-type.test.js new file mode 100644 index 00000000000..2d037c60e26 --- /dev/null +++ b/test/content-type.test.js @@ -0,0 +1,181 @@ +'use strict' + +const { describe, test } = require('node:test') +const ContentType = require('../lib/content-type') +const Fastify = require('..') + +test('should remove content-type for setErrorHandler', async t => { + t.plan(8) + let count = 0 + + const fastify = Fastify() + fastify.setErrorHandler(function (error, request, reply) { + t.assert.strictEqual(error.message, 'kaboom') + t.assert.strictEqual(reply.hasHeader('content-type'), false) + reply.code(400).send({ foo: 'bar' }) + }) + fastify.addHook('onSend', async function (request, reply, payload) { + count++ + t.assert.strictEqual(typeof payload, 'string') + switch (count) { + case 1: { + // should guess the correct content-type based on payload + t.assert.strictEqual(reply.getHeader('content-type'), 'text/plain; charset=utf-8') + throw Error('kaboom') + } + case 2: { + // should guess the correct content-type based on payload + t.assert.strictEqual(reply.getHeader('content-type'), 'application/json; charset=utf-8') + return payload + } + default: { + t.fail('should not reach') + } + } + }) + fastify.get('/', function (request, reply) { + reply.send('plain-text') + }) + + const { statusCode, body } = await fastify.inject({ method: 'GET', path: '/' }) + t.assert.strictEqual(statusCode, 400) + t.assert.strictEqual(body, JSON.stringify({ foo: 'bar' })) +}) + +describe('ContentType class', () => { + test('returns empty instance for empty value', (t) => { + let found = new ContentType('') + t.assert.equal(found.isEmpty, true) + + found = new ContentType('undefined') + t.assert.equal(found.isEmpty, true) + + found = new ContentType() + t.assert.equal(found.isEmpty, true) + }) + + test('indicates media type is not correct format', (t) => { + let found = new ContentType('foo') + t.assert.equal(found.isEmpty, true) + t.assert.equal(found.isValid, false) + + found = new ContentType('foo /bar') + t.assert.equal(found.isEmpty, true) + t.assert.equal(found.isValid, false) + + found = new ContentType('foo/ bar') + t.assert.equal(found.isEmpty, true) + t.assert.equal(found.isValid, false) + + found = new ContentType('foo; param=1') + t.assert.equal(found.isEmpty, true) + t.assert.equal(found.isValid, false) + + found = new ContentType('foo/π; param=1') + t.assert.equal(found.isEmpty, true) + t.assert.equal(found.isValid, false) + + found = new ContentType('application/json') + t.assert.equal(found.isEmpty, true) + t.assert.equal(found.isValid, false) + + found = new ContentType('application/json/extra/slashes') + t.assert.equal(found.isEmpty, true) + t.assert.equal(found.isValid, false) + + found = new ContentType('application/json(garbage)') + t.assert.equal(found.isEmpty, true) + t.assert.equal(found.isValid, false) + + found = new ContentType('application/json@evil') + t.assert.equal(found.isEmpty, true) + t.assert.equal(found.isValid, false) + + found = new ContentType('application/json\x00garbage') + t.assert.equal(found.isEmpty, true) + t.assert.equal(found.isValid, false) + }) + + test('subtype with multiple fields validates as incorrect', (t) => { + let found = new ContentType('application/json whatever') + t.assert.equal(found.isValid, false) + t.assert.equal(found.isEmpty, true) + + found = new ContentType('application/ json whatever') + t.assert.equal(found.isValid, false) + t.assert.equal(found.isEmpty, true) + + found = new ContentType('application/json whatever; foo=bar') + t.assert.equal(found.isValid, false) + t.assert.equal(found.isEmpty, true) + + found = new ContentType('application/ json whatever; foo=bar') + t.assert.equal(found.isValid, false) + t.assert.equal(found.isEmpty, true) + }) + + test('returns a plain media type instance', (t) => { + const found = new ContentType('Application/JSON') + t.assert.equal(found.mediaType, 'application/json') + t.assert.equal(found.type, 'application') + t.assert.equal(found.subtype, 'json') + t.assert.equal(found.parameters.size, 0) + }) + + test('handles empty parameters list', (t) => { + const found = new ContentType('Application/JSON ;') + t.assert.equal(found.isEmpty, false) + t.assert.equal(found.mediaType, 'application/json') + t.assert.equal(found.type, 'application') + t.assert.equal(found.subtype, 'json') + t.assert.equal(found.parameters.size, 0) + }) + + test('returns a media type instance with parameters', (t) => { + const found = new ContentType('Application/JSON ; charset=utf-8; foo=BaR;baz=" 42"') + t.assert.equal(found.isEmpty, false) + t.assert.equal(found.mediaType, 'application/json') + t.assert.equal(found.type, 'application') + t.assert.equal(found.subtype, 'json') + t.assert.equal(found.parameters.size, 3) + + const expected = [ + ['charset', 'utf-8'], + ['foo', 'BaR'], + ['baz', ' 42'] + ] + t.assert.deepStrictEqual( + Array.from(found.parameters.entries()), + expected + ) + + t.assert.equal( + found.toString(), + 'application/json; charset="utf-8"; foo="BaR"; baz=" 42"' + ) + }) + + test('skips invalid quoted string parameters', (t) => { + const found = new ContentType('Application/JSON ; charset=utf-8; foo=BaR;baz=" 42') + t.assert.equal(found.isEmpty, false) + t.assert.equal(found.mediaType, 'application/json') + t.assert.equal(found.type, 'application') + t.assert.equal(found.subtype, 'json') + t.assert.equal(found.parameters.size, 3) + + const expected = [ + ['charset', 'utf-8'], + ['foo', 'BaR'], + ['baz', 'invalid quoted string'] + ] + t.assert.deepStrictEqual( + Array.from(found.parameters.entries()), + expected + ) + + t.assert.equal( + found.toString(), + 'application/json; charset="utf-8"; foo="BaR"; baz="invalid quoted string"' + ) + }) +}) diff --git a/test/context-config.test.js b/test/context-config.test.js index f50acc2cb77..00f4bc62068 100644 --- a/test/context-config.test.js +++ b/test/context-config.test.js @@ -1,7 +1,8 @@ 'use strict' -const t = require('tap') -const test = t.test +const { test } = require('node:test') + +const { kRouteContext } = require('../lib/symbols') const Fastify = require('..') const schema = { @@ -13,11 +14,11 @@ const schema = { } function handler (req, reply) { - reply.send(reply.context.config) + reply.send(reply[kRouteContext].config) } -test('config', t => { - t.plan(9) +test('config', async t => { + t.plan(6) const fastify = Fastify() fastify.get('/get', { @@ -40,36 +41,33 @@ test('config', t => { handler }) - fastify.inject({ + let response = await fastify.inject({ method: 'GET', - url: '/get' - }, (err, response) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(JSON.parse(response.payload), Object.assign({ url: '/get', method: 'GET' }, schema.config)) + url: '/route' }) - fastify.inject({ + t.assert.strictEqual(response.statusCode, 200) + t.assert.deepStrictEqual(response.json(), Object.assign({ url: '/route', method: 'GET' }, schema.config)) + + response = await fastify.inject({ method: 'GET', url: '/route' - }, (err, response) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(JSON.parse(response.payload), Object.assign({ url: '/route', method: 'GET' }, schema.config)) }) - fastify.inject({ + t.assert.strictEqual(response.statusCode, 200) + t.assert.deepStrictEqual(response.json(), Object.assign({ url: '/route', method: 'GET' }, schema.config)) + + response = await fastify.inject({ method: 'GET', url: '/no-config' - }, (err, response) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(JSON.parse(response.payload), { url: '/no-config', method: 'GET' }) }) + + t.assert.strictEqual(response.statusCode, 200) + t.assert.deepStrictEqual(response.json(), { url: '/no-config', method: 'GET' }) }) -test('config with exposeHeadRoutes', t => { - t.plan(9) +test('config with exposeHeadRoutes', async t => { + t.plan(6) const fastify = Fastify({ exposeHeadRoutes: true }) fastify.get('/get', { @@ -92,36 +90,33 @@ test('config with exposeHeadRoutes', t => { handler }) - fastify.inject({ + let response = await fastify.inject({ method: 'GET', url: '/get' - }, (err, response) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(JSON.parse(response.payload), Object.assign({ url: '/get', method: 'GET' }, schema.config)) }) - fastify.inject({ + t.assert.strictEqual(response.statusCode, 200) + t.assert.deepStrictEqual(response.json(), Object.assign({ url: '/get', method: 'GET' }, schema.config)) + + response = await fastify.inject({ method: 'GET', url: '/route' - }, (err, response) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(JSON.parse(response.payload), Object.assign({ url: '/route', method: 'GET' }, schema.config)) }) - fastify.inject({ + t.assert.strictEqual(response.statusCode, 200) + t.assert.deepStrictEqual(response.json(), Object.assign({ url: '/route', method: 'GET' }, schema.config)) + + response = await fastify.inject({ method: 'GET', url: '/no-config' - }, (err, response) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(JSON.parse(response.payload), { url: '/no-config', method: 'GET' }) }) + + t.assert.strictEqual(response.statusCode, 200) + t.assert.deepStrictEqual(response.json(), { url: '/no-config', method: 'GET' }) }) -test('config without exposeHeadRoutes', t => { - t.plan(9) +test('config without exposeHeadRoutes', async t => { + t.plan(6) const fastify = Fastify({ exposeHeadRoutes: false }) fastify.get('/get', { @@ -144,30 +139,26 @@ test('config without exposeHeadRoutes', t => { handler }) - fastify.inject({ + let response = await fastify.inject({ method: 'GET', url: '/get' - }, (err, response) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(JSON.parse(response.payload), Object.assign({ url: '/get', method: 'GET' }, schema.config)) }) - fastify.inject({ + t.assert.strictEqual(response.statusCode, 200) + t.assert.deepStrictEqual(response.json(), Object.assign({ url: '/get', method: 'GET' }, schema.config)) + + response = await fastify.inject({ method: 'GET', url: '/route' - }, (err, response) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(JSON.parse(response.payload), Object.assign({ url: '/route', method: 'GET' }, schema.config)) }) + t.assert.strictEqual(response.statusCode, 200) + t.assert.deepStrictEqual(response.json(), Object.assign({ url: '/route', method: 'GET' }, schema.config)) - fastify.inject({ + response = await fastify.inject({ method: 'GET', url: '/no-config' - }, (err, response) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(JSON.parse(response.payload), { url: '/no-config', method: 'GET' }) }) + + t.assert.strictEqual(response.statusCode, 200) + t.assert.deepStrictEqual(response.json(), { url: '/no-config', method: 'GET' }) }) diff --git a/test/custom-http-server.test.js b/test/custom-http-server.test.js index 3ad212f3842..b0cf7c64789 100644 --- a/test/custom-http-server.test.js +++ b/test/custom-http-server.test.js @@ -1,73 +1,118 @@ 'use strict' -const t = require('tap') -const test = t.test +const { test } = require('node:test') +const http = require('node:http') +const dns = require('node:dns').promises const Fastify = require('..') -const http = require('http') const { FST_ERR_FORCE_CLOSE_CONNECTIONS_IDLE_NOT_AVAILABLE } = require('../lib/errors') -const sget = require('simple-get').concat -const dns = require('dns').promises -test('Should support a custom http server', async t => { +async function setup () { const localAddresses = await dns.lookup('localhost', { all: true }) - t.plan(localAddresses.length + 3) + test('Should support a custom http server', { skip: localAddresses.length < 1 }, async t => { + t.plan(5) - const serverFactory = (handler, opts) => { - t.ok(opts.serverFactory, 'it is called twice for every HOST interface') + const fastify = Fastify({ + serverFactory: (handler, opts) => { + t.assert.ok(opts.serverFactory, 'it is called once for localhost') - const server = http.createServer((req, res) => { - req.custom = true - handler(req, res) - }) + const server = http.createServer((req, res) => { + req.custom = true + handler(req, res) + }) - return server - } + return server + } + }) - const fastify = Fastify({ serverFactory }) + t.after(() => fastify.close()) + fastify.get('/', (req, reply) => { + t.assert.ok(req.raw.custom) + reply.send({ hello: 'world' }) + }) - t.teardown(fastify.close.bind(fastify)) + await fastify.listen({ port: 0 }) - fastify.get('/', (req, reply) => { - t.ok(req.raw.custom) - reply.send({ hello: 'world' }) + const response = await fetch('http://localhost:' + fastify.server.address().port, { + method: 'GET' + }) + t.assert.ok(response.ok) + t.assert.strictEqual(response.status, 200) + const body = await response.text() + t.assert.deepStrictEqual(JSON.parse(body), { hello: 'world' }) }) - await fastify.listen({ port: 0 }) + test('Should not allow forceCloseConnection=idle if the server does not support closeIdleConnections', t => { + t.plan(1) - await new Promise((resolve, reject) => { - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port, - rejectUnauthorized: false - }, (err, response, body) => { - if (err) { - return reject(err) - } - t.equal(response.statusCode, 200) - t.same(JSON.parse(body), { hello: 'world' }) - resolve() + t.assert.throws( + () => { + Fastify({ + forceCloseConnections: 'idle', + serverFactory (handler, opts) { + return { + on () { + + } + } + } + }) + }, + FST_ERR_FORCE_CLOSE_CONNECTIONS_IDLE_NOT_AVAILABLE, + "Cannot set forceCloseConnections to 'idle' as your HTTP server does not support closeIdleConnections method" + ) + }) + + test('Should accept user defined serverFactory and ignore secondary server creation', async t => { + const server = http.createServer(() => { }) + t.after(() => new Promise(resolve => server.close(resolve))) + const app = Fastify({ + serverFactory: () => server }) + await t.assert.doesNotReject(async () => { await app.listen({ port: 0 }) }) }) -}) -test('Should not allow forceCloseConnection=idle if the server does not support closeIdleConnections', t => { - t.plan(1) + test('Should not call close on the server if it has not created it', async t => { + const server = http.createServer() - t.throws( - () => { - Fastify({ - forceCloseConnections: 'idle', - serverFactory (handler, opts) { - return { - on () { + const serverFactory = (handler, opts) => { + server.on('request', handler) + return server + } - } - } + const fastify = Fastify({ serverFactory }) + + fastify.get('/', (req, reply) => { + reply.send({ hello: 'world' }) + }) + + await fastify.ready() + + await new Promise((resolve, reject) => { + server.listen(0) + server.on('listening', resolve) + server.on('error', reject) + }) + + const address = server.address() + t.assert.strictEqual(server.listening, true) + await fastify.close() + + t.assert.strictEqual(server.listening, true) + t.assert.deepStrictEqual(server.address(), address) + t.assert.deepStrictEqual(fastify.addresses(), [address]) + + await new Promise((resolve, reject) => { + server.close((err) => { + if (err) { + return reject(err) } + resolve() }) - }, - FST_ERR_FORCE_CLOSE_CONNECTIONS_IDLE_NOT_AVAILABLE, - "Cannot set forceCloseConnections to 'idle' as your HTTP server does not support closeIdleConnections method" - ) -}) + }) + t.assert.strictEqual(server.listening, false) + t.assert.deepStrictEqual(server.address(), null) + }) +} + +setup() diff --git a/test/custom-parser-async.test.js b/test/custom-parser-async.test.js index 4eea7550118..679d99e0f76 100644 --- a/test/custom-parser-async.test.js +++ b/test/custom-parser-async.test.js @@ -1,14 +1,12 @@ 'use strict' -const t = require('tap') -const test = t.test -const sget = require('simple-get').concat +const { test } = require('node:test') const Fastify = require('../fastify') process.removeAllListeners('warning') -test('contentTypeParser should add a custom async parser', t => { - t.plan(3) +test('contentTypeParser should add a custom async parser', async t => { + t.plan(2) const fastify = Fastify() fastify.post('/', (req, reply) => { @@ -24,43 +22,38 @@ test('contentTypeParser should add a custom async parser', t => { return res }) - fastify.listen({ port: 0 }, err => { - t.error(err) + t.after(() => fastify.close()) + const fastifyServer = await fastify.listen({ port: 0 }) - t.teardown(() => fastify.close()) + await t.test('in POST', async t => { + t.plan(3) - t.test('in POST', t => { - t.plan(3) - - sget({ - method: 'POST', - url: 'http://localhost:' + fastify.server.address().port, - body: '{"hello":"world"}', - headers: { - 'Content-Type': 'application/jsoff' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body.toString(), JSON.stringify({ hello: 'world' })) - }) + const result = await fetch(fastifyServer, { + method: 'POST', + headers: { + 'Content-Type': 'application/jsoff' + }, + body: '{"hello":"world"}' }) - t.test('in OPTIONS', t => { - t.plan(3) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.deepStrictEqual(await result.json(), { hello: 'world' }) + }) + + await t.test('in OPTIONS', async t => { + t.plan(3) - sget({ - method: 'OPTIONS', - url: 'http://localhost:' + fastify.server.address().port, - body: '{"hello":"world"}', - headers: { - 'Content-Type': 'application/jsoff' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body.toString(), JSON.stringify({ hello: 'world' })) - }) + const result = await fetch(fastifyServer, { + method: 'OPTIONS', + headers: { + 'Content-Type': 'application/jsoff' + }, + body: '{"hello":"world"}' }) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.deepStrictEqual(await result.json(), { hello: 'world' }) }) }) diff --git a/test/custom-parser.0.test.js b/test/custom-parser.0.test.js new file mode 100644 index 00000000000..a7649ff4f2b --- /dev/null +++ b/test/custom-parser.0.test.js @@ -0,0 +1,701 @@ +'use strict' + +const fs = require('node:fs') +const { test } = require('node:test') +const Fastify = require('../fastify') +const jsonParser = require('fast-json-body') +const { plainTextParser } = require('./helper') + +process.removeAllListeners('warning') + +test('contentTypeParser method should exist', t => { + t.plan(1) + const fastify = Fastify() + t.assert.ok(fastify.addContentTypeParser) +}) + +test('contentTypeParser should add a custom parser', async (t) => { + t.plan(2) + const fastify = Fastify() + + fastify.post('/', (req, reply) => { + reply.send(req.body) + }) + + fastify.options('/', (req, reply) => { + reply.send(req.body) + }) + + fastify.addContentTypeParser('application/jsoff', function (req, payload, done) { + jsonParser(payload, function (err, body) { + done(err, body) + }) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + await t.test('in POST', async (t) => { + t.plan(3) + + const result = await fetch(fastifyServer, { + method: 'POST', + body: '{"hello":"world"}', + headers: { + 'Content-Type': 'application/jsoff' + } + }) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.deepStrictEqual(await result.json(), { hello: 'world' }) + }) + + await t.test('in OPTIONS', async (t) => { + t.plan(2) + + const result = await fetch(fastifyServer, { + method: 'OPTIONS', + body: '{"hello":"world"}', + headers: { + 'Content-Type': 'application/jsoff' + } + }) + + t.assert.strictEqual(result.status, 200) + const body = await result.text() + t.assert.strictEqual(body, JSON.stringify({ hello: 'world' })) + }) +}) + +test('contentTypeParser should handle multiple custom parsers', async (t) => { + t.plan(6) + const fastify = Fastify() + + fastify.post('/', (req, reply) => { + reply.send(req.body) + }) + + fastify.post('/hello', (req, reply) => { + reply.send(req.body) + }) + + function customParser (req, payload, done) { + jsonParser(payload, function (err, body) { + done(err, body) + }) + } + + fastify.addContentTypeParser('application/jsoff', customParser) + fastify.addContentTypeParser('application/ffosj', customParser) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const result1 = await fetch(fastifyServer, { + method: 'POST', + body: '{"hello":"world"}', + headers: { + 'Content-Type': 'application/jsoff' + } + }) + + t.assert.ok(result1.ok) + t.assert.strictEqual(result1.status, 200) + t.assert.deepStrictEqual(await result1.json(), { hello: 'world' }) + + const result2 = await fetch(fastifyServer + '/hello', { + method: 'POST', + body: '{"hello":"world"}', + headers: { + 'Content-Type': 'application/ffosj' + } + }) + + t.assert.ok(result2.ok) + t.assert.strictEqual(result2.status, 200) + + t.assert.deepStrictEqual(await result2.json(), { hello: 'world' }) +}) + +test('contentTypeParser should handle an array of custom contentTypes', async (t) => { + t.plan(6) + const fastify = Fastify() + + fastify.post('/', (req, reply) => { + reply.send(req.body) + }) + + fastify.post('/hello', (req, reply) => { + reply.send(req.body) + }) + + function customParser (req, payload, done) { + jsonParser(payload, function (err, body) { + done(err, body) + }) + } + + fastify.addContentTypeParser(['application/jsoff', 'application/ffosj'], customParser) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const result1 = await fetch(fastifyServer, { + method: 'POST', + body: '{"hello":"world"}', + headers: { + 'Content-Type': 'application/jsoff' + } + }) + + t.assert.ok(result1.ok) + t.assert.strictEqual(result1.status, 200) + t.assert.deepStrictEqual(await result1.json(), { hello: 'world' }) + + const result2 = await fetch(fastifyServer + '/hello', { + method: 'POST', + body: '{"hello":"world"}', + headers: { + 'Content-Type': 'application/ffosj' + } + }) + + t.assert.ok(result2.ok) + t.assert.strictEqual(result2.status, 200) + t.assert.deepStrictEqual(await result2.json(), { hello: 'world' }) +}) + +test('contentTypeParser should handle errors', async (t) => { + t.plan(1) + const fastify = Fastify() + + fastify.post('/', (req, reply) => { + reply.send(req.body) + }) + + fastify.addContentTypeParser('application/jsoff', function (req, payload, done) { + done(new Error('kaboom!'), {}) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const result = await fetch(fastifyServer, { + method: 'POST', + body: '{"hello":"world"}', + headers: { + 'Content-Type': 'application/jsoff' + } + }) + + t.assert.strictEqual(result.status, 500) +}) + +test('contentTypeParser should support encapsulation', (t, testDone) => { + t.plan(6) + const fastify = Fastify() + + fastify.register((instance, opts, done) => { + instance.addContentTypeParser('application/jsoff', () => {}) + t.assert.ok(instance.hasContentTypeParser('application/jsoff')) + + instance.register((instance, opts, done) => { + instance.addContentTypeParser('application/ffosj', () => {}) + t.assert.ok(instance.hasContentTypeParser('application/jsoff')) + t.assert.ok(instance.hasContentTypeParser('application/ffosj')) + done() + }) + + done() + }) + + fastify.ready(err => { + t.assert.ifError(err) + t.assert.ok(!fastify.hasContentTypeParser('application/jsoff')) + t.assert.ok(!fastify.hasContentTypeParser('application/ffosj')) + testDone() + }) +}) + +test('contentTypeParser should support encapsulation, second try', async (t) => { + t.plan(2) + const fastify = Fastify() + + fastify.register((instance, opts, done) => { + instance.post('/', (req, reply) => { + reply.send(req.body) + }) + + instance.addContentTypeParser('application/jsoff', function (req, payload, done) { + jsonParser(payload, function (err, body) { + done(err, body) + }) + }) + + done() + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const result = await fetch(fastifyServer, { + method: 'POST', + body: '{"hello":"world"}', + headers: { + 'Content-Type': 'application/jsoff' + } + }) + + t.assert.strictEqual(result.status, 200) + const body = await result.text() + t.assert.strictEqual(body, JSON.stringify({ hello: 'world' })) +}) + +test('contentTypeParser shouldn\'t support request with undefined "Content-Type"', async (t) => { + t.plan(1) + const fastify = Fastify() + + fastify.post('/', (req, reply) => { + reply.send(req.body) + }) + + fastify.addContentTypeParser('application/jsoff', function (req, payload, done) { + jsonParser(payload, function (err, body) { + done(err, body) + }) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const result = await fetch(fastifyServer, { + method: 'POST', + body: 'unknown content type!', + headers: { + 'Content-Type': undefined + } + }) + + t.assert.strictEqual(result.status, 415) +}) + +test('the content type should be a string or RegExp', t => { + t.plan(2) + const fastify = Fastify() + + try { + fastify.addContentTypeParser(null, () => {}) + t.assert.fail() + } catch (err) { + t.assert.strictEqual(err.code, 'FST_ERR_CTP_INVALID_TYPE') + t.assert.strictEqual(err.message, 'The content type should be a string or a RegExp') + } +}) + +test('the content type cannot be an empty string', t => { + t.plan(2) + const fastify = Fastify() + + try { + fastify.addContentTypeParser('', () => {}) + t.assert.fail() + } catch (err) { + t.assert.strictEqual(err.code, 'FST_ERR_CTP_EMPTY_TYPE') + t.assert.strictEqual(err.message, 'The content type cannot be an empty string') + } +}) + +test('the content type handler should be a function', t => { + t.plan(2) + const fastify = Fastify() + + try { + fastify.addContentTypeParser('aaa', null) + t.assert.fail() + } catch (err) { + t.assert.strictEqual(err.code, 'FST_ERR_CTP_INVALID_HANDLER') + t.assert.strictEqual(err.message, 'The content type handler should be a function') + } +}) + +test('catch all content type parser', async (t) => { + t.plan(6) + const fastify = Fastify() + + fastify.post('/', (req, reply) => { + reply.send(req.body) + }) + + fastify.addContentTypeParser('*', function (req, payload, done) { + let data = '' + payload.on('data', chunk => { data += chunk }) + payload.on('end', () => { + done(null, data) + }) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const result1 = await fetch(fastifyServer, { + method: 'POST', + body: 'hello', + headers: { + 'Content-Type': 'application/jsoff' + } + }) + + t.assert.ok(result1.ok) + t.assert.strictEqual(result1.status, 200) + t.assert.strictEqual(await result1.text(), 'hello') + + const result2 = await fetch(fastifyServer, { + method: 'POST', + body: 'hello', + headers: { + 'Content-Type': 'very-weird-content-type/foo' + } + }) + + t.assert.ok(result2.ok) + t.assert.strictEqual(result2.status, 200) + t.assert.strictEqual(await result2.text(), 'hello') +}) + +test('catch all content type parser should not interfere with other content type parsers', async (t) => { + t.plan(6) + const fastify = Fastify() + + fastify.post('/', (req, reply) => { + reply.send(req.body) + }) + + fastify.addContentTypeParser('*', function (req, payload, done) { + let data = '' + payload.on('data', chunk => { data += chunk }) + payload.on('end', () => { + done(null, data) + }) + }) + + fastify.addContentTypeParser('application/jsoff', function (req, payload, done) { + jsonParser(payload, function (err, body) { + done(err, body) + }) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const result1 = await fetch(fastifyServer, { + method: 'POST', + body: '{"hello":"world"}', + headers: { + 'Content-Type': 'application/jsoff' + } + }) + + t.assert.ok(result1.ok) + t.assert.strictEqual(result1.status, 200) + t.assert.deepStrictEqual(await result1.json(), { hello: 'world' }) + + const result2 = await fetch(fastifyServer, { + method: 'POST', + body: 'hello', + headers: { + 'Content-Type': 'very-weird-content-type/foo' + } + }) + + t.assert.ok(result2.ok) + t.assert.strictEqual(result2.status, 200) + t.assert.strictEqual(await result2.text(), 'hello') +}) + +// Issue 492 https://github.com/fastify/fastify/issues/492 +test('\'*\' catch undefined Content-Type requests', async (t) => { + t.plan(3) + + const fastify = Fastify() + + t.after(() => fastify.close()) + + fastify.addContentTypeParser('*', function (req, payload, done) { + let data = '' + payload.on('data', chunk => { data += chunk }) + payload.on('end', () => { + done(null, data) + }) + }) + + fastify.post('/', (req, res) => { + // Needed to avoid json stringify + res.type('text/plain').send(req.body) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + + const fileStream = fs.createReadStream(__filename) + + const result = await fetch(fastifyServer + '/', { + method: 'POST', + body: fileStream, + duplex: 'half' + }) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.strictEqual(await result.text(), fs.readFileSync(__filename).toString()) +}) + +test('cannot add custom parser after binding', (t, testDone) => { + t.plan(2) + + const fastify = Fastify() + + t.after(() => fastify.close()) + + fastify.post('/', (req, res) => { + res.type('text/plain').send(req.body) + }) + + fastify.listen({ port: 0 }, function (err) { + t.assert.ifError(err) + + try { + fastify.addContentTypeParser('*', () => {}) + t.assert.fail() + } catch (e) { + t.assert.ok(true) + testDone() + } + }) +}) + +test('Can override the default json parser', async (t) => { + t.plan(3) + const fastify = Fastify() + + fastify.post('/', (req, reply) => { + reply.send(req.body) + }) + + fastify.addContentTypeParser('application/json', function (req, payload, done) { + t.assert.ok('called') + jsonParser(payload, function (err, body) { + done(err, body) + }) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const result = await fetch(fastifyServer, { + method: 'POST', + body: '{"hello":"world"}', + headers: { + 'Content-Type': 'application/json' + } + }) + + t.assert.strictEqual(result.status, 200) + const body = await result.text() + t.assert.strictEqual(body, '{"hello":"world"}') +}) + +test('Can override the default plain text parser', async (t) => { + t.plan(3) + const fastify = Fastify() + + fastify.post('/', (req, reply) => { + reply.send(req.body) + }) + + fastify.addContentTypeParser('text/plain', function (req, payload, done) { + t.assert.ok('called') + plainTextParser(payload, function (err, body) { + done(err, body) + }) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const result = await fetch(fastifyServer, { + method: 'POST', + body: 'hello world', + headers: { + 'Content-Type': 'text/plain' + } + }) + + t.assert.strictEqual(result.status, 200) + const body = await result.text() + t.assert.strictEqual(body, 'hello world') +}) + +test('Can override the default json parser in a plugin', async (t) => { + t.plan(3) + const fastify = Fastify() + + fastify.register((instance, opts, done) => { + instance.addContentTypeParser('application/json', function (req, payload, done) { + t.assert.ok('called') + jsonParser(payload, function (err, body) { + done(err, body) + }) + }) + + instance.post('/', (req, reply) => { + reply.send(req.body) + }) + + done() + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const result = await fetch(fastifyServer, { + method: 'POST', + body: '{"hello":"world"}', + headers: { + 'Content-Type': 'application/json' + } + }) + + t.assert.strictEqual(result.status, 200) + const body = await result.text() + t.assert.strictEqual(body, '{"hello":"world"}') +}) + +test('Can\'t override the json parser multiple times', t => { + t.plan(2) + const fastify = Fastify() + + fastify.addContentTypeParser('application/json', function (req, payload, done) { + jsonParser(payload, function (err, body) { + done(err, body) + }) + }) + + try { + fastify.addContentTypeParser('application/json', function (req, payload, done) { + t.assert.ok('called') + jsonParser(payload, function (err, body) { + done(err, body) + }) + }) + } catch (err) { + t.assert.strictEqual(err.code, 'FST_ERR_CTP_ALREADY_PRESENT') + t.assert.strictEqual(err.message, 'Content type parser \'application/json\' already present.') + } +}) + +test('Can\'t override the plain text parser multiple times', t => { + t.plan(2) + const fastify = Fastify() + + fastify.addContentTypeParser('text/plain', function (req, payload, done) { + plainTextParser(payload, function (err, body) { + done(err, body) + }) + }) + + try { + fastify.addContentTypeParser('text/plain', function (req, payload, done) { + t.assert.ok('called') + plainTextParser(payload, function (err, body) { + done(err, body) + }) + }) + } catch (err) { + t.assert.strictEqual(err.code, 'FST_ERR_CTP_ALREADY_PRESENT') + t.assert.strictEqual(err.message, 'Content type parser \'text/plain\' already present.') + } +}) + +test('Should get the body as string', async (t) => { + t.plan(4) + const fastify = Fastify() + + fastify.post('/', (req, reply) => { + reply.send(req.body) + }) + + fastify.addContentTypeParser('application/json', { parseAs: 'string' }, function (req, body, done) { + t.assert.ok('called') + t.assert.ok(typeof body === 'string') + try { + const json = JSON.parse(body) + done(null, json) + } catch (err) { + err.statusCode = 400 + done(err, undefined) + } + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const result = await fetch(fastifyServer, { + method: 'POST', + body: '{"hello":"world"}', + headers: { + 'Content-Type': 'application/json' + } + }) + + t.assert.strictEqual(result.status, 200) + const body = await result.text() + t.assert.strictEqual(body, '{"hello":"world"}') +}) + +test('Should return defined body with no custom parser defined and content type = \'text/plain\'', async (t) => { + t.plan(2) + const fastify = Fastify() + + fastify.post('/', (req, reply) => { + reply.send(req.body) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const result = await fetch(fastifyServer, { + method: 'POST', + body: 'hello world', + headers: { + 'Content-Type': 'text/plain' + } + }) + + t.assert.strictEqual(result.status, 200) + const body = await result.text() + t.assert.strictEqual(body, 'hello world') +}) + +test('Should have typeof body object with no custom parser defined, no body defined and content type = \'text/plain\'', async (t) => { + t.plan(3) + const fastify = Fastify() + + fastify.post('/', (req, reply) => { + reply.send(req.body) + }) + + t.after(() => fastify.close()) + + const fastifyServer = await fastify.listen({ port: 0 }) + + const result = await fetch(fastifyServer, { + method: 'POST', + headers: { + 'Content-Type': 'text/plain' + } + }) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.strictEqual(await result.text(), '') +}) diff --git a/test/custom-parser.1.test.js b/test/custom-parser.1.test.js new file mode 100644 index 00000000000..ea0fe31ceb0 --- /dev/null +++ b/test/custom-parser.1.test.js @@ -0,0 +1,266 @@ +'use strict' + +const { test } = require('node:test') +const Fastify = require('../fastify') +const jsonParser = require('fast-json-body') + +process.removeAllListeners('warning') + +test('Should have typeof body object with no custom parser defined, null body and content type = \'text/plain\'', async (t) => { + t.plan(3) + const fastify = Fastify() + + fastify.post('/', (req, reply) => { + reply.send(req.body) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const result = await fetch(fastifyServer, { + method: 'POST', + body: null, + headers: { + 'Content-Type': 'text/plain' + } + }) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.strictEqual(await result.text(), '') +}) + +test('Should have typeof body object with no custom parser defined, undefined body and content type = \'text/plain\'', async (t) => { + t.plan(3) + const fastify = Fastify() + + fastify.post('/', (req, reply) => { + reply.send(req.body) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const result = await fetch(fastifyServer, { + method: 'POST', + body: undefined, + headers: { + 'Content-Type': 'text/plain' + } + }) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.strictEqual(await result.text(), '') +}) + +test('Should get the body as string /1', async (t) => { + t.plan(4) + const fastify = Fastify() + + fastify.post('/', (req, reply) => { + reply.send(req.body) + }) + + fastify.addContentTypeParser('text/plain', { parseAs: 'string' }, function (req, body, done) { + t.assert.ok('called') + t.assert.ok(typeof body === 'string') + try { + const plainText = body + done(null, plainText) + } catch (err) { + err.statusCode = 400 + done(err, undefined) + } + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const result = await fetch(fastifyServer, { + method: 'POST', + body: 'hello world', + headers: { + 'Content-Type': 'text/plain' + } + }) + + t.assert.strictEqual(result.status, 200) + t.assert.strictEqual(await result.text(), 'hello world') +}) + +test('Should get the body as buffer', async (t) => { + t.plan(4) + const fastify = Fastify() + + fastify.post('/', (req, reply) => { + reply.send(req.body) + }) + + fastify.addContentTypeParser('application/json', { parseAs: 'buffer' }, function (req, body, done) { + t.assert.ok('called') + t.assert.ok(body instanceof Buffer) + try { + const json = JSON.parse(body) + done(null, json) + } catch (err) { + err.statusCode = 400 + done(err, undefined) + } + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const result = await fetch(fastifyServer, { + method: 'POST', + body: '{"hello":"world"}', + headers: { + 'Content-Type': 'application/json' + } + }) + + t.assert.strictEqual(result.status, 200) + t.assert.strictEqual(await result.text(), '{"hello":"world"}') +}) + +test('Should get the body as buffer', async (t) => { + t.plan(4) + const fastify = Fastify() + + fastify.post('/', (req, reply) => { + reply.send(req.body) + }) + + fastify.addContentTypeParser('text/plain', { parseAs: 'buffer' }, function (req, body, done) { + t.assert.ok('called') + t.assert.ok(body instanceof Buffer) + try { + const plainText = body + done(null, plainText) + } catch (err) { + err.statusCode = 400 + done(err, undefined) + } + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const result = await fetch(fastifyServer, { + method: 'POST', + body: 'hello world', + headers: { + 'Content-Type': 'text/plain' + } + }) + + t.assert.strictEqual(result.status, 200) + t.assert.strictEqual(await result.text(), 'hello world') +}) + +test('Should parse empty bodies as a string', async (t) => { + t.plan(8) + const fastify = Fastify() + + fastify.addContentTypeParser('text/plain', { parseAs: 'string' }, (req, body, done) => { + t.assert.strictEqual(body, '') + done(null, body) + }) + + fastify.route({ + method: ['POST', 'DELETE'], + url: '/', + handler (request, reply) { + reply.send(request.body) + } + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const postResult = await fetch(fastifyServer, { + method: 'POST', + body: '', + headers: { + 'Content-Type': 'text/plain' + } + }) + + t.assert.ok(postResult.ok) + t.assert.strictEqual(postResult.status, 200) + t.assert.strictEqual(await postResult.text(), '') + + const deleteResult = await fetch(fastifyServer, { + method: 'DELETE', + body: '', + headers: { + 'Content-Type': 'text/plain', + 'Content-Length': '0' + } + }) + + t.assert.ok(deleteResult.ok) + t.assert.strictEqual(deleteResult.status, 200) + t.assert.strictEqual(await deleteResult.text(), '') +}) + +test('Should parse empty bodies as a buffer', async (t) => { + t.plan(4) + const fastify = Fastify() + + fastify.post('/', (req, reply) => { + reply.send(req.body) + }) + + fastify.addContentTypeParser('text/plain', { parseAs: 'buffer' }, function (req, body, done) { + t.assert.ok(body instanceof Buffer) + t.assert.strictEqual(body.length, 0) + done(null, body) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const result = await fetch(fastifyServer, { + method: 'POST', + body: '', + headers: { + 'Content-Type': 'text/plain' + } + }) + + t.assert.strictEqual(result.status, 200) + t.assert.strictEqual((await result.arrayBuffer()).byteLength, 0) +}) + +test('The charset should not interfere with the content type handling', async (t) => { + t.plan(4) + const fastify = Fastify() + + fastify.post('/', (req, reply) => { + reply.send(req.body) + }) + + fastify.addContentTypeParser('application/json', function (req, payload, done) { + t.assert.ok('called') + jsonParser(payload, function (err, body) { + done(err, body) + }) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const result = await fetch(fastifyServer, { + method: 'POST', + body: '{"hello":"world"}', + headers: { + 'Content-Type': 'application/json; charset=utf-8' + } + }) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.strictEqual(await result.text(), '{"hello":"world"}') +}) diff --git a/test/custom-parser.2.test.js b/test/custom-parser.2.test.js new file mode 100644 index 00000000000..88d596ca6c4 --- /dev/null +++ b/test/custom-parser.2.test.js @@ -0,0 +1,91 @@ +'use strict' + +const { test } = require('node:test') +const Fastify = require('..') + +process.removeAllListeners('warning') + +test('Wrong parseAs parameter', t => { + t.plan(2) + const fastify = Fastify() + + try { + fastify.addContentTypeParser('application/json', { parseAs: 'fireworks' }, () => {}) + t.assert.fail('should throw') + } catch (err) { + t.assert.strictEqual(err.code, 'FST_ERR_CTP_INVALID_PARSE_TYPE') + t.assert.strictEqual(err.message, "The body parser can only parse your data as 'string' or 'buffer', you asked 'fireworks' which is not supported.") + } +}) + +test('Should allow defining the bodyLimit per parser', async (t) => { + t.plan(2) + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.post('/', (req, reply) => { + reply.send(req.body) + }) + + fastify.addContentTypeParser( + 'x/foo', + { parseAs: 'string', bodyLimit: 5 }, + function (req, body, done) { + t.assert.fail('should not be invoked') + done() + } + ) + + const fastifyServer = await fastify.listen({ port: 0 }) + + const result = await fetch(fastifyServer, { + method: 'POST', + body: '1234567890', + headers: { + 'Content-Type': 'x/foo' + } + }) + + t.assert.ok(!result.ok) + t.assert.deepStrictEqual(await result.json(), { + statusCode: 413, + code: 'FST_ERR_CTP_BODY_TOO_LARGE', + error: 'Payload Too Large', + message: 'Request body is too large' + }) +}) + +test('route bodyLimit should take precedence over a custom parser bodyLimit', async (t) => { + t.plan(2) + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.post('/', { bodyLimit: 5 }, (request, reply) => { + reply.send(request.body) + }) + + fastify.addContentTypeParser( + 'x/foo', + { parseAs: 'string', bodyLimit: 100 }, + function (req, body, done) { + t.assert.fail('should not be invoked') + done() + } + ) + + const fastifyServer = await fastify.listen({ port: 0 }) + + const result = await fetch(fastifyServer, { + method: 'POST', + body: '1234567890', + headers: { 'Content-Type': 'x/foo' } + }) + + t.assert.ok(!result.ok) + t.assert.deepStrictEqual(await result.json(), { + statusCode: 413, + code: 'FST_ERR_CTP_BODY_TOO_LARGE', + error: 'Payload Too Large', + message: 'Request body is too large' + }) +}) diff --git a/test/custom-parser.3.test.js b/test/custom-parser.3.test.js new file mode 100644 index 00000000000..b8758fcbad6 --- /dev/null +++ b/test/custom-parser.3.test.js @@ -0,0 +1,208 @@ +'use strict' + +const { test } = require('node:test') +const Fastify = require('..') +const jsonParser = require('fast-json-body') + +process.removeAllListeners('warning') + +test('should be able to use default parser for extra content type', async t => { + t.plan(3) + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.post('/', (request, reply) => { + reply.send(request.body) + }) + + fastify.addContentTypeParser('text/json', { parseAs: 'string' }, fastify.getDefaultJsonParser('ignore', 'ignore')) + + const fastifyServer = await fastify.listen({ port: 0 }) + + const response = await fetch(fastifyServer, { + method: 'POST', + body: '{"hello":"world"}', + headers: { + 'Content-Type': 'text/json' + } + }) + t.assert.ok(response.ok) + t.assert.strictEqual(response.status, 200) + t.assert.deepStrictEqual(await response.json(), { hello: 'world' }) +}) + +test('contentTypeParser should add a custom parser with RegExp value', async (t) => { + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.post('/', (req, reply) => { + reply.send(req.body) + }) + + fastify.options('/', (req, reply) => { + reply.send(req.body) + }) + + fastify.addContentTypeParser(/.*\+json$/, function (req, payload, done) { + jsonParser(payload, function (err, body) { + done(err, body) + }) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + + await t.test('in POST', async t => { + t.plan(3) + + const response = await fetch(fastifyServer, { + method: 'POST', + body: '{"hello":"world"}', + headers: { + 'Content-Type': 'application/vnd.test+json' + } + }) + t.assert.ok(response.ok) + t.assert.strictEqual(response.status, 200) + const body = await response.text() + t.assert.deepStrictEqual(body.toString(), JSON.stringify({ hello: 'world' })) + }) + + await t.test('in OPTIONS', async t => { + t.plan(3) + + const response = await fetch(fastifyServer, { + method: 'OPTIONS', + body: '{"hello":"world"}', + headers: { + 'Content-Type': 'weird/content-type+json' + } + }) + t.assert.ok(response.ok) + t.assert.strictEqual(response.status, 200) + const body = await response.text() + t.assert.deepStrictEqual(body.toString(), JSON.stringify({ hello: 'world' })) + }) +}) + +test('contentTypeParser should add multiple custom parsers with RegExp values', async t => { + t.plan(6) + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.post('/', (req, reply) => { + reply.send(req.body) + }) + + fastify.addContentTypeParser(/.*\+json$/, function (req, payload, done) { + jsonParser(payload, function (err, body) { + done(err, body) + }) + }) + + fastify.addContentTypeParser(/.*\+xml$/, function (req, payload, done) { + done(null, 'xml') + }) + + fastify.addContentTypeParser(/.*\+myExtension$/i, function (req, payload, done) { + let data = '' + payload.on('data', chunk => { data += chunk }) + payload.on('end', () => { + done(null, data + 'myExtension') + }) + }) + + await fastify.ready() + + { + const response = await fastify.inject({ + method: 'POST', + url: '/', + body: '{"hello":"world"}', + headers: { + 'Content-Type': 'application/vnd.hello+json' + } + }) + t.assert.strictEqual(response.statusCode, 200) + t.assert.deepStrictEqual(response.payload.toString(), '{"hello":"world"}') + } + + { + const response = await fastify.inject({ + method: 'POST', + url: '/', + body: '{"hello":"world"}', + headers: { + 'Content-Type': 'application/test+xml' + } + }) + t.assert.strictEqual(response.statusCode, 200) + t.assert.deepStrictEqual(response.payload.toString(), 'xml') + } + + await fastify.inject({ + method: 'POST', + path: '/', + payload: 'abcdefg', + headers: { + 'Content-Type': 'application/+myExtension' + } + }).then((response) => { + t.assert.strictEqual(response.statusCode, 200) + t.assert.deepStrictEqual(response.payload.toString(), 'abcdefgmyExtension') + }).catch((err) => { + t.assert.ifError(err) + }) +}) + +test('catch all content type parser should not interfere with content type parser', async t => { + t.plan(9) + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.post('/', (req, reply) => { + reply.send(req.body) + }) + + fastify.addContentTypeParser('*', function (req, payload, done) { + let data = '' + payload.on('data', chunk => { data += chunk }) + payload.on('end', () => { + done(null, data) + }) + }) + + fastify.addContentTypeParser(/^application\/.*/, function (req, payload, done) { + jsonParser(payload, function (err, body) { + done(err, body) + }) + }) + + fastify.addContentTypeParser('text/html', function (req, payload, done) { + let data = '' + payload.on('data', chunk => { data += chunk }) + payload.on('end', () => { + done(null, data + 'html') + }) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + + const assertions = [ + { body: '{"myKey":"myValue"}', contentType: 'application/json', expected: JSON.stringify({ myKey: 'myValue' }) }, + { body: 'body', contentType: 'very-weird-content-type/foo', expected: 'body' }, + { body: 'my text', contentType: 'text/html', expected: 'my texthtml' } + ] + + for (const { body, contentType, expected } of assertions) { + const response = await fetch(fastifyServer, { + method: 'POST', + body, + headers: { + 'Content-Type': contentType + } + }) + t.assert.ok(response.ok) + t.assert.strictEqual(response.status, 200) + t.assert.deepStrictEqual(await response.text(), expected) + } +}) diff --git a/test/custom-parser.4.test.js b/test/custom-parser.4.test.js new file mode 100644 index 00000000000..6481a48f8bf --- /dev/null +++ b/test/custom-parser.4.test.js @@ -0,0 +1,218 @@ +'use strict' + +const { test } = require('node:test') +const Fastify = require('../fastify') +const jsonParser = require('fast-json-body') + +process.removeAllListeners('warning') + +test('should prefer string content types over RegExp ones', async (t) => { + t.plan(6) + const fastify = Fastify() + t.after(() => { fastify.close() }) + fastify.post('/', (req, reply) => { + reply.send(req.body) + }) + + fastify.addContentTypeParser(/^application\/.*/, function (req, payload, done) { + let data = '' + payload.on('data', chunk => { data += chunk }) + payload.on('end', () => { + done(null, data) + }) + }) + + fastify.addContentTypeParser('application/json', function (req, payload, done) { + jsonParser(payload, function (err, body) { + done(err, body) + }) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + + const result1 = await fetch(fastifyServer, { + method: 'POST', + body: '{"k1":"myValue", "k2": "myValue"}', + headers: { + 'Content-Type': 'application/json' + } + }) + + t.assert.ok(result1.ok) + t.assert.strictEqual(result1.status, 200) + t.assert.equal(await result1.text(), JSON.stringify({ k1: 'myValue', k2: 'myValue' })) + + const result2 = await fetch(fastifyServer, { + method: 'POST', + body: 'javascript', + headers: { + 'Content-Type': 'application/javascript' + } + }) + + t.assert.ok(result2.ok) + t.assert.strictEqual(result2.status, 200) + t.assert.equal(await result2.text(), 'javascript') +}) + +test('removeContentTypeParser should support arrays of content types to remove', async (t) => { + t.plan(7) + + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.addContentTypeParser('application/xml', function (req, payload, done) { + payload.on('data', () => {}) + payload.on('end', () => { + done(null, 'xml') + }) + }) + + fastify.addContentTypeParser(/^image\/.*/, function (req, payload, done) { + payload.on('data', () => {}) + payload.on('end', () => { + done(null, 'image') + }) + }) + + fastify.removeContentTypeParser([/^image\/.*/, 'application/json']) + + fastify.post('/', (req, reply) => { + reply.send(req.body) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + + const result1 = await fetch(fastifyServer, { + method: 'POST', + body: '', + headers: { + 'Content-Type': 'application/xml' + } + }) + + t.assert.ok(result1.ok) + t.assert.strictEqual(result1.status, 200) + t.assert.equal(await result1.text(), 'xml') + + const result2 = await fetch(fastifyServer, { + method: 'POST', + body: '', + headers: { + 'Content-Type': 'image/png' + } + }) + + t.assert.ok(!result2.ok) + t.assert.strictEqual(result2.status, 415) + + const result3 = await fetch(fastifyServer, { + method: 'POST', + body: '{test: "test"}', + headers: { + 'Content-Type': 'application/json' + } + }) + + t.assert.ok(!result3.ok) + t.assert.strictEqual(result3.status, 415) +}) + +test('removeContentTypeParser should support encapsulation', async (t) => { + t.plan(5) + + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.addContentTypeParser('application/xml', function (req, payload, done) { + payload.on('data', () => {}) + payload.on('end', () => { + done(null, 'xml') + }) + }) + + fastify.post('/', (req, reply) => { + reply.send(req.body) + }) + + fastify.register(function (instance, options, done) { + instance.removeContentTypeParser('application/xml') + + instance.post('/encapsulated', (req, reply) => { + reply.send(req.body) + }) + + done() + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + + const result1 = await fetch(fastifyServer + '/encapsulated', { + method: 'POST', + body: '', + headers: { + 'Content-Type': 'application/xml' + } + }) + + t.assert.ok(!result1.ok) + t.assert.strictEqual(result1.status, 415) + + const result2 = await fetch(fastifyServer, { + method: 'POST', + body: '', + headers: { + 'Content-Type': 'application/xml' + } + }) + + t.assert.ok(result2.ok) + t.assert.strictEqual(result2.status, 200) + t.assert.equal(await result2.text(), 'xml') +}) + +test('removeAllContentTypeParsers should support encapsulation', async (t) => { + t.plan(5) + + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.post('/', (req, reply) => { + reply.send(req.body) + }) + + fastify.register(function (instance, options, done) { + instance.removeAllContentTypeParsers() + + instance.post('/encapsulated', (req, reply) => { + reply.send(req.body) + }) + + done() + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + + const result1 = await fetch(fastifyServer + '/encapsulated', { + method: 'POST', + body: '{}', + headers: { + 'Content-Type': 'application/json' + } + }) + + t.assert.ok(!result1.ok) + t.assert.strictEqual(result1.status, 415) + + const result2 = await fetch(fastifyServer, { + method: 'POST', + body: '{"test":1}', + headers: { + 'Content-Type': 'application/json' + } + }) + + t.assert.ok(result2.ok) + t.assert.strictEqual(result2.status, 200) + t.assert.equal(JSON.parse(await result2.text()).test, 1) +}) diff --git a/test/custom-parser.5.test.js b/test/custom-parser.5.test.js new file mode 100644 index 00000000000..f630e851e43 --- /dev/null +++ b/test/custom-parser.5.test.js @@ -0,0 +1,130 @@ +'use strict' + +const { test } = require('node:test') +const Fastify = require('../fastify') +const jsonParser = require('fast-json-body') +const { plainTextParser } = require('./helper') + +process.removeAllListeners('warning') + +test('cannot remove all content type parsers after binding', async (t) => { + t.plan(1) + + const fastify = Fastify() + + t.after(() => fastify.close()) + + await fastify.listen({ port: 0 }) + t.assert.throws(() => fastify.removeAllContentTypeParsers()) +}) + +test('cannot remove content type parsers after binding', async (t) => { + t.plan(1) + + const fastify = Fastify() + t.after(() => fastify.close()) + + await fastify.listen({ port: 0 }) + t.assert.throws(() => fastify.removeContentTypeParser('application/json')) +}) + +test('should be able to override the default json parser after removeAllContentTypeParsers', async (t) => { + t.plan(4) + + const fastify = Fastify() + + fastify.post('/', (req, reply) => { + reply.send(req.body) + }) + + fastify.removeAllContentTypeParsers() + + fastify.addContentTypeParser('application/json', function (req, payload, done) { + t.assert.ok('called') + jsonParser(payload, function (err, body) { + done(err, body) + }) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + + const result = await fetch(fastifyServer, { + method: 'POST', + body: JSON.stringify({ hello: 'world' }), + headers: { + 'Content-Type': 'application/json' + } + }) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.deepStrictEqual(await result.text(), JSON.stringify({ hello: 'world' })) + await fastify.close() +}) + +test('should be able to override the default plain text parser after removeAllContentTypeParsers', async (t) => { + t.plan(4) + + const fastify = Fastify() + + fastify.post('/', (req, reply) => { + reply.send(req.body) + }) + + fastify.removeAllContentTypeParsers() + + fastify.addContentTypeParser('text/plain', function (req, payload, done) { + t.assert.ok('called') + plainTextParser(payload, function (err, body) { + done(err, body) + }) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + + const result = await fetch(fastifyServer, { + method: 'POST', + body: 'hello world', + headers: { + 'Content-Type': 'text/plain' + } + }) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.strictEqual(await result.text(), 'hello world') + await fastify.close() +}) + +test('should be able to add a custom content type parser after removeAllContentTypeParsers', async (t) => { + t.plan(4) + + const fastify = Fastify() + + fastify.post('/', (req, reply) => { + reply.send(req.body) + }) + + fastify.removeAllContentTypeParsers() + fastify.addContentTypeParser('application/jsoff', function (req, payload, done) { + t.assert.ok('called') + jsonParser(payload, function (err, body) { + done(err, body) + }) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + + const result = await fetch(fastifyServer, { + method: 'POST', + body: JSON.stringify({ hello: 'world' }), + headers: { + 'Content-Type': 'application/jsoff' + } + }) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.deepStrictEqual(await result.text(), JSON.stringify({ hello: 'world' })) + await fastify.close() +}) diff --git a/test/custom-parser.test.js b/test/custom-parser.test.js deleted file mode 100644 index 8986216afba..00000000000 --- a/test/custom-parser.test.js +++ /dev/null @@ -1,1761 +0,0 @@ -'use strict' - -const fs = require('fs') -const t = require('tap') -const test = t.test -const sget = require('simple-get').concat -const Fastify = require('..') - -const jsonParser = require('fast-json-body') - -function plainTextParser (request, callback) { - let body = '' - request.setEncoding('utf8') - request.on('error', onError) - request.on('data', onData) - request.on('end', onEnd) - function onError (err) { - callback(err, null) - } - function onData (chunk) { - body += chunk - } - function onEnd () { - callback(null, body) - } -} - -function getUrl (app) { - const { address, port } = app.server.address() - if (address === '::1') { - return `http://[${address}]:${port}` - } else { - return `http://${address}:${port}` - } -} - -process.removeAllListeners('warning') - -test('contentTypeParser method should exist', t => { - t.plan(1) - const fastify = Fastify() - t.ok(fastify.addContentTypeParser) -}) - -test('contentTypeParser should add a custom parser', t => { - t.plan(3) - const fastify = Fastify() - - fastify.post('/', (req, reply) => { - reply.send(req.body) - }) - - fastify.options('/', (req, reply) => { - reply.send(req.body) - }) - - fastify.addContentTypeParser('application/jsoff', function (req, payload, done) { - jsonParser(payload, function (err, body) { - done(err, body) - }) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - t.teardown(() => fastify.close()) - - t.test('in POST', t => { - t.plan(3) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: '{"hello":"world"}', - headers: { - 'Content-Type': 'application/jsoff' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body.toString(), JSON.stringify({ hello: 'world' })) - }) - }) - - t.test('in OPTIONS', t => { - t.plan(3) - - sget({ - method: 'OPTIONS', - url: getUrl(fastify), - body: '{"hello":"world"}', - headers: { - 'Content-Type': 'application/jsoff' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body.toString(), JSON.stringify({ hello: 'world' })) - }) - }) - }) -}) - -test('contentTypeParser should handle multiple custom parsers', t => { - t.plan(7) - const fastify = Fastify() - - fastify.post('/', (req, reply) => { - reply.send(req.body) - }) - - fastify.post('/hello', (req, reply) => { - reply.send(req.body) - }) - - function customParser (req, payload, done) { - jsonParser(payload, function (err, body) { - done(err, body) - }) - } - - fastify.addContentTypeParser('application/jsoff', customParser) - fastify.addContentTypeParser('application/ffosj', customParser) - - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: '{"hello":"world"}', - headers: { - 'Content-Type': 'application/jsoff' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body.toString(), JSON.stringify({ hello: 'world' })) - }) - - sget({ - method: 'POST', - url: getUrl(fastify) + '/hello', - body: '{"hello":"world"}', - headers: { - 'Content-Type': 'application/ffosj' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body.toString(), JSON.stringify({ hello: 'world' })) - }) - }) -}) - -test('contentTypeParser should handle an array of custom contentTypes', t => { - t.plan(7) - const fastify = Fastify() - - fastify.post('/', (req, reply) => { - reply.send(req.body) - }) - - fastify.post('/hello', (req, reply) => { - reply.send(req.body) - }) - - function customParser (req, payload, done) { - jsonParser(payload, function (err, body) { - done(err, body) - }) - } - - fastify.addContentTypeParser(['application/jsoff', 'application/ffosj'], customParser) - - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: '{"hello":"world"}', - headers: { - 'Content-Type': 'application/jsoff' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body.toString(), JSON.stringify({ hello: 'world' })) - }) - - sget({ - method: 'POST', - url: getUrl(fastify) + '/hello', - body: '{"hello":"world"}', - headers: { - 'Content-Type': 'application/ffosj' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body.toString(), JSON.stringify({ hello: 'world' })) - }) - }) -}) - -test('contentTypeParser should handle errors', t => { - t.plan(3) - const fastify = Fastify() - - fastify.post('/', (req, reply) => { - reply.send(req.body) - }) - - fastify.addContentTypeParser('application/jsoff', function (req, payload, done) { - done(new Error('kaboom!'), {}) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: '{"hello":"world"}', - headers: { - 'Content-Type': 'application/jsoff' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 500) - fastify.close() - }) - }) -}) - -test('contentTypeParser should support encapsulation', t => { - t.plan(6) - const fastify = Fastify() - - fastify.register((instance, opts, done) => { - instance.addContentTypeParser('application/jsoff', () => {}) - t.ok(instance.hasContentTypeParser('application/jsoff')) - - instance.register((instance, opts, done) => { - instance.addContentTypeParser('application/ffosj', () => {}) - t.ok(instance.hasContentTypeParser('application/jsoff')) - t.ok(instance.hasContentTypeParser('application/ffosj')) - done() - }) - - done() - }) - - fastify.ready(err => { - t.error(err) - t.notOk(fastify.hasContentTypeParser('application/jsoff')) - t.notOk(fastify.hasContentTypeParser('application/ffosj')) - }) -}) - -test('contentTypeParser should support encapsulation, second try', t => { - t.plan(4) - const fastify = Fastify() - - fastify.register((instance, opts, done) => { - instance.post('/', (req, reply) => { - reply.send(req.body) - }) - - instance.addContentTypeParser('application/jsoff', function (req, payload, done) { - jsonParser(payload, function (err, body) { - done(err, body) - }) - }) - - done() - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: '{"hello":"world"}', - headers: { - 'Content-Type': 'application/jsoff' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body.toString(), JSON.stringify({ hello: 'world' })) - fastify.close() - }) - }) -}) - -test('contentTypeParser shouldn\'t support request with undefined "Content-Type"', t => { - t.plan(3) - const fastify = Fastify() - - fastify.post('/', (req, reply) => { - reply.send(req.body) - }) - - fastify.addContentTypeParser('application/jsoff', function (req, payload, done) { - jsonParser(payload, function (err, body) { - done(err, body) - }) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: 'unknown content type!', - headers: { - // 'Content-Type': undefined - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 415) - fastify.close() - }) - }) -}) - -test('the content type should be a string or RegExp', t => { - t.plan(2) - const fastify = Fastify() - - try { - fastify.addContentTypeParser(null, () => {}) - t.fail() - } catch (err) { - t.equal(err.code, 'FST_ERR_CTP_INVALID_TYPE') - t.equal(err.message, 'The content type should be a string or a RegExp') - } -}) - -test('the content type cannot be an empty string', t => { - t.plan(2) - const fastify = Fastify() - - try { - fastify.addContentTypeParser('', () => {}) - t.fail() - } catch (err) { - t.equal(err.code, 'FST_ERR_CTP_EMPTY_TYPE') - t.equal(err.message, 'The content type cannot be an empty string') - } -}) - -test('the content type handler should be a function', t => { - t.plan(2) - const fastify = Fastify() - - try { - fastify.addContentTypeParser('aaa', null) - t.fail() - } catch (err) { - t.equal(err.code, 'FST_ERR_CTP_INVALID_HANDLER') - t.equal(err.message, 'The content type handler should be a function') - } -}) - -test('catch all content type parser', t => { - t.plan(7) - const fastify = Fastify() - - fastify.post('/', (req, reply) => { - reply.send(req.body) - }) - - fastify.addContentTypeParser('*', function (req, payload, done) { - let data = '' - payload.on('data', chunk => { data += chunk }) - payload.on('end', () => { - done(null, data) - }) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: 'hello', - headers: { - 'Content-Type': 'application/jsoff' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body.toString(), 'hello') - - sget({ - method: 'POST', - url: getUrl(fastify), - body: 'hello', - headers: { - 'Content-Type': 'very-weird-content-type' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body.toString(), 'hello') - fastify.close() - }) - }) - }) -}) - -test('catch all content type parser should not interfere with other conte type parsers', t => { - t.plan(7) - const fastify = Fastify() - - fastify.post('/', (req, reply) => { - reply.send(req.body) - }) - - fastify.addContentTypeParser('*', function (req, payload, done) { - let data = '' - payload.on('data', chunk => { data += chunk }) - payload.on('end', () => { - done(null, data) - }) - }) - - fastify.addContentTypeParser('application/jsoff', function (req, payload, done) { - jsonParser(payload, function (err, body) { - done(err, body) - }) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: '{"hello":"world"}', - headers: { - 'Content-Type': 'application/jsoff' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body.toString(), JSON.stringify({ hello: 'world' })) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: 'hello', - headers: { - 'Content-Type': 'very-weird-content-type' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body.toString(), 'hello') - fastify.close() - }) - }) - }) -}) - -// Issue 492 https://github.com/fastify/fastify/issues/492 -test('\'*\' catch undefined Content-Type requests', t => { - t.plan(4) - - const fastify = Fastify() - - t.teardown(fastify.close.bind(fastify)) - - fastify.addContentTypeParser('*', function (req, payload, done) { - let data = '' - payload.on('data', chunk => { data += chunk }) - payload.on('end', () => { - done(null, data) - }) - }) - - fastify.post('/', (req, res) => { - // Needed to avoid json stringify - res.type('text/plain').send(req.body) - }) - - fastify.listen({ port: 0 }, function (err) { - t.error(err) - - const fileStream = fs.createReadStream(__filename) - - sget({ - method: 'POST', - url: getUrl(fastify) + '/', - body: fileStream - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(body + '', fs.readFileSync(__filename).toString()) - }) - }) -}) - -test('cannot add custom parser after binding', t => { - t.plan(2) - - const fastify = Fastify() - - t.teardown(fastify.close.bind(fastify)) - - fastify.post('/', (req, res) => { - res.type('text/plain').send(req.body) - }) - - fastify.listen({ port: 0 }, function (err) { - t.error(err) - - try { - fastify.addContentTypeParser('*', () => {}) - t.fail() - } catch (e) { - t.pass() - } - }) -}) - -test('Can override the default json parser', t => { - t.plan(5) - const fastify = Fastify() - - fastify.post('/', (req, reply) => { - reply.send(req.body) - }) - - fastify.addContentTypeParser('application/json', function (req, payload, done) { - t.ok('called') - jsonParser(payload, function (err, body) { - done(err, body) - }) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: '{"hello":"world"}', - headers: { - 'Content-Type': 'application/json' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(body.toString(), '{"hello":"world"}') - fastify.close() - }) - }) -}) - -test('Can override the default plain text parser', t => { - t.plan(5) - const fastify = Fastify() - - fastify.post('/', (req, reply) => { - reply.send(req.body) - }) - - fastify.addContentTypeParser('text/plain', function (req, payload, done) { - t.ok('called') - plainTextParser(payload, function (err, body) { - done(err, body) - }) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: 'hello world', - headers: { - 'Content-Type': 'text/plain' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(body.toString(), 'hello world') - fastify.close() - }) - }) -}) - -test('Can override the default json parser in a plugin', t => { - t.plan(5) - const fastify = Fastify() - - fastify.register((instance, opts, done) => { - instance.addContentTypeParser('application/json', function (req, payload, done) { - t.ok('called') - jsonParser(payload, function (err, body) { - done(err, body) - }) - }) - - instance.post('/', (req, reply) => { - reply.send(req.body) - }) - - done() - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: '{"hello":"world"}', - headers: { - 'Content-Type': 'application/json' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(body.toString(), '{"hello":"world"}') - fastify.close() - }) - }) -}) - -test('Can\'t override the json parser multiple times', t => { - t.plan(2) - const fastify = Fastify() - - fastify.addContentTypeParser('application/json', function (req, payload, done) { - jsonParser(payload, function (err, body) { - done(err, body) - }) - }) - - try { - fastify.addContentTypeParser('application/json', function (req, payload, done) { - t.ok('called') - jsonParser(payload, function (err, body) { - done(err, body) - }) - }) - } catch (err) { - t.equal(err.code, 'FST_ERR_CTP_ALREADY_PRESENT') - t.equal(err.message, 'Content type parser \'application/json\' already present.') - } -}) - -test('Can\'t override the plain text parser multiple times', t => { - t.plan(2) - const fastify = Fastify() - - fastify.addContentTypeParser('text/plain', function (req, payload, done) { - plainTextParser(payload, function (err, body) { - done(err, body) - }) - }) - - try { - fastify.addContentTypeParser('text/plain', function (req, payload, done) { - t.ok('called') - plainTextParser(payload, function (err, body) { - done(err, body) - }) - }) - } catch (err) { - t.equal(err.code, 'FST_ERR_CTP_ALREADY_PRESENT') - t.equal(err.message, 'Content type parser \'text/plain\' already present.') - } -}) - -test('Should get the body as string', t => { - t.plan(6) - const fastify = Fastify() - - fastify.post('/', (req, reply) => { - reply.send(req.body) - }) - - fastify.addContentTypeParser('application/json', { parseAs: 'string' }, function (req, body, done) { - t.ok('called') - t.ok(typeof body === 'string') - try { - const json = JSON.parse(body) - done(null, json) - } catch (err) { - err.statusCode = 400 - done(err, undefined) - } - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: '{"hello":"world"}', - headers: { - 'Content-Type': 'application/json' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(body.toString(), '{"hello":"world"}') - fastify.close() - }) - }) -}) - -test('Should return defined body with no custom parser defined and content type = \'text/plain\'', t => { - t.plan(4) - const fastify = Fastify() - - fastify.post('/', (req, reply) => { - reply.send(req.body) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: 'hello world', - headers: { - 'Content-Type': 'text/plain' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(body.toString(), 'hello world') - fastify.close() - }) - }) -}) - -test('Should have typeof body object with no custom parser defined, no body defined and content type = \'text/plain\'', t => { - t.plan(4) - const fastify = Fastify() - - fastify.post('/', (req, reply) => { - reply.send(req.body) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'POST', - url: getUrl(fastify), - headers: { - 'Content-Type': 'text/plain' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(typeof body, 'object') - fastify.close() - }) - }) -}) - -test('Should have typeof body object with no custom parser defined, null body and content type = \'text/plain\'', t => { - t.plan(4) - const fastify = Fastify() - - fastify.post('/', (req, reply) => { - reply.send(req.body) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: null, - headers: { - 'Content-Type': 'text/plain' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(typeof body, 'object') - fastify.close() - }) - }) -}) - -test('Should have typeof body object with no custom parser defined, undefined body and content type = \'text/plain\'', t => { - t.plan(4) - const fastify = Fastify() - - fastify.post('/', (req, reply) => { - reply.send(req.body) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: undefined, - headers: { - 'Content-Type': 'text/plain' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(typeof body, 'object') - fastify.close() - }) - }) -}) - -test('Should get the body as string', t => { - t.plan(6) - const fastify = Fastify() - - fastify.post('/', (req, reply) => { - reply.send(req.body) - }) - - fastify.addContentTypeParser('text/plain', { parseAs: 'string' }, function (req, body, done) { - t.ok('called') - t.ok(typeof body === 'string') - try { - const plainText = body - done(null, plainText) - } catch (err) { - err.statusCode = 400 - done(err, undefined) - } - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: 'hello world', - headers: { - 'Content-Type': 'text/plain' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(body.toString(), 'hello world') - fastify.close() - }) - }) -}) - -test('Should get the body as buffer', t => { - t.plan(6) - const fastify = Fastify() - - fastify.post('/', (req, reply) => { - reply.send(req.body) - }) - - fastify.addContentTypeParser('application/json', { parseAs: 'buffer' }, function (req, body, done) { - t.ok('called') - t.ok(body instanceof Buffer) - try { - const json = JSON.parse(body) - done(null, json) - } catch (err) { - err.statusCode = 400 - done(err, undefined) - } - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: '{"hello":"world"}', - headers: { - 'Content-Type': 'application/json' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(body.toString(), '{"hello":"world"}') - fastify.close() - }) - }) -}) - -test('Should get the body as buffer', t => { - t.plan(6) - const fastify = Fastify() - - fastify.post('/', (req, reply) => { - reply.send(req.body) - }) - - fastify.addContentTypeParser('text/plain', { parseAs: 'buffer' }, function (req, body, done) { - t.ok('called') - t.ok(body instanceof Buffer) - try { - const plainText = body - done(null, plainText) - } catch (err) { - err.statusCode = 400 - done(err, undefined) - } - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: 'hello world', - headers: { - 'Content-Type': 'text/plain' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(body.toString(), 'hello world') - fastify.close() - }) - }) -}) - -test('Should parse empty bodies as a string', t => { - t.plan(9) - const fastify = Fastify() - - fastify.addContentTypeParser('text/plain', { parseAs: 'string' }, (req, body, done) => { - t.equal(body, '') - done(null, body) - }) - - fastify.route({ - method: ['POST', 'DELETE'], - url: '/', - handler (request, reply) { - reply.send(request.body) - } - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: '', - headers: { - 'Content-Type': 'text/plain' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(body.toString(), '') - }) - - sget({ - method: 'DELETE', - url: getUrl(fastify), - body: '', - headers: { - 'Content-Type': 'text/plain', - 'Content-Length': '0' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(body.toString(), '') - }) - }) -}) - -test('Should parse empty bodies as a buffer', t => { - t.plan(6) - const fastify = Fastify() - - fastify.post('/', (req, reply) => { - reply.send(req.body) - }) - - fastify.addContentTypeParser('text/plain', { parseAs: 'buffer' }, function (req, body, done) { - t.ok(body instanceof Buffer) - t.equal(body.length, 0) - done(null, body) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: '', - headers: { - 'Content-Type': 'text/plain' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(body.length, 0) - fastify.close() - }) - }) -}) - -test('The charset should not interfere with the content type handling', t => { - t.plan(5) - const fastify = Fastify() - - fastify.post('/', (req, reply) => { - reply.send(req.body) - }) - - fastify.addContentTypeParser('application/json', function (req, payload, done) { - t.ok('called') - jsonParser(payload, function (err, body) { - done(err, body) - }) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: '{"hello":"world"}', - headers: { - 'Content-Type': 'application/json charset=utf-8' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(body.toString(), '{"hello":"world"}') - fastify.close() - }) - }) -}) - -test('Wrong parseAs parameter', t => { - t.plan(2) - const fastify = Fastify() - - try { - fastify.addContentTypeParser('application/json', { parseAs: 'fireworks' }, () => {}) - t.fail('should throw') - } catch (err) { - t.equal(err.code, 'FST_ERR_CTP_INVALID_PARSE_TYPE') - t.equal(err.message, "The body parser can only parse your data as 'string' or 'buffer', you asked 'fireworks' which is not supported.") - } -}) - -test('Should allow defining the bodyLimit per parser', t => { - t.plan(3) - const fastify = Fastify() - t.teardown(() => fastify.close()) - - fastify.post('/', (req, reply) => { - reply.send(req.body) - }) - - fastify.addContentTypeParser( - 'x/foo', - { parseAs: 'string', bodyLimit: 5 }, - function (req, body, done) { - t.fail('should not be invoked') - done() - } - ) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: '1234567890', - headers: { - 'Content-Type': 'x/foo' - } - }, (err, response, body) => { - t.error(err) - t.strictSame(JSON.parse(body.toString()), { - statusCode: 413, - code: 'FST_ERR_CTP_BODY_TOO_LARGE', - error: 'Payload Too Large', - message: 'Request body is too large' - }) - fastify.close() - }) - }) -}) - -test('route bodyLimit should take precedence over a custom parser bodyLimit', t => { - t.plan(3) - const fastify = Fastify() - t.teardown(() => fastify.close()) - - fastify.post('/', { bodyLimit: 5 }, (request, reply) => { - reply.send(request.body) - }) - - fastify.addContentTypeParser( - 'x/foo', - { parseAs: 'string', bodyLimit: 100 }, - function (req, body, done) { - t.fail('should not be invoked') - done() - } - ) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: '1234567890', - headers: { 'Content-Type': 'x/foo' } - }, (err, response, body) => { - t.error(err) - t.strictSame(JSON.parse(body.toString()), { - statusCode: 413, - code: 'FST_ERR_CTP_BODY_TOO_LARGE', - error: 'Payload Too Large', - message: 'Request body is too large' - }) - fastify.close() - }) - }) -}) - -test('should be able to use default parser for extra content type', t => { - t.plan(4) - const fastify = Fastify() - t.teardown(() => fastify.close()) - - fastify.post('/', (request, reply) => { - reply.send(request.body) - }) - - fastify.addContentTypeParser('text/json', { parseAs: 'string' }, fastify.getDefaultJsonParser('ignore', 'ignore')) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: '{"hello":"world"}', - headers: { - 'Content-Type': 'text/json' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.strictSame(JSON.parse(body.toString()), { hello: 'world' }) - fastify.close() - }) - }) -}) - -test('contentTypeParser should add a custom parser with RegExp value', t => { - t.plan(3) - - const fastify = Fastify() - - fastify.post('/', (req, reply) => { - reply.send(req.body) - }) - - fastify.options('/', (req, reply) => { - reply.send(req.body) - }) - - fastify.addContentTypeParser(/.*\+json$/, function (req, payload, done) { - jsonParser(payload, function (err, body) { - done(err, body) - }) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - t.teardown(() => fastify.close()) - - t.test('in POST', t => { - t.plan(3) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: '{"hello":"world"}', - headers: { - 'Content-Type': 'application/vnd.test+json' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body.toString(), JSON.stringify({ hello: 'world' })) - }) - }) - - t.test('in OPTIONS', t => { - t.plan(3) - - sget({ - method: 'OPTIONS', - url: getUrl(fastify), - body: '{"hello":"world"}', - headers: { - 'Content-Type': 'weird-content-type+json' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body.toString(), JSON.stringify({ hello: 'world' })) - }) - }) - }) -}) - -test('contentTypeParser should add multiple custom parsers with RegExp values', async t => { - t.plan(6) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - - fastify.post('/', (req, reply) => { - reply.send(req.body) - }) - - fastify.addContentTypeParser(/.*\+json$/, function (req, payload, done) { - jsonParser(payload, function (err, body) { - done(err, body) - }) - }) - - fastify.addContentTypeParser(/.*\+xml$/, function (req, payload, done) { - done(null, 'xml') - }) - - fastify.addContentTypeParser(/.*\+myExtension$/, function (req, payload, done) { - let data = '' - payload.on('data', chunk => { data += chunk }) - payload.on('end', () => { - done(null, data + 'myExtension') - }) - }) - - await fastify.ready() - - { - const response = await fastify.inject({ - method: 'POST', - url: '/', - body: '{"hello":"world"}', - headers: { - 'Content-Type': 'application/vnd.hello+json' - } - }) - t.equal(response.statusCode, 200) - t.same(response.payload.toString(), '{"hello":"world"}') - } - - { - const response = await fastify.inject({ - method: 'POST', - url: '/', - body: '{"hello":"world"}', - headers: { - 'Content-Type': 'application/test+xml' - } - }) - t.equal(response.statusCode, 200) - t.same(response.payload.toString(), 'xml') - } - - await fastify.inject({ - method: 'POST', - path: '/', - payload: 'abcdefg', - headers: { - 'Content-Type': 'application/+myExtension' - } - }).then((response) => { - t.equal(response.statusCode, 200) - t.same(response.payload.toString(), 'abcdefgmyExtension') - }).catch((err) => { - t.error(err) - }) -}) - -test('catch all content type parser should not interfere with content type parser', t => { - t.plan(10) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - - fastify.post('/', (req, reply) => { - reply.send(req.body) - }) - - fastify.addContentTypeParser('*', function (req, payload, done) { - let data = '' - payload.on('data', chunk => { data += chunk }) - payload.on('end', () => { - done(null, data) - }) - }) - - fastify.addContentTypeParser(/^application\/.*/, function (req, payload, done) { - jsonParser(payload, function (err, body) { - done(err, body) - }) - }) - - fastify.addContentTypeParser('text/html', function (req, payload, done) { - let data = '' - payload.on('data', chunk => { data += chunk }) - payload.on('end', () => { - done(null, data + 'html') - }) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: '{"myKey":"myValue"}', - headers: { - 'Content-Type': 'application/json' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body.toString(), JSON.stringify({ myKey: 'myValue' })) - }) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: 'body', - headers: { - 'Content-Type': 'very-weird-content-type' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body.toString(), 'body') - }) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: 'my text', - headers: { - 'Content-Type': 'text/html' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body.toString(), 'my texthtml') - }) - }) -}) - -test('should prefer string content types over RegExp ones', t => { - t.plan(7) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - - fastify.post('/', (req, reply) => { - reply.send(req.body) - }) - - fastify.addContentTypeParser(/^application\/.*/, function (req, payload, done) { - let data = '' - payload.on('data', chunk => { data += chunk }) - payload.on('end', () => { - done(null, data) - }) - }) - - fastify.addContentTypeParser('application/json', function (req, payload, done) { - jsonParser(payload, function (err, body) { - done(err, body) - }) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: '{"k1":"myValue", "k2": "myValue"}', - headers: { - 'Content-Type': 'application/json' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body.toString(), JSON.stringify({ k1: 'myValue', k2: 'myValue' })) - }) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: 'javascript', - headers: { - 'Content-Type': 'application/javascript' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body.toString(), 'javascript') - }) - }) -}) - -test('removeContentTypeParser should support arrays of content types to remove', t => { - t.plan(8) - - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - - fastify.addContentTypeParser('application/xml', function (req, payload, done) { - payload.on('data', () => {}) - payload.on('end', () => { - done(null, 'xml') - }) - }) - - fastify.addContentTypeParser(/^image\/.*/, function (req, payload, done) { - payload.on('data', () => {}) - payload.on('end', () => { - done(null, 'image') - }) - }) - - fastify.removeContentTypeParser([/^image\/.*/, 'application/json']) - - fastify.post('/', (req, reply) => { - reply.send(req.body) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: '', - headers: { - 'Content-Type': 'application/xml' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body.toString(), 'xml') - }) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: '', - headers: { - 'Content-Type': 'image/png' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 415) - }) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: '{test: "test"}', - headers: { - 'Content-Type': 'application/json' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 415) - }) - }) -}) - -test('removeContentTypeParser should support encapsulation', t => { - t.plan(6) - - const fastify = Fastify() - - fastify.addContentTypeParser('application/xml', function (req, payload, done) { - payload.on('data', () => {}) - payload.on('end', () => { - done(null, 'xml') - }) - }) - - fastify.post('/', (req, reply) => { - reply.send(req.body) - }) - - fastify.register(function (instance, options, done) { - instance.removeContentTypeParser('application/xml') - - instance.post('/encapsulated', (req, reply) => { - reply.send(req.body) - }) - - done() - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'POST', - url: getUrl(fastify) + '/encapsulated', - body: '', - headers: { - 'Content-Type': 'application/xml' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 415) - }) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: '', - headers: { - 'Content-Type': 'application/xml' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body.toString(), 'xml') - fastify.close() - }) - }) -}) - -test('removeAllContentTypeParsers should support encapsulation', t => { - t.plan(6) - - const fastify = Fastify() - - fastify.post('/', (req, reply) => { - reply.send(req.body) - }) - - fastify.register(function (instance, options, done) { - instance.removeAllContentTypeParsers() - - instance.post('/encapsulated', (req, reply) => { - reply.send(req.body) - }) - - done() - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'POST', - url: getUrl(fastify) + '/encapsulated', - body: '{}', - headers: { - 'Content-Type': 'application/json' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 415) - }) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: '{"test":1}', - headers: { - 'Content-Type': 'application/json' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(JSON.parse(body.toString()).test, 1) - fastify.close() - }) - }) -}) - -test('cannot remove all content type parsers after binding', t => { - t.plan(2) - - const fastify = Fastify() - - t.teardown(fastify.close.bind(fastify)) - - fastify.listen({ port: 0 }, function (err) { - t.error(err) - - t.throws(() => fastify.removeAllContentTypeParsers()) - }) -}) - -test('cannot remove content type parsers after binding', t => { - t.plan(2) - - const fastify = Fastify() - - t.teardown(fastify.close.bind(fastify)) - - fastify.listen({ port: 0 }, function (err) { - t.error(err) - - t.throws(() => fastify.removeContentTypeParser('application/json')) - }) -}) - -test('should be able to override the default json parser after removeAllContentTypeParsers', t => { - t.plan(5) - - const fastify = Fastify() - - fastify.post('/', (req, reply) => { - reply.send(req.body) - }) - - fastify.removeAllContentTypeParsers() - - fastify.addContentTypeParser('application/json', function (req, payload, done) { - t.ok('called') - jsonParser(payload, function (err, body) { - done(err, body) - }) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: '{"hello":"world"}', - headers: { - 'Content-Type': 'application/json' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body.toString(), JSON.stringify({ hello: 'world' })) - fastify.close() - }) - }) -}) - -test('should be able to override the default plain text parser after removeAllContentTypeParsers', t => { - t.plan(5) - - const fastify = Fastify() - - fastify.post('/', (req, reply) => { - reply.send(req.body) - }) - - fastify.removeAllContentTypeParsers() - - fastify.addContentTypeParser('text/plain', function (req, payload, done) { - t.ok('called') - plainTextParser(payload, function (err, body) { - done(err, body) - }) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: 'hello world', - headers: { - 'Content-Type': 'text/plain' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(body.toString(), 'hello world') - fastify.close() - }) - }) -}) - -test('should be able to add a custom content type parser after removeAllContentTypeParsers', t => { - t.plan(5) - - const fastify = Fastify() - - fastify.post('/', (req, reply) => { - reply.send(req.body) - }) - - fastify.removeAllContentTypeParsers() - - fastify.addContentTypeParser('application/jsoff', function (req, payload, done) { - t.ok('called') - jsonParser(payload, function (err, body) { - done(err, body) - }) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'POST', - url: getUrl(fastify), - body: '{"hello":"world"}', - headers: { - 'Content-Type': 'application/jsoff' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body.toString(), JSON.stringify({ hello: 'world' })) - fastify.close() - }) - }) -}) diff --git a/test/custom-querystring-parser.test.js b/test/custom-querystring-parser.test.js index 80280d99c50..a6f8a24d8d1 100644 --- a/test/custom-querystring-parser.test.js +++ b/test/custom-querystring-parser.test.js @@ -1,123 +1,97 @@ 'use strict' -const t = require('tap') -const test = t.test -const querystring = require('querystring') -const sget = require('simple-get').concat +const { test } = require('node:test') +const querystring = require('node:querystring') const Fastify = require('..') -test('Custom querystring parser', t => { - t.plan(9) +test('Custom querystring parser', async t => { + t.plan(7) const fastify = Fastify({ querystringParser: function (str) { - t.equal(str, 'foo=bar&baz=faz') + t.assert.strictEqual(str, 'foo=bar&baz=faz') return querystring.parse(str) } }) fastify.get('/', (req, reply) => { - t.same(req.query, { + t.assert.deepEqual(req.query, { foo: 'bar', baz: 'faz' }) reply.send({ hello: 'world' }) }) - fastify.listen({ port: 0 }, (err, address) => { - t.error(err) - t.teardown(() => fastify.close()) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) - sget({ - method: 'GET', - url: `${address}?foo=bar&baz=faz` - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - }) + const result = await fetch(`${fastifyServer}?foo=bar&baz=faz`) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) - fastify.inject({ - method: 'GET', - url: `${address}?foo=bar&baz=faz` - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - }) + const injectResponse = await fastify.inject({ + method: 'GET', + url: `${fastifyServer}?foo=bar&baz=faz` }) + t.assert.strictEqual(injectResponse.statusCode, 200) }) -test('Custom querystring parser should be called also if there is nothing to parse', t => { - t.plan(9) +test('Custom querystring parser should be called also if there is nothing to parse', async t => { + t.plan(7) const fastify = Fastify({ querystringParser: function (str) { - t.equal(str, '') + t.assert.strictEqual(str, '') return querystring.parse(str) } }) fastify.get('/', (req, reply) => { - t.same(req.query, {}) + t.assert.deepEqual(req.query, {}) reply.send({ hello: 'world' }) }) - fastify.listen({ port: 0 }, (err, address) => { - t.error(err) - t.teardown(() => fastify.close()) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) - sget({ - method: 'GET', - url: address - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - }) + const result = await fetch(fastifyServer) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) - fastify.inject({ - method: 'GET', - url: address - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - }) + const injectResponse = await fastify.inject({ + method: 'GET', + url: fastifyServer }) + t.assert.strictEqual(injectResponse.statusCode, 200) }) -test('Querystring without value', t => { - t.plan(9) +test('Querystring without value', async t => { + t.plan(7) const fastify = Fastify({ querystringParser: function (str) { - t.equal(str, 'foo') + t.assert.strictEqual(str, 'foo') return querystring.parse(str) } }) fastify.get('/', (req, reply) => { - t.same(req.query, { foo: '' }) + t.assert.deepEqual(req.query, { foo: '' }) reply.send({ hello: 'world' }) }) - fastify.listen({ port: 0 }, (err, address) => { - t.error(err) - t.teardown(() => fastify.close()) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) - sget({ - method: 'GET', - url: `${address}?foo` - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - }) + const result = await fetch(`${fastifyServer}?foo`) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) - fastify.inject({ - method: 'GET', - url: `${address}?foo` - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - }) + const injectResponse = await fastify.inject({ + method: 'GET', + url: `${fastifyServer}?foo` }) + t.assert.strictEqual(injectResponse.statusCode, 200) }) test('Custom querystring parser should be a function', t => { @@ -127,9 +101,27 @@ test('Custom querystring parser should be a function', t => { Fastify({ querystringParser: 10 }) + t.assert.fail('Should throw') + } catch (err) { + t.assert.strictEqual( + err.message, + "querystringParser option should be a function, instead got 'number'" + ) + } +}) + +test('Custom querystring parser should be a function', t => { + t.plan(1) + + try { + Fastify({ + routerOptions: { + querystringParser: 10 + } + }) t.fail('Should throw') } catch (err) { - t.equal( + t.assert.equal( err.message, "querystringParser option should be a function, instead got 'number'" ) diff --git a/test/decorator.test.js b/test/decorator.test.js index 88ab6888714..972658f9c58 100644 --- a/test/decorator.test.js +++ b/test/decorator.test.js @@ -1,99 +1,103 @@ 'use strict' -/* eslint no-prototype-builtins: 0 */ - -const t = require('tap') -const test = t.test +const { test, describe } = require('node:test') const Fastify = require('..') const fp = require('fastify-plugin') -const sget = require('simple-get').concat const symbols = require('../lib/symbols.js') -const proxyquire = require('proxyquire') test('server methods should exist', t => { t.plan(2) const fastify = Fastify() - t.ok(fastify.decorate) - t.ok(fastify.hasDecorator) + t.assert.ok(fastify.decorate) + t.assert.ok(fastify.hasDecorator) }) -test('should check if the given decoration already exist when null', t => { +test('should check if the given decoration already exist when null', (t, done) => { t.plan(1) const fastify = Fastify() fastify.decorate('null', null) fastify.ready(() => { - t.ok(fastify.hasDecorator('null')) + t.assert.ok(fastify.hasDecorator('null')) + done() }) }) -test('server methods should be encapsulated via .register', t => { +test('server methods should be encapsulated via .register', (t, done) => { t.plan(2) const fastify = Fastify() fastify.register((instance, opts, done) => { instance.decorate('test', () => {}) - t.ok(instance.test) + t.assert.ok(instance.test) done() }) fastify.ready(() => { - t.notOk(fastify.test) + t.assert.strictEqual(fastify.test, undefined) + done() }) }) -test('hasServerMethod should check if the given method already exist', t => { +test('hasServerMethod should check if the given method already exist', (t, done) => { t.plan(2) const fastify = Fastify() fastify.register((instance, opts, done) => { instance.decorate('test', () => {}) - t.ok(instance.hasDecorator('test')) + t.assert.ok(instance.hasDecorator('test')) done() }) fastify.ready(() => { - t.notOk(fastify.hasDecorator('test')) + t.assert.strictEqual(fastify.hasDecorator('test'), false) + done() }) }) -test('decorate should throw if a declared dependency is not present', t => { +test('decorate should throw if a declared dependency is not present', (t, done) => { t.plan(3) const fastify = Fastify() fastify.register((instance, opts, done) => { try { instance.decorate('test', () => {}, ['dependency']) - t.fail() + t.assert.fail() } catch (e) { - t.same(e.code, 'FST_ERR_DEC_MISSING_DEPENDENCY') - t.same(e.message, 'The decorator is missing dependency \'dependency\'.') + t.assert.strictEqual(e.code, 'FST_ERR_DEC_MISSING_DEPENDENCY') + t.assert.strictEqual(e.message, 'The decorator is missing dependency \'dependency\'.') } done() }) - fastify.ready(() => t.pass()) + fastify.ready(() => { + t.assert.ok('ready') + done() + }) }) -test('decorate should throw if declared dependency is not array', t => { +test('decorate should throw if declared dependency is not array', (t, done) => { t.plan(3) const fastify = Fastify() fastify.register((instance, opts, done) => { try { instance.decorate('test', () => {}, {}) - t.fail() + t.assert.fail() } catch (e) { - t.same(e.code, 'FST_ERR_DEC_DEPENDENCY_INVALID_TYPE') - t.same(e.message, 'The dependencies of decorator \'test\' must be of type Array.') + t.assert.strictEqual(e.code, 'FST_ERR_DEC_DEPENDENCY_INVALID_TYPE') + t.assert.strictEqual(e.message, 'The dependencies of decorator \'test\' must be of type Array.') } done() }) - fastify.ready(() => t.pass()) + fastify.ready(() => { + t.assert.ok('ready') + done() + }) }) // issue #777 -test('should pass error for missing request decorator', t => { +test('should pass error for missing request decorator', (t, done) => { t.plan(2) const fastify = Fastify() @@ -107,20 +111,37 @@ test('should pass error for missing request decorator', t => { fastify .register(plugin) .ready((err) => { - t.type(err, Error) - t.match(err, /The decorator 'foo'/) + t.assert.ok(err instanceof Error) + t.assert.ok(err.message.includes("'foo'")) + done() }) }) -test('decorateReply inside register', t => { - t.plan(11) +const runTests = async (t, fastifyServer) => { + const endpoints = [ + { path: '/yes', expectedBody: { hello: 'world' } }, + { path: '/no', expectedBody: { hello: 'world' } } + ] + + for (const { path, expectedBody } of endpoints) { + const result = await fetch(`${fastifyServer}${path}`) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), expectedBody) + } +} + +test('decorateReply inside register', async (t) => { + t.plan(10) const fastify = Fastify() fastify.register((instance, opts, done) => { instance.decorateReply('test', 'test') instance.get('/yes', (req, reply) => { - t.ok(reply.test, 'test exists') + t.assert.ok(reply.test, 'test exists') reply.send({ hello: 'world' }) }) @@ -128,38 +149,18 @@ test('decorateReply inside register', t => { }) fastify.get('/no', (req, reply) => { - t.notOk(reply.test) + t.assert.ok(!reply.test) reply.send({ hello: 'world' }) }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/yes' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/no' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) - }) + await runTests(t, fastifyServer) }) -test('decorateReply as plugin (inside .after)', t => { - t.plan(11) +test('decorateReply as plugin (inside .after)', async t => { + t.plan(10) const fastify = Fastify() fastify.register((instance, opts, done) => { @@ -168,7 +169,7 @@ test('decorateReply as plugin (inside .after)', t => { n() })).after(() => { instance.get('/yes', (req, reply) => { - t.ok(reply.test) + t.assert.ok(reply.test) reply.send({ hello: 'world' }) }) }) @@ -176,38 +177,18 @@ test('decorateReply as plugin (inside .after)', t => { }) fastify.get('/no', (req, reply) => { - t.notOk(reply.test) + t.assert.ok(!reply.test) reply.send({ hello: 'world' }) }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/yes' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) - - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/no' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) - }) + await runTests(t, fastifyServer) }) -test('decorateReply as plugin (outside .after)', t => { - t.plan(11) +test('decorateReply as plugin (outside .after)', async t => { + t.plan(10) const fastify = Fastify() fastify.register((instance, opts, done) => { @@ -217,52 +198,32 @@ test('decorateReply as plugin (outside .after)', t => { })) instance.get('/yes', (req, reply) => { - t.ok(reply.test) + t.assert.ok(reply.test) reply.send({ hello: 'world' }) }) done() }) fastify.get('/no', (req, reply) => { - t.notOk(reply.test) + t.assert.ok(!reply.test) reply.send({ hello: 'world' }) }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/yes' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/no' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) - }) + await runTests(t, fastifyServer) }) -test('decorateRequest inside register', t => { - t.plan(11) +test('decorateRequest inside register', async t => { + t.plan(10) const fastify = Fastify() fastify.register((instance, opts, done) => { instance.decorateRequest('test', 'test') instance.get('/yes', (req, reply) => { - t.ok(req.test, 'test exists') + t.assert.ok(req.test, 'test exists') reply.send({ hello: 'world' }) }) @@ -270,38 +231,18 @@ test('decorateRequest inside register', t => { }) fastify.get('/no', (req, reply) => { - t.notOk(req.test) + t.assert.ok(!req.test) reply.send({ hello: 'world' }) }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/yes' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) - - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/no' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) - }) + await runTests(t, fastifyServer) }) -test('decorateRequest as plugin (inside .after)', t => { - t.plan(11) +test('decorateRequest as plugin (inside .after)', async t => { + t.plan(10) const fastify = Fastify() fastify.register((instance, opts, done) => { @@ -310,7 +251,7 @@ test('decorateRequest as plugin (inside .after)', t => { n() })).after(() => { instance.get('/yes', (req, reply) => { - t.ok(req.test) + t.assert.ok(req.test) reply.send({ hello: 'world' }) }) }) @@ -318,38 +259,18 @@ test('decorateRequest as plugin (inside .after)', t => { }) fastify.get('/no', (req, reply) => { - t.notOk(req.test) + t.assert.ok(!req.test) reply.send({ hello: 'world' }) }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/yes' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/no' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) - }) + await runTests(t, fastifyServer) }) -test('decorateRequest as plugin (outside .after)', t => { - t.plan(11) +test('decorateRequest as plugin (outside .after)', async (t) => { + t.plan(10) const fastify = Fastify() fastify.register((instance, opts, done) => { @@ -359,44 +280,24 @@ test('decorateRequest as plugin (outside .after)', t => { })) instance.get('/yes', (req, reply) => { - t.ok(req.test) + t.assert.ok(req.test) reply.send({ hello: 'world' }) }) done() }) fastify.get('/no', (req, reply) => { - t.notOk(req.test) + t.assert.ok(!req.test) reply.send({ hello: 'world' }) }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/yes' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) - - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/no' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) - }) + await runTests(t, fastifyServer) }) -test('decorators should be instance separated', t => { +test('decorators should be instance separated', (t, done) => { t.plan(1) const fastify1 = Fastify() @@ -411,140 +312,119 @@ test('decorators should be instance separated', t => { fastify1.decorateReply('test', 'foo') fastify2.decorateReply('test', 'foo') - t.pass() + t.assert.ok('Done') + done() }) -test('hasRequestDecorator', t => { +describe('hasRequestDecorator', () => { const requestDecoratorName = 'my-decorator-name' - t.test('is a function', t => { - t.plan(1) + test('is a function', async t => { const fastify = Fastify() - t.ok(fastify.hasRequestDecorator) + t.assert.ok(fastify.hasRequestDecorator) }) - t.test('should check if the given request decoration already exist', t => { - t.plan(2) + test('should check if the given request decoration already exist', async t => { const fastify = Fastify() - t.notOk(fastify.hasRequestDecorator(requestDecoratorName)) + t.assert.ok(!fastify.hasRequestDecorator(requestDecoratorName)) fastify.decorateRequest(requestDecoratorName, 42) - t.ok(fastify.hasRequestDecorator(requestDecoratorName)) + t.assert.ok(fastify.hasRequestDecorator(requestDecoratorName)) }) - t.test('should check if the given request decoration already exist when null', t => { - t.plan(2) + test('should check if the given request decoration already exist when null', async t => { const fastify = Fastify() - t.notOk(fastify.hasRequestDecorator(requestDecoratorName)) + t.assert.ok(!fastify.hasRequestDecorator(requestDecoratorName)) fastify.decorateRequest(requestDecoratorName, null) - t.ok(fastify.hasRequestDecorator(requestDecoratorName)) + t.assert.ok(fastify.hasRequestDecorator(requestDecoratorName)) }) - t.test('should be plugin encapsulable', t => { - t.plan(4) + test('should be plugin encapsulable', async t => { const fastify = Fastify() - t.notOk(fastify.hasRequestDecorator(requestDecoratorName)) + t.assert.ok(!fastify.hasRequestDecorator(requestDecoratorName)) - fastify.register(function (fastify2, opts, done) { + await fastify.register(async function (fastify2, opts) { fastify2.decorateRequest(requestDecoratorName, 42) - t.ok(fastify2.hasRequestDecorator(requestDecoratorName)) - done() + t.assert.ok(fastify2.hasRequestDecorator(requestDecoratorName)) }) - t.notOk(fastify.hasRequestDecorator(requestDecoratorName)) + t.assert.ok(!fastify.hasRequestDecorator(requestDecoratorName)) - fastify.ready(function () { - t.notOk(fastify.hasRequestDecorator(requestDecoratorName)) - }) + await fastify.ready() + t.assert.ok(!fastify.hasRequestDecorator(requestDecoratorName)) }) - t.test('should be inherited', t => { - t.plan(2) + test('should be inherited', async t => { const fastify = Fastify() fastify.decorateRequest(requestDecoratorName, 42) - fastify.register(function (fastify2, opts, done) { - t.ok(fastify2.hasRequestDecorator(requestDecoratorName)) - done() + await fastify.register(async function (fastify2, opts) { + t.assert.ok(fastify2.hasRequestDecorator(requestDecoratorName)) }) - fastify.ready(function () { - t.ok(fastify.hasRequestDecorator(requestDecoratorName)) - }) + await fastify.ready() + t.assert.ok(fastify.hasRequestDecorator(requestDecoratorName)) }) - - t.end() }) -test('hasReplyDecorator', t => { +describe('hasReplyDecorator', () => { const replyDecoratorName = 'my-decorator-name' - t.test('is a function', t => { - t.plan(1) + test('is a function', async t => { const fastify = Fastify() - t.ok(fastify.hasReplyDecorator) + t.assert.ok(fastify.hasReplyDecorator) }) - t.test('should check if the given reply decoration already exist', t => { - t.plan(2) + test('should check if the given reply decoration already exist', async t => { const fastify = Fastify() - t.notOk(fastify.hasReplyDecorator(replyDecoratorName)) + t.assert.ok(!fastify.hasReplyDecorator(replyDecoratorName)) fastify.decorateReply(replyDecoratorName, 42) - t.ok(fastify.hasReplyDecorator(replyDecoratorName)) + t.assert.ok(fastify.hasReplyDecorator(replyDecoratorName)) }) - t.test('should check if the given reply decoration already exist when null', t => { - t.plan(2) + test('should check if the given reply decoration already exist when null', async t => { const fastify = Fastify() - t.notOk(fastify.hasReplyDecorator(replyDecoratorName)) + t.assert.ok(!fastify.hasReplyDecorator(replyDecoratorName)) fastify.decorateReply(replyDecoratorName, null) - t.ok(fastify.hasReplyDecorator(replyDecoratorName)) + t.assert.ok(fastify.hasReplyDecorator(replyDecoratorName)) }) - t.test('should be plugin encapsulable', t => { - t.plan(4) + test('should be plugin encapsulable', async t => { const fastify = Fastify() - t.notOk(fastify.hasReplyDecorator(replyDecoratorName)) + t.assert.ok(!fastify.hasReplyDecorator(replyDecoratorName)) - fastify.register(function (fastify2, opts, done) { + await fastify.register(async function (fastify2, opts) { fastify2.decorateReply(replyDecoratorName, 42) - t.ok(fastify2.hasReplyDecorator(replyDecoratorName)) - done() + t.assert.ok(fastify2.hasReplyDecorator(replyDecoratorName)) }) - t.notOk(fastify.hasReplyDecorator(replyDecoratorName)) + t.assert.ok(!fastify.hasReplyDecorator(replyDecoratorName)) - fastify.ready(function () { - t.notOk(fastify.hasReplyDecorator(replyDecoratorName)) - }) + await fastify.ready() + t.assert.ok(!fastify.hasReplyDecorator(replyDecoratorName)) }) - t.test('should be inherited', t => { - t.plan(2) + test('should be inherited', async t => { const fastify = Fastify() fastify.decorateReply(replyDecoratorName, 42) - fastify.register(function (fastify2, opts, done) { - t.ok(fastify2.hasReplyDecorator(replyDecoratorName)) - done() + await fastify.register(async function (fastify2, opts) { + t.assert.ok(fastify2.hasReplyDecorator(replyDecoratorName)) }) - fastify.ready(function () { - t.ok(fastify.hasReplyDecorator(replyDecoratorName)) - }) + await fastify.ready() + t.assert.ok(fastify.hasReplyDecorator(replyDecoratorName)) }) - - t.end() }) -test('should register properties via getter/setter objects', t => { +test('should register properties via getter/setter objects', (t, done) => { t.plan(3) const fastify = Fastify() @@ -554,17 +434,18 @@ test('should register properties via getter/setter objects', t => { return 'a getter' } }) - t.ok(instance.test) - t.ok(instance.test, 'a getter') + t.assert.ok(instance.test) + t.assert.ok(instance.test, 'a getter') done() }) fastify.ready(() => { - t.notOk(fastify.test) + t.assert.ok(!fastify.test) + done() }) }) -test('decorateRequest should work with getter/setter', t => { +test('decorateRequest should work with getter/setter', (t, done) => { t.plan(5) const fastify = Fastify() @@ -583,24 +464,34 @@ test('decorateRequest should work with getter/setter', t => { }) fastify.get('/not-decorated', (req, res) => { - t.notOk(req.test) + t.assert.ok(!req.test) res.send() }) + let pending = 2 + + function completed () { + if (--pending === 0) { + done() + } + } + fastify.ready(() => { fastify.inject({ url: '/req-decorated-get-set' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { test: 'a getter' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { test: 'a getter' }) + completed() }) fastify.inject({ url: '/not-decorated' }, (err, res) => { - t.error(err) - t.pass() + t.assert.ifError(err) + t.assert.ok('ok', 'not decorated') + completed() }) }) }) -test('decorateReply should work with getter/setter', t => { +test('decorateReply should work with getter/setter', (t, done) => { t.plan(5) const fastify = Fastify() @@ -619,39 +510,49 @@ test('decorateReply should work with getter/setter', t => { }) fastify.get('/not-decorated', (req, res) => { - t.notOk(res.test) + t.assert.ok(!res.test) res.send() }) + let pending = 2 + + function completed () { + if (--pending === 0) { + done() + } + } fastify.ready(() => { fastify.inject({ url: '/res-decorated-get-set' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { test: 'a getter' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { test: 'a getter' }) + completed() }) fastify.inject({ url: '/not-decorated' }, (err, res) => { - t.error(err) - t.pass() + t.assert.ifError(err) + t.assert.ok('ok') + completed() }) }) }) -test('should register empty values', t => { +test('should register empty values', (t, done) => { t.plan(2) const fastify = Fastify() fastify.register((instance, opts, done) => { instance.decorate('test', null) - t.ok(instance.hasOwnProperty('test')) + t.assert.ok(Object.hasOwn(instance, 'test')) done() }) fastify.ready(() => { - t.notOk(fastify.test) + t.assert.ok(!fastify.test) + done() }) }) -test('nested plugins can override things', t => { +test('nested plugins can override things', (t, done) => { t.plan(6) const fastify = Fastify() @@ -666,20 +567,21 @@ test('nested plugins can override things', t => { instance.decorateRequest('test', func) instance.decorateReply('test', func) - t.equal(instance.test, func) - t.equal(instance[symbols.kRequest].prototype.test, func) - t.equal(instance[symbols.kReply].prototype.test, func) + t.assert.strictEqual(instance.test, func) + t.assert.strictEqual(instance[symbols.kRequest].prototype.test, func) + t.assert.strictEqual(instance[symbols.kReply].prototype.test, func) done() }) fastify.ready(() => { - t.equal(fastify.test, rootFunc) - t.equal(fastify[symbols.kRequest].prototype.test, rootFunc) - t.equal(fastify[symbols.kReply].prototype.test, rootFunc) + t.assert.strictEqual(fastify.test, rootFunc) + t.assert.strictEqual(fastify[symbols.kRequest].prototype.test, rootFunc) + t.assert.strictEqual(fastify[symbols.kReply].prototype.test, rootFunc) + done() }) }) -test('a decorator should addSchema to all the encapsulated tree', t => { +test('a decorator should addSchema to all the encapsulated tree', (t, done) => { t.plan(1) const fastify = Fastify() @@ -703,10 +605,13 @@ test('a decorator should addSchema to all the encapsulated tree', t => { done() }) - fastify.ready(t.error) + fastify.ready(() => { + t.assert.ifError() + done() + }) }) -test('after can access to a decorated instance and previous plugin decoration', t => { +test('after can access to a decorated instance and previous plugin decoration', (t, done) => { t.plan(11) const TEST_VALUE = {} const OTHER_TEST_VALUE = {} @@ -719,38 +624,39 @@ test('after can access to a decorated instance and previous plugin decoration', done() })).after(function (err, instance, done) { - t.error(err) - t.equal(instance.test, TEST_VALUE) + t.assert.ifError(err) + t.assert.strictEqual(instance.test, TEST_VALUE) instance.decorate('test2', OTHER_TEST_VALUE) done() }) fastify.register(fp(function (instance, options, done) { - t.equal(instance.test, TEST_VALUE) - t.equal(instance.test2, OTHER_TEST_VALUE) + t.assert.strictEqual(instance.test, TEST_VALUE) + t.assert.strictEqual(instance.test2, OTHER_TEST_VALUE) instance.decorate('test3', NEW_TEST_VALUE) done() })).after(function (err, instance, done) { - t.error(err) - t.equal(instance.test, TEST_VALUE) - t.equal(instance.test2, OTHER_TEST_VALUE) - t.equal(instance.test3, NEW_TEST_VALUE) + t.assert.ifError(err) + t.assert.strictEqual(instance.test, TEST_VALUE) + t.assert.strictEqual(instance.test2, OTHER_TEST_VALUE) + t.assert.strictEqual(instance.test3, NEW_TEST_VALUE) done() }) fastify.get('/', function (req, res) { - t.equal(this.test, TEST_VALUE) - t.equal(this.test2, OTHER_TEST_VALUE) + t.assert.strictEqual(this.test, TEST_VALUE) + t.assert.strictEqual(this.test2, OTHER_TEST_VALUE) res.send({}) }) fastify.inject('/') .then(response => { - t.equal(response.statusCode, 200) + t.assert.strictEqual(response.statusCode, 200) + done() }) }) @@ -767,67 +673,66 @@ test('decorate* should throw if called after ready', async t => { await fastify.listen({ port: 0 }) try { fastify.decorate('test', true) - t.fail('should not decorate') + t.assert.fail('should not decorate') } catch (err) { - t.same(err.code, 'FST_ERR_DEC_AFTER_START') - t.same(err.message, "The decorator 'test' has been added after start!") + t.assert.strictEqual(err.code, 'FST_ERR_DEC_AFTER_START') + t.assert.strictEqual(err.message, "The decorator 'test' has been added after start!") } try { fastify.decorateRequest('test', true) - t.fail('should not decorate') + t.assert.fail('should not decorate') } catch (e) { - t.same(e.code, 'FST_ERR_DEC_AFTER_START') - t.same(e.message, "The decorator 'test' has been added after start!") + t.assert.strictEqual(e.code, 'FST_ERR_DEC_AFTER_START') + t.assert.strictEqual(e.message, "The decorator 'test' has been added after start!") } try { fastify.decorateReply('test', true) - t.fail('should not decorate') + t.assert.fail('should not decorate') } catch (e) { - t.same(e.code, 'FST_ERR_DEC_AFTER_START') - t.same(e.message, "The decorator 'test' has been added after start!") + t.assert.strictEqual(e.code, 'FST_ERR_DEC_AFTER_START') + t.assert.strictEqual(e.message, "The decorator 'test' has been added after start!") } await fastify.close() }) -test('decorate* should emit warning if an array is passed', t => { +test('decorate* should emit error if an array is passed', t => { t.plan(2) - function onWarning (code, name) { - t.equal(name, 'test_array') - t.equal(code, 'FSTDEP006') - } - const warning = { - emit: onWarning + + const fastify = Fastify() + try { + fastify.decorateRequest('test_array', []) + t.assert.fail('should not decorate') + } catch (err) { + t.assert.strictEqual(err.code, 'FST_ERR_DEC_REFERENCE_TYPE') + t.assert.strictEqual(err.message, "The decorator 'test_array' of type 'object' is a reference type. Use the { getter, setter } interface instead.") } +}) - const decorate = proxyquire('../lib/decorate', { './warnings': warning }) - const fastify = proxyquire('..', { './lib/decorate.js': decorate })() - fastify.decorateRequest('test_array', []) +test('server.decorate should not emit error if reference type is passed', async t => { + t.plan(1) + + const fastify = Fastify() + fastify.decorate('test_array', []) + fastify.decorate('test_object', {}) + await fastify.ready() + t.assert.ok('Done') }) test('decorate* should emit warning if object type is passed', t => { t.plan(2) - function onWarning (code, name) { - t.equal(name, 'test_object') - t.equal(code, 'FSTDEP006') - } - const warning = { - emit: onWarning - } - const decorate = proxyquire('../lib/decorate', { './warnings': warning }) - const fastify = proxyquire('..', { './lib/decorate.js': decorate })() - fastify.decorateRequest('test_object', { foo: 'bar' }) + const fastify = Fastify() + try { + fastify.decorateRequest('test_object', { foo: 'bar' }) + t.assert.fail('should not decorate') + } catch (err) { + t.assert.strictEqual(err.code, 'FST_ERR_DEC_REFERENCE_TYPE') + t.assert.strictEqual(err.message, "The decorator 'test_object' of type 'object' is a reference type. Use the { getter, setter } interface instead.") + } }) test('decorate* should not emit warning if object with getter/setter is passed', t => { - function onWarning (warning) { - t.fail('Should not call a warn') - } - const warning = { - emit: onWarning - } - const decorate = proxyquire('../lib/decorate', { './warnings': warning }) - const fastify = proxyquire('..', { './lib/decorate.js': decorate })() + const fastify = Fastify() fastify.decorateRequest('test_getter_setter', { setter (val) { @@ -837,19 +742,77 @@ test('decorate* should not emit warning if object with getter/setter is passed', return 'a getter' } }) - t.end('Done') + t.assert.ok('Done') }) -test('decorate* should not emit warning if string,bool,numbers are passed', t => { - function onWarning (warning) { - t.fail('Should not call a warn') - } - const warning = { - emit: onWarning - } +test('decorateRequest with getter/setter can handle encapsulation', async t => { + t.plan(24) + + const fastify = Fastify({ logger: true }) - const decorate = proxyquire('../lib/decorate', { './warnings': warning }) - const fastify = proxyquire('..', { './lib/decorate.js': decorate })() + fastify.decorateRequest('test_getter_setter_holder') + fastify.decorateRequest('test_getter_setter', { + getter () { + this.test_getter_setter_holder ??= {} + return this.test_getter_setter_holder + } + }) + + fastify.get('/', async function (req, reply) { + t.assert.deepStrictEqual(req.test_getter_setter, {}, 'a getter') + req.test_getter_setter.a = req.id + t.assert.deepStrictEqual(req.test_getter_setter, { a: req.id }) + }) + + fastify.addHook('onResponse', async function hook (req, reply) { + t.assert.deepStrictEqual(req.test_getter_setter, { a: req.id }) + }) + + await Promise.all([ + fastify.inject('/').then(res => t.assert.strictEqual(res.statusCode, 200)), + fastify.inject('/').then(res => t.assert.strictEqual(res.statusCode, 200)), + fastify.inject('/').then(res => t.assert.strictEqual(res.statusCode, 200)), + fastify.inject('/').then(res => t.assert.strictEqual(res.statusCode, 200)), + fastify.inject('/').then(res => t.assert.strictEqual(res.statusCode, 200)), + fastify.inject('/').then(res => t.assert.strictEqual(res.statusCode, 200)) + ]) +}) + +test('decorateRequest with getter/setter can handle encapsulation with arrays', async t => { + t.plan(24) + + const fastify = Fastify({ logger: true }) + + fastify.decorateRequest('array_holder') + fastify.decorateRequest('my_array', { + getter () { + this.array_holder ??= [] + return this.array_holder + } + }) + + fastify.get('/', async function (req, reply) { + t.assert.deepStrictEqual(req.my_array, []) + req.my_array.push(req.id) + t.assert.deepStrictEqual(req.my_array, [req.id]) + }) + + fastify.addHook('onResponse', async function hook (req, reply) { + t.assert.deepStrictEqual(req.my_array, [req.id]) + }) + + await Promise.all([ + fastify.inject('/').then(res => t.assert.strictEqual(res.statusCode, 200)), + fastify.inject('/').then(res => t.assert.strictEqual(res.statusCode, 200)), + fastify.inject('/').then(res => t.assert.strictEqual(res.statusCode, 200)), + fastify.inject('/').then(res => t.assert.strictEqual(res.statusCode, 200)), + fastify.inject('/').then(res => t.assert.strictEqual(res.statusCode, 200)), + fastify.inject('/').then(res => t.assert.strictEqual(res.statusCode, 200)) + ]) +}) + +test('decorate* should not emit error if string,bool,numbers are passed', t => { + const fastify = Fastify() fastify.decorateRequest('test_str', 'foo') fastify.decorateRequest('test_bool', true) @@ -861,7 +824,7 @@ test('decorate* should not emit warning if string,bool,numbers are passed', t => fastify.decorateReply('test_number', 42) fastify.decorateReply('test_null', null) fastify.decorateReply('test_undefined', undefined) - t.end('Done') + t.assert.ok('Done') }) test('Request/reply decorators should be able to access the server instance', async t => { @@ -871,9 +834,9 @@ test('Request/reply decorators should be able to access the server instance', as server.decorateRequest('assert', rootAssert) server.decorateReply('assert', rootAssert) - server.get('/root-assert', async (req, rep) => { + server.get('/root-assert', async (req, res) => { req.assert() - rep.assert() + res.assert() return 'done' }) @@ -882,9 +845,9 @@ test('Request/reply decorators should be able to access the server instance', as instance.decorateReply('assert', nestedAssert) instance.decorate('foo', 'bar') - instance.get('/nested-assert', async (req, rep) => { + instance.get('/nested-assert', async (req, res) => { req.assert() - rep.assert() + res.assert() return 'done' }) }) @@ -894,12 +857,12 @@ test('Request/reply decorators should be able to access the server instance', as // ---- function rootAssert () { - t.equal(this.server, server) + t.assert.strictEqual(this.server, server) } function nestedAssert () { - t.not(this.server, server) - t.equal(this.server.foo, 'bar') + t.assert.notStrictEqual(this.server, server) + t.assert.strictEqual(this.server.foo, 'bar') } }) @@ -936,94 +899,79 @@ test('plugin required decorators', async t => { await app.ready() }) -test('decorateRequest/decorateReply empty string', t => { - t.plan(7) +test('decorateRequest/decorateReply empty string', async t => { + t.plan(6) const fastify = Fastify() fastify.decorateRequest('test', '') fastify.decorateReply('test2', '') fastify.get('/yes', (req, reply) => { - t.equal(req.test, '') - t.equal(reply.test2, '') + t.assert.strictEqual(req.test, '') + t.assert.strictEqual(reply.test2, '') reply.send({ hello: 'world' }) }) - t.teardown(fastify.close.bind(fastify)) + t.after(() => fastify.close()) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/yes' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) - }) + const result = await fetch(`${fastifyServer}/yes`) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { hello: 'world' }) }) -test('decorateRequest/decorateReply is undefined', t => { - t.plan(7) +test('decorateRequest/decorateReply is undefined', async t => { + t.plan(6) const fastify = Fastify() fastify.decorateRequest('test', undefined) fastify.decorateReply('test2', undefined) fastify.get('/yes', (req, reply) => { - t.equal(req.test, undefined) - t.equal(reply.test2, undefined) + t.assert.strictEqual(req.test, undefined) + t.assert.strictEqual(reply.test2, undefined) reply.send({ hello: 'world' }) }) - t.teardown(fastify.close.bind(fastify)) + t.after(() => fastify.close()) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/yes' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) - }) + const result = await fetch(`${fastifyServer}/yes`) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { hello: 'world' }) }) -test('decorateRequest/decorateReply is not set to a value', t => { - t.plan(7) +test('decorateRequest/decorateReply is not set to a value', async t => { + t.plan(6) const fastify = Fastify() fastify.decorateRequest('test') fastify.decorateReply('test2') fastify.get('/yes', (req, reply) => { - t.equal(req.test, undefined) - t.equal(reply.test2, undefined) + t.assert.strictEqual(req.test, undefined) + t.assert.strictEqual(reply.test2, undefined) reply.send({ hello: 'world' }) }) - t.teardown(fastify.close.bind(fastify)) + t.after(() => fastify.close()) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/yes' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) - }) + const result = await fetch(`${fastifyServer}/yes`) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { hello: 'world' }) }) -test('decorateRequest with dependencies', (t) => { +test('decorateRequest with dependencies', (t, done) => { t.plan(2) const app = Fastify() @@ -1037,12 +985,13 @@ test('decorateRequest with dependencies', (t) => { app.hasDecorator('decorator1') && app.hasRequestDecorator('decorator1') ) { - t.doesNotThrow(() => app.decorateRequest('decorator2', decorator2, ['decorator1'])) - t.ok(app.hasRequestDecorator('decorator2')) + t.assert.doesNotThrow(() => app.decorateRequest('decorator2', decorator2, ['decorator1'])) + t.assert.ok(app.hasRequestDecorator('decorator2')) + done() } }) -test('decorateRequest with dependencies (functions)', (t) => { +test('decorateRequest with dependencies (functions)', (t, done) => { t.plan(2) const app = Fastify() @@ -1056,12 +1005,13 @@ test('decorateRequest with dependencies (functions)', (t) => { app.hasDecorator('decorator1') && app.hasRequestDecorator('decorator1') ) { - t.doesNotThrow(() => app.decorateRequest('decorator2', decorator2, ['decorator1'])) - t.ok(app.hasRequestDecorator('decorator2')) + t.assert.doesNotThrow(() => app.decorateRequest('decorator2', decorator2, ['decorator1'])) + t.assert.ok(app.hasRequestDecorator('decorator2')) + done() } }) -test('chain of decorators on Request', async (t) => { +test('chain of decorators on Request', async t => { const fastify = Fastify() fastify.register(fp(async function (fastify) { fastify.decorateRequest('foo', 'toto') @@ -1105,32 +1055,32 @@ test('chain of decorators on Request', async (t) => { { const response = await fastify.inject('/foo') - t.equal(response.body, 'toto') + t.assert.strictEqual(response.body, 'toto') } { const response = await fastify.inject('/bar') - t.equal(response.body, 'tata') + t.assert.strictEqual(response.body, 'tata') } { const response = await fastify.inject('/plugin2/foo') - t.equal(response.body, 'toto') + t.assert.strictEqual(response.body, 'toto') } { const response = await fastify.inject('/plugin2/bar') - t.equal(response.body, 'tata') + t.assert.strictEqual(response.body, 'tata') } { const response = await fastify.inject('/plugin2/plugin3/foo') - t.equal(response.body, 'toto') + t.assert.strictEqual(response.body, 'toto') } { const response = await fastify.inject('/plugin2/plugin3/bar') - t.equal(response.body, 'tata') + t.assert.strictEqual(response.body, 'tata') } }) @@ -1178,31 +1128,203 @@ test('chain of decorators on Reply', async (t) => { { const response = await fastify.inject('/foo') - t.equal(response.body, 'toto') + t.assert.strictEqual(response.body, 'toto') } { const response = await fastify.inject('/bar') - t.equal(response.body, 'tata') + t.assert.strictEqual(response.body, 'tata') } { const response = await fastify.inject('/plugin2/foo') - t.equal(response.body, 'toto') + t.assert.strictEqual(response.body, 'toto') } { const response = await fastify.inject('/plugin2/bar') - t.equal(response.body, 'tata') + t.assert.strictEqual(response.body, 'tata') } { const response = await fastify.inject('/plugin2/plugin3/foo') - t.equal(response.body, 'toto') + t.assert.strictEqual(response.body, 'toto') } { const response = await fastify.inject('/plugin2/plugin3/bar') - t.equal(response.body, 'tata') + t.assert.strictEqual(response.body, 'tata') } }) + +test('getDecorator should return the decorator', (t, done) => { + t.plan(12) + const fastify = Fastify() + + fastify.decorate('root', 'from_root') + fastify.decorateRequest('root', 'from_root_request') + fastify.decorateReply('root', 'from_root_reply') + + t.assert.strictEqual(fastify.getDecorator('root'), 'from_root') + fastify.get('/', async (req, res) => { + t.assert.strictEqual(req.getDecorator('root'), 'from_root_request') + t.assert.strictEqual(res.getDecorator('root'), 'from_root_reply') + + res.send() + }) + + fastify.register((child) => { + child.decorate('child', 'from_child') + + t.assert.strictEqual(child.getDecorator('child'), 'from_child') + t.assert.strictEqual(child.getDecorator('root'), 'from_root') + + child.get('/child', async (req, res) => { + t.assert.strictEqual(req.getDecorator('root'), 'from_root_request') + t.assert.strictEqual(res.getDecorator('root'), 'from_root_reply') + + res.send() + }) + }) + + fastify.ready((err) => { + t.assert.ifError(err) + fastify.inject({ url: '/' }, (err, res) => { + t.assert.ifError(err) + t.assert.ok(true) + }) + + fastify.inject({ url: '/child' }, (err, res) => { + t.assert.ifError(err) + t.assert.ok(true) + done() + }) + }) +}) + +test('getDecorator should return function decorators with expected binded context', (t, done) => { + t.plan(12) + const fastify = Fastify() + + fastify.decorate('a', function () { + return this + }) + fastify.decorateRequest('b', function () { + return this + }) + fastify.decorateReply('c', function () { + return this + }) + + fastify.register((child) => { + child.decorate('a', function () { + return this + }) + + t.assert.deepEqual(child.getDecorator('a')(), child) + child.get('/child', async (req, res) => { + t.assert.deepEqual(req.getDecorator('b')(), req) + t.assert.deepEqual(res.getDecorator('c')(), res) + + res.send() + }) + }) + + t.assert.deepEqual(fastify.getDecorator('a')(), fastify) + fastify.get('/', async (req, res) => { + t.assert.deepEqual(req.getDecorator('b')(), req) + t.assert.deepEqual(res.getDecorator('c')(), res) + res.send() + }) + + fastify.ready((err) => { + t.assert.ifError(err) + fastify.inject({ url: '/' }, (err, res) => { + t.assert.ifError(err) + t.assert.ok(true, 'passed') + }) + + fastify.inject({ url: '/child' }, (err, res) => { + t.assert.ifError(err) + t.assert.ok(true, 'passed') + done() + }) + t.assert.ok(true, 'passed') + }) +}) + +test('getDecorator should only return decorators existing in the scope', (t, done) => { + t.plan(9) + + function assertsThrowOnUndeclaredDecorator (notDecorated, instanceType) { + try { + notDecorated.getDecorator('foo') + t.assert.fail() + } catch (e) { + t.assert.deepEqual(e.code, 'FST_ERR_DEC_UNDECLARED') + t.assert.deepEqual(e.message, `No decorator 'foo' has been declared on ${instanceType}.`) + } + } + + const fastify = Fastify() + fastify.register(child => { + child.decorate('foo', true) + child.decorateRequest('foo', true) + child.decorateReply('foo', true) + }) + + fastify.get('/', async (req, res) => { + assertsThrowOnUndeclaredDecorator(req, 'request') + assertsThrowOnUndeclaredDecorator(res, 'reply') + + return { hello: 'world' } + }) + + fastify.ready((err) => { + t.assert.ifError(err) + + assertsThrowOnUndeclaredDecorator(fastify, 'instance') + fastify.inject({ url: '/' }, (err, res) => { + t.assert.ifError(err) + t.assert.ok(true, 'passed') + done() + }) + }) +}) + +test('Request.setDecorator should update an existing decorator', (t, done) => { + t.plan(7) + const fastify = Fastify() + + fastify.decorateRequest('session', null) + fastify.decorateRequest('utility', null) + fastify.addHook('onRequest', async (req, reply) => { + req.setDecorator('session', { user: 'Jean' }) + req.setDecorator('utility', function () { + return this + }) + try { + req.setDecorator('foo', { user: 'Jean' }) + t.assert.fail() + } catch (e) { + t.assert.deepEqual(e.code, 'FST_ERR_DEC_UNDECLARED') + t.assert.deepEqual(e.message, "No decorator 'foo' has been declared on request.") + } + }) + + fastify.get('/', async (req, res) => { + t.assert.deepEqual(req.getDecorator('session'), { user: 'Jean' }) + t.assert.deepEqual(req.getDecorator('utility')(), req) + + res.send() + }) + + fastify.ready((err) => { + t.assert.ifError(err) + fastify.inject({ url: '/' }, (err, res) => { + t.assert.ifError(err) + t.assert.ok(true, 'passed') + done() + }) + }) +}) diff --git a/test/default-route.test.js b/test/default-route.test.js deleted file mode 100644 index 64de3c259cd..00000000000 --- a/test/default-route.test.js +++ /dev/null @@ -1,43 +0,0 @@ -'use strict' - -const t = require('tap') -const test = t.test -const Fastify = require('..') - -test('should fail if defaultRoute is not a function', t => { - t.plan(1) - - const fastify = Fastify() - const defaultRoute = {} - - fastify.get('/', () => {}) - - try { - fastify.setDefaultRoute(defaultRoute) - } catch (error) { - t.equal(error.code, 'FST_ERR_DEFAULT_ROUTE_INVALID_TYPE') - } -}) - -test('correctly sets, returns, and calls defaultRoute', t => { - t.plan(3) - - const fastify = Fastify() - const defaultRoute = (req, res) => { - res.end('hello from defaultRoute') - } - - fastify.setDefaultRoute(defaultRoute) - const returnedDefaultRoute = fastify.getDefaultRoute() - t.equal(returnedDefaultRoute, defaultRoute) - - fastify.get('/', () => {}) - - fastify.inject({ - method: 'GET', - url: '/random' - }, (err, res) => { - t.error(err) - t.equal(res.body, 'hello from defaultRoute') - }) -}) diff --git a/test/delete.test.js b/test/delete.test.js index 605b0cb0e8d..3621703846d 100644 --- a/test/delete.test.js +++ b/test/delete.test.js @@ -1,8 +1,6 @@ 'use strict' -const t = require('tap') -const test = t.test -const sget = require('simple-get').concat +const { test } = require('node:test') const fastify = require('..')() const schema = { @@ -85,15 +83,17 @@ const bodySchema = { } } -test('shorthand - delete', t => { +test('shorthand - delete', (t, done) => { t.plan(1) try { fastify.delete('/', schema, function (req, reply) { reply.code(200).send({ hello: 'world' }) }) - t.pass() + t.assert.ok(true) } catch (e) { - t.fail() + t.assert.fail() + } finally { + done() } }) @@ -103,9 +103,9 @@ test('shorthand - delete params', t => { fastify.delete('/params/:foo/:test', paramsSchema, function (req, reply) { reply.code(200).send(req.params) }) - t.pass() + t.assert.ok(true) } catch (e) { - t.fail() + t.assert.fail() } }) @@ -115,9 +115,9 @@ test('shorthand - delete, querystring schema', t => { fastify.delete('/query', querySchema, function (req, reply) { reply.send(req.query) }) - t.pass() + t.assert.ok(true) } catch (e) { - t.fail() + t.assert.fail() } }) @@ -127,9 +127,9 @@ test('shorthand - get, headers schema', t => { fastify.delete('/headers', headersSchema, function (req, reply) { reply.code(200).send(req.headers) }) - t.pass() + t.assert.ok(true) } catch (e) { - t.fail() + t.assert.fail() } }) @@ -139,9 +139,9 @@ test('missing schema - delete', t => { fastify.delete('/missing', function (req, reply) { reply.code(200).send({ hello: 'world' }) }) - t.pass() + t.assert.ok(true) } catch (e) { - t.fail() + t.assert.fail() } }) @@ -151,158 +151,184 @@ test('body - delete', t => { fastify.delete('/body', bodySchema, function (req, reply) { reply.send(req.body) }) - t.pass() + t.assert.ok(true) } catch (e) { - t.fail() + t.assert.fail() } }) -fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) +test('delete tests', async t => { + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) - test('shorthand - request delete', t => { + await t.test('shorthand - request delete', async t => { t.plan(4) - sget({ - method: 'DELETE', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) + + const response = await fetch(fastifyServer, { + method: 'DELETE' }) + t.assert.ok(response.ok) + t.assert.strictEqual(response.status, 200) + const body = await response.text() + t.assert.strictEqual(response.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { hello: 'world' }) }) - test('shorthand - request delete params schema', t => { + await t.test('shorthand - request delete params schema', async t => { t.plan(4) - sget({ - method: 'DELETE', - url: 'http://localhost:' + fastify.server.address().port + '/params/world/123' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { foo: 'world', test: 123 }) + + const response = await fetch(fastifyServer + '/params/world/123', { + method: 'DELETE' }) + t.assert.ok(response.ok) + t.assert.strictEqual(response.status, 200) + const body = await response.text() + t.assert.strictEqual(response.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { foo: 'world', test: 123 }) }) - test('shorthand - request delete params schema error', t => { + await t.test('shorthand - request delete params schema error', async t => { t.plan(3) - sget({ - method: 'DELETE', - url: 'http://localhost:' + fastify.server.address().port + '/params/world/string' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 400) - t.same(JSON.parse(body), { - error: 'Bad Request', - message: 'params/test must be integer', - statusCode: 400 - }) + + const response = await fetch(fastifyServer + '/params/world/string', { + method: 'DELETE' + }) + + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 400) + t.assert.deepStrictEqual(await response.json(), { + error: 'Bad Request', + code: 'FST_ERR_VALIDATION', + message: 'params/test must be integer', + statusCode: 400 }) }) - test('shorthand - request delete headers schema', t => { + await t.test('shorthand - request delete headers schema', async t => { t.plan(4) - sget({ + + const response = await fetch(fastifyServer + '/headers', { method: 'DELETE', headers: { - 'x-test': 1 - }, - url: 'http://localhost:' + fastify.server.address().port + '/headers' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.equal(JSON.parse(body)['x-test'], 1) + 'x-test': '1' + } }) + t.assert.ok(response.ok) + t.assert.strictEqual(response.status, 200) + const body = await response.text() + t.assert.strictEqual(response.headers.get('content-length'), '' + body.length) + t.assert.strictEqual(JSON.parse(body)['x-test'], 1) }) - test('shorthand - request delete headers schema error', t => { + await t.test('shorthand - request delete headers schema error', async t => { t.plan(3) - sget({ + + const response = await fetch(fastifyServer + '/headers', { method: 'DELETE', headers: { 'x-test': 'abc' - }, - url: 'http://localhost:' + fastify.server.address().port + '/headers' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 400) - t.same(JSON.parse(body), { - error: 'Bad Request', - message: 'headers/x-test must be number', - statusCode: 400 - }) + } + }) + + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 400) + const body = await response.text() + t.assert.deepStrictEqual(JSON.parse(body), { + error: 'Bad Request', + code: 'FST_ERR_VALIDATION', + message: 'headers/x-test must be number', + statusCode: 400 }) }) - test('shorthand - request delete querystring schema', t => { + await t.test('shorthand - request delete querystring schema', async t => { t.plan(4) - sget({ - method: 'DELETE', - url: 'http://localhost:' + fastify.server.address().port + '/query?hello=123' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 123 }) + + const response = await fetch(fastifyServer + '/query?hello=123', { + method: 'DELETE' }) + t.assert.ok(response.ok) + t.assert.strictEqual(response.status, 200) + const body = await response.text() + t.assert.strictEqual(response.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { hello: 123 }) }) - test('shorthand - request delete querystring schema error', t => { + await t.test('shorthand - request delete querystring schema error', async t => { t.plan(3) - sget({ - method: 'DELETE', - url: 'http://localhost:' + fastify.server.address().port + '/query?hello=world' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 400) - t.same(JSON.parse(body), { - error: 'Bad Request', - message: 'querystring/hello must be integer', - statusCode: 400 - }) + + const response = await fetch(fastifyServer + '/query?hello=world', { + method: 'DELETE' + }) + + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 400) + const body = await response.text() + t.assert.deepStrictEqual(JSON.parse(body), { + error: 'Bad Request', + code: 'FST_ERR_VALIDATION', + message: 'querystring/hello must be integer', + statusCode: 400 }) }) - test('shorthand - request delete missing schema', t => { + await t.test('shorthand - request delete missing schema', async t => { t.plan(4) - sget({ - method: 'DELETE', - url: 'http://localhost:' + fastify.server.address().port + '/missing' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) + + const response = await fetch(fastifyServer + '/missing', { + method: 'DELETE' }) + t.assert.ok(response.ok) + t.assert.strictEqual(response.status, 200) + const body = await response.text() + t.assert.strictEqual(response.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { hello: 'world' }) }) - test('shorthand - delete with body', t => { + await t.test('shorthand - delete with body', async t => { t.plan(3) - sget({ + + const response = await fetch(fastifyServer + '/body', { method: 'DELETE', - url: 'http://localhost:' + fastify.server.address().port + '/body', - body: { - hello: 'world' - }, - json: true - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body, { hello: 'world' }) + body: JSON.stringify({ hello: 'world' }), + headers: { + 'Content-Type': 'application/json' + } }) + t.assert.ok(response.ok) + t.assert.strictEqual(response.status, 200) + const body = await response.json() + t.assert.deepStrictEqual(body, { hello: 'world' }) + }) +}) + +test('shorthand - delete with application/json Content-Type header and null body', (t, done) => { + t.plan(4) + const fastify = require('..')() + fastify.delete('/', {}, (req, reply) => { + t.assert.strictEqual(req.body, null) + reply.send(req.body) + }) + fastify.inject({ + method: 'DELETE', + url: '/', + headers: { 'Content-Type': 'application/json' }, + body: 'null' + }, (err, response) => { + t.assert.ifError(err) + t.assert.strictEqual(response.statusCode, 200) + t.assert.strictEqual(response.payload.toString(), 'null') + done() }) }) // https://github.com/fastify/fastify/issues/936 -test('shorthand - delete with application/json Content-Type header and without body', t => { +// Skip this test because this is an invalid request +test('shorthand - delete with application/json Content-Type header and without body', { skip: 'https://github.com/fastify/fastify/pull/5419' }, t => { t.plan(4) const fastify = require('..')() fastify.delete('/', {}, (req, reply) => { - t.equal(req.body, undefined) + t.assert.strictEqual(req.body, undefined) reply.send(req.body) }) fastify.inject({ @@ -311,8 +337,8 @@ test('shorthand - delete with application/json Content-Type header and without b headers: { 'Content-Type': 'application/json' }, body: null }, (err, response) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(response.payload.toString(), '') + t.assert.ifError(err) + t.assert.strictEqual(response.statusCode, 200) + t.assert.strictEqual(response.payload.toString(), '') }) }) diff --git a/test/diagnostics-channel/404.test.js b/test/diagnostics-channel/404.test.js new file mode 100644 index 00000000000..f02b0b644d1 --- /dev/null +++ b/test/diagnostics-channel/404.test.js @@ -0,0 +1,49 @@ +'use strict' + +const { test } = require('node:test') +const diagnostics = require('node:diagnostics_channel') +const Fastify = require('../..') +const Request = require('../../lib/request') +const Reply = require('../../lib/reply') + +test('diagnostics channel sync events fire in expected order', async t => { + t.plan(9) + let callOrder = 0 + let firstEncounteredMessage + + diagnostics.subscribe('tracing:fastify.request.handler:start', (msg) => { + t.assert.strictEqual(callOrder++, 0) + firstEncounteredMessage = msg + t.assert.ok(msg.request instanceof Request) + t.assert.ok(msg.reply instanceof Reply) + }) + + diagnostics.subscribe('tracing:fastify.request.handler:end', (msg) => { + t.assert.ok(msg.request instanceof Request) + t.assert.ok(msg.reply instanceof Reply) + t.assert.strictEqual(callOrder++, 1) + t.assert.strictEqual(msg, firstEncounteredMessage) + }) + + diagnostics.subscribe('tracing:fastify.request.handler:error', (msg) => { + t.assert.fail('should not trigger error channel') + }) + + const fastify = Fastify() + fastify.route({ + method: 'GET', + url: '/', + handler: function (req, reply) { + reply.callNotFound() + } + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) + + const response = await fetch(fastifyServer, { + method: 'GET' + }) + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 404) +}) diff --git a/test/diagnostics-channel/async-delay-request.test.js b/test/diagnostics-channel/async-delay-request.test.js new file mode 100644 index 00000000000..d86ae124fe5 --- /dev/null +++ b/test/diagnostics-channel/async-delay-request.test.js @@ -0,0 +1,65 @@ +'use strict' + +const diagnostics = require('node:diagnostics_channel') +const { test } = require('node:test') +const Fastify = require('../..') +const Request = require('../../lib/request') +const Reply = require('../../lib/reply') + +test('diagnostics channel async events fire in expected order', async t => { + t.plan(19) + let callOrder = 0 + let firstEncounteredMessage + + diagnostics.subscribe('tracing:fastify.request.handler:start', (msg) => { + t.assert.strictEqual(callOrder++, 0) + firstEncounteredMessage = msg + t.assert.ok(msg.request instanceof Request) + t.assert.ok(msg.reply instanceof Reply) + }) + + diagnostics.subscribe('tracing:fastify.request.handler:end', (msg) => { + t.assert.strictEqual(callOrder++, 1) + t.assert.ok(msg.request instanceof Request) + t.assert.ok(msg.reply instanceof Reply) + t.assert.strictEqual(msg, firstEncounteredMessage) + t.assert.strictEqual(msg.async, true) + }) + + diagnostics.subscribe('tracing:fastify.request.handler:asyncStart', (msg) => { + t.assert.strictEqual(callOrder++, 2) + t.assert.ok(msg.request instanceof Request) + t.assert.ok(msg.reply instanceof Reply) + t.assert.strictEqual(msg, firstEncounteredMessage) + }) + + diagnostics.subscribe('tracing:fastify.request.handler:asyncEnd', (msg) => { + t.assert.strictEqual(callOrder++, 3) + t.assert.ok(msg.request instanceof Request) + t.assert.ok(msg.reply instanceof Reply) + t.assert.strictEqual(msg, firstEncounteredMessage) + }) + + diagnostics.subscribe('tracing:fastify.request.handler:error', (msg) => { + t.assert.fail('should not trigger error channel') + }) + + const fastify = Fastify() + fastify.route({ + method: 'GET', + url: '/', + handler: async function (req, reply) { + setImmediate(() => reply.send({ hello: 'world' })) + return reply + } + }) + + t.after(() => { fastify.close() }) + + const fastifyServer = await fastify.listen({ port: 0 }) + + const result = await fetch(fastifyServer + '/') + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.deepStrictEqual(await result.json(), { hello: 'world' }) +}) diff --git a/test/diagnostics-channel/async-request.test.js b/test/diagnostics-channel/async-request.test.js new file mode 100644 index 00000000000..813eccdd8d7 --- /dev/null +++ b/test/diagnostics-channel/async-request.test.js @@ -0,0 +1,64 @@ +'use strict' + +const { test } = require('node:test') +const diagnostics = require('node:diagnostics_channel') +const Fastify = require('../..') +const Request = require('../../lib/request') +const Reply = require('../../lib/reply') + +test('diagnostics channel async events fire in expected order', async t => { + t.plan(18) + let callOrder = 0 + let firstEncounteredMessage + + diagnostics.subscribe('tracing:fastify.request.handler:start', (msg) => { + t.assert.strictEqual(callOrder++, 0) + firstEncounteredMessage = msg + t.assert.ok(msg.request instanceof Request) + t.assert.ok(msg.reply instanceof Reply) + }) + + diagnostics.subscribe('tracing:fastify.request.handler:end', (msg) => { + t.assert.strictEqual(callOrder++, 1) + t.assert.ok(msg.request instanceof Request) + t.assert.ok(msg.reply instanceof Reply) + t.assert.strictEqual(msg, firstEncounteredMessage) + }) + + diagnostics.subscribe('tracing:fastify.request.handler:asyncStart', (msg) => { + t.assert.strictEqual(callOrder++, 2) + t.assert.ok(msg.request instanceof Request) + t.assert.ok(msg.reply instanceof Reply) + t.assert.strictEqual(msg, firstEncounteredMessage) + }) + + diagnostics.subscribe('tracing:fastify.request.handler:asyncEnd', (msg) => { + t.assert.strictEqual(callOrder++, 3) + t.assert.ok(msg.request instanceof Request) + t.assert.ok(msg.reply instanceof Reply) + t.assert.strictEqual(msg, firstEncounteredMessage) + }) + + diagnostics.subscribe('tracing:fastify.request.handler:error', (msg) => { + t.assert.fail('should not trigger error channel') + }) + + const fastify = Fastify() + fastify.route({ + method: 'GET', + url: '/', + handler: async function (req, reply) { + return { hello: 'world' } + } + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + + t.after(() => { fastify.close() }) + + const response = await fetch(fastifyServer) + t.assert.ok(response.ok) + t.assert.strictEqual(response.status, 200) + const body = await response.text() + t.assert.deepStrictEqual(JSON.parse(body), { hello: 'world' }) +}) diff --git a/test/diagnostics-channel/error-before-handler.test.js b/test/diagnostics-channel/error-before-handler.test.js new file mode 100644 index 00000000000..edac466b706 --- /dev/null +++ b/test/diagnostics-channel/error-before-handler.test.js @@ -0,0 +1,35 @@ +'use strict' + +const diagnostics = require('node:diagnostics_channel') +const { test } = require('node:test') +require('../../lib/hooks').onSendHookRunner = function Stub () {} +const Request = require('../../lib/request') +const Reply = require('../../lib/reply') +const symbols = require('../../lib/symbols.js') +const { preHandlerCallback } = require('../../lib/handle-request')[Symbol.for('internals')] + +test('diagnostics channel handles an error before calling context handler', t => { + t.plan(3) + let callOrder = 0 + + diagnostics.subscribe('tracing:fastify.request.handler:start', (msg) => { + t.assert.strictEqual(callOrder++, 0) + }) + + diagnostics.subscribe('tracing:fastify.request.handler:error', (msg) => { + t.assert.strictEqual(callOrder++, 1) + t.assert.strictEqual(msg.error.message, 'oh no') + }) + + const error = new Error('oh no') + const request = new Request() + const reply = new Reply({}, request) + request[symbols.kRouteContext] = { + config: { + url: '/foo', + method: 'GET' + } + } + + preHandlerCallback(error, request, reply) +}) diff --git a/test/diagnostics-channel/error-request.test.js b/test/diagnostics-channel/error-request.test.js new file mode 100644 index 00000000000..17ee85ef9c6 --- /dev/null +++ b/test/diagnostics-channel/error-request.test.js @@ -0,0 +1,53 @@ +'use strict' + +const { test } = require('node:test') +const diagnostics = require('node:diagnostics_channel') +const Fastify = require('../..') +const Request = require('../../lib/request') +const Reply = require('../../lib/reply') + +test('diagnostics channel events report on errors', async t => { + t.plan(14) + let callOrder = 0 + let firstEncounteredMessage + + diagnostics.subscribe('tracing:fastify.request.handler:start', (msg) => { + t.assert.strictEqual(callOrder++, 0) + firstEncounteredMessage = msg + t.assert.ok(msg.request instanceof Request) + t.assert.ok(msg.reply instanceof Reply) + }) + + diagnostics.subscribe('tracing:fastify.request.handler:end', (msg) => { + t.assert.ok(msg.request instanceof Request) + t.assert.ok(msg.reply instanceof Reply) + t.assert.strictEqual(callOrder++, 2) + t.assert.strictEqual(msg, firstEncounteredMessage) + }) + + diagnostics.subscribe('tracing:fastify.request.handler:error', (msg) => { + t.assert.ok(msg.request instanceof Request) + t.assert.ok(msg.reply instanceof Reply) + t.assert.ok(msg.error instanceof Error) + t.assert.strictEqual(callOrder++, 1) + t.assert.strictEqual(msg.error.message, 'borked') + }) + + const fastify = Fastify() + fastify.route({ + method: 'GET', + url: '/', + handler: function (req, reply) { + throw new Error('borked') + } + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) + + const response = await fetch(fastifyServer, { + method: 'GET' + }) + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 500) +}) diff --git a/test/diagnostics-channel/error-status.test.js b/test/diagnostics-channel/error-status.test.js new file mode 100644 index 00000000000..474a91c2c51 --- /dev/null +++ b/test/diagnostics-channel/error-status.test.js @@ -0,0 +1,123 @@ +'use strict' + +const { test } = require('node:test') +const Fastify = require('../..') +const statusCodes = require('node:http').STATUS_CODES +const diagnostics = require('node:diagnostics_channel') + +test('diagnostics channel error event should report correct status code', async (t) => { + t.plan(3) + const fastify = Fastify() + t.after(() => fastify.close()) + + let diagnosticsStatusCode + + const channel = diagnostics.channel('tracing:fastify.request.handler:error') + const handler = (msg) => { + diagnosticsStatusCode = msg.reply.statusCode + } + channel.subscribe(handler) + t.after(() => channel.unsubscribe(handler)) + + fastify.get('/', async () => { + const err = new Error('test error') + err.statusCode = 503 + throw err + }) + + const res = await fastify.inject('/') + + t.assert.strictEqual(res.statusCode, 503) + t.assert.strictEqual(diagnosticsStatusCode, 503, 'diagnostics channel should report correct status code') + t.assert.strictEqual(diagnosticsStatusCode, res.statusCode, 'diagnostics status should match response status') +}) + +test('diagnostics channel error event should report 500 for errors without status', async (t) => { + t.plan(3) + const fastify = Fastify() + t.after(() => fastify.close()) + + let diagnosticsStatusCode + + const channel = diagnostics.channel('tracing:fastify.request.handler:error') + const handler = (msg) => { + diagnosticsStatusCode = msg.reply.statusCode + } + channel.subscribe(handler) + t.after(() => channel.unsubscribe(handler)) + + fastify.get('/', async () => { + throw new Error('plain error without status') + }) + + const res = await fastify.inject('/') + + t.assert.strictEqual(res.statusCode, 500) + t.assert.strictEqual(diagnosticsStatusCode, 500, 'diagnostics channel should report 500 for plain errors') + t.assert.strictEqual(diagnosticsStatusCode, res.statusCode, 'diagnostics status should match response status') +}) + +test('diagnostics channel error event should report correct status with custom error handler', async (t) => { + t.plan(3) + const fastify = Fastify() + t.after(() => fastify.close()) + + let diagnosticsStatusCode + + const channel = diagnostics.channel('tracing:fastify.request.handler:error') + const handler = (msg) => { + diagnosticsStatusCode = msg.reply.statusCode + } + channel.subscribe(handler) + t.after(() => channel.unsubscribe(handler)) + + fastify.setErrorHandler((error, request, reply) => { + reply.status(503).send({ error: error.message }) + }) + + fastify.get('/', async () => { + throw new Error('handler error') + }) + + const res = await fastify.inject('/') + + // Note: The diagnostics channel fires before the custom error handler runs, + // so it reports 500 (default) rather than 503 (set by custom handler). + // This is expected behavior - the error channel reports the initial error state. + t.assert.strictEqual(res.statusCode, 503) + t.assert.strictEqual(diagnosticsStatusCode, 500, 'diagnostics channel reports status before custom handler') + t.assert.notStrictEqual(diagnosticsStatusCode, res.statusCode, 'custom handler can change status after diagnostics') +}) + +test('Error.status property support', (t, done) => { + t.plan(4) + const fastify = Fastify() + t.after(() => fastify.close()) + const err = new Error('winter is coming') + err.status = 418 + + diagnostics.subscribe('tracing:fastify.request.handler:error', (msg) => { + t.assert.strictEqual(msg.error.message, 'winter is coming') + }) + + fastify.get('/', () => { + return Promise.reject(err) + }) + + fastify.inject({ + method: 'GET', + url: '/' + }, (error, res) => { + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 418) + t.assert.deepStrictEqual( + { + error: statusCodes['418'], + message: err.message, + statusCode: 418 + }, + JSON.parse(res.payload) + ) + done() + }) +}) diff --git a/test/diagnostics-channel.test.js b/test/diagnostics-channel/init.test.js similarity index 50% rename from test/diagnostics-channel.test.js rename to test/diagnostics-channel/init.test.js index 9b8e301cea6..295cacc8297 100644 --- a/test/diagnostics-channel.test.js +++ b/test/diagnostics-channel/init.test.js @@ -1,7 +1,6 @@ 'use strict' -const t = require('tap') -const test = t.test +const { test } = require('node:test') const proxyquire = require('proxyquire') test('diagnostics_channel when present and subscribers', t => { @@ -9,13 +8,13 @@ test('diagnostics_channel when present and subscribers', t => { let fastifyInHook - const dc = { + const diagnostics = { channel (name) { - t.equal(name, 'fastify.initialization') + t.assert.strictEqual(name, 'fastify.initialization') return { hasSubscribers: true, publish (event) { - t.ok(event.fastify) + t.assert.ok(event.fastify) fastifyInHook = event.fastify } } @@ -23,39 +22,29 @@ test('diagnostics_channel when present and subscribers', t => { '@noCallThru': true } - const fastify = proxyquire('../fastify', { - diagnostics_channel: dc + const fastify = proxyquire('../../fastify', { + 'node:diagnostics_channel': diagnostics })() - t.equal(fastifyInHook, fastify) + t.assert.strictEqual(fastifyInHook, fastify) }) test('diagnostics_channel when present and no subscribers', t => { t.plan(1) - const dc = { + const diagnostics = { channel (name) { - t.equal(name, 'fastify.initialization') + t.assert.strictEqual(name, 'fastify.initialization') return { hasSubscribers: false, publish () { - t.fail('publish should not be called') + t.assert.fail('publish should not be called') } } }, '@noCallThru': true } - proxyquire('../fastify', { - diagnostics_channel: dc + proxyquire('../../fastify', { + 'node:diagnostics_channel': diagnostics })() }) - -test('diagnostics_channel when not present', t => { - t.plan(1) - - t.doesNotThrow(() => { - proxyquire('../fastify', { - diagnostics_channel: null - })() - }) -}) diff --git a/test/diagnostics-channel/sync-delay-request.test.js b/test/diagnostics-channel/sync-delay-request.test.js new file mode 100644 index 00000000000..f83bffec1ee --- /dev/null +++ b/test/diagnostics-channel/sync-delay-request.test.js @@ -0,0 +1,49 @@ +'use strict' + +const { test } = require('node:test') +const diagnostics = require('node:diagnostics_channel') +const Fastify = require('../..') +const Request = require('../../lib/request') +const Reply = require('../../lib/reply') + +test('diagnostics channel sync events fire in expected order', async t => { + t.plan(10) + let callOrder = 0 + let firstEncounteredMessage + + diagnostics.subscribe('tracing:fastify.request.handler:start', (msg) => { + t.assert.strictEqual(callOrder++, 0) + firstEncounteredMessage = msg + t.assert.ok(msg.request instanceof Request) + t.assert.ok(msg.reply instanceof Reply) + }) + + diagnostics.subscribe('tracing:fastify.request.handler:end', (msg) => { + t.assert.ok(msg.request instanceof Request) + t.assert.ok(msg.reply instanceof Reply) + t.assert.strictEqual(callOrder++, 1) + t.assert.strictEqual(msg, firstEncounteredMessage) + }) + + diagnostics.subscribe('tracing:fastify.request.handler:error', (msg) => { + t.assert.fail('should not trigger error channel') + }) + + const fastify = Fastify() + fastify.route({ + method: 'GET', + url: '/', + handler: function (req, reply) { + setImmediate(() => reply.send({ hello: 'world' })) + } + }) + + t.after(() => { fastify.close() }) + + const fastifyServer = await fastify.listen({ port: 0 }) + + const result = await fetch(fastifyServer + '/') + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.deepStrictEqual(await result.json(), { hello: 'world' }) +}) diff --git a/test/diagnostics-channel/sync-request-reply.test.js b/test/diagnostics-channel/sync-request-reply.test.js new file mode 100644 index 00000000000..d1e38b6f445 --- /dev/null +++ b/test/diagnostics-channel/sync-request-reply.test.js @@ -0,0 +1,51 @@ +'use strict' + +const { test } = require('node:test') +const diagnostics = require('node:diagnostics_channel') +const Fastify = require('../..') +const Request = require('../../lib/request') +const Reply = require('../../lib/reply') + +test('diagnostics channel sync events fire in expected order', async t => { + t.plan(10) + let callOrder = 0 + let firstEncounteredMessage + + diagnostics.subscribe('tracing:fastify.request.handler:start', (msg) => { + t.assert.strictEqual(callOrder++, 0) + firstEncounteredMessage = msg + t.assert.ok(msg.request instanceof Request) + t.assert.ok(msg.reply instanceof Reply) + }) + + diagnostics.subscribe('tracing:fastify.request.handler:end', (msg) => { + t.assert.ok(msg.request instanceof Request) + t.assert.ok(msg.reply instanceof Reply) + t.assert.strictEqual(callOrder++, 1) + t.assert.strictEqual(msg, firstEncounteredMessage) + }) + + diagnostics.subscribe('tracing:fastify.request.handler:error', (msg) => { + t.assert.fail('should not trigger error channel') + }) + + const fastify = Fastify() + fastify.route({ + method: 'GET', + url: '/', + handler: function (req, reply) { + reply.send({ hello: 'world' }) + } + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) + + const response = await fetch(fastifyServer, { + method: 'GET' + }) + t.assert.ok(response.ok) + t.assert.strictEqual(response.status, 200) + const body = await response.text() + t.assert.deepStrictEqual(JSON.parse(body), { hello: 'world' }) +}) diff --git a/test/diagnostics-channel/sync-request.test.js b/test/diagnostics-channel/sync-request.test.js new file mode 100644 index 00000000000..c80935397fa --- /dev/null +++ b/test/diagnostics-channel/sync-request.test.js @@ -0,0 +1,54 @@ +'use strict' + +const { test } = require('node:test') +const diagnostics = require('node:diagnostics_channel') +const Fastify = require('../..') +const Request = require('../../lib/request') +const Reply = require('../../lib/reply') + +test('diagnostics channel sync events fire in expected order', async t => { + t.plan(13) + let callOrder = 0 + let firstEncounteredMessage + + diagnostics.subscribe('tracing:fastify.request.handler:start', (msg) => { + t.assert.strictEqual(callOrder++, 0) + firstEncounteredMessage = msg + t.assert.ok(msg.request instanceof Request) + t.assert.ok(msg.reply instanceof Reply) + t.assert.ok(msg.route) + t.assert.strictEqual(msg.route.url, '/:id') + t.assert.strictEqual(msg.route.method, 'GET') + }) + + diagnostics.subscribe('tracing:fastify.request.handler:end', (msg) => { + t.assert.ok(msg.request instanceof Request) + t.assert.ok(msg.reply instanceof Reply) + t.assert.strictEqual(callOrder++, 1) + t.assert.strictEqual(msg, firstEncounteredMessage) + }) + + diagnostics.subscribe('tracing:fastify.request.handler:error', (msg) => { + t.assert.fail('should not trigger error channel') + }) + + const fastify = Fastify() + fastify.route({ + method: 'GET', + url: '/:id', + handler: function (req, reply) { + return { hello: 'world' } + } + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) + + const response = await fetch(fastifyServer + '/7', { + method: 'GET' + }) + t.assert.ok(response.ok) + t.assert.strictEqual(response.status, 200) + const body = await response.text() + t.assert.deepStrictEqual(JSON.parse(body), { hello: 'world' }) +}) diff --git a/test/encapsulated-child-logger-factory.test.js b/test/encapsulated-child-logger-factory.test.js new file mode 100644 index 00000000000..9ef7c9fd35b --- /dev/null +++ b/test/encapsulated-child-logger-factory.test.js @@ -0,0 +1,69 @@ +'use strict' + +const { test } = require('node:test') +const Fastify = require('..') +const fp = require('fastify-plugin') + +test('encapsulates an child logger factory', async t => { + t.plan(4) + + const fastify = Fastify() + fastify.register(async function (fastify) { + fastify.setChildLoggerFactory(function pluginFactory (logger, bindings, opts) { + const child = logger.child(bindings, opts) + child.customLog = function (message) { + t.assert.strictEqual(message, 'custom') + } + return child + }) + fastify.get('/encapsulated', async (req) => { + req.log.customLog('custom') + }) + }) + + fastify.setChildLoggerFactory(function globalFactory (logger, bindings, opts) { + const child = logger.child(bindings, opts) + child.globalLog = function (message) { + t.assert.strictEqual(message, 'global') + } + return child + }) + fastify.get('/not-encapsulated', async (req) => { + req.log.globalLog('global') + }) + + const res1 = await fastify.inject('/encapsulated') + t.assert.strictEqual(res1.statusCode, 200) + + const res2 = await fastify.inject('/not-encapsulated') + t.assert.strictEqual(res2.statusCode, 200) +}) + +test('child logger factory set on root scope when using fastify-plugin', async t => { + t.plan(4) + + const fastify = Fastify() + fastify.register(fp(async function (fastify) { + // Using fastify-plugin, the factory should be set on the root scope + fastify.setChildLoggerFactory(function pluginFactory (logger, bindings, opts) { + const child = logger.child(bindings, opts) + child.customLog = function (message) { + t.assert.strictEqual(message, 'custom') + } + return child + }) + fastify.get('/not-encapsulated-1', async (req) => { + req.log.customLog('custom') + }) + })) + + fastify.get('/not-encapsulated-2', async (req) => { + req.log.customLog('custom') + }) + + const res1 = await fastify.inject('/not-encapsulated-1') + t.assert.strictEqual(res1.statusCode, 200) + + const res2 = await fastify.inject('/not-encapsulated-2') + t.assert.strictEqual(res2.statusCode, 200) +}) diff --git a/test/encapsulated-error-handler.test.js b/test/encapsulated-error-handler.test.js index aba8c1a8c1f..48c29d3e8ee 100644 --- a/test/encapsulated-error-handler.test.js +++ b/test/encapsulated-error-handler.test.js @@ -1,27 +1,68 @@ 'use strict' -const { test } = require('tap') +const { test } = require('node:test') const Fastify = require('..') -test('encapuslates an error handler', async t => { +// Because of how error handlers wrap things, following the control flow can be tricky +// In this test file numbered comments indicate the order statements are expected to execute + +test('encapsulates an asynchronous error handler', async t => { t.plan(3) const fastify = Fastify() fastify.register(async function (fastify) { fastify.setErrorHandler(async function a (err) { - t.equal(err.message, 'kaboom') - throw new Error('caught') + // 3. the inner error handler catches the error, and throws a new error + t.assert.strictEqual(err.message, 'from_endpoint') + throw new Error('from_inner') + }) + fastify.get('/encapsulated', async () => { + // 2. the endpoint throws an error + throw new Error('from_endpoint') }) - fastify.get('/encapsulated', async () => { throw new Error('kaboom') }) }) fastify.setErrorHandler(async function b (err) { - t.equal(err.message, 'caught') - throw new Error('wrapped') + // 4. the outer error handler catches the error thrown by the inner error handler + t.assert.strictEqual(err.message, 'from_inner') + // 5. the outer error handler throws a new error + throw new Error('from_outer') }) + // 1. the endpoint is called const res = await fastify.inject('/encapsulated') - t.equal(res.json().message, 'wrapped') + // 6. the default error handler returns the error from the outer error handler + t.assert.strictEqual(res.json().message, 'from_outer') +}) + +// See discussion in https://github.com/fastify/fastify/pull/5222#discussion_r1432573655 +test('encapsulates a synchronous error handler', async t => { + t.plan(3) + + const fastify = Fastify() + fastify.register(async function (fastify) { + fastify.setErrorHandler(function a (err) { + // 3. the inner error handler catches the error, and throws a new error + t.assert.strictEqual(err.message, 'from_endpoint') + throw new Error('from_inner') + }) + fastify.get('/encapsulated', async () => { + // 2. the endpoint throws an error + throw new Error('from_endpoint') + }) + }) + + fastify.setErrorHandler(async function b (err) { + // 4. the outer error handler catches the error thrown by the inner error handler + t.assert.strictEqual(err.message, 'from_inner') + // 5. the outer error handler throws a new error + throw new Error('from_outer') + }) + + // 1. the endpoint is called + const res = await fastify.inject('/encapsulated') + // 6. the default error handler returns the error from the outer error handler + t.assert.strictEqual(res.json().message, 'from_outer') }) test('onError hook nested', async t => { @@ -30,21 +71,167 @@ test('onError hook nested', async t => { const fastify = Fastify() fastify.register(async function (fastify) { fastify.setErrorHandler(async function a (err) { - t.equal(err.message, 'kaboom') - throw new Error('caught') + // 4. the inner error handler catches the error, and throws a new error + t.assert.strictEqual(err.message, 'from_endpoint') + throw new Error('from_inner') + }) + fastify.get('/encapsulated', async () => { + // 2. the endpoint throws an error + throw new Error('from_endpoint') }) - fastify.get('/encapsulated', async () => { throw new Error('kaboom') }) }) fastify.setErrorHandler(async function b (err) { - t.equal(err.message, 'caught') - throw new Error('wrapped') + // 5. the outer error handler catches the error thrown by the inner error handler + t.assert.strictEqual(err.message, 'from_inner') + // 6. the outer error handler throws a new error + throw new Error('from_outer') }) fastify.addHook('onError', async function (request, reply, err) { - t.equal(err.message, 'kaboom') + // 3. the hook receives the error + t.assert.strictEqual(err.message, 'from_endpoint') + }) + + // 1. the endpoint is called + const res = await fastify.inject('/encapsulated') + // 7. the default error handler returns the error from the outer error handler + t.assert.strictEqual(res.json().message, 'from_outer') +}) + +// See https://github.com/fastify/fastify/issues/5220 +test('encapuslates an error handler, for errors thrown in hooks', async t => { + t.plan(3) + + const fastify = Fastify() + fastify.register(async function (fastify) { + fastify.setErrorHandler(function a (err) { + // 3. the inner error handler catches the error, and throws a new error + t.assert.strictEqual(err.message, 'from_hook') + throw new Error('from_inner') + }) + fastify.addHook('onRequest', async () => { + // 2. the hook throws an error + throw new Error('from_hook') + }) + fastify.get('/encapsulated', async () => {}) + }) + + fastify.setErrorHandler(function b (err) { + // 4. the outer error handler catches the error thrown by the inner error handler + t.assert.strictEqual(err.message, 'from_inner') + // 5. the outer error handler throws a new error + throw new Error('from_outer') }) + // 1. the endpoint is called + const res = await fastify.inject('/encapsulated') + // 6. the default error handler returns the error from the outer error handler + t.assert.strictEqual(res.json().message, 'from_outer') +}) + +// See https://github.com/fastify/fastify/issues/5220 +test('encapuslates many synchronous error handlers that rethrow errors', async t => { + const DEPTH = 100 + t.plan(DEPTH + 2) + + /** + * This creates a very nested set of error handlers, that looks like: + * plugin + * - error handler + * - plugin + * - error handler + * - plugin + * ... {to DEPTH levels} + * - plugin + * - error handler + * - GET /encapsulated + */ + const createNestedRoutes = (fastify, depth) => { + if (depth < 0) { + throw new Error('Expected depth >= 0') + } else if (depth === 0) { + fastify.setErrorHandler(function a (err) { + // 3. innermost error handler catches the error, and throws a new error + t.assert.strictEqual(err.message, 'from_route') + throw new Error(`from_handler_${depth}`) + }) + fastify.get('/encapsulated', async () => { + // 2. the endpoint throws an error + throw new Error('from_route') + }) + } else { + fastify.setErrorHandler(function d (err) { + // 4 to {DEPTH+4}. error handlers each catch errors, and then throws a new error + t.assert.strictEqual(err.message, `from_handler_${depth - 1}`) + throw new Error(`from_handler_${depth}`) + }) + + fastify.register(async function (fastify) { + createNestedRoutes(fastify, depth - 1) + }) + } + } + + const fastify = Fastify() + createNestedRoutes(fastify, DEPTH) + + // 1. the endpoint is called + const res = await fastify.inject('/encapsulated') + // {DEPTH+5}. the default error handler returns the error from the outermost error handler + t.assert.strictEqual(res.json().message, `from_handler_${DEPTH}`) +}) + +// See https://github.com/fastify/fastify/issues/5220 +// This was not failing previously, but we want to make sure the behavior continues to work in the same way across async and sync handlers +// Plus, the current setup is somewhat fragile to tweaks to wrapThenable as that's what retries (by calling res.send(err) again) +test('encapuslates many asynchronous error handlers that rethrow errors', async t => { + const DEPTH = 100 + t.plan(DEPTH + 2) + + /** + * This creates a very nested set of error handlers, that looks like: + * plugin + * - error handler + * - plugin + * - error handler + * - plugin + * ... {to DEPTH levels} + * - plugin + * - error handler + * - GET /encapsulated + */ + const createNestedRoutes = (fastify, depth) => { + if (depth < 0) { + throw new Error('Expected depth >= 0') + } else if (depth === 0) { + fastify.setErrorHandler(async function a (err) { + // 3. innermost error handler catches the error, and throws a new error + t.assert.strictEqual(err.message, 'from_route') + throw new Error(`from_handler_${depth}`) + }) + fastify.get('/encapsulated', async () => { + // 2. the endpoint throws an error + throw new Error('from_route') + }) + } else { + fastify.setErrorHandler(async function m (err) { + // 4 to {DEPTH+4}. error handlers each catch errors, and then throws a new error + t.assert.strictEqual(err.message, `from_handler_${depth - 1}`) + throw new Error(`from_handler_${depth}`) + }) + + fastify.register(async function (fastify) { + createNestedRoutes(fastify, depth - 1) + }) + } + } + + const fastify = Fastify() + createNestedRoutes(fastify, DEPTH) + + // 1. the endpoint is called const res = await fastify.inject('/encapsulated') - t.equal(res.json().message, 'wrapped') + // {DEPTH+5}. the default error handler returns the error from the outermost error handler + t.assert.strictEqual(res.json().message, `from_handler_${DEPTH}`) }) diff --git a/test/esm/errorCodes.test.mjs b/test/esm/errorCodes.test.mjs new file mode 100644 index 00000000000..cfeaf261355 --- /dev/null +++ b/test/esm/errorCodes.test.mjs @@ -0,0 +1,10 @@ +import { errorCodes } from '../../fastify.js' +import { test } from 'node:test' + +test('errorCodes in ESM', async t => { + // test a custom fastify error using errorCodes with ESM + const customError = errorCodes.FST_ERR_VALIDATION('custom error message') + t.assert.ok(typeof customError !== 'undefined') + t.assert.ok(customError instanceof errorCodes.FST_ERR_VALIDATION) + t.assert.strictEqual(customError.message, 'custom error message') +}) diff --git a/test/esm/esm.mjs b/test/esm/esm.test.mjs similarity index 64% rename from test/esm/esm.mjs rename to test/esm/esm.test.mjs index aab4b614acf..49c6bfebf20 100644 --- a/test/esm/esm.mjs +++ b/test/esm/esm.test.mjs @@ -1,7 +1,7 @@ -import t from 'tap' +import { test } from 'node:test' import Fastify from '../../fastify.js' -t.test('esm support', async t => { +test('esm support', async t => { const fastify = Fastify() fastify.register(import('./plugin.mjs'), { foo: 'bar' }) @@ -9,5 +9,5 @@ t.test('esm support', async t => { await fastify.ready() - t.equal(fastify.foo, 'bar') + t.assert.strictEqual(fastify.foo, 'bar') }) diff --git a/test/esm/index.test.js b/test/esm/index.test.js index ef1ae056e5a..f1cf2a72c53 100644 --- a/test/esm/index.test.js +++ b/test/esm/index.test.js @@ -1,18 +1,8 @@ 'use strict' -const t = require('tap') -const semver = require('semver') - -if (semver.lt(process.versions.node, '14.13.0')) { - t.skip('Skip named exports because Node version < 14.13.0') -} else { - // Node v8 throw a `SyntaxError: Unexpected token import` - // even if this branch is never touch in the code, - // by using `eval` we can avoid this issue. - // eslint-disable-next-line - new Function('module', 'return import(module)')('./named-exports.mjs').catch((err) => { +import('./named-exports.mjs') + .catch(err => { process.nextTick(() => { throw err }) }) -} diff --git a/test/esm/named-exports.mjs b/test/esm/named-exports.mjs index 3f540849ff9..84323e92402 100644 --- a/test/esm/named-exports.mjs +++ b/test/esm/named-exports.mjs @@ -1,7 +1,8 @@ -import t from 'tap' +import { test } from 'node:test' import { fastify } from '../../fastify.js' -t.test('named exports support', async t => { +// This test is executed in index.test.js +test('named exports support', async t => { const app = fastify() app.register(import('./plugin.mjs'), { foo: 'bar' }) @@ -9,5 +10,5 @@ t.test('named exports support', async t => { await app.ready() - t.equal(app.foo, 'bar') + t.assert.strictEqual(app.foo, 'bar') }) diff --git a/test/esm/other.mjs b/test/esm/other.mjs index 5d42b4b2a8d..448064cbaf4 100644 --- a/test/esm/other.mjs +++ b/test/esm/other.mjs @@ -1,7 +1,8 @@ -import t from 'tap' +// Imported in both index.test.js & esm.test.mjs +import { strictEqual } from 'node:assert' async function other (fastify, opts) { - t.equal(fastify.foo, 'bar') + strictEqual(fastify.foo, 'bar') } export default other diff --git a/test/esm/plugin.mjs b/test/esm/plugin.mjs index 1435f563ab9..bbead40f6d8 100644 --- a/test/esm/plugin.mjs +++ b/test/esm/plugin.mjs @@ -1,3 +1,4 @@ +// Imported in both index.test.js & esm.test.mjs async function plugin (fastify, opts) { fastify.decorate('foo', opts.foo) } diff --git a/test/fastify-instance.test.js b/test/fastify-instance.test.js index f013358c320..69996b6d40d 100644 --- a/test/fastify-instance.test.js +++ b/test/fastify-instance.test.js @@ -1,16 +1,21 @@ 'use strict' -const t = require('tap') -const test = t.test +const { test } = require('node:test') const Fastify = require('..') +const os = require('node:os') + const { kOptions, - kErrorHandler + kErrorHandler, + kChildLoggerFactory, + kState } = require('../lib/symbols') +const isIPv6Missing = !Object.values(os.networkInterfaces()).flat().some(({ family }) => family === 'IPv6') + test('root fastify instance is an object', t => { t.plan(1) - t.type(Fastify(), 'object') + t.assert.strictEqual(typeof Fastify(), 'object') }) test('fastify instance should contains ajv options', t => { @@ -22,7 +27,7 @@ test('fastify instance should contains ajv options', t => { } } }) - t.same(fastify[kOptions].ajv, { + t.assert.deepStrictEqual(fastify[kOptions].ajv, { customOptions: { nullable: false }, @@ -40,7 +45,7 @@ test('fastify instance should contains ajv options.plugins nested arrays', t => plugins: [[]] } }) - t.same(fastify[kOptions].ajv, { + t.assert.deepStrictEqual(fastify[kOptions].ajv, { customOptions: { nullable: false }, @@ -50,7 +55,7 @@ test('fastify instance should contains ajv options.plugins nested arrays', t => test('fastify instance get invalid ajv options', t => { t.plan(1) - t.throws(() => Fastify({ + t.assert.throws(() => Fastify({ ajv: { customOptions: 8 } @@ -59,7 +64,7 @@ test('fastify instance get invalid ajv options', t => { test('fastify instance get invalid ajv options.plugins', t => { t.plan(1) - t.throws(() => Fastify({ + t.assert.throws(() => Fastify({ ajv: { customOptions: {}, plugins: 8 @@ -70,9 +75,9 @@ test('fastify instance get invalid ajv options.plugins', t => { test('fastify instance should contain default errorHandler', t => { t.plan(3) const fastify = Fastify() - t.ok(fastify[kErrorHandler].func instanceof Function) - t.same(fastify.errorHandler, fastify[kErrorHandler].func) - t.same(Object.getOwnPropertyDescriptor(fastify, 'errorHandler').set, undefined) + t.assert.ok(fastify[kErrorHandler].func instanceof Function) + t.assert.deepStrictEqual(fastify.errorHandler, fastify[kErrorHandler].func) + t.assert.deepStrictEqual(Object.getOwnPropertyDescriptor(fastify, 'errorHandler').set, undefined) }) test('errorHandler in plugin should be separate from the external one', async t => { @@ -86,14 +91,210 @@ test('errorHandler in plugin should be separate from the external one', async t instance.setErrorHandler(inPluginErrHandler) - t.notSame(instance.errorHandler, fastify.errorHandler) - t.equal(instance.errorHandler.name, 'bound inPluginErrHandler') + t.assert.notDeepStrictEqual(instance.errorHandler, fastify.errorHandler) + t.assert.strictEqual(instance.errorHandler.name, 'bound inPluginErrHandler') + + done() + }) + + await fastify.ready() + + t.assert.ok(fastify[kErrorHandler].func instanceof Function) + t.assert.deepStrictEqual(fastify.errorHandler, fastify[kErrorHandler].func) +}) + +test('fastify instance should contain default childLoggerFactory', t => { + t.plan(3) + const fastify = Fastify() + t.assert.ok(fastify[kChildLoggerFactory] instanceof Function) + t.assert.deepStrictEqual(fastify.childLoggerFactory, fastify[kChildLoggerFactory]) + t.assert.deepStrictEqual(Object.getOwnPropertyDescriptor(fastify, 'childLoggerFactory').set, undefined) +}) + +test('childLoggerFactory in plugin should be separate from the external one', async t => { + t.plan(4) + const fastify = Fastify() + + fastify.register((instance, opts, done) => { + const inPluginLoggerFactory = function (logger, bindings, opts) { + return logger.child(bindings, opts) + } + + instance.setChildLoggerFactory(inPluginLoggerFactory) + + t.assert.notDeepStrictEqual(instance.childLoggerFactory, fastify.childLoggerFactory) + t.assert.strictEqual(instance.childLoggerFactory.name, 'inPluginLoggerFactory') done() }) await fastify.ready() - t.ok(fastify[kErrorHandler].func instanceof Function) - t.same(fastify.errorHandler, fastify[kErrorHandler].func) + t.assert.ok(fastify[kChildLoggerFactory] instanceof Function) + t.assert.deepStrictEqual(fastify.childLoggerFactory, fastify[kChildLoggerFactory]) +}) + +test('ready should resolve in order when called multiply times (promises only)', async (t) => { + const app = Fastify() + const expectedOrder = [1, 2, 3, 4, 5] + const result = [] + + const promises = [1, 2, 3, 4, 5] + .map((id) => app.ready().then(() => result.push(id))) + + await Promise.all(promises) + + t.assert.deepStrictEqual(result, expectedOrder, 'Should resolve in order') +}) + +test('ready should reject in order when called multiply times (promises only)', async (t) => { + const app = Fastify() + const expectedOrder = [1, 2, 3, 4, 5] + const result = [] + + app.register((instance, opts, done) => { + setTimeout(() => done(new Error('test')), 500) + }) + + const promises = [1, 2, 3, 4, 5] + .map((id) => app.ready().catch(() => result.push(id))) + + await Promise.all(promises) + + t.assert.deepStrictEqual(result, expectedOrder, 'Should resolve in order') +}) + +test('ready should reject in order when called multiply times (callbacks only)', async (t) => { + const app = Fastify() + const expectedOrder = [1, 2, 3, 4, 5] + const result = [] + + app.register((instance, opts, done) => { + setTimeout(() => done(new Error('test')), 500) + }) + + expectedOrder.map((id) => app.ready(() => result.push(id))) + + await app.ready().catch(err => { + t.assert.strictEqual(err.message, 'test') + }) + + t.assert.deepStrictEqual(result, expectedOrder, 'Should resolve in order') +}) + +test('ready should resolve in order when called multiply times (callbacks only)', async (t) => { + const app = Fastify() + const expectedOrder = [1, 2, 3, 4, 5] + const result = [] + + expectedOrder.map((id) => app.ready(() => result.push(id))) + + await app.ready() + + t.assert.deepStrictEqual(result, expectedOrder, 'Should resolve in order') +}) + +test('ready should resolve in order when called multiply times (mixed)', async (t) => { + const app = Fastify() + const expectedOrder = [1, 2, 3, 4, 5, 6] + const result = [] + + for (const order of expectedOrder) { + if (order % 2) { + app.ready(() => result.push(order)) + } else { + app.ready().then(() => result.push(order)) + } + } + + await app.ready() + + t.assert.deepStrictEqual(result, expectedOrder, 'Should resolve in order') +}) + +test('ready should reject in order when called multiply times (mixed)', async (t) => { + const app = Fastify() + const expectedOrder = [1, 2, 3, 4, 5, 6] + const result = [] + + app.register((instance, opts, done) => { + setTimeout(() => done(new Error('test')), 500) + }) + + for (const order of expectedOrder) { + if (order % 2) { + app.ready(() => result.push(order)) + } else { + app.ready().then(null, () => result.push(order)) + } + } + + await app.ready().catch(err => { + t.assert.strictEqual(err.message, 'test') + }) + + t.assert.deepStrictEqual(result, expectedOrder, 'Should resolve in order') +}) + +test('ready should resolve in order when called multiply times (mixed)', async (t) => { + const app = Fastify() + const expectedOrder = [1, 2, 3, 4, 5, 6] + const result = [] + + for (const order of expectedOrder) { + if (order % 2) { + app.ready().then(() => result.push(order)) + } else { + app.ready(() => result.push(order)) + } + } + + await app.ready() + + t.assert.deepStrictEqual(result, expectedOrder, 'Should resolve in order') +}) + +test('fastify instance should contains listeningOrigin property (with port and host)', async t => { + t.plan(1) + const port = 3000 + const host = '127.0.0.1' + const fastify = Fastify() + await fastify.listen({ port, host }) + t.assert.deepStrictEqual(fastify.listeningOrigin, `http://${host}:${port}`) + await fastify.close() +}) + +test('fastify instance should contains listeningOrigin property (with port and https)', async t => { + t.plan(1) + const port = 3000 + const host = '127.0.0.1' + const fastify = Fastify({ https: {} }) + await fastify.listen({ port, host }) + t.assert.deepStrictEqual(fastify.listeningOrigin, `https://${host}:${port}`) + await fastify.close() +}) + +test('fastify instance should contains listeningOrigin property (unix socket)', { skip: os.platform() === 'win32' }, async t => { + const fastify = Fastify() + const path = `fastify.${Date.now()}.sock` + await fastify.listen({ path }) + t.assert.deepStrictEqual(fastify.listeningOrigin, path) + await fastify.close() +}) + +test('fastify instance should contains listeningOrigin property (IPv6)', { skip: isIPv6Missing }, async t => { + t.plan(1) + const port = 3000 + const host = '::1' + const fastify = Fastify() + await fastify.listen({ port, host }) + t.assert.deepStrictEqual(fastify.listeningOrigin, `http://[::1]:${port}`) + await fastify.close() +}) + +test('fastify instance should ensure ready promise cleanup on ready', async t => { + t.plan(1) + const fastify = Fastify() + await fastify.ready() + t.assert.strictEqual(fastify[kState].readyResolver, null) }) diff --git a/test/find-route.test.js b/test/find-route.test.js new file mode 100644 index 00000000000..9e39ef49055 --- /dev/null +++ b/test/find-route.test.js @@ -0,0 +1,152 @@ +'use strict' + +const { test } = require('node:test') +const Fastify = require('..') +const fastifyPlugin = require('fastify-plugin') + +test('findRoute should return null when route cannot be found due to a different method', t => { + t.plan(1) + const fastify = Fastify() + + fastify.get('/artists/:artistId', { + schema: { + params: { artistId: { type: 'integer' } } + }, + handler: (req, reply) => reply.send(typeof req.params.artistId) + }) + + t.assert.strictEqual(fastify.findRoute({ + method: 'POST', + url: '/artists/:artistId' + }), null) +}) + +test('findRoute should return an immutable route to avoid leaking and runtime route modifications', t => { + t.plan(1) + const fastify = Fastify() + + fastify.get('/artists/:artistId', { + schema: { + params: { artistId: { type: 'integer' } } + }, + handler: (req, reply) => reply.send(typeof req.params.artistId) + }) + + let route = fastify.findRoute({ + method: 'GET', + url: '/artists/:artistId' + }) + + route.params = { + ...route.params, + id: ':id' + } + + route = fastify.findRoute({ + method: 'GET', + url: '/artists/:artistId' + }) + + t.assert.strictEqual(route.params.artistId, ':artistId') +}) + +test('findRoute should return null when when url is not passed', t => { + t.plan(1) + const fastify = Fastify() + + fastify.get('/artists/:artistId', { + schema: { + params: { artistId: { type: 'integer' } } + }, + handler: (req, reply) => reply.send(typeof req.params.artistId) + }) + + t.assert.strictEqual(fastify.findRoute({ + method: 'POST' + }), null) +}) + +test('findRoute should return null when route cannot be found due to a different path', t => { + t.plan(1) + const fastify = Fastify() + + fastify.get('/artists/:artistId', { + schema: { + params: { artistId: { type: 'integer' } } + }, + handler: (req, reply) => reply.send(typeof req.params.artistId) + }) + + t.assert.strictEqual(fastify.findRoute({ + method: 'GET', + url: '/books/:bookId' + }), null) +}) + +test('findRoute should return the route when found', t => { + t.plan(1) + const fastify = Fastify() + + const handler = (req, reply) => reply.send(typeof req.params.artistId) + + fastify.get('/artists/:artistId', { + schema: { + params: { artistId: { type: 'integer' } } + }, + handler + }) + + const route = fastify.findRoute({ + method: 'GET', + url: '/artists/:artistId' + }) + t.assert.strictEqual(route.params.artistId, ':artistId') +}) + +test('findRoute should work correctly when used within plugins', (t, done) => { + t.plan(1) + const fastify = Fastify() + const handler = (req, reply) => reply.send(typeof req.params.artistId) + fastify.get('/artists/:artistId', { + schema: { + params: { artistId: { type: 'integer' } } + }, + handler + }) + + function validateRoutePlugin (instance, opts, done) { + const validateParams = function () { + return instance.findRoute({ + method: 'GET', + url: '/artists/:artistId' + }) !== null + } + instance.decorate('validateRoutes', { validateParams }) + done() + } + + fastify.register(fastifyPlugin(validateRoutePlugin)) + + fastify.ready(() => { + t.assert.strictEqual(fastify.validateRoutes.validateParams(), true) + done() + }) +}) + +test('findRoute should not expose store', t => { + t.plan(1) + const fastify = Fastify() + + fastify.get('/artists/:artistId', { + schema: { + params: { artistId: { type: 'integer' } } + }, + handler: (req, reply) => reply.send(typeof req.params.artistId) + }) + + const route = fastify.findRoute({ + method: 'GET', + url: '/artists/:artistId' + }) + t.assert.strictEqual(route.store, undefined) +}) diff --git a/test/fluent-schema.test.js b/test/fluent-schema.test.js index b6884d6d317..4e6d7ba46e1 100644 --- a/test/fluent-schema.test.js +++ b/test/fluent-schema.test.js @@ -1,12 +1,11 @@ 'use strict' -const t = require('tap') -const test = t.test +const { test } = require('node:test') const Fastify = require('..') const S = require('fluent-json-schema') -test('use fluent-json-schema object', t => { - t.plan(15) +test('use fluent-json-schema object', async (t) => { + t.plan(10) const fastify = Fastify() fastify.post('/:id', { @@ -24,73 +23,62 @@ test('use fluent-json-schema object', t => { } }) - // check params - fastify.inject({ + const res1 = await fastify.inject({ method: 'POST', url: '/1', headers: { 'x-custom': 'me@me.me' }, query: { surname: 'bar' }, payload: { name: 'foo' } - }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) - t.same(res.json(), { statusCode: 400, error: 'Bad Request', message: 'params/id must be >= 42' }) }) + t.assert.strictEqual(res1.statusCode, 400) + t.assert.deepStrictEqual(res1.json(), { statusCode: 400, code: 'FST_ERR_VALIDATION', error: 'Bad Request', message: 'params/id must be >= 42' }) // check header - fastify.inject({ + const res2 = await fastify.inject({ method: 'POST', url: '/42', headers: { 'x-custom': 'invalid' }, query: { surname: 'bar' }, payload: { name: 'foo' } - }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) - t.same(res.json(), { statusCode: 400, error: 'Bad Request', message: 'headers/x-custom must match format "email"' }) }) + t.assert.strictEqual(res2.statusCode, 400) + t.assert.deepStrictEqual(res2.json(), { statusCode: 400, code: 'FST_ERR_VALIDATION', error: 'Bad Request', message: 'headers/x-custom must match format "email"' }) // check query - fastify.inject({ + const res3 = await fastify.inject({ method: 'POST', url: '/42', headers: { 'x-custom': 'me@me.me' }, query: { }, payload: { name: 'foo' } - }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) - t.same(res.json(), { statusCode: 400, error: 'Bad Request', message: 'querystring must have required property \'surname\'' }) }) + t.assert.strictEqual(res3.statusCode, 400) + t.assert.deepStrictEqual(res3.json(), { statusCode: 400, code: 'FST_ERR_VALIDATION', error: 'Bad Request', message: 'querystring must have required property \'surname\'' }) // check body - fastify.inject({ + const res4 = await fastify.inject({ method: 'POST', url: '/42', headers: { 'x-custom': 'me@me.me' }, query: { surname: 'bar' }, payload: { name: [1, 2, 3] } - }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) - t.same(res.json(), { statusCode: 400, error: 'Bad Request', message: 'body/name must be string' }) }) + t.assert.strictEqual(res4.statusCode, 400) + t.assert.deepStrictEqual(res4.json(), { statusCode: 400, code: 'FST_ERR_VALIDATION', error: 'Bad Request', message: 'body/name must be string' }) // check response - fastify.inject({ + const res5 = await fastify.inject({ method: 'POST', url: '/42', headers: { 'x-custom': 'me@me.me' }, query: { surname: 'bar' }, payload: { name: 'foo' } - }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same(res.json(), { name: 'a', surname: 'b' }) }) + t.assert.strictEqual(res5.statusCode, 200) + t.assert.deepStrictEqual(res5.json(), { name: 'a', surname: 'b' }) }) -test('use complex fluent-json-schema object', t => { +test('use complex fluent-json-schema object', (t, done) => { t.plan(1) const fastify = Fastify() @@ -113,10 +101,13 @@ test('use complex fluent-json-schema object', t => { .prop('office', S.ref('https://fastify/demo#/definitions/addressSchema')).required() fastify.post('/the/url', { schema: { body: bodyJsonSchema } }, () => { }) - fastify.ready(err => t.error(err)) + fastify.ready(err => { + t.assert.ifError(err) + done() + }) }) -test('use fluent schema and plain JSON schema', t => { +test('use fluent schema and plain JSON schema', (t, done) => { t.plan(1) const fastify = Fastify() @@ -154,10 +145,13 @@ test('use fluent schema and plain JSON schema', t => { .prop('office', S.ref('https://fastify/demo#/definitions/addressSchema')).required() fastify.post('/the/url', { schema: { body: bodyJsonSchema } }, () => { }) - fastify.ready(err => t.error(err)) + fastify.ready(err => { + t.assert.ifError(err) + done() + }) }) -test('Should call valueOf internally', t => { +test('Should call valueOf internally', (t, done) => { t.plan(1) const fastify = new Fastify() @@ -208,5 +202,8 @@ test('Should call valueOf internally', t => { } }) - fastify.ready(t.error) + fastify.ready(err => { + t.assert.ifError(err) + done() + }) }) diff --git a/test/genReqId.test.js b/test/genReqId.test.js index 74ab442f286..682935ba4ac 100644 --- a/test/genReqId.test.js +++ b/test/genReqId.test.js @@ -1,9 +1,11 @@ 'use strict' -const { test } = require('tap') +const { Readable } = require('node:stream') +const { test } = require('node:test') +const fp = require('fastify-plugin') const Fastify = require('..') -test('Should accept a custom genReqId function', t => { +test('Should accept a custom genReqId function', (t, done) => { t.plan(4) const fastify = Fastify({ @@ -12,21 +14,413 @@ test('Should accept a custom genReqId function', t => { } }) + t.after(() => fastify.close()) fastify.get('/', (req, reply) => { - t.ok(req.id) + t.assert.ok(req.id) reply.send({ id: req.id }) }) fastify.listen({ port: 0 }, err => { - t.error(err) + t.assert.ifError(err) fastify.inject({ method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + url: `http://localhost:${fastify.server.address().port}` }, (err, res) => { - t.error(err) + t.assert.ifError(err) const payload = JSON.parse(res.payload) - t.equal(payload.id, 'a') - fastify.close() + t.assert.strictEqual(payload.id, 'a') + done() }) }) }) + +test('Custom genReqId function gets raw request as argument', (t, done) => { + t.plan(9) + + const REQUEST_ID = 'REQ-1234' + + const fastify = Fastify({ + genReqId: function (req) { + t.assert.strictEqual('id' in req, false) + t.assert.strictEqual('raw' in req, false) + t.assert.ok(req instanceof Readable) + // http.IncomingMessage does have `rawHeaders` property, but FastifyRequest does not + const index = req.rawHeaders.indexOf('x-request-id') + const xReqId = req.rawHeaders[index + 1] + t.assert.strictEqual(xReqId, REQUEST_ID) + t.assert.strictEqual(req.headers['x-request-id'], REQUEST_ID) + return xReqId + } + }) + t.after(() => fastify.close()) + + fastify.get('/', (req, reply) => { + t.assert.strictEqual(req.id, REQUEST_ID) + reply.send({ id: req.id }) + }) + + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + fastify.inject({ + method: 'GET', + headers: { + 'x-request-id': REQUEST_ID + }, + url: `http://localhost:${fastify.server.address().port}` + }, (err, res) => { + t.assert.ifError(err) + const payload = JSON.parse(res.payload) + t.assert.strictEqual(payload.id, REQUEST_ID) + done() + }) + }) +}) + +test('Should handle properly requestIdHeader option', t => { + t.plan(4) + + t.assert.strictEqual(Fastify({ requestIdHeader: '' }).initialConfig.requestIdHeader, false) + t.assert.strictEqual(Fastify({ requestIdHeader: false }).initialConfig.requestIdHeader, false) + t.assert.strictEqual(Fastify({ requestIdHeader: true }).initialConfig.requestIdHeader, 'request-id') + t.assert.strictEqual(Fastify({ requestIdHeader: 'x-request-id' }).initialConfig.requestIdHeader, 'x-request-id') +}) + +test('Should accept option to set genReqId with setGenReqId option', (t, done) => { + t.plan(9) + + const fastify = Fastify({ + genReqId: function (req) { + return 'base' + } + }) + + t.after(() => fastify.close()) + + fastify.register(function (instance, opts, next) { + instance.setGenReqId(function (req) { + return 'foo' + }) + instance.get('/', (req, reply) => { + t.assert.ok(req.id) + reply.send({ id: req.id }) + }) + next() + }, { prefix: 'foo' }) + + fastify.register(function (instance, opts, next) { + instance.setGenReqId(function (req) { + return 'bar' + }) + instance.get('/', (req, reply) => { + t.assert.ok(req.id) + reply.send({ id: req.id }) + }) + next() + }, { prefix: 'bar' }) + + fastify.get('/', (req, reply) => { + t.assert.ok(req.id) + reply.send({ id: req.id }) + }) + + let pending = 3 + + function completed () { + if (--pending === 0) { + done() + } + } + + fastify.inject({ + method: 'GET', + url: '/' + }, (err, res) => { + t.assert.ifError(err) + const payload = JSON.parse(res.payload) + t.assert.strictEqual(payload.id, 'base') + completed() + }) + + fastify.inject({ + method: 'GET', + url: '/foo' + }, (err, res) => { + t.assert.ifError(err) + const payload = JSON.parse(res.payload) + t.assert.strictEqual(payload.id, 'foo') + completed() + }) + + fastify.inject({ + method: 'GET', + url: '/bar' + }, (err, res) => { + t.assert.ifError(err) + const payload = JSON.parse(res.payload) + t.assert.strictEqual(payload.id, 'bar') + completed() + }) +}) + +test('Should encapsulate setGenReqId', (t, done) => { + t.plan(12) + + const fastify = Fastify({ + genReqId: function (req) { + return 'base' + } + }) + + t.after(() => fastify.close()) + const bazInstance = function (instance, opts, next) { + instance.register(barInstance, { prefix: 'baz' }) + + instance.setGenReqId(function (req) { + return 'baz' + }) + instance.get('/', (req, reply) => { + t.assert.ok(req.id) + reply.send({ id: req.id }) + }) + next() + } + + const barInstance = function (instance, opts, next) { + instance.setGenReqId(function (req) { + return 'bar' + }) + instance.get('/', (req, reply) => { + t.assert.ok(req.id) + reply.send({ id: req.id }) + }) + next() + } + + const fooInstance = function (instance, opts, next) { + instance.register(bazInstance, { prefix: 'baz' }) + instance.register(barInstance, { prefix: 'bar' }) + + instance.setGenReqId(function (req) { + return 'foo' + }) + + instance.get('/', (req, reply) => { + t.assert.ok(req.id) + reply.send({ id: req.id }) + }) + next() + } + + fastify.register(fooInstance, { prefix: 'foo' }) + + fastify.get('/', (req, reply) => { + t.assert.ok(req.id) + reply.send({ id: req.id }) + }) + + let pending = 4 + + function completed () { + if (--pending === 0) { + done() + } + } + + fastify.inject({ + method: 'GET', + url: '/' + }, (err, res) => { + t.assert.ifError(err) + const payload = JSON.parse(res.payload) + t.assert.strictEqual(payload.id, 'base') + completed() + }) + + fastify.inject({ + method: 'GET', + url: '/foo' + }, (err, res) => { + t.assert.ifError(err) + const payload = JSON.parse(res.payload) + t.assert.strictEqual(payload.id, 'foo') + completed() + }) + + fastify.inject({ + method: 'GET', + url: '/foo/bar' + }, (err, res) => { + t.assert.ifError(err) + const payload = JSON.parse(res.payload) + t.assert.strictEqual(payload.id, 'bar') + completed() + }) + + fastify.inject({ + method: 'GET', + url: '/foo/baz' + }, (err, res) => { + t.assert.ifError(err) + const payload = JSON.parse(res.payload) + t.assert.strictEqual(payload.id, 'baz') + completed() + }) +}) + +test('Should not alter parent of genReqId', (t, done) => { + t.plan(6) + + const fastify = Fastify() + t.after(() => fastify.close()) + const fooInstance = function (instance, opts, next) { + instance.setGenReqId(function (req) { + return 'foo' + }) + + instance.get('/', (req, reply) => { + t.assert.ok(req.id) + reply.send({ id: req.id }) + }) + next() + } + + fastify.register(fooInstance, { prefix: 'foo' }) + + fastify.get('/', (req, reply) => { + t.assert.ok(req.id) + reply.send({ id: req.id }) + }) + + let pending = 2 + + function completed () { + if (--pending === 0) { + done() + } + } + + fastify.inject({ + method: 'GET', + url: '/' + }, (err, res) => { + t.assert.ifError(err) + const payload = JSON.parse(res.payload) + t.assert.strictEqual(payload.id, 'req-1') + completed() + }) + + fastify.inject({ + method: 'GET', + url: '/foo' + }, (err, res) => { + t.assert.ifError(err) + const payload = JSON.parse(res.payload) + t.assert.strictEqual(payload.id, 'foo') + completed() + }) +}) + +test('Should have child instance user parent genReqId', (t, done) => { + t.plan(6) + + const fastify = Fastify({ + genReqId: function (req) { + return 'foo' + } + }) + t.after(() => fastify.close()) + + const fooInstance = function (instance, opts, next) { + instance.get('/', (req, reply) => { + t.assert.ok(req.id) + reply.send({ id: req.id }) + }) + next() + } + + fastify.register(fooInstance, { prefix: 'foo' }) + + fastify.get('/', (req, reply) => { + t.assert.ok(req.id) + reply.send({ id: req.id }) + }) + + let pending = 2 + + function completed () { + if (--pending === 0) { + done() + } + } + + fastify.inject({ + method: 'GET', + url: '/' + }, (err, res) => { + t.assert.ifError(err) + const payload = JSON.parse(res.payload) + t.assert.strictEqual(payload.id, 'foo') + completed() + }) + + fastify.inject({ + method: 'GET', + url: '/foo' + }, (err, res) => { + t.assert.ifError(err) + const payload = JSON.parse(res.payload) + t.assert.strictEqual(payload.id, 'foo') + completed() + }) +}) + +test('genReqId set on root scope when using fastify-plugin', (t, done) => { + t.plan(6) + + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.register(fp(function (fastify, options, done) { + fastify.setGenReqId(function (req) { + return 'not-encapsulated' + }) + fastify.get('/not-encapsulated-1', (req, reply) => { + t.assert.ok(req.id) + reply.send({ id: req.id }) + }) + done() + })) + + fastify.get('/not-encapsulated-2', (req, reply) => { + t.assert.ok(req.id) + reply.send({ id: req.id }) + }) + + let pending = 2 + + function completed () { + if (--pending === 0) { + done() + } + } + + fastify.inject({ + method: 'GET', + url: '/not-encapsulated-1' + }, (err, res) => { + t.assert.ifError(err) + const payload = JSON.parse(res.payload) + t.assert.strictEqual(payload.id, 'not-encapsulated') + completed() + }) + + fastify.inject({ + method: 'GET', + url: '/not-encapsulated-2' + }, (err, res) => { + t.assert.ifError(err) + const payload = JSON.parse(res.payload) + t.assert.strictEqual(payload.id, 'not-encapsulated') + completed() + }) +}) diff --git a/test/get.test.js b/test/get.test.js deleted file mode 100644 index 7dd6aece733..00000000000 --- a/test/get.test.js +++ /dev/null @@ -1,380 +0,0 @@ -'use strict' - -const t = require('tap') -const test = t.test -const sget = require('simple-get').concat -const fastify = require('..')() - -const schema = { - schema: { - response: { - '2xx': { - type: 'object', - properties: { - hello: { - type: 'string' - } - } - } - } - } -} - -const nullSchema = { - schema: { - response: { - '2xx': { - type: 'null' - } - } - } -} - -const numberSchema = { - schema: { - response: { - '2xx': { - type: 'object', - properties: { - hello: { - type: 'number' - } - } - } - } - } -} - -const querySchema = { - schema: { - querystring: { - type: 'object', - properties: { - hello: { - type: 'integer' - } - } - } - } -} - -const paramsSchema = { - schema: { - params: { - type: 'object', - properties: { - foo: { - type: 'string' - }, - test: { - type: 'integer' - } - } - } - } -} - -const headersSchema = { - schema: { - headers: { - type: 'object', - properties: { - 'x-test': { - type: 'number' - }, - 'Y-Test': { - type: 'number' - } - } - } - } -} - -test('shorthand - get', t => { - t.plan(1) - try { - fastify.get('/', schema, function (req, reply) { - reply.code(200).send({ hello: 'world' }) - }) - t.pass() - } catch (e) { - t.fail() - } -}) - -test('shorthand - get (return null)', t => { - t.plan(1) - try { - fastify.get('/null', nullSchema, function (req, reply) { - reply.code(200).send(null) - }) - t.pass() - } catch (e) { - t.fail() - } -}) - -test('shorthand - get params', t => { - t.plan(1) - try { - fastify.get('/params/:foo/:test', paramsSchema, function (req, reply) { - reply.code(200).send(req.params) - }) - t.pass() - } catch (e) { - t.fail() - } -}) - -test('shorthand - get, querystring schema', t => { - t.plan(1) - try { - fastify.get('/query', querySchema, function (req, reply) { - reply.code(200).send(req.query) - }) - t.pass() - } catch (e) { - t.fail() - } -}) - -test('shorthand - get, headers schema', t => { - t.plan(1) - try { - fastify.get('/headers', headersSchema, function (req, reply) { - reply.code(200).send(req.headers) - }) - t.pass() - } catch (e) { - t.fail() - } -}) - -test('missing schema - get', t => { - t.plan(1) - try { - fastify.get('/missing', function (req, reply) { - reply.code(200).send({ hello: 'world' }) - }) - t.pass() - } catch (e) { - t.fail() - } -}) - -test('custom serializer - get', t => { - t.plan(1) - - function customSerializer (data) { - return JSON.stringify(data) - } - - try { - fastify.get('/custom-serializer', numberSchema, function (req, reply) { - reply.code(200).serializer(customSerializer).send({ hello: 'world' }) - }) - t.pass() - } catch (e) { - t.fail() - } -}) - -test('empty response', t => { - t.plan(1) - try { - fastify.get('/empty', function (req, reply) { - reply.code(200).send() - }) - t.pass() - } catch (e) { - t.fail() - } -}) - -test('send a falsy boolean', t => { - t.plan(1) - try { - fastify.get('/boolean', function (req, reply) { - reply.code(200).send(false) - }) - t.pass() - } catch (e) { - t.fail() - } -}) - -fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - test('shorthand - request get', t => { - t.plan(4) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) - }) - - test('shorthand - request get params schema', t => { - t.plan(4) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/params/world/123' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { foo: 'world', test: 123 }) - }) - }) - - test('shorthand - request get params schema error', t => { - t.plan(3) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/params/world/string' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 400) - t.same(JSON.parse(body), { - error: 'Bad Request', - message: 'params/test must be integer', - statusCode: 400 - }) - }) - }) - - test('shorthand - request get headers schema', t => { - t.plan(4) - sget({ - method: 'GET', - headers: { - 'x-test': '1', - 'Y-Test': '3' - }, - json: true, - url: 'http://localhost:' + fastify.server.address().port + '/headers' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(body['x-test'], 1) - t.equal(body['y-test'], 3) - }) - }) - - test('shorthand - request get headers schema error', t => { - t.plan(3) - sget({ - method: 'GET', - headers: { - 'x-test': 'abc' - }, - url: 'http://localhost:' + fastify.server.address().port + '/headers' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 400) - t.same(JSON.parse(body), { - error: 'Bad Request', - message: 'headers/x-test must be number', - statusCode: 400 - }) - }) - }) - - test('shorthand - request get querystring schema', t => { - t.plan(4) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/query?hello=123' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 123 }) - }) - }) - - test('shorthand - request get querystring schema error', t => { - t.plan(3) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/query?hello=world' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 400) - t.same(JSON.parse(body), { - error: 'Bad Request', - message: 'querystring/hello must be integer', - statusCode: 400 - }) - }) - }) - - test('shorthand - request get missing schema', t => { - t.plan(4) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/missing' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) - }) - - test('shorthand - custom serializer', t => { - t.plan(4) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/custom-serializer' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) - }) - - test('shorthand - empty response', t => { - t.plan(4) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/empty' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '0') - t.same(body.toString(), '') - }) - }) - - test('shorthand - send a falsy boolean', t => { - t.plan(3) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/boolean' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body.toString(), 'false') - }) - }) - - test('shorthand - send null value', t => { - t.plan(3) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/null' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body.toString(), 'null') - }) - }) -}) diff --git a/test/handler-context.test.js b/test/handler-context.test.js index 0836cfa8abd..868377341cf 100644 --- a/test/handler-context.test.js +++ b/test/handler-context.test.js @@ -1,19 +1,9 @@ 'use strict' +const { test } = require('node:test') +const { kRouteContext } = require('../lib/symbols') +const fastify = require('..') -const http = require('http') -const test = require('tap').test -const fastify = require('../') - -function getUrl (app) { - const { address, port } = app.server.address() - if (address === '::1') { - return `http://[${address}]:${port}` - } else { - return `http://${address}:${port}` - } -} - -test('handlers receive correct `this` context', (t) => { +test('handlers receive correct `this` context', async (t) => { t.plan(4) // simulate plugin that uses fastify-plugin @@ -27,37 +17,29 @@ test('handlers receive correct `this` context', (t) => { instance.register(plugin) instance.get('/', function (req, reply) { - t.ok(this.foo) - t.equal(this.foo, 'foo') + t.assert.ok(this.foo) + t.assert.strictEqual(this.foo, 'foo') reply.send() }) - instance.listen({ port: 0 }, (err) => { - instance.server.unref() - if (err) t.threw(err) - t.ok(instance.foo) - t.equal(instance.foo, 'foo') + await instance.inject('/') - http.get(getUrl(instance), () => {}).on('error', t.threw) - }) + t.assert.ok(instance.foo) + t.assert.strictEqual(instance.foo, 'foo') }) -test('handlers have access to the internal context', (t) => { +test('handlers have access to the internal context', async (t) => { t.plan(5) const instance = fastify() instance.get('/', { config: { foo: 'bar' } }, function (req, reply) { - t.ok(reply.context) - t.ok(reply.context.config) - t.type(reply.context.config, Object) - t.ok(reply.context.config.foo) - t.equal(reply.context.config.foo, 'bar') + t.assert.ok(reply[kRouteContext]) + t.assert.ok(reply[kRouteContext].config) + t.assert.ok(typeof reply[kRouteContext].config, Object) + t.assert.ok(reply[kRouteContext].config.foo) + t.assert.strictEqual(reply[kRouteContext].config.foo, 'bar') reply.send() }) - instance.listen({ port: 0 }, (err) => { - instance.server.unref() - if (err) t.threw(err) - http.get(getUrl(instance), () => {}).on('error', t.threw) - }) + await instance.inject('/') }) diff --git a/test/handler-timeout.test.js b/test/handler-timeout.test.js new file mode 100644 index 00000000000..60ac3847714 --- /dev/null +++ b/test/handler-timeout.test.js @@ -0,0 +1,367 @@ +'use strict' + +const { test } = require('node:test') +const net = require('node:net') +const Fastify = require('..') +const { Readable } = require('node:stream') +const { kTimeoutTimer, kOnAbort } = require('../lib/symbols') + +// --- Option validation --- + +test('server-level handlerTimeout defaults to 0 in initialConfig', t => { + t.plan(1) + const fastify = Fastify() + t.assert.strictEqual(fastify.initialConfig.handlerTimeout, 0) +}) + +test('server-level handlerTimeout: 5000 is accepted and exposed in initialConfig', t => { + t.plan(1) + const fastify = Fastify({ handlerTimeout: 5000 }) + t.assert.strictEqual(fastify.initialConfig.handlerTimeout, 5000) +}) + +test('route-level handlerTimeout rejects invalid values', async t => { + const fastify = Fastify() + + t.assert.throws(() => { + fastify.get('/a', { handlerTimeout: 'fast' }, async () => 'ok') + }, { code: 'FST_ERR_ROUTE_HANDLER_TIMEOUT_OPTION_NOT_INT' }) + + t.assert.throws(() => { + fastify.get('/b', { handlerTimeout: -1 }, async () => 'ok') + }, { code: 'FST_ERR_ROUTE_HANDLER_TIMEOUT_OPTION_NOT_INT' }) + + t.assert.throws(() => { + fastify.get('/c', { handlerTimeout: 1.5 }, async () => 'ok') + }, { code: 'FST_ERR_ROUTE_HANDLER_TIMEOUT_OPTION_NOT_INT' }) + + t.assert.throws(() => { + fastify.get('/d', { handlerTimeout: 0 }, async () => 'ok') + }, { code: 'FST_ERR_ROUTE_HANDLER_TIMEOUT_OPTION_NOT_INT' }) +}) + +// --- Lazy signal without handlerTimeout --- + +test('when handlerTimeout is 0 (default), request.signal is lazily created', async t => { + t.plan(3) + const fastify = Fastify() + + fastify.get('/', async (request) => { + const signal = request.signal + t.assert.ok(signal instanceof AbortSignal) + t.assert.strictEqual(signal.aborted, false) + return { ok: true } + }) + + const res = await fastify.inject({ method: 'GET', url: '/' }) + t.assert.strictEqual(res.statusCode, 200) +}) + +test('client disconnect aborts lazily created signal (no handlerTimeout)', async t => { + t.plan(1) + + const fastify = Fastify() + let signalAborted = false + + fastify.get('/', async (request) => { + await new Promise((resolve) => { + request.signal.addEventListener('abort', () => { + signalAborted = true + resolve() + }) + }) + return 'should not reach' + }) + + await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const address = fastify.server.address() + await new Promise((resolve) => { + const client = net.connect(address.port, () => { + client.write('GET / HTTP/1.1\r\nHost: localhost\r\n\r\n') + setTimeout(() => { + client.destroy() + setTimeout(resolve, 100) + }, 50) + }) + }) + + t.assert.strictEqual(signalAborted, true) +}) + +// --- Basic timeout behavior --- + +test('slow handler returns 503 with FST_ERR_HANDLER_TIMEOUT', async t => { + t.plan(2) + const fastify = Fastify() + + fastify.get('/', { handlerTimeout: 50 }, async () => { + await new Promise(resolve => setTimeout(resolve, 500)) + return 'too late' + }) + + const res = await fastify.inject({ method: 'GET', url: '/' }) + t.assert.strictEqual(res.statusCode, 503) + t.assert.strictEqual(JSON.parse(res.payload).code, 'FST_ERR_HANDLER_TIMEOUT') +}) + +test('fast handler completes normally with 200', async t => { + t.plan(2) + const fastify = Fastify() + + fastify.get('/', { handlerTimeout: 5000 }, async () => { + return { hello: 'world' } + }) + + const res = await fastify.inject({ method: 'GET', url: '/' }) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) +}) + +// --- Per-route override --- + +test('route-level handlerTimeout overrides server default', async t => { + t.plan(4) + const fastify = Fastify({ handlerTimeout: 5000 }) + + fastify.get('/slow', { handlerTimeout: 50 }, async () => { + await new Promise(resolve => setTimeout(resolve, 500)) + return 'too late' + }) + + fastify.get('/fast', async () => { + return { ok: true } + }) + + const resSlow = await fastify.inject({ method: 'GET', url: '/slow' }) + t.assert.strictEqual(resSlow.statusCode, 503) + t.assert.strictEqual(JSON.parse(resSlow.payload).code, 'FST_ERR_HANDLER_TIMEOUT') + + const resFast = await fastify.inject({ method: 'GET', url: '/fast' }) + t.assert.strictEqual(resFast.statusCode, 200) + t.assert.deepStrictEqual(JSON.parse(resFast.payload), { ok: true }) +}) + +// --- request.signal behavior --- + +test('request.signal is an AbortSignal when handlerTimeout > 0', async t => { + t.plan(2) + const fastify = Fastify() + + fastify.get('/', { handlerTimeout: 5000 }, async (request) => { + t.assert.ok(request.signal instanceof AbortSignal) + t.assert.strictEqual(request.signal.aborted, false) + return 'ok' + }) + + await fastify.inject({ method: 'GET', url: '/' }) +}) + +test('request.signal aborts when timeout fires with reason', async t => { + t.plan(2) + const fastify = Fastify() + + let signalReason = null + fastify.get('/', { handlerTimeout: 50 }, async (request) => { + request.signal.addEventListener('abort', () => { + signalReason = request.signal.reason + }) + await new Promise(resolve => setTimeout(resolve, 500)) + return 'too late' + }) + + await fastify.inject({ method: 'GET', url: '/' }) + t.assert.ok(signalReason !== null) + t.assert.strictEqual(signalReason.code, 'FST_ERR_HANDLER_TIMEOUT') +}) + +// --- Streaming response --- + +test('streaming response: timer clears when response finishes', async t => { + t.plan(1) + + const fastify = Fastify() + fastify.get('/', { handlerTimeout: 5000 }, async (request, reply) => { + const stream = new Readable({ + read () { + this.push('hello') + this.push(null) + } + }) + reply.type('text/plain').send(stream) + return reply + }) + + await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const address = fastify.server.address() + const res = await fetch(`http://localhost:${address.port}/`) + t.assert.strictEqual(res.status, 200) +}) + +// --- SSE with reply.hijack() --- + +test('reply.hijack() clears timeout timer', async t => { + t.plan(1) + + const fastify = Fastify() + fastify.get('/', { handlerTimeout: 100 }, async (request, reply) => { + reply.hijack() + // Write after the original timeout would have fired + await new Promise(resolve => setTimeout(resolve, 200)) + reply.raw.writeHead(200, { 'Content-Type': 'text/plain' }) + reply.raw.end('hijacked response') + }) + + await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const address = fastify.server.address() + const res = await fetch(`http://localhost:${address.port}/`) + t.assert.strictEqual(res.status, 200) +}) + +// --- Error handler integration --- + +test('route-level errorHandler receives FST_ERR_HANDLER_TIMEOUT', async t => { + t.plan(3) + const fastify = Fastify() + + fastify.get('/', { + handlerTimeout: 50, + errorHandler: (error, request, reply) => { + t.assert.strictEqual(error.code, 'FST_ERR_HANDLER_TIMEOUT') + reply.code(504).send({ custom: 'timeout' }) + } + }, async () => { + await new Promise(resolve => setTimeout(resolve, 500)) + return 'too late' + }) + + const res = await fastify.inject({ method: 'GET', url: '/' }) + t.assert.strictEqual(res.statusCode, 504) + t.assert.deepStrictEqual(JSON.parse(res.payload), { custom: 'timeout' }) +}) + +// --- Timer cleanup / no leaks --- + +test('timer is cleaned up after fast response (no leak)', async t => { + t.plan(3) + const fastify = Fastify() + + let capturedRequest + fastify.get('/', { handlerTimeout: 60000 }, async (request) => { + capturedRequest = request + return 'fast' + }) + + const res = await fastify.inject({ method: 'GET', url: '/' }) + t.assert.strictEqual(res.statusCode, 200) + // Timer and listener should be cleaned up + t.assert.strictEqual(capturedRequest[kTimeoutTimer], null) + t.assert.strictEqual(capturedRequest[kOnAbort], null) +}) + +// --- routeOptions exposure --- + +test('request.routeOptions.handlerTimeout reflects configured value', async t => { + t.plan(2) + const fastify = Fastify() + + fastify.get('/', { handlerTimeout: 3000 }, async (request) => { + t.assert.strictEqual(request.routeOptions.handlerTimeout, 3000) + return 'ok' + }) + + const res = await fastify.inject({ method: 'GET', url: '/' }) + t.assert.strictEqual(res.statusCode, 200) +}) + +test('request.routeOptions.handlerTimeout reflects server default', async t => { + t.plan(2) + const fastify = Fastify({ handlerTimeout: 7000 }) + + fastify.get('/', async (request) => { + t.assert.strictEqual(request.routeOptions.handlerTimeout, 7000) + return 'ok' + }) + + const res = await fastify.inject({ method: 'GET', url: '/' }) + t.assert.strictEqual(res.statusCode, 200) +}) + +// --- Client disconnect aborts signal --- + +test('client disconnect aborts request.signal', async t => { + t.plan(1) + + const fastify = Fastify() + let signalAborted = false + + fastify.get('/', { handlerTimeout: 5000 }, async (request) => { + await new Promise((resolve) => { + request.signal.addEventListener('abort', () => { + signalAborted = true + resolve() + }) + }) + return 'should not reach' + }) + + await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const address = fastify.server.address() + await new Promise((resolve) => { + const client = net.connect(address.port, () => { + client.write('GET / HTTP/1.1\r\nHost: localhost\r\n\r\n') + setTimeout(() => { + client.destroy() + // Give the server time to process the close event + setTimeout(resolve, 100) + }, 50) + }) + }) + + t.assert.strictEqual(signalAborted, true) +}) + +// --- Race: handler completes just as timeout fires --- + +test('no double-send when handler completes near timeout boundary', async t => { + t.plan(2) + const fastify = Fastify() + + fastify.get('/', { handlerTimeout: 50 }, async (request, reply) => { + // Respond just before timeout + await new Promise(resolve => setTimeout(resolve, 40)) + reply.send({ ok: true }) + return reply + }) + + const res = await fastify.inject({ method: 'GET', url: '/' }) + // Should get either 200 or 503 depending on race, but never crash + t.assert.ok(res.statusCode === 200 || res.statusCode === 503) + // Verify response is valid JSON regardless of which won the race + t.assert.ok(JSON.parse(res.payload)) +}) + +// --- Server default inherited by routes --- + +test('routes inherit server-level handlerTimeout', async t => { + t.plan(3) + const fastify = Fastify({ handlerTimeout: 50 }) + + fastify.get('/', async (request) => { + // Verify the signal is present (inherited from server default) + t.assert.ok(request.signal instanceof AbortSignal) + await new Promise(resolve => setTimeout(resolve, 500)) + return 'too late' + }) + + const res = await fastify.inject({ method: 'GET', url: '/' }) + t.assert.strictEqual(res.statusCode, 503) + t.assert.strictEqual(JSON.parse(res.payload).code, 'FST_ERR_HANDLER_TIMEOUT') +}) diff --git a/test/has-route.test.js b/test/has-route.test.js new file mode 100644 index 00000000000..97761b1eb13 --- /dev/null +++ b/test/has-route.test.js @@ -0,0 +1,88 @@ +'use strict' + +const { test, describe } = require('node:test') +const Fastify = require('..') + +const fastify = Fastify() + +describe('hasRoute', async t => { + test('hasRoute - invalid options', t => { + t.plan(3) + + t.assert.strictEqual(fastify.hasRoute({ }), false) + t.assert.strictEqual(fastify.hasRoute({ method: 'GET' }), false) + t.assert.strictEqual(fastify.hasRoute({ constraints: [] }), false) + }) + + test('hasRoute - primitive method', t => { + t.plan(2) + fastify.route({ + method: 'GET', + url: '/', + handler: function (req, reply) { + reply.send({ hello: 'world' }) + } + }) + + t.assert.strictEqual(fastify.hasRoute({ + method: 'GET', + url: '/' + }), true) + + t.assert.strictEqual(fastify.hasRoute({ + method: 'POST', + url: '/' + }), false) + }) + + test('hasRoute - with constraints', t => { + t.plan(2) + fastify.route({ + method: 'GET', + url: '/', + constraints: { version: '1.2.0' }, + handler: (req, reply) => { + reply.send({ hello: 'world' }) + } + }) + + t.assert.strictEqual(fastify.hasRoute({ + method: 'GET', + url: '/', + constraints: { version: '1.2.0' } + }), true) + + t.assert.strictEqual(fastify.hasRoute({ + method: 'GET', + url: '/', + constraints: { version: '1.3.0' } + }), false) + }) + + test('hasRoute - parametric route regexp with constraints', t => { + t.plan(1) + // parametric with regexp + fastify.get('/example/:file(^\\d+).png', function (request, reply) { }) + + t.assert.strictEqual(fastify.hasRoute({ + method: 'GET', + url: '/example/:file(^\\d+).png' + }), true) + }) + + test('hasRoute - finds a route even if method is not uppercased', t => { + t.plan(1) + fastify.route({ + method: 'GET', + url: '/equal', + handler: function (req, reply) { + reply.send({ hello: 'world' }) + } + }) + + t.assert.strictEqual(fastify.hasRoute({ + method: 'get', + url: '/equal' + }), true) + }) +}) diff --git a/test/head.test.js b/test/head.test.js deleted file mode 100644 index 2787c0e9f52..00000000000 --- a/test/head.test.js +++ /dev/null @@ -1,164 +0,0 @@ -'use strict' - -const t = require('tap') -const test = t.test -const sget = require('simple-get').concat -const fastify = require('..')() - -const schema = { - schema: { - response: { - '2xx': { - type: 'null' - } - } - } -} - -const querySchema = { - schema: { - querystring: { - type: 'object', - properties: { - hello: { - type: 'integer' - } - } - } - } -} - -const paramsSchema = { - schema: { - params: { - type: 'object', - properties: { - foo: { - type: 'string' - }, - test: { - type: 'integer' - } - } - } - } -} - -test('shorthand - head', t => { - t.plan(1) - try { - fastify.head('/', schema, function (req, reply) { - reply.code(200).send(null) - }) - t.pass() - } catch (e) { - t.fail() - } -}) - -test('shorthand - head params', t => { - t.plan(1) - try { - fastify.head('/params/:foo/:test', paramsSchema, function (req, reply) { - reply.send(null) - }) - t.pass() - } catch (e) { - t.fail() - } -}) - -test('shorthand - head, querystring schema', t => { - t.plan(1) - try { - fastify.head('/query', querySchema, function (req, reply) { - reply.code(200).send(null) - }) - t.pass() - } catch (e) { - t.fail() - } -}) - -test('missing schema - head', t => { - t.plan(1) - try { - fastify.head('/missing', function (req, reply) { - reply.code(200).send(null) - }) - t.pass() - } catch (e) { - t.fail() - } -}) - -fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - test('shorthand - request head', t => { - t.plan(2) - sget({ - method: 'HEAD', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response) => { - t.error(err) - t.equal(response.statusCode, 200) - }) - }) - - test('shorthand - request head params schema', t => { - t.plan(2) - sget({ - method: 'HEAD', - url: 'http://localhost:' + fastify.server.address().port + '/params/world/123' - }, (err, response) => { - t.error(err) - t.equal(response.statusCode, 200) - }) - }) - - test('shorthand - request head params schema error', t => { - t.plan(2) - sget({ - method: 'HEAD', - url: 'http://localhost:' + fastify.server.address().port + '/params/world/string' - }, (err, response) => { - t.error(err) - t.equal(response.statusCode, 400) - }) - }) - - test('shorthand - request head querystring schema', t => { - t.plan(2) - sget({ - method: 'HEAD', - url: 'http://localhost:' + fastify.server.address().port + '/query?hello=123' - }, (err, response) => { - t.error(err) - t.equal(response.statusCode, 200) - }) - }) - - test('shorthand - request head querystring schema error', t => { - t.plan(2) - sget({ - method: 'HEAD', - url: 'http://localhost:' + fastify.server.address().port + '/query?hello=world' - }, (err, response) => { - t.error(err) - t.equal(response.statusCode, 400) - }) - }) - - test('shorthand - request head missing schema', t => { - t.plan(2) - sget({ - method: 'HEAD', - url: 'http://localhost:' + fastify.server.address().port + '/missing' - }, (err, response) => { - t.error(err) - t.equal(response.statusCode, 200) - }) - }) -}) diff --git a/test/header-overflow.test.js b/test/header-overflow.test.js new file mode 100644 index 00000000000..cfc32b88f2d --- /dev/null +++ b/test/header-overflow.test.js @@ -0,0 +1,55 @@ +'use strict' + +const { test } = require('node:test') +const Fastify = require('..') + +const maxHeaderSize = 1024 + +test('Should return 431 if request header fields are too large', async (t) => { + t.plan(2) + + const fastify = Fastify({ http: { maxHeaderSize } }) + fastify.route({ + method: 'GET', + url: '/', + handler: (_req, reply) => { + reply.send({ hello: 'world' }) + } + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + + const result = await fetch(fastifyServer, { + method: 'GET', + headers: { + 'Large-Header': 'a'.repeat(maxHeaderSize) + } + }) + + t.assert.ok(!result.ok) + t.assert.strictEqual(result.status, 431) + + t.after(() => fastify.close()) +}) + +test('Should return 431 if URI is too long', async (t) => { + t.plan(2) + + const fastify = Fastify({ http: { maxHeaderSize } }) + fastify.route({ + method: 'GET', + url: '/', + handler: (_req, reply) => { + reply.send({ hello: 'world' }) + } + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + + const result = await fetch(`${fastifyServer}/${'a'.repeat(maxHeaderSize)}`) + + t.assert.ok(!result.ok) + t.assert.strictEqual(result.status, 431) + + t.after(() => fastify.close()) +}) diff --git a/test/helper.js b/test/helper.js index 9451adbbdbc..43c1db27e8c 100644 --- a/test/helper.js +++ b/test/helper.js @@ -1,13 +1,17 @@ 'use strict' -const sget = require('simple-get').concat -const dns = require('dns').promises -const stream = require('stream') +const dns = require('node:dns').promises +const stream = require('node:stream') +const { promisify } = require('node:util') const symbols = require('../lib/symbols') +const { waitForCb } = require('./toolkit') +const assert = require('node:assert') + +module.exports.sleep = promisify(setTimeout) /** * @param method HTTP request method - * @param t tap instance + * @param t node:test instance * @param isSetErrorHandler true: using setErrorHandler */ module.exports.payloadMethod = function (method, t, isSetErrorHandler = false) { @@ -16,8 +20,8 @@ module.exports.payloadMethod = function (method, t, isSetErrorHandler = false) { if (isSetErrorHandler) { fastify.setErrorHandler(function (err, request, reply) { - t.type(request, 'object') - t.type(request, fastify[symbols.kRequest].parent) + assert.ok(request instanceof fastify[symbols.kRequest].parent) + assert.strictEqual(typeof request, 'object') reply .code(err.statusCode) .type('application/json; charset=utf-8') @@ -49,9 +53,9 @@ module.exports.payloadMethod = function (method, t, isSetErrorHandler = false) { fastify[loMethod]('/', schema, function (req, reply) { reply.code(200).send(req.body) }) - t.pass() + t.assert.ok(true) } catch (e) { - t.fail() + t.assert.fail() } }) @@ -61,9 +65,9 @@ module.exports.payloadMethod = function (method, t, isSetErrorHandler = false) { fastify[loMethod]('/missing', function (req, reply) { reply.code(200).send(req.body) }) - t.pass() + t.assert.ok(true) } catch (e) { - t.fail() + t.assert.fail() } }) @@ -74,9 +78,9 @@ module.exports.payloadMethod = function (method, t, isSetErrorHandler = false) { req.body.hello = req.body.hello + req.query.foo reply.code(200).send(req.body) }) - t.pass() + t.assert.ok(true) } catch (e) { - t.fail() + t.assert.fail() } }) @@ -86,111 +90,114 @@ module.exports.payloadMethod = function (method, t, isSetErrorHandler = false) { fastify[loMethod]('/with-limit', { bodyLimit: 1 }, function (req, reply) { reply.send(req.body) }) - t.pass() + t.assert.ok(true) } catch (e) { - t.fail() + t.assert.fail() } }) fastify.listen({ port: 0 }, function (err) { if (err) { - t.error(err) + t.assert.ifError(err) return } - t.teardown(() => { fastify.close() }) + t.after(() => { fastify.close() }) - test(`${upMethod} - correctly replies`, t => { + test(`${upMethod} - correctly replies`, async (t) => { t.plan(3) - sget({ + + const result = await fetch('http://localhost:' + fastify.server.address().port, { method: upMethod, - url: 'http://localhost:' + fastify.server.address().port, - body: { - hello: 'world' - }, - json: true - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body, { hello: 'world' }) + body: JSON.stringify({ hello: 'world' }), + headers: { + 'Content-Type': 'application/json' + } }) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.deepStrictEqual(await result.json(), { hello: 'world' }) }) - test(`${upMethod} - correctly replies with very large body`, t => { + test(`${upMethod} - correctly replies with very large body`, async (t) => { t.plan(3) const largeString = 'world'.repeat(13200) - sget({ + const result = await fetch('http://localhost:' + fastify.server.address().port, { method: upMethod, - url: 'http://localhost:' + fastify.server.address().port, - body: { hello: largeString }, - json: true - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body, { hello: largeString }) + body: JSON.stringify({ hello: largeString }), + headers: { + 'Content-Type': 'application/json' + } }) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.deepStrictEqual(await result.json(), { hello: largeString }) }) - test(`${upMethod} - correctly replies if the content type has the charset`, t => { + test(`${upMethod} - correctly replies if the content type has the charset`, async (t) => { t.plan(3) - sget({ + + const result = await fetch('http://localhost:' + fastify.server.address().port, { method: upMethod, - url: 'http://localhost:' + fastify.server.address().port, body: JSON.stringify({ hello: 'world' }), headers: { 'content-type': 'application/json; charset=utf-8' } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body.toString(), JSON.stringify({ hello: 'world' })) }) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.deepStrictEqual(await result.text(), JSON.stringify({ hello: 'world' })) }) - test(`${upMethod} without schema - correctly replies`, t => { + test(`${upMethod} without schema - correctly replies`, async (t) => { t.plan(3) - sget({ + + const result = await fetch('http://localhost:' + fastify.server.address().port + '/missing', { method: upMethod, - url: 'http://localhost:' + fastify.server.address().port + '/missing', - body: { - hello: 'world' - }, - json: true - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body, { hello: 'world' }) + body: JSON.stringify({ hello: 'world' }), + headers: { + 'Content-Type': 'application/json' + } }) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.deepStrictEqual(await result.json(), { hello: 'world' }) }) - test(`${upMethod} with body and querystring - correctly replies`, t => { + test(`${upMethod} with body and querystring - correctly replies`, async (t) => { t.plan(3) - sget({ + + const result = await fetch('http://localhost:' + fastify.server.address().port + '/with-query?foo=hello', { method: upMethod, - url: 'http://localhost:' + fastify.server.address().port + '/with-query?foo=hello', - body: { - hello: 'world' - }, - json: true - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body, { hello: 'worldhello' }) + body: JSON.stringify({ hello: 'world' }), + headers: { + 'Content-Type': 'application/json' + } }) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.deepStrictEqual(await result.json(), { hello: 'worldhello' }) }) test(`${upMethod} with no body - correctly replies`, t => { t.plan(6) - sget({ + const { stepIn, patience } = waitForCb({ steps: 2 }) + + fetch('http://localhost:' + fastify.server.address().port + '/missing', { method: upMethod, - url: 'http://localhost:' + fastify.server.address().port + '/missing', headers: { 'Content-Length': '0' } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(body.toString(), '') + }).then(async (response) => { + t.assert.ok(response.ok) + t.assert.strictEqual(response.status, 200) + t.assert.strictEqual(await response.text(), '') + stepIn() }) // Must use inject to make a request without a Content-Length header @@ -198,91 +205,109 @@ module.exports.payloadMethod = function (method, t, isSetErrorHandler = false) { method: upMethod, url: '/missing' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.equal(res.payload.toString(), '') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload.toString(), '') + stepIn() }) + + return patience }) - test(`${upMethod} returns 415 - incorrect media type if body is not json`, t => { + test(`${upMethod} returns 415 - incorrect media type if body is not json`, async (t) => { t.plan(2) - sget({ - method: upMethod, - url: 'http://localhost:' + fastify.server.address().port + '/missing', - body: 'hello world' - }, (err, response, body) => { - t.error(err) - if (upMethod === 'OPTIONS') { - t.equal(response.statusCode, 200) - } else { - t.equal(response.statusCode, 415) + const result = await fetch('http://localhost:' + fastify.server.address().port + '/missing', { + method: upMethod, + body: 'hello world', + headers: { + 'Content-Type': undefined } }) + + t.assert.ok(!result.ok) + t.assert.strictEqual(result.status, 415) }) if (loMethod === 'options') { - test('OPTIONS returns 415 - should return 415 if Content-Type is not json or plain text', t => { + test('OPTIONS returns 415 - should return 415 if Content-Type is not json or plain text', async (t) => { t.plan(2) - sget({ + + const result = await fetch('http://localhost:' + fastify.server.address().port + '/missing', { method: upMethod, - url: 'http://localhost:' + fastify.server.address().port + '/missing', body: 'hello world', headers: { 'Content-Type': 'text/xml' } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 415) }) + + t.assert.ok(!result.ok) + t.assert.strictEqual(result.status, 415) }) } test(`${upMethod} returns 400 - Bad Request`, t => { - t.plan(4) + const isOptions = upMethod === 'OPTIONS' + t.plan(isOptions ? 2 : 4) + + const { stepIn, patience } = waitForCb({ steps: isOptions ? 1 : 2 }) - sget({ + fetch('http://localhost:' + fastify.server.address().port, { method: upMethod, - url: 'http://localhost:' + fastify.server.address().port, body: 'hello world', headers: { 'Content-Type': 'application/json' } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 400) + }).then((response) => { + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 400) + stepIn() }) - sget({ - method: upMethod, - url: 'http://localhost:' + fastify.server.address().port, - headers: { - 'Content-Type': 'application/json', - 'Content-Length': '0' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 400) - }) + if (!isOptions) { + fetch(`http://localhost:${fastify.server.address().port}`, { + method: upMethod, + headers: { + 'Content-Type': 'application/json', + 'Content-Length': '0' + } + }).then((response) => { + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 400) + stepIn() + }) + } + + return patience }) test(`${upMethod} returns 413 - Payload Too Large`, t => { - t.plan(upMethod === 'OPTIONS' ? 4 : 6) + const isOptions = upMethod === 'OPTIONS' + t.plan(isOptions ? 3 : 5) - sget({ + const { stepIn, patience } = waitForCb({ steps: isOptions ? 2 : 3 }) + + fetch(`http://localhost:${fastify.server.address().port}`, { method: upMethod, - url: 'http://localhost:' + fastify.server.address().port, + body: JSON.stringify({ w: 'w'.repeat(1024 * 1024 + 1) }), headers: { - 'Content-Type': 'application/json', - 'Content-Length': 1024 * 1024 + 1 + 'Content-Type': 'application/json' } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 413) + }).then((response) => { + t.assert.strictEqual(response.status, 413) + stepIn() + }).catch((err) => { + // Handle EPIPE error - server closed connection after sending 413 + if (err.cause?.code === 'EPIPE' || err.message.includes('fetch failed')) { + t.assert.ok(true, 'Expected EPIPE error due to server closing connection on 413') + } else { + throw err + } + stepIn() }) // Node errors for OPTIONS requests with a stream body and no Content-Length header - if (upMethod !== 'OPTIONS') { + if (!isOptions) { let chunk = Buffer.alloc(1024 * 1024 + 1, 0) const largeStream = new stream.Readable({ read () { @@ -290,34 +315,46 @@ module.exports.payloadMethod = function (method, t, isSetErrorHandler = false) { chunk = null } }) - sget({ + fetch('http://localhost:' + fastify.server.address().port, { method: upMethod, - url: 'http://localhost:' + fastify.server.address().port, headers: { 'Content-Type': 'application/json' }, - body: largeStream - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 413) + body: largeStream, + duplex: 'half' + }).then((response) => { + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 413) + stepIn() + }).catch((err) => { + // Handle EPIPE error - server closed connection after sending 413 + if (err.cause?.code === 'EPIPE' || err.message.includes('fetch failed')) { + t.assert.ok(true, 'Expected EPIPE error due to server closing connection on 413') + } else { + throw err + } + stepIn() }) } - sget({ + fetch(`http://localhost:${fastify.server.address().port}/with-limit`, { method: upMethod, - url: `http://localhost:${fastify.server.address().port}/with-limit`, headers: { 'Content-Type': 'application/json' }, - body: {}, - json: true - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 413) + body: JSON.stringify({}) + }).then((response) => { + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 413) + stepIn() }) + + return patience }) test(`${upMethod} should fail with empty body and application/json content-type`, t => { - if (upMethod === 'OPTIONS') return t.end() + if (upMethod === 'OPTIONS') return t.plan(12) + const { stepIn, patience } = waitForCb({ steps: 5 }) + fastify.inject({ method: `${upMethod}`, url: '/', @@ -325,8 +362,8 @@ module.exports.payloadMethod = function (method, t, isSetErrorHandler = false) { 'Content-Type': 'application/json' } }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { error: 'Bad Request', code: 'FST_ERR_CTP_EMPTY_JSON_BODY', message: 'Body cannot be empty when content-type is set to \'application/json\'', @@ -334,20 +371,20 @@ module.exports.payloadMethod = function (method, t, isSetErrorHandler = false) { }) }) - sget({ + fetch(`http://localhost:${fastify.server.address().port}`, { method: upMethod, - url: `http://localhost:${fastify.server.address().port}`, headers: { 'Content-Type': 'application/json' } - }, (err, res, body) => { - t.error(err) - t.same(JSON.parse(body.toString()), { + }).then(async (res) => { + t.assert.ok(!res.ok) + t.assert.deepStrictEqual(await res.json(), { error: 'Bad Request', code: 'FST_ERR_CTP_EMPTY_JSON_BODY', message: 'Body cannot be empty when content-type is set to \'application/json\'', statusCode: 400 }) + stepIn() }) fastify.inject({ @@ -358,30 +395,31 @@ module.exports.payloadMethod = function (method, t, isSetErrorHandler = false) { }, payload: null }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { error: 'Bad Request', code: 'FST_ERR_CTP_EMPTY_JSON_BODY', message: 'Body cannot be empty when content-type is set to \'application/json\'', statusCode: 400 }) + stepIn() }) - sget({ + fetch(`http://localhost:${fastify.server.address().port}`, { method: upMethod, - url: `http://localhost:${fastify.server.address().port}`, headers: { 'Content-Type': 'application/json' }, - payload: null - }, (err, res, body) => { - t.error(err) - t.same(JSON.parse(body.toString()), { + body: null + }).then(async (res) => { + t.assert.ok(!res.ok) + t.assert.deepStrictEqual(await res.json(), { error: 'Bad Request', code: 'FST_ERR_CTP_EMPTY_JSON_BODY', message: 'Body cannot be empty when content-type is set to \'application/json\'', statusCode: 400 }) + stepIn() }) fastify.inject({ @@ -392,45 +430,67 @@ module.exports.payloadMethod = function (method, t, isSetErrorHandler = false) { }, payload: undefined }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { error: 'Bad Request', code: 'FST_ERR_CTP_EMPTY_JSON_BODY', message: 'Body cannot be empty when content-type is set to \'application/json\'', statusCode: 400 }) + stepIn() }) - sget({ + fetch(`http://localhost:${fastify.server.address().port}`, { method: upMethod, - url: `http://localhost:${fastify.server.address().port}`, headers: { 'Content-Type': 'application/json' }, - payload: undefined - }, (err, res, body) => { - t.error(err) - t.same(JSON.parse(body.toString()), { + body: undefined + }).then(async (res) => { + t.assert.ok(!res.ok) + t.assert.deepStrictEqual(await res.json(), { error: 'Bad Request', code: 'FST_ERR_CTP_EMPTY_JSON_BODY', message: 'Body cannot be empty when content-type is set to \'application/json\'', statusCode: 400 }) + stepIn() }) + + return patience }) }) } -module.exports.getLoopbackHost = async () => { - let localhostForURL +function lookupToIp (lookup) { + return lookup.family === 6 ? `[${lookup.address}]` : lookup.address +} +module.exports.getLoopbackHost = async () => { const lookup = await dns.lookup('localhost') - const localhost = lookup.address - if (lookup.family === 6) { - localhostForURL = `[${lookup.address}]` - } else { - localhostForURL = localhost + return [lookup.address, lookupToIp(lookup)] +} + +module.exports.plainTextParser = function (request, callback) { + let body = '' + request.setEncoding('utf8') + request.on('error', onError) + request.on('data', onData) + request.on('end', onEnd) + function onError (err) { + callback(err, null) + } + function onData (chunk) { + body += chunk } + function onEnd () { + callback(null, body) + } +} - return [localhost, localhostForURL] +module.exports.getServerUrl = function (app) { + const { address, port } = app.server.address() + return address === '::1' + ? `http://[${address}]:${port}` + : `http://${address}:${port}` } diff --git a/test/hooks-async.test.js b/test/hooks-async.test.js index c28ee9bc118..698eb99e228 100644 --- a/test/hooks-async.test.js +++ b/test/hooks-async.test.js @@ -1,18 +1,16 @@ 'use strict' -const { Readable } = require('stream') -const t = require('tap') -const test = t.test -const sget = require('simple-get').concat +const { Readable } = require('node:stream') +const { test, describe } = require('node:test') const Fastify = require('../fastify') -const fs = require('fs') -const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) +const fs = require('node:fs') +const { sleep } = require('./helper') +const { waitForCb } = require('./toolkit') process.removeAllListeners('warning') -test('async hooks', t => { - t.plan(21) - +test('async hooks', async t => { + t.plan(20) const fastify = Fastify({ exposeHeadRoutes: false }) fastify.addHook('onRequest', async function (request, reply) { await sleep(1) @@ -25,8 +23,8 @@ test('async hooks', t => { fastify.addHook('preHandler', async function (request, reply) { await sleep(1) - t.equal(request.test, 'the request is coming') - t.equal(reply.test, 'the reply has come') + t.assert.strictEqual(request.test, 'the request is coming') + t.assert.strictEqual(reply.test, 'the reply has come') if (request.raw.method === 'HEAD') { throw new Error('some error') } @@ -34,17 +32,21 @@ test('async hooks', t => { fastify.addHook('onSend', async function (request, reply, payload) { await sleep(1) - t.ok('onSend called') + t.assert.ok('onSend called') }) + const completion = waitForCb({ + steps: 6 + }) fastify.addHook('onResponse', async function (request, reply) { await sleep(1) - t.ok('onResponse called') + t.assert.ok('onResponse called') + completion.stepIn() }) fastify.get('/', function (request, reply) { - t.equal(request.test, 'the request is coming') - t.equal(reply.test, 'the reply has come') + t.assert.strictEqual(request.test, 'the request is coming') + t.assert.strictEqual(reply.test, 'the reply has come') reply.code(200).send({ hello: 'world' }) }) @@ -56,39 +58,37 @@ test('async hooks', t => { reply.code(200).send({ hello: 'world' }) }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) + const response1 = await fetch(fastifyServer, { + method: 'GET' + }) + t.assert.ok(response1.ok) + t.assert.strictEqual(response1.status, 200) + const body1 = await response1.text() + t.assert.strictEqual(response1.headers.get('content-length'), '' + body1.length) + t.assert.deepStrictEqual(JSON.parse(body1), { hello: 'world' }) + completion.stepIn() - sget({ - method: 'HEAD', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 500) - }) + const response2 = await fetch(fastifyServer, { + method: 'HEAD' + }) + t.assert.ok(!response2.ok) + t.assert.strictEqual(response2.status, 500) + completion.stepIn() - sget({ - method: 'DELETE', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 500) - }) + const response3 = await fetch(fastifyServer, { + method: 'DELETE' }) + t.assert.ok(!response3.ok) + t.assert.strictEqual(response3.status, 500) + completion.stepIn() + + return completion.patience }) -test('modify payload', t => { +test('modify payload', (t, testDone) => { t.plan(10) const fastify = Fastify() const payload = { hello: 'world' } @@ -96,20 +96,20 @@ test('modify payload', t => { const anotherPayload = '"winter is coming"' fastify.addHook('onSend', async function (request, reply, thePayload) { - t.ok('onSend called') - t.same(JSON.parse(thePayload), payload) + t.assert.ok('onSend called') + t.assert.deepStrictEqual(JSON.parse(thePayload), payload) return thePayload.replace('world', 'modified') }) fastify.addHook('onSend', async function (request, reply, thePayload) { - t.ok('onSend called') - t.same(JSON.parse(thePayload), modifiedPayload) + t.assert.ok('onSend called') + t.assert.deepStrictEqual(JSON.parse(thePayload), modifiedPayload) return anotherPayload }) fastify.addHook('onSend', async function (request, reply, thePayload) { - t.ok('onSend called') - t.equal(thePayload, anotherPayload) + t.assert.ok('onSend called') + t.assert.deepStrictEqual(thePayload, anotherPayload) }) fastify.get('/', (req, reply) => { @@ -120,14 +120,15 @@ test('modify payload', t => { method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.equal(res.payload, anotherPayload) - t.equal(res.statusCode, 200) - t.equal(res.headers['content-length'], '18') + t.assert.ifError(err) + t.assert.strictEqual(res.payload, anotherPayload) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['content-length'], '18') + testDone() }) }) -test('onRequest hooks should be able to block a request', t => { +test('onRequest hooks should be able to block a request', (t, testDone) => { t.plan(5) const fastify = Fastify() @@ -136,36 +137,37 @@ test('onRequest hooks should be able to block a request', t => { }) fastify.addHook('onRequest', async (req, reply) => { - t.fail('this should not be called') + t.assert.fail('this should not be called') }) fastify.addHook('preHandler', async (req, reply) => { - t.fail('this should not be called') + t.assert.fail('this should not be called') }) fastify.addHook('onSend', async (req, reply, payload) => { - t.ok('called') + t.assert.ok('called') }) fastify.addHook('onResponse', async (request, reply) => { - t.ok('called') + t.assert.ok('called') }) fastify.get('/', function (request, reply) { - t.fail('we should not be here') + t.assert.fail('we should not be here') }) fastify.inject({ url: '/', method: 'GET' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.equal(res.payload, 'hello') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload, 'hello') + testDone() }) }) -test('preParsing hooks should be able to modify the payload', t => { +test('preParsing hooks should be able to modify the payload', (t, testDone) => { t.plan(3) const fastify = Fastify() @@ -188,16 +190,176 @@ test('preParsing hooks should be able to modify the payload', t => { url: '/', payload: { hello: 'world' } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same(JSON.parse(res.payload), { hello: 'another world' }) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'another world' }) + testDone() }) }) -test('preParsing hooks should handle errors', t => { - t.plan(3) +test('preParsing hooks should be able to supply statusCode', (t, testDone) => { + t.plan(4) + const fastify = Fastify() + + fastify.addHook('preParsing', async (req, reply, payload) => { + const stream = new Readable({ + read () { + const error = new Error('kaboom') + error.statusCode = 408 + this.destroy(error) + } + }) + stream.receivedEncodedLength = 20 + return stream + }) + + fastify.addHook('onError', async (req, res, err) => { + t.assert.strictEqual(err.statusCode, 408) + }) + + fastify.post('/', function (request, reply) { + t.assert.fail('should not be called') + }) + + fastify.inject({ + method: 'POST', + url: '/', + payload: { hello: 'world' } + }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 408) + t.assert.deepStrictEqual(JSON.parse(res.payload), { + statusCode: 408, + error: 'Request Timeout', + message: 'kaboom' + }) + + testDone() + }) +}) + +test('preParsing hooks should ignore statusCode 200 in stream error', (t, testDone) => { + t.plan(4) + const fastify = Fastify() + + fastify.addHook('preParsing', async (req, reply, payload) => { + const stream = new Readable({ + read () { + const error = new Error('kaboom') + error.statusCode = 200 + this.destroy(error) + } + }) + stream.receivedEncodedLength = 20 + return stream + }) + + fastify.addHook('onError', async (req, res, err) => { + t.assert.strictEqual(err.statusCode, 400) + }) + + fastify.post('/', function (request, reply) { + t.assert.fail('should not be called') + }) + + fastify.inject({ + method: 'POST', + url: '/', + payload: { hello: 'world' } + }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) + t.assert.deepStrictEqual(JSON.parse(res.payload), { + statusCode: 400, + error: 'Bad Request', + message: 'kaboom' + }) + testDone() + }) +}) + +test('preParsing hooks should ignore non-number statusCode in stream error', (t, testDone) => { + t.plan(4) const fastify = Fastify() + fastify.addHook('preParsing', async (req, reply, payload) => { + const stream = new Readable({ + read () { + const error = new Error('kaboom') + error.statusCode = '418' + this.destroy(error) + } + }) + stream.receivedEncodedLength = 20 + return stream + }) + + fastify.addHook('onError', async (req, res, err) => { + t.assert.strictEqual(err.statusCode, 400) + }) + + fastify.post('/', function (request, reply) { + t.assert.fail('should not be called') + }) + + fastify.inject({ + method: 'POST', + url: '/', + payload: { hello: 'world' } + }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) + t.assert.deepStrictEqual(JSON.parse(res.payload), { + statusCode: 400, + error: 'Bad Request', + message: 'kaboom' + }) + testDone() + }) +}) + +test('preParsing hooks should default to statusCode 400 if stream error', (t, testDone) => { + t.plan(4) + const fastify = Fastify() + + fastify.addHook('preParsing', async (req, reply, payload) => { + const stream = new Readable({ + read () { + this.destroy(new Error('kaboom')) + } + }) + stream.receivedEncodedLength = 20 + return stream + }) + + fastify.addHook('onError', async (req, res, err) => { + t.assert.strictEqual(err.statusCode, 400) + }) + + fastify.post('/', function (request, reply) { + t.assert.fail('should not be called') + }) + + fastify.inject({ + method: 'POST', + url: '/', + payload: { hello: 'world' } + }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) + t.assert.deepStrictEqual(JSON.parse(res.payload), { + statusCode: 400, + error: 'Bad Request', + message: 'kaboom' + }) + testDone() + }) +}) + +test('preParsing hooks should handle errors', (t, testDone) => { + t.plan(3) + + const fastify = Fastify() fastify.addHook('preParsing', async (req, reply, payload) => { const e = new Error('kaboom') e.statusCode = 501 @@ -213,13 +375,14 @@ test('preParsing hooks should handle errors', t => { url: '/', payload: { hello: 'world' } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 501) - t.same(JSON.parse(res.payload), { error: 'Not Implemented', message: 'kaboom', statusCode: 501 }) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 501) + t.assert.deepStrictEqual(JSON.parse(res.payload), { error: 'Not Implemented', message: 'kaboom', statusCode: 501 }) + testDone() }) }) -test('preHandler hooks should be able to block a request', t => { +test('preHandler hooks should be able to block a request', (t, testDone) => { t.plan(5) const fastify = Fastify() @@ -228,32 +391,33 @@ test('preHandler hooks should be able to block a request', t => { }) fastify.addHook('preHandler', async (req, reply) => { - t.fail('this should not be called') + t.assert.fail('this should not be called') }) fastify.addHook('onSend', async (req, reply, payload) => { - t.equal(payload, 'hello') + t.assert.strictEqual(payload, 'hello') }) fastify.addHook('onResponse', async (request, reply) => { - t.ok('called') + t.assert.ok('called') }) fastify.get('/', function (request, reply) { - t.fail('we should not be here') + t.assert.fail('we should not be here') }) fastify.inject({ url: '/', method: 'GET' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.equal(res.payload, 'hello') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload, 'hello') + testDone() }) }) -test('preValidation hooks should be able to block a request', t => { +test('preValidation hooks should be able to block a request', (t, testDone) => { t.plan(5) const fastify = Fastify() @@ -262,38 +426,40 @@ test('preValidation hooks should be able to block a request', t => { }) fastify.addHook('preValidation', async (req, reply) => { - t.fail('this should not be called') + t.assert.fail('this should not be called') }) fastify.addHook('onSend', async (req, reply, payload) => { - t.equal(payload, 'hello') + t.assert.strictEqual(payload, 'hello') }) fastify.addHook('onResponse', async (request, reply) => { - t.ok('called') + t.assert.ok('called') }) fastify.get('/', function (request, reply) { - t.fail('we should not be here') + t.assert.fail('we should not be here') }) fastify.inject({ url: '/', method: 'GET' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.equal(res.payload, 'hello') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload, 'hello') + testDone() }) }) -test('preValidation hooks should be able to change request body before validation', t => { +test('preValidation hooks should be able to change request body before validation', (t, testDone) => { t.plan(4) const fastify = Fastify() fastify.addHook('preValidation', async (req, _reply) => { const buff = Buffer.from(req.body.message, 'base64') req.body = JSON.parse(buff.toString('utf-8')) + t.assert.ok('has been called') }) fastify.post( @@ -315,7 +481,6 @@ test('preValidation hooks should be able to change request body before validatio } }, (req, reply) => { - t.pass() reply.status(200).send('hello') } ) @@ -327,13 +492,14 @@ test('preValidation hooks should be able to change request body before validatio message: Buffer.from(JSON.stringify({ foo: 'example', bar: 1 })).toString('base64') } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.equal(res.payload, 'hello') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload, 'hello') + testDone() }) }) -test('preSerialization hooks should be able to modify the payload', t => { +test('preSerialization hooks should be able to modify the payload', (t, testDone) => { t.plan(3) const fastify = Fastify() @@ -349,13 +515,14 @@ test('preSerialization hooks should be able to modify the payload', t => { url: '/', method: 'GET' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same(JSON.parse(res.payload), { hello: 'another world' }) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'another world' }) + testDone() }) }) -test('preSerialization hooks should handle errors', t => { +test('preSerialization hooks should handle errors', (t, testDone) => { t.plan(3) const fastify = Fastify() @@ -371,18 +538,19 @@ test('preSerialization hooks should handle errors', t => { url: '/', method: 'GET' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 500) - t.same(JSON.parse(res.payload), { error: 'Internal Server Error', message: 'kaboom', statusCode: 500 }) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 500) + t.assert.deepStrictEqual(JSON.parse(res.payload), { error: 'Internal Server Error', message: 'kaboom', statusCode: 500 }) + testDone() }) }) -test('preValidation hooks should handle throwing null', t => { +test('preValidation hooks should handle throwing null', (t, testDone) => { t.plan(4) const fastify = Fastify() fastify.setErrorHandler(async (error, request, reply) => { - t.ok(error instanceof Error) + t.assert.ok(error instanceof Error) await reply.send(error) }) @@ -391,24 +559,25 @@ test('preValidation hooks should handle throwing null', t => { throw null }) - fastify.get('/', function (request, reply) { t.fail('the handler must not be called') }) + fastify.get('/', function (request, reply) { t.assert.fail('the handler must not be called') }) fastify.inject({ url: '/', method: 'GET' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 500) - t.same(res.json(), { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 500) + t.assert.deepStrictEqual(res.json(), { error: 'Internal Server Error', code: 'FST_ERR_SEND_UNDEFINED_ERR', message: 'Undefined error has occurred', statusCode: 500 }) + testDone() }) }) -test('preValidation hooks should handle throwing a string', t => { +test('preValidation hooks should handle throwing a string', (t, testDone) => { t.plan(3) const fastify = Fastify() @@ -417,19 +586,20 @@ test('preValidation hooks should handle throwing a string', t => { throw 'this is an error' }) - fastify.get('/', function (request, reply) { t.fail('the handler must not be called') }) + fastify.get('/', function (request, reply) { t.assert.fail('the handler must not be called') }) fastify.inject({ url: '/', method: 'GET' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 500) - t.equal(res.payload, 'this is an error') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 500) + t.assert.strictEqual(res.payload, 'this is an error') + testDone() }) }) -test('onRequest hooks should be able to block a request (last hook)', t => { +test('onRequest hooks should be able to block a request (last hook)', (t, testDone) => { t.plan(5) const fastify = Fastify() @@ -438,32 +608,33 @@ test('onRequest hooks should be able to block a request (last hook)', t => { }) fastify.addHook('preHandler', async (req, reply) => { - t.fail('this should not be called') + t.assert.fail('this should not be called') }) fastify.addHook('onSend', async (req, reply, payload) => { - t.ok('called') + t.assert.ok('called') }) fastify.addHook('onResponse', async (request, reply) => { - t.ok('called') + t.assert.ok('called') }) fastify.get('/', function (request, reply) { - t.fail('we should not be here') + t.assert.fail('we should not be here') }) fastify.inject({ url: '/', method: 'GET' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.equal(res.payload, 'hello') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload, 'hello') + testDone() }) }) -test('preHandler hooks should be able to block a request (last hook)', t => { +test('preHandler hooks should be able to block a request (last hook)', (t, testDone) => { t.plan(5) const fastify = Fastify() @@ -472,34 +643,35 @@ test('preHandler hooks should be able to block a request (last hook)', t => { }) fastify.addHook('onSend', async (req, reply, payload) => { - t.equal(payload, 'hello') + t.assert.strictEqual(payload, 'hello') }) fastify.addHook('onResponse', async (request, reply) => { - t.ok('called') + t.assert.ok('called') }) fastify.get('/', function (request, reply) { - t.fail('we should not be here') + t.assert.fail('we should not be here') }) fastify.inject({ url: '/', method: 'GET' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.equal(res.payload, 'hello') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload, 'hello') + testDone() }) }) -test('onRequest respond with a stream', t => { +test('onRequest respond with a stream', (t, testDone) => { t.plan(4) const fastify = Fastify() fastify.addHook('onRequest', async (req, reply) => { return new Promise((resolve, reject) => { - const stream = fs.createReadStream(process.cwd() + '/test/stream.test.js', 'utf8') + const stream = fs.createReadStream(__filename, 'utf8') // stream.pipe(res) // res.once('finish', resolve) reply.send(stream).then(() => { @@ -509,40 +681,41 @@ test('onRequest respond with a stream', t => { }) fastify.addHook('onRequest', async (req, res) => { - t.fail('this should not be called') + t.assert.fail('this should not be called') }) fastify.addHook('preHandler', async (req, reply) => { - t.fail('this should not be called') + t.assert.fail('this should not be called') }) fastify.addHook('onSend', async (req, reply, payload) => { - t.ok('called') + t.assert.ok('called') }) fastify.addHook('onResponse', async (request, reply) => { - t.ok('called') + t.assert.ok('called') }) fastify.get('/', function (request, reply) { - t.fail('we should not be here') + t.assert.fail('we should not be here') }) fastify.inject({ url: '/', method: 'GET' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + testDone() }) }) -test('preHandler respond with a stream', t => { +test('preHandler respond with a stream', (t, testDone) => { t.plan(7) const fastify = Fastify() fastify.addHook('onRequest', async (req, res) => { - t.ok('called') + t.assert.ok('called') }) // we are calling `reply.send` inside the `preHandler` hook with a stream, @@ -550,78 +723,98 @@ test('preHandler respond with a stream', t => { const order = [1, 2] fastify.addHook('preHandler', async (req, reply) => { - const stream = fs.createReadStream(process.cwd() + '/test/stream.test.js', 'utf8') + const stream = fs.createReadStream(__filename, 'utf8') reply.raw.once('finish', () => { - t.equal(order.shift(), 2) + t.assert.strictEqual(order.shift(), 2) }) return reply.send(stream) }) fastify.addHook('preHandler', async (req, reply) => { - t.fail('this should not be called') + t.assert.fail('this should not be called') }) fastify.addHook('onSend', async (req, reply, payload) => { - t.equal(order.shift(), 1) - t.equal(typeof payload.pipe, 'function') + t.assert.strictEqual(order.shift(), 1) + t.assert.strictEqual(typeof payload.pipe, 'function') }) fastify.addHook('onResponse', async (request, reply) => { - t.ok('called') + t.assert.ok('called') }) fastify.get('/', function (request, reply) { - t.fail('we should not be here') + t.assert.fail('we should not be here') }) fastify.inject({ url: '/', method: 'GET' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + testDone() }) }) -test('Should log a warning if is an async function with `done`', t => { - t.test('3 arguments', t => { - t.plan(1) +describe('Should log a warning if is an async function with `done`', () => { + test('2 arguments', t => { const fastify = Fastify() try { - fastify.addHook('onRequest', async (req, reply, done) => {}) + fastify.addHook('onRequestAbort', async (req, done) => { + t.assert.fail('should have not be called') + }) } catch (e) { - t.ok(e.message === 'Async function has too many arguments. Async hooks should not use the \'done\' argument.') + t.assert.strictEqual(e.code, 'FST_ERR_HOOK_INVALID_ASYNC_HANDLER') + t.assert.strictEqual(e.message, 'Async function has too many arguments. Async hooks should not use the \'done\' argument.') } }) - t.test('4 arguments', t => { - t.plan(3) + test('3 arguments', t => { const fastify = Fastify() try { - fastify.addHook('onSend', async (req, reply, payload, done) => {}) + fastify.addHook('onRequest', async (req, reply, done) => { + t.assert.fail('should have not be called') + }) } catch (e) { - t.ok(e.message === 'Async function has too many arguments. Async hooks should not use the \'done\' argument.') + t.assert.strictEqual(e.code, 'FST_ERR_HOOK_INVALID_ASYNC_HANDLER') + t.assert.strictEqual(e.message, 'Async function has too many arguments. Async hooks should not use the \'done\' argument.') } + }) + + test('4 arguments', t => { + const fastify = Fastify() + try { - fastify.addHook('preSerialization', async (req, reply, payload, done) => {}) + fastify.addHook('onSend', async (req, reply, payload, done) => { + t.assert.fail('should have not be called') + }) } catch (e) { - t.ok(e.message === 'Async function has too many arguments. Async hooks should not use the \'done\' argument.') + t.assert.strictEqual(e.code, 'FST_ERR_HOOK_INVALID_ASYNC_HANDLER') + t.assert.strictEqual(e.message, 'Async function has too many arguments. Async hooks should not use the \'done\' argument.') } try { - fastify.addHook('onError', async (req, reply, payload, done) => {}) + fastify.addHook('preSerialization', async (req, reply, payload, done) => { + t.assert.fail('should have not be called') + }) } catch (e) { - t.ok(e.message === 'Async function has too many arguments. Async hooks should not use the \'done\' argument.') + t.assert.strictEqual(e.code, 'FST_ERR_HOOK_INVALID_ASYNC_HANDLER') + t.assert.strictEqual(e.message, 'Async function has too many arguments. Async hooks should not use the \'done\' argument.') + } + try { + fastify.addHook('onError', async (req, reply, payload, done) => { + t.assert.fail('should have not be called') + }) + } catch (e) { + t.assert.strictEqual(e.code, 'FST_ERR_HOOK_INVALID_ASYNC_HANDLER') + t.assert.strictEqual(e.message, 'Async function has too many arguments. Async hooks should not use the \'done\' argument.') } }) - - t.end() }) test('early termination, onRequest async', async t => { - t.plan(2) - const app = Fastify() app.addHook('onRequest', async (req, reply) => { @@ -630,12 +823,12 @@ test('early termination, onRequest async', async t => { }) app.get('/', (req, reply) => { - t.fail('should not happen') + t.assert.fail('should not happen') }) const res = await app.inject('/') - t.equal(res.statusCode, 200) - t.equal(res.body.toString(), 'hello world') + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.body.toString(), 'hello world') }) test('The this should be the same of the encapsulation level', async t => { @@ -643,9 +836,9 @@ test('The this should be the same of the encapsulation level', async t => { fastify.addHook('onRequest', async function (req, reply) { if (req.raw.url === '/nested') { - t.equal(this.foo, 'bar') + t.assert.strictEqual(this.foo, 'bar') } else { - t.equal(this.foo, undefined) + t.assert.strictEqual(this.foo, undefined) } }) @@ -663,12 +856,13 @@ test('The this should be the same of the encapsulation level', async t => { await fastify.inject({ method: 'GET', path: '/nested' }) }) -test('preSerializationEnd should handle errors if the serialize method throws', t => { - t.test('works with sync preSerialization', t => { - t.plan(2) +describe('preSerializationEnd should handle errors if the serialize method throws', () => { + test('works with sync preSerialization', (t, testDone) => { + t.plan(3) const fastify = Fastify() fastify.addHook('preSerialization', (request, reply, payload, done) => { + t.assert.ok('called') done(null, payload) }) @@ -681,16 +875,18 @@ test('preSerializationEnd should handle errors if the serialize method throws', method: 'POST', url: '/' }, (err, res) => { - t.error(err) - t.not(res.statusCode, 200) + t.assert.ifError(err) + t.assert.notEqual(res.statusCode, 200) + testDone() }) }) - t.test('works with async preSerialization', t => { - t.plan(2) + test('works with async preSerialization', (t, testDone) => { + t.plan(3) const fastify = Fastify() fastify.addHook('preSerialization', async (request, reply, payload) => { + t.assert.ok('called') return payload }) @@ -703,10 +899,201 @@ test('preSerializationEnd should handle errors if the serialize method throws', method: 'POST', url: '/' }, (err, res) => { - t.error(err) - t.not(res.statusCode, 200) + t.assert.ifError(err) + t.assert.notEqual(res.statusCode, 200) + testDone() }) }) +}) + +test('nested hooks to do not crash on 404', (t, testDone) => { + t.plan(3) + const fastify = Fastify() + + fastify.get('/hello', (req, reply) => { + reply.send({ hello: 'world' }) + }) + + fastify.register(async function (fastify) { + fastify.get('/something', (req, reply) => { + reply.callNotFound() + }) - t.end() + fastify.setNotFoundHandler(async (request, reply) => { + t.assert.ok('called') + reply.statusCode = 404 + return { status: 'nested-not-found' } + }) + + fastify.setErrorHandler(async (error, request, reply) => { + t.assert.fail('should have not be called') + reply.statusCode = 500 + return { status: 'nested-error', error } + }) + }, { prefix: '/nested' }) + + fastify.setNotFoundHandler(async (request, reply) => { + t.assert.fail('should have not be called') + reply.statusCode = 404 + return { status: 'not-found' } + }) + + fastify.setErrorHandler(async (error, request, reply) => { + t.assert.fail('should have not be called') + reply.statusCode = 500 + return { status: 'error', error } + }) + + fastify.inject({ + method: 'GET', + url: '/nested/something' + }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 404) + testDone() + }) +}) + +test('Register an hook (preHandler) as route option should fail if mixing async and callback style', t => { + const fastify = Fastify() + + try { + fastify.get( + '/', + { + preHandler: [ + async (request, reply, done) => { + done() + } + ] + }, + async (request, reply) => { + return { hello: 'world' } + } + ) + t.assert.fail('preHandler mixing async and callback style') + } catch (e) { + t.assert.strictEqual(e.code, 'FST_ERR_HOOK_INVALID_ASYNC_HANDLER') + t.assert.strictEqual(e.message, 'Async function has too many arguments. Async hooks should not use the \'done\' argument.') + } +}) + +test('Register an hook (onSend) as route option should fail if mixing async and callback style', t => { + const fastify = Fastify() + + try { + fastify.get( + '/', + { + onSend: [ + async (request, reply, payload, done) => { + done() + } + ] + }, + async (request, reply) => { + return { hello: 'world' } + } + ) + t.assert.fail('onSend mixing async and callback style') + } catch (e) { + t.assert.strictEqual(e.code, 'FST_ERR_HOOK_INVALID_ASYNC_HANDLER') + t.assert.strictEqual(e.message, 'Async function has too many arguments. Async hooks should not use the \'done\' argument.') + } +}) + +test('Register an hook (preSerialization) as route option should fail if mixing async and callback style', t => { + const fastify = Fastify() + + try { + fastify.get( + '/', + { + preSerialization: [ + async (request, reply, payload, done) => { + done() + } + ] + }, + async (request, reply) => { + return { hello: 'world' } + } + ) + t.assert.fail('preSerialization mixing async and callback style') + } catch (e) { + t.assert.strictEqual(e.code, 'FST_ERR_HOOK_INVALID_ASYNC_HANDLER') + t.assert.strictEqual(e.message, 'Async function has too many arguments. Async hooks should not use the \'done\' argument.') + } +}) + +test('Register an hook (onError) as route option should fail if mixing async and callback style', t => { + const fastify = Fastify() + + try { + fastify.get( + '/', + { + onError: [ + async (request, reply, error, done) => { + done() + } + ] + }, + async (request, reply) => { + return { hello: 'world' } + } + ) + t.assert.fail('onError mixing async and callback style') + } catch (e) { + t.assert.strictEqual(e.code, 'FST_ERR_HOOK_INVALID_ASYNC_HANDLER') + t.assert.strictEqual(e.message, 'Async function has too many arguments. Async hooks should not use the \'done\' argument.') + } +}) + +test('Register an hook (preParsing) as route option should fail if mixing async and callback style', t => { + const fastify = Fastify() + + try { + fastify.get( + '/', + { + preParsing: [ + async (request, reply, payload, done) => { + done() + } + ] + }, + async (request, reply) => { + return { hello: 'world' } + } + ) + t.assert.fail('preParsing mixing async and callback style') + } catch (e) { + t.assert.strictEqual(e.code, 'FST_ERR_HOOK_INVALID_ASYNC_HANDLER') + t.assert.strictEqual(e.message, 'Async function has too many arguments. Async hooks should not use the \'done\' argument.') + } +}) + +test('Register an hook (onRequestAbort) as route option should fail if mixing async and callback style', (t) => { + const fastify = Fastify() + + try { + fastify.get( + '/', + { + onRequestAbort: [ + async (request, done) => { + done() + } + ] + }, + async (request, reply) => { + return { hello: 'world' } + } + ) + t.assert.fail('onRequestAbort mixing async and callback style') + } catch (e) { + t.assert.strictEqual(e.code, 'FST_ERR_HOOK_INVALID_ASYNC_HANDLER') + t.assert.strictEqual(e.message, 'Async function has too many arguments. Async hooks should not use the \'done\' argument.') + } }) diff --git a/test/hooks.on-listen.test.js b/test/hooks.on-listen.test.js new file mode 100644 index 00000000000..d1791926fa7 --- /dev/null +++ b/test/hooks.on-listen.test.js @@ -0,0 +1,1162 @@ +'use strict' + +const { test, before } = require('node:test') +const Fastify = require('../fastify') +const fp = require('fastify-plugin') +const split = require('split2') +const helper = require('./helper') +const { kState } = require('../lib/symbols') +const { networkInterfaces } = require('node:os') + +const isIPv6Missing = !Object.values(networkInterfaces()).flat().some(({ family }) => family === 'IPv6') + +let localhost +before(async function () { + [localhost] = await helper.getLoopbackHost() +}) + +test('onListen should not be processed when .ready() is called', (t, testDone) => { + t.plan(1) + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.addHook('onListen', function (done) { + t.assert.fail() + done() + }) + + fastify.ready(err => { + t.assert.ifError(err) + testDone() + }) +}) + +test('localhost onListen should be called in order', (t, testDone) => { + t.plan(2) + const fastify = Fastify() + t.after(() => fastify.close()) + let order = 0 + + fastify.addHook('onListen', function (done) { + t.assert.strictEqual(++order, 1, '1st called in root') + done() + }) + + fastify.addHook('onListen', function (done) { + t.assert.strictEqual(++order, 2, '2nd called in root') + done() + }) + + fastify.listen({ + host: 'localhost', + port: 0 + }, testDone) +}) + +test('localhost async onListen should be called in order', async t => { + t.plan(3) + const fastify = Fastify() + t.after(() => fastify.close()) + let order = 0 + + fastify.addHook('onListen', async function () { + t.assert.strictEqual(++order, 1, '1st async called in root') + }) + + fastify.addHook('onListen', async function () { + t.assert.strictEqual(++order, 2, '2nd async called in root') + }) + + await fastify.listen({ + host: 'localhost', + port: 0 + }) + t.assert.strictEqual(order, 2, 'the onListen hooks are awaited') +}) + +test('localhost onListen sync should log errors as warnings and continue /1', async t => { + t.plan(8) + let order = 0 + const stream = split(JSON.parse) + const fastify = Fastify({ + forceCloseConnections: false, + logger: { + stream, + level: 'info' + } + }) + t.after(() => fastify.close()) + + stream.on('data', message => { + if (message.msg.includes('FAIL ON LISTEN')) { + t.assert.strictEqual(order, 2) + t.assert.ok('Logged Error Message') + } + }) + + fastify.addHook('onListen', function (done) { + t.assert.strictEqual(++order, 1, '1st call') + t.assert.ok('called in root') + done() + }) + + fastify.addHook('onListen', function (done) { + t.assert.strictEqual(++order, 2, '2nd call') + t.assert.ok('called onListen error') + throw new Error('FAIL ON LISTEN') + }) + + fastify.addHook('onListen', function (done) { + t.assert.strictEqual(++order, 3, '3rd call') + t.assert.ok('onListen hooks continue after error') + done() + }) + + await fastify.listen({ + host: 'localhost', + port: 0 + }) +}) + +test('localhost onListen sync should log errors as warnings and continue /2', (t, testDone) => { + t.plan(7) + const stream = split(JSON.parse) + const fastify = Fastify({ + forceCloseConnections: false, + logger: { + stream, + level: 'info' + } + }) + t.after(() => fastify.close()) + + let order = 0 + + stream.on('data', message => { + if (message.msg.includes('FAIL ON LISTEN')) { + t.assert.ok('Logged Error Message') + } + }) + + fastify.addHook('onListen', function (done) { + t.assert.strictEqual(++order, 1, '1st call') + t.assert.ok('called in root') + done() + }) + + fastify.addHook('onListen', function (done) { + t.assert.strictEqual(++order, 2, '2nd call') + t.assert.ok('called onListen error') + done(new Error('FAIL ON LISTEN')) + }) + + fastify.addHook('onListen', function (done) { + t.assert.strictEqual(++order, 3, '3rd call') + t.assert.ok('onListen hooks continue after error') + done() + }) + + fastify.listen({ + host: 'localhost', + port: 0 + }, testDone) +}) + +test('localhost onListen async should log errors as warnings and continue', async t => { + t.plan(4) + const stream = split(JSON.parse) + const fastify = Fastify({ + forceCloseConnections: false, + logger: { + stream, + level: 'info' + } + }) + t.after(() => fastify.close()) + + stream.on('data', message => { + if (message.msg.includes('FAIL ON LISTEN')) { + t.assert.ok('Logged Error Message') + } + }) + + fastify.addHook('onListen', async function () { + t.assert.ok('called in root') + }) + + fastify.addHook('onListen', async function () { + t.assert.ok('called onListen error') + throw new Error('FAIL ON LISTEN') + }) + + fastify.addHook('onListen', async function () { + t.assert.ok('onListen hooks continue after error') + }) + + await fastify.listen({ + host: 'localhost', + port: 0 + }) +}) + +test('localhost Register onListen hook after a plugin inside a plugin', (t, testDone) => { + t.plan(3) + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.register(fp(function (instance, opts, done) { + instance.addHook('onListen', function (done) { + t.assert.ok('called') + done() + }) + done() + })) + + fastify.register(fp(function (instance, opts, done) { + instance.addHook('onListen', function (done) { + t.assert.ok('called') + done() + }) + + instance.addHook('onListen', function (done) { + t.assert.ok('called') + done() + }) + + done() + })) + + fastify.listen({ + host: 'localhost', + port: 0 + }, testDone) +}) + +test('localhost Register onListen hook after a plugin inside a plugin should log errors as warnings and continue', (t, testDone) => { + t.plan(6) + const stream = split(JSON.parse) + const fastify = Fastify({ + forceCloseConnections: false, + logger: { + stream, + level: 'info' + } + }) + t.after(() => fastify.close()) + + stream.on('data', message => { + if (message.msg.includes('Plugin Error')) { + t.assert.ok('Logged Error Message') + } + }) + + fastify.register(fp(function (instance, opts, done) { + instance.addHook('onListen', function () { + t.assert.ok('called') + throw new Error('Plugin Error') + }) + done() + })) + + fastify.register(fp(function (instance, opts, done) { + instance.addHook('onListen', function () { + t.assert.ok('called') + throw new Error('Plugin Error') + }) + + instance.addHook('onListen', function () { + t.assert.ok('called') + throw new Error('Plugin Error') + }) + + done() + })) + + fastify.listen({ + host: 'localhost', + port: 0 + }, testDone) +}) + +test('localhost onListen encapsulation should be called in order', async t => { + t.plan(8) + const fastify = Fastify() + t.after(() => fastify.close()) + + let order = 0 + + fastify.addHook('onListen', function (done) { + t.assert.strictEqual(++order, 1, 'called in root') + t.assert.strictEqual(this.pluginName, fastify.pluginName, 'the this binding is the right instance') + done() + }) + + await fastify.register(async (childOne, o) => { + childOne.addHook('onListen', function (done) { + t.assert.strictEqual(++order, 2, 'called in childOne') + t.assert.strictEqual(this.pluginName, childOne.pluginName, 'the this binding is the right instance') + done() + }) + + await childOne.register(async (childTwo, o) => { + childTwo.addHook('onListen', async function () { + t.assert.strictEqual(++order, 3, 'called in childTwo') + t.assert.strictEqual(this.pluginName, childTwo.pluginName, 'the this binding is the right instance') + }) + }) + + await childOne.register(async (childTwoPeer, o) => { + childTwoPeer.addHook('onListen', async function () { + t.assert.strictEqual(++order, 4, 'called second in childTwo') + t.assert.strictEqual(this.pluginName, childTwoPeer.pluginName, 'the this binding is the right instance') + }) + }) + }) + await fastify.listen({ + host: 'localhost', + port: 0 + }) +}) + +test('localhost onListen encapsulation with only nested hook', async t => { + t.plan(1) + const fastify = Fastify() + t.after(() => fastify.close()) + + await fastify.register(async (child) => { + await child.register(async (child2) => { + child2.addHook('onListen', function (done) { + t.assert.ok() + done() + }) + }) + }) + + await fastify.listen({ + host: 'localhost', + port: 0 + }) +}) + +test('localhost onListen peer encapsulations with only nested hooks', async t => { + t.plan(2) + const fastify = Fastify() + t.after(() => fastify.close()) + + await fastify.register(async (child) => { + await child.register(async (child2) => { + child2.addHook('onListen', function (done) { + t.assert.ok() + done() + }) + }) + + await child.register(async (child2) => { + child2.addHook('onListen', function (done) { + t.assert.ok() + done() + }) + }) + }) + + await fastify.listen({ + host: 'localhost', + port: 0 + }) +}) + +test('localhost onListen encapsulation should be called in order and should log errors as warnings and continue', (t, testDone) => { + t.plan(7) + const stream = split(JSON.parse) + const fastify = Fastify({ + forceCloseConnections: false, + logger: { + stream, + level: 'info' + } + }) + t.after(() => fastify.close()) + + stream.on('data', message => { + if (message.msg.includes('Error in onListen hook of childTwo')) { + t.assert.ok('Logged Error Message') + } + }) + + let order = 0 + + fastify.addHook('onListen', function (done) { + t.assert.strictEqual(++order, 1, 'called in root') + t.assert.strictEqual(this.pluginName, fastify.pluginName, 'the this binding is the right instance') + done() + }) + + fastify.register(async (childOne, o) => { + childOne.addHook('onListen', function (done) { + t.assert.strictEqual(++order, 2, 'called in childOne') + t.assert.strictEqual(this.pluginName, childOne.pluginName, 'the this binding is the right instance') + done() + }) + childOne.register(async (childTwo, o) => { + childTwo.addHook('onListen', async function () { + t.assert.strictEqual(++order, 3, 'called in childTwo') + t.assert.strictEqual(this.pluginName, childTwo.pluginName, 'the this binding is the right instance') + throw new Error('Error in onListen hook of childTwo') + }) + }) + }) + fastify.listen({ + host: 'localhost', + port: 0 + }, testDone) +}) + +test('non-localhost onListen should be called in order', { skip: isIPv6Missing }, (t, testDone) => { + t.plan(2) + + const fastify = Fastify() + t.after(() => fastify.close()) + + let order = 0 + + fastify.addHook('onListen', function (done) { + t.assert.strictEqual(++order, 1, '1st called in root') + done() + }) + + fastify.addHook('onListen', function (done) { + t.assert.strictEqual(++order, 2, '2nd called in root') + done() + }) + fastify.listen({ + host: '::1', + port: 0 + }, testDone) +}) + +test('non-localhost async onListen should be called in order', { skip: isIPv6Missing }, async t => { + t.plan(2) + const fastify = Fastify() + t.after(() => fastify.close()) + let order = 0 + + fastify.addHook('onListen', async function () { + t.assert.strictEqual(++order, 1, '1st async called in root') + }) + + fastify.addHook('onListen', async function () { + t.assert.strictEqual(++order, 2, '2nd async called in root') + }) + + await fastify.listen({ + host: '::1', + port: 0 + }) +}) + +test('non-localhost sync onListen should log errors as warnings and continue', { skip: isIPv6Missing }, (t, testDone) => { + t.plan(4) + const stream = split(JSON.parse) + const fastify = Fastify({ + forceCloseConnections: false, + logger: { + stream, + level: 'info' + } + }) + t.after(() => fastify.close()) + + stream.on('data', message => { + if (message.msg.includes('FAIL ON LISTEN')) { + t.assert.ok('Logged Error Message') + } + }) + let order = 0 + + fastify.addHook('onListen', function (done) { + t.assert.strictEqual(++order, 1) + done() + }) + + fastify.addHook('onListen', function () { + t.assert.strictEqual(++order, 2) + throw new Error('FAIL ON LISTEN') + }) + + fastify.addHook('onListen', function (done) { + t.assert.strictEqual(++order, 3, 'should still run') + done() + }) + + fastify.listen({ + host: '::1', + port: 0 + }, testDone) +}) + +test('non-localhost async onListen should log errors as warnings and continue', { skip: isIPv6Missing }, async t => { + t.plan(6) + const stream = split(JSON.parse) + const fastify = Fastify({ + forceCloseConnections: false, + logger: { + stream, + level: 'info' + } + }) + t.after(() => fastify.close()) + + stream.on('data', message => { + if (message.msg.includes('FAIL ON LISTEN')) { + t.assert.ok('Logged Error Message') + } + }) + + let order = 0 + + fastify.addHook('onListen', async function () { + t.assert.strictEqual(++order, 1) + t.assert.ok('called in root') + }) + + fastify.addHook('onListen', async function () { + t.assert.strictEqual(++order, 2, '2nd async failed in root') + throw new Error('FAIL ON LISTEN') + }) + + fastify.addHook('onListen', async function () { + t.assert.strictEqual(++order, 3) + t.assert.ok('should still run') + }) + + await fastify.listen({ + host: '::1', + port: 0 + }) +}) + +test('non-localhost Register onListen hook after a plugin inside a plugin', { skip: isIPv6Missing }, (t, testDone) => { + t.plan(3) + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.register(fp(function (instance, opts, done) { + instance.addHook('onListen', function (done) { + t.assert.ok('called') + done() + }) + done() + })) + + fastify.register(fp(function (instance, opts, done) { + instance.addHook('onListen', function (done) { + t.assert.ok('called') + done() + }) + + instance.addHook('onListen', function (done) { + t.assert.ok('called') + done() + }) + + done() + })) + + fastify.listen({ + host: '::1', + port: 0 + }, testDone) +}) + +test('non-localhost Register onListen hook after a plugin inside a plugin should log errors as warnings and continue', { skip: isIPv6Missing }, (t, testDone) => { + t.plan(6) + const stream = split(JSON.parse) + const fastify = Fastify({ + forceCloseConnections: false, + logger: { + stream, + level: 'info' + } + }) + t.after(() => fastify.close()) + + stream.on('data', message => { + if (message.msg.includes('Plugin Error')) { + t.assert.ok('Logged Error Message') + } + }) + + fastify.register(fp(function (instance, opts, done) { + instance.addHook('onListen', function () { + t.assert.ok('called') + throw new Error('Plugin Error') + }) + done() + })) + + fastify.register(fp(function (instance, opts, done) { + instance.addHook('onListen', function () { + t.assert.ok('called') + throw new Error('Plugin Error') + }) + + instance.addHook('onListen', function () { + t.assert.ok('called') + throw new Error('Plugin Error') + }) + + done() + })) + + fastify.listen({ + host: '::1', + port: 0 + }, testDone) +}) + +test('non-localhost onListen encapsulation should be called in order', { skip: isIPv6Missing }, (t, testDone) => { + t.plan(6) + const fastify = Fastify() + t.after(() => fastify.close()) + + let order = 0 + + fastify.addHook('onListen', function (done) { + t.assert.strictEqual(++order, 1, 'called in root') + t.assert.strictEqual(this.pluginName, fastify.pluginName, 'the this binding is the right instance') + done() + }) + + fastify.register(async (childOne, o) => { + childOne.addHook('onListen', function (done) { + t.assert.strictEqual(++order, 2, 'called in childOne') + t.assert.strictEqual(this.pluginName, childOne.pluginName, 'the this binding is the right instance') + done() + }) + childOne.register(async (childTwo, o) => { + childTwo.addHook('onListen', async function () { + t.assert.strictEqual(++order, 3, 'called in childTwo') + t.assert.strictEqual(this.pluginName, childTwo.pluginName, 'the this binding is the right instance') + }) + }) + }) + fastify.listen({ + host: '::1', + port: 0 + }, testDone) +}) + +test('non-localhost onListen encapsulation should be called in order and should log errors as warnings and continue', { skip: isIPv6Missing }, (t, testDone) => { + t.plan(7) + const stream = split(JSON.parse) + const fastify = Fastify({ + forceCloseConnections: false, + logger: { + stream, + level: 'info' + } + }) + t.after(() => fastify.close()) + + stream.on('data', message => { + if (message.msg.includes('Error in onListen hook of childTwo')) { + t.assert.ok('Logged Error Message') + } + }) + + let order = 0 + + fastify.addHook('onListen', function (done) { + t.assert.strictEqual(++order, 1, 'called in root') + + t.assert.strictEqual(this.pluginName, fastify.pluginName, 'the this binding is the right instance') + done() + }) + + fastify.register(async (childOne, o) => { + childOne.addHook('onListen', function (done) { + t.assert.strictEqual(++order, 2, 'called in childOne') + t.assert.strictEqual(this.pluginName, childOne.pluginName, 'the this binding is the right instance') + done() + }) + childOne.register(async (childTwo, o) => { + childTwo.addHook('onListen', async function () { + t.assert.strictEqual(++order, 3, 'called in childTwo') + t.assert.strictEqual(this.pluginName, childTwo.pluginName, 'the this binding is the right instance') + throw new Error('Error in onListen hook of childTwo') + }) + }) + }) + fastify.listen({ + host: '::1', + port: 0 + }, testDone) +}) + +test('onListen localhost should work in order with callback', (t, testDone) => { + t.plan(4) + const fastify = Fastify() + t.after(() => fastify.close()) + let order = 0 + + fastify.addHook('onListen', function (done) { + t.assert.strictEqual(++order, 1, '1st called in root') + done() + }) + + fastify.addHook('onListen', function (done) { + t.assert.strictEqual(++order, 2, '2nd called in root') + done() + }) + + fastify.listen({ port: 0 }, (err) => { + t.assert.strictEqual(fastify.server.address().address, localhost) + t.assert.ifError(err) + testDone() + }) +}) + +test('onListen localhost should work in order with callback in async', (t, testDone) => { + t.plan(4) + const fastify = Fastify() + t.after(() => fastify.close()) + let order = 0 + + fastify.addHook('onListen', async function () { + t.assert.strictEqual(++order, 1, '1st called in root') + }) + + fastify.addHook('onListen', async function () { + t.assert.strictEqual(++order, 2, '2nd called in root') + }) + + fastify.listen({ host: 'localhost', port: 0 }, (err) => { + t.assert.strictEqual(fastify.server.address().address, localhost) + t.assert.ifError(err) + testDone() + }) +}) + +test('onListen localhost sync with callback should log errors as warnings and continue', (t, testDone) => { + t.plan(6) + const stream = split(JSON.parse) + const fastify = Fastify({ + forceCloseConnections: false, + logger: { + stream, + level: 'info' + } + }) + t.after(() => fastify.close()) + + stream.on('data', message => { + if (message.msg.includes('FAIL ON LISTEN')) { + t.assert.ok('Logged Error Message') + } + }) + + let order = 0 + + fastify.addHook('onListen', function (done) { + t.assert.strictEqual(++order, 1, '1st called in root') + done() + }) + + fastify.addHook('onListen', function () { + t.assert.strictEqual(++order, 2, 'error sync called in root') + throw new Error('FAIL ON LISTEN') + }) + + fastify.addHook('onListen', function (done) { + t.assert.strictEqual(++order, 3, '1st called in root') + done() + }) + + fastify.listen({ port: 0 }, (err) => { + t.assert.ifError(err) + t.assert.strictEqual(fastify.server.address().address, localhost) + testDone() + }) +}) + +test('onListen localhost async with callback should log errors as warnings and continue', (t, testDone) => { + t.plan(6) + const stream = split(JSON.parse) + const fastify = Fastify({ + forceCloseConnections: false, + logger: { + stream, + level: 'info' + } + }) + t.after(() => fastify.close()) + + stream.on('data', message => { + if (message.msg.includes('FAIL ON LISTEN')) { + t.assert.ok('Logged Error Message') + } + }) + + let order = 0 + + fastify.addHook('onListen', async function () { + t.assert.ok('1st called in root') + }) + + fastify.addHook('onListen', async function () { + t.assert.strictEqual(++order, 1, 'error sync called in root') + throw new Error('FAIL ON LISTEN') + }) + + fastify.addHook('onListen', async function () { + t.assert.ok('3rd called in root') + }) + + fastify.listen({ port: 0 }, (err) => { + t.assert.ifError(err) + t.assert.strictEqual(fastify.server.address().address, localhost) + testDone() + }) +}) + +test('Register onListen hook localhost with callback after a plugin inside a plugin', (t, testDone) => { + t.plan(5) + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.register(fp(function (instance, opts, done) { + instance.addHook('onListen', function (done) { + t.assert.ok('called') + done() + }) + done() + })) + + fastify.register(fp(function (instance, opts, done) { + instance.addHook('onListen', function (done) { + t.assert.ok('called') + done() + }) + + instance.addHook('onListen', function (done) { + t.assert.ok('called') + done() + }) + + done() + })) + + fastify.listen({ port: 0 }, (err) => { + t.assert.strictEqual(fastify.server.address().address, localhost) + t.assert.ifError(err) + testDone() + }) +}) + +test('onListen localhost with callback encapsulation should be called in order', (t, testDone) => { + t.plan(8) + const fastify = Fastify() + t.after(() => fastify.close()) + + let order = 0 + + fastify.addHook('onListen', function (done) { + t.assert.strictEqual(++order, 1, 'called in root') + t.assert.strictEqual(this.pluginName, fastify.pluginName, 'the this binding is the right instance') + done() + }) + + fastify.register(async (childOne, o) => { + childOne.addHook('onListen', function (done) { + t.assert.strictEqual(++order, 2, 'called in childOne') + t.assert.strictEqual(this.pluginName, childOne.pluginName, 'the this binding is the right instance') + done() + }) + childOne.register(async (childTwo, o) => { + childTwo.addHook('onListen', async function () { + t.assert.strictEqual(++order, 3, 'called in childTwo') + t.assert.strictEqual(this.pluginName, childTwo.pluginName, 'the this binding is the right instance') + }) + }) + }) + fastify.listen({ port: 0 }, (err) => { + t.assert.strictEqual(fastify.server.address().address, localhost) + t.assert.ifError(err) + testDone() + }) +}) + +test('onListen non-localhost should work in order with callback in sync', { skip: isIPv6Missing }, (t, testDone) => { + t.plan(4) + const fastify = Fastify() + t.after(() => fastify.close()) + let order = 0 + + fastify.addHook('onListen', function (done) { + t.assert.strictEqual(++order, 1, '1st called in root') + done() + }) + + fastify.addHook('onListen', function (done) { + t.assert.strictEqual(++order, 2, '2nd called in root') + done() + }) + + fastify.listen({ host: '::1', port: 0 }, (err) => { + t.assert.strictEqual(fastify.server.address().address, '::1') + t.assert.ifError(err) + testDone() + }) +}) + +test('onListen non-localhost should work in order with callback in async', { skip: isIPv6Missing }, (t, testDone) => { + t.plan(4) + const fastify = Fastify() + t.after(() => fastify.close()) + let order = 0 + + fastify.addHook('onListen', async function () { + t.assert.strictEqual(++order, 1, '1st called in root') + }) + + fastify.addHook('onListen', async function () { + t.assert.strictEqual(++order, 2, '2nd called in root') + }) + + fastify.listen({ host: '::1', port: 0 }, (err) => { + t.assert.strictEqual(fastify.server.address().address, '::1') + t.assert.ifError(err) + testDone() + }) +}) + +test('onListen non-localhost sync with callback should log errors as warnings and continue', { skip: isIPv6Missing }, (t, testDone) => { + t.plan(8) + + const stream = split(JSON.parse) + const fastify = Fastify({ + forceCloseConnections: false, + logger: { + stream, + level: 'info' + } + }) + t.after(() => fastify.close()) + + stream.on('data', message => { + if (message.msg.includes('FAIL ON LISTEN')) { + t.assert.ok('Logged Error Message') + } + }) + + let order = 0 + + fastify.addHook('onListen', function (done) { + t.assert.strictEqual(++order, 1) + t.assert.ok('1st called in root') + done() + }) + + fastify.addHook('onListen', function () { + t.assert.strictEqual(++order, 2) + throw new Error('FAIL ON LISTEN') + }) + + fastify.addHook('onListen', function (done) { + t.assert.strictEqual(++order, 3) + t.assert.ok('3rd called in root') + done() + }) + + fastify.listen({ host: '::1', port: 0 }, (err) => { + t.assert.ifError(err) + t.assert.strictEqual(fastify.server.address().address, '::1') + testDone() + }) +}) + +test('onListen non-localhost async with callback should log errors as warnings and continue', { skip: isIPv6Missing }, (t, testDone) => { + t.plan(8) + + const stream = split(JSON.parse) + const fastify = Fastify({ + forceCloseConnections: false, + logger: { + stream, + level: 'info' + } + }) + t.after(() => fastify.close()) + + stream.on('data', message => { + if (message.msg.includes('FAIL ON LISTEN')) { + t.assert.ok('Logged Error Message') + } + }) + + let order = 0 + + fastify.addHook('onListen', async function () { + t.assert.strictEqual(++order, 1) + t.assert.ok('1st called in root') + }) + + fastify.addHook('onListen', async function () { + t.assert.strictEqual(++order, 2, 'error sync called in root') + throw new Error('FAIL ON LISTEN') + }) + + fastify.addHook('onListen', async function () { + t.assert.strictEqual(++order, 3) + t.assert.ok('3rd called in root') + }) + + fastify.listen({ host: '::1', port: 0 }, (err) => { + t.assert.ifError(err) + t.assert.strictEqual(fastify.server.address().address, '::1') + testDone() + }) +}) + +test('Register onListen hook non-localhost with callback after a plugin inside a plugin', { skip: isIPv6Missing }, (t, testDone) => { + t.plan(5) + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.register(fp(function (instance, opts, done) { + instance.addHook('onListen', function (done) { + t.assert.ok('called') + done() + }) + done() + })) + + fastify.register(fp(function (instance, opts, done) { + instance.addHook('onListen', function (done) { + t.assert.ok('called') + done() + }) + + instance.addHook('onListen', function (done) { + t.assert.ok('called') + done() + }) + + done() + })) + + fastify.listen({ host: '::1', port: 0 }, (err) => { + t.assert.strictEqual(fastify.server.address().address, '::1') + t.assert.ifError(err) + testDone() + }) +}) + +test('onListen non-localhost with callback encapsulation should be called in order', { skip: isIPv6Missing }, (t, testDone) => { + t.plan(8) + const fastify = Fastify() + t.after(() => fastify.close()) + + let order = 0 + + fastify.addHook('onListen', function (done) { + t.assert.strictEqual(++order, 1, 'called in root') + t.assert.strictEqual(this.pluginName, fastify.pluginName, 'the this binding is the right instance') + done() + }) + + fastify.register(async (childOne, o) => { + childOne.addHook('onListen', function (done) { + t.assert.strictEqual(++order, 2, 'called in childOne') + t.assert.strictEqual(this.pluginName, childOne.pluginName, 'the this binding is the right instance') + done() + }) + childOne.register(async (childTwo, o) => { + childTwo.addHook('onListen', async function () { + t.assert.strictEqual(++order, 3, 'called in childTwo') + t.assert.strictEqual(this.pluginName, childTwo.pluginName, 'the this binding is the right instance') + }) + }) + }) + fastify.listen({ host: '::1', port: 0 }, (err) => { + t.assert.strictEqual(fastify.server.address().address, '::1') + t.assert.ifError(err) + testDone() + }) +}) + +test('onListen sync should work if user does not pass done', (t, testDone) => { + t.plan(2) + const fastify = Fastify() + t.after(() => fastify.close()) + let order = 0 + + fastify.addHook('onListen', function () { + t.assert.strictEqual(++order, 1, '1st called in root') + }) + + fastify.addHook('onListen', function () { + t.assert.strictEqual(++order, 2, '2nd called in root') + }) + + fastify.listen({ + host: 'localhost', + port: 0 + }, testDone) +}) + +test('async onListen does not need to be awaited', (t, testDone) => { + const fastify = Fastify() + t.after(() => fastify.close()) + let order = 0 + + fastify.addHook('onListen', async function () { + t.assert.strictEqual(++order, 1, '1st async called in root') + }) + + fastify.addHook('onListen', async function () { + t.assert.strictEqual(++order, 2, '2nd async called in root') + t.end() + }) + + fastify.listen({ + host: 'localhost', + port: 0 + }, testDone) +}) + +test('onListen hooks do not block /1', (t, testDone) => { + t.plan(2) + + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.addHook('onListen', function (done) { + t.assert.strictEqual(fastify[kState].listening, true) + done() + }) + + fastify.listen({ + host: 'localhost', + port: 0 + }, err => { + t.assert.ifError(err) + testDone() + }) +}) + +test('onListen hooks do not block /2', async t => { + t.plan(1) + + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.addHook('onListen', async function () { + t.assert.strictEqual(fastify[kState].listening, true) + }) + + await fastify.listen({ + host: 'localhost', + port: 0 + }) +}) diff --git a/test/hooks.on-ready.test.js b/test/hooks.on-ready.test.js index 42c241742a0..4e0379eb34f 100644 --- a/test/hooks.on-ready.test.js +++ b/test/hooks.on-ready.test.js @@ -1,41 +1,60 @@ 'use strict' -const t = require('tap') +const { test } = require('node:test') const Fastify = require('../fastify') -const immediate = require('util').promisify(setImmediate) +const immediate = require('node:util').promisify(setImmediate) -t.test('onReady should be called in order', t => { +test('onReady should be called in order', (t, done) => { t.plan(7) const fastify = Fastify() let order = 0 fastify.addHook('onReady', function (done) { - t.equal(order++, 0, 'called in root') - t.equal(this.pluginName, fastify.pluginName, 'the this binding is the right instance') + t.assert.strictEqual(order++, 0, 'called in root') + t.assert.strictEqual(this.pluginName, fastify.pluginName, 'the this binding is the right instance') done() }) fastify.register(async (childOne, o) => { childOne.addHook('onReady', function (done) { - t.equal(order++, 1, 'called in childOne') - t.equal(this.pluginName, childOne.pluginName, 'the this binding is the right instance') + t.assert.strictEqual(order++, 1, 'called in childOne') + t.assert.strictEqual(this.pluginName, childOne.pluginName, 'the this binding is the right instance') done() }) childOne.register(async (childTwo, o) => { childTwo.addHook('onReady', async function () { await immediate() - t.equal(order++, 2, 'called in childTwo') - t.equal(this.pluginName, childTwo.pluginName, 'the this binding is the right instance') + t.assert.strictEqual(order++, 2, 'called in childTwo') + t.assert.strictEqual(this.pluginName, childTwo.pluginName, 'the this binding is the right instance') }) }) }) - fastify.ready(err => t.error(err)) + fastify.ready(err => { + t.assert.ifError(err) + done() + }) +}) + +test('onReady should be called once', async (t) => { + const app = Fastify() + let counter = 0 + + app.addHook('onReady', async function () { + counter++ + }) + + const promises = [1, 2, 3, 4, 5].map((id) => app.ready().then(() => id)) + + const result = await Promise.race(promises) + + t.assert.strictEqual(result, 1, 'Should resolve in order') + t.assert.strictEqual(counter, 1, 'Should call onReady only once') }) -t.test('async onReady should be called in order', async t => { +test('async onReady should be called in order', async t => { t.plan(7) const fastify = Fastify() @@ -43,31 +62,31 @@ t.test('async onReady should be called in order', async t => { fastify.addHook('onReady', async function () { await immediate() - t.equal(order++, 0, 'called in root') - t.equal(this.pluginName, fastify.pluginName, 'the this binding is the right instance') + t.assert.strictEqual(order++, 0, 'called in root') + t.assert.strictEqual(this.pluginName, fastify.pluginName, 'the this binding is the right instance') }) fastify.register(async (childOne, o) => { childOne.addHook('onReady', async function () { await immediate() - t.equal(order++, 1, 'called in childOne') - t.equal(this.pluginName, childOne.pluginName, 'the this binding is the right instance') + t.assert.strictEqual(order++, 1, 'called in childOne') + t.assert.strictEqual(this.pluginName, childOne.pluginName, 'the this binding is the right instance') }) childOne.register(async (childTwo, o) => { childTwo.addHook('onReady', async function () { await immediate() - t.equal(order++, 2, 'called in childTwo') - t.equal(this.pluginName, childTwo.pluginName, 'the this binding is the right instance') + t.assert.strictEqual(order++, 2, 'called in childTwo') + t.assert.strictEqual(this.pluginName, childTwo.pluginName, 'the this binding is the right instance') }) }) }) await fastify.ready() - t.pass('ready') + t.assert.ok('ready') }) -t.test('mix ready and onReady', async t => { +test('mix ready and onReady', async t => { t.plan(2) const fastify = Fastify() let order = 0 @@ -78,13 +97,13 @@ t.test('mix ready and onReady', async t => { }) await fastify.ready() - t.equal(order, 1) + t.assert.strictEqual(order, 1) await fastify.ready() - t.equal(order, 1, 'ready hooks execute once') + t.assert.strictEqual(order, 1, 'ready hooks execute once') }) -t.test('listen and onReady order', async t => { +test('listen and onReady order', async t => { t.plan(9) const fastify = Fastify() @@ -111,19 +130,19 @@ t.test('listen and onReady order', async t => { fastify.addHook('onReady', checkOrder.bind(null, 3)) await fastify.ready() - t.pass('trigger the onReady') + t.assert.ok('trigger the onReady') await fastify.listen({ port: 0 }) - t.pass('do not trigger the onReady') + t.assert.ok('do not trigger the onReady') await fastify.close() function checkOrder (shouldbe) { - t.equal(order, shouldbe) + t.assert.strictEqual(order, shouldbe) order++ } }) -t.test('multiple ready calls', async t => { +test('multiple ready calls', async t => { t.plan(11) const fastify = Fastify() @@ -138,7 +157,7 @@ t.test('multiple ready calls', async t => { subinstance.addHook('onReady', checkOrder.bind(null, 7)) }) - t.equal(order, 0, 'ready and hooks not triggered yet') + t.assert.strictEqual(order, 0, 'ready and hooks not triggered yet') order++ }) @@ -147,139 +166,145 @@ t.test('multiple ready calls', async t => { fastify.addHook('onReady', checkOrder.bind(null, 5)) await fastify.ready() - t.pass('trigger the onReady') + t.assert.ok('trigger the onReady') await fastify.ready() - t.pass('do not trigger the onReady') + t.assert.ok('do not trigger the onReady') await fastify.ready() - t.pass('do not trigger the onReady') + t.assert.ok('do not trigger the onReady') function checkOrder (shouldbe) { - t.equal(order, shouldbe) + t.assert.strictEqual(order, shouldbe) order++ } }) -t.test('onReady should manage error in sync', t => { +test('onReady should manage error in sync', (t, done) => { t.plan(4) const fastify = Fastify() fastify.addHook('onReady', function (done) { - t.pass('called in root') + t.assert.ok('called in root') done() }) fastify.register(async (childOne, o) => { childOne.addHook('onReady', function (done) { - t.pass('called in childOne') + t.assert.ok('called in childOne') done(new Error('FAIL ON READY')) }) childOne.register(async (childTwo, o) => { childTwo.addHook('onReady', async function () { - t.fail('should not be called') + t.assert.fail('should not be called') }) }) }) fastify.ready(err => { - t.ok(err) - t.equal(err.message, 'FAIL ON READY') + t.assert.ok(err) + t.assert.strictEqual(err.message, 'FAIL ON READY') + done() }) }) -t.test('onReady should manage error in async', t => { +test('onReady should manage error in async', (t, done) => { t.plan(4) const fastify = Fastify() fastify.addHook('onReady', function (done) { - t.pass('called in root') + t.assert.ok('called in root') done() }) fastify.register(async (childOne, o) => { childOne.addHook('onReady', async function () { - t.pass('called in childOne') + t.assert.ok('called in childOne') throw new Error('FAIL ON READY') }) childOne.register(async (childTwo, o) => { childTwo.addHook('onReady', async function () { - t.fail('should not be called') + t.assert.fail('should not be called') }) }) }) fastify.ready(err => { - t.ok(err) - t.equal(err.message, 'FAIL ON READY') + t.assert.ok(err) + t.assert.strictEqual(err.message, 'FAIL ON READY') + done() }) }) -t.test('onReady should manage sync error', t => { +test('onReady should manage sync error', (t, done) => { t.plan(4) const fastify = Fastify() fastify.addHook('onReady', function (done) { - t.pass('called in root') + t.assert.ok('called in root') done() }) fastify.register(async (childOne, o) => { childOne.addHook('onReady', function (done) { - t.pass('called in childOne') + t.assert.ok('called in childOne') throw new Error('FAIL UNWANTED SYNC EXCEPTION') }) childOne.register(async (childTwo, o) => { childTwo.addHook('onReady', async function () { - t.fail('should not be called') + t.assert.fail('should not be called') }) }) }) fastify.ready(err => { - t.ok(err) - t.equal(err.message, 'FAIL UNWANTED SYNC EXCEPTION') + t.assert.ok(err) + t.assert.strictEqual(err.message, 'FAIL UNWANTED SYNC EXCEPTION') + done() }) }) -t.test('onReady can not add decorators or application hooks', t => { +test('onReady can not add decorators or application hooks', (t, done) => { t.plan(3) const fastify = Fastify() fastify.addHook('onReady', function (done) { - t.pass('called in root') + t.assert.ok('called in root') fastify.decorate('test', () => {}) fastify.addHook('onReady', async function () { - t.fail('it will be not called') + t.assert.fail('it will be not called') }) done() }) fastify.addHook('onReady', function (done) { - t.ok(this.hasDecorator('test')) + t.assert.ok(this.hasDecorator('test')) done() }) - fastify.ready(err => { t.error(err) }) + fastify.ready(err => { + t.assert.ifError(err) + done() + }) }) -t.test('onReady cannot add lifecycle hooks', t => { +test('onReady cannot add lifecycle hooks', (t, done) => { t.plan(5) const fastify = Fastify() fastify.addHook('onReady', function (done) { - t.pass('called in root') + t.assert.ok('called in root') try { fastify.addHook('onRequest', (request, reply, done) => {}) } catch (error) { - t.ok(error) - t.equal(error.message, 'Root plugin has already booted') + t.assert.ok(error) + t.assert.strictEqual(error.message, 'Root plugin has already booted') // TODO: look where the error pops up - t.equal(error.code, 'AVV_ERR_PLUGIN_NOT_VALID') + t.assert.strictEqual(error.code, 'AVV_ERR_ROOT_PLG_BOOTED') done(error) } }) @@ -287,87 +312,110 @@ t.test('onReady cannot add lifecycle hooks', t => { fastify.addHook('onRequest', (request, reply, done) => {}) fastify.get('/', async () => 'hello') - fastify.ready((err) => { t.ok(err) }) + fastify.ready((err) => { + t.assert.ok(err) + done() + }) }) -t.test('onReady throw loading error', t => { - t.plan(1) +test('onReady throw loading error', t => { + t.plan(2) const fastify = Fastify() try { fastify.addHook('onReady', async function (done) {}) } catch (e) { - t.ok(e.message === 'Async function has too many arguments. Async hooks should not use the \'done\' argument.') + t.assert.strictEqual(e.code, 'FST_ERR_HOOK_INVALID_ASYNC_HANDLER') + t.assert.ok(e.message === 'Async function has too many arguments. Async hooks should not use the \'done\' argument.') } }) -t.test('onReady does not call done', t => { +test('onReady does not call done', (t, done) => { t.plan(6) const fastify = Fastify({ pluginTimeout: 500 }) - fastify.addHook('onReady', function (done) { - t.pass('called in root') + fastify.addHook('onReady', function someHookName (done) { + t.assert.ok('called in root') // done() // don't call done to test timeout }) fastify.ready(err => { - t.ok(err) - t.equal(err.message, "A callback for 'onReady' hook timed out. You may have forgotten to call 'done' function or to resolve a Promise") - t.equal(err.code, 'FST_ERR_HOOK_TIMEOUT') - t.ok(err.cause) - t.equal(err.cause.code, 'AVV_ERR_READY_TIMEOUT') + t.assert.ok(err) + t.assert.strictEqual(err.message, 'A callback for \'onReady\' hook "someHookName" timed out. You may have forgotten to call \'done\' function or to resolve a Promise') + t.assert.strictEqual(err.code, 'FST_ERR_HOOK_TIMEOUT') + t.assert.ok(err.cause) + t.assert.strictEqual(err.cause.code, 'AVV_ERR_READY_TIMEOUT') + done() }) }) -t.test('onReady execution order', t => { +test('onReady execution order', (t, done) => { t.plan(3) const fastify = Fastify({ }) let i = 0 - fastify.ready(() => { i++; t.equal(i, 1) }) - fastify.ready(() => { i++; t.equal(i, 2) }) - fastify.ready(() => { i++; t.equal(i, 3) }) + fastify.ready(() => { i++; t.assert.strictEqual(i, 1) }) + fastify.ready(() => { i++; t.assert.strictEqual(i, 2) }) + fastify.ready(() => { + i++ + t.assert.strictEqual(i, 3) + done() + }) }) -t.test('ready return the server with callback', t => { +test('ready return the server with callback', (t, done) => { t.plan(2) const fastify = Fastify() fastify.ready((err, instance) => { - t.error(err) - t.same(instance, fastify) + t.assert.ifError(err) + t.assert.deepStrictEqual(instance, fastify) + done() }) }) -t.test('ready return the server with Promise', t => { +test('ready return the server with Promise', async t => { t.plan(1) const fastify = Fastify() - fastify.ready() - .then(instance => { t.same(instance, fastify) }) - .catch(err => { t.fail(err) }) + await fastify.ready() + .then(instance => { t.assert.deepStrictEqual(instance, fastify) }) + .catch(err => { t.assert.fail(err) }) }) -t.test('ready return registered', t => { +test('ready return registered', async t => { t.plan(4) const fastify = Fastify() fastify.register((one, opts, done) => { - one.ready().then(itself => { t.same(itself, one) }) + one.ready().then(itself => { t.assert.deepStrictEqual(itself, one) }) done() }) fastify.register((two, opts, done) => { - two.ready().then(itself => { t.same(itself, two) }) + two.ready().then(itself => { t.assert.deepStrictEqual(itself, two) }) two.register((twoDotOne, opts, done) => { - twoDotOne.ready().then(itself => { t.same(itself, twoDotOne) }) + twoDotOne.ready().then(itself => { t.assert.deepStrictEqual(itself, twoDotOne) }) done() }) done() }) - fastify.ready() - .then(instance => { t.same(instance, fastify) }) - .catch(err => { t.fail(err) }) + await fastify.ready() + .then(instance => { t.assert.deepStrictEqual(instance, fastify) }) + .catch(err => { t.assert.fail(err) }) +}) + +test('do not crash with error in follow up onReady hook', async t => { + const fastify = Fastify() + + fastify.addHook('onReady', async function () { + }) + + fastify.addHook('onReady', function () { + throw new Error('kaboom') + }) + + await t.assert.rejects(fastify.ready()) }) diff --git a/test/hooks.test.js b/test/hooks.test.js index eef4173374e..f5aab8b2360 100644 --- a/test/hooks.test.js +++ b/test/hooks.test.js @@ -1,90 +1,99 @@ 'use strict' -const t = require('tap') -const test = t.test -const sget = require('simple-get').concat -const stream = require('stream') +const { test } = require('node:test') +const stream = require('node:stream') const Fastify = require('..') const fp = require('fastify-plugin') -const fs = require('fs') +const fs = require('node:fs') const split = require('split2') const symbols = require('../lib/symbols.js') const payload = { hello: 'world' } +const proxyquire = require('proxyquire') +const { connect } = require('node:net') +const { sleep } = require('./helper') +const { waitForCb } = require('./toolkit.js') process.removeAllListeners('warning') -function getUrl (app) { - const { address, port } = app.server.address() - if (address === '::1') { - return `http://[${address}]:${port}` - } else { - return `http://${address}:${port}` - } -} - -test('hooks', t => { - t.plan(43) +test('hooks', async t => { + t.plan(48) const fastify = Fastify({ exposeHeadRoutes: false }) try { fastify.addHook('preHandler', function (request, reply, done) { - t.equal(request.test, 'the request is coming') - t.equal(reply.test, 'the reply has come') + t.assert.strictEqual(request.test, 'the request is coming') + t.assert.strictEqual(reply.test, 'the reply has come') if (request.raw.method === 'HEAD') { done(new Error('some error')) } else { done() } }) - t.pass() + t.assert.ok('should pass') + } catch (e) { + t.assert.fail() + } + + try { + fastify.addHook('preHandler', null) } catch (e) { - t.fail() + t.assert.strictEqual(e.code, 'FST_ERR_HOOK_INVALID_HANDLER') + t.assert.strictEqual(e.message, 'preHandler hook should be a function, instead got null') + t.assert.ok('should pass') + } + + try { + fastify.addHook('preParsing') + } catch (e) { + t.assert.strictEqual(e.code, 'FST_ERR_HOOK_INVALID_HANDLER') + t.assert.strictEqual(e.message, 'preParsing hook should be a function, instead got undefined') + t.assert.ok('should pass') } try { fastify.addHook('preParsing', function (request, reply, payload, done) { request.preParsing = true - t.equal(request.test, 'the request is coming') - t.equal(reply.test, 'the reply has come') + t.assert.strictEqual(request.test, 'the request is coming') + t.assert.strictEqual(reply.test, 'the reply has come') done() }) - t.pass() + t.assert.ok('should pass') } catch (e) { - t.fail() + t.assert.fail() } try { fastify.addHook('preParsing', function (request, reply, payload, done) { request.preParsing = true - t.equal(request.test, 'the request is coming') - t.equal(reply.test, 'the reply has come') + t.assert.strictEqual(request.test, 'the request is coming') + t.assert.strictEqual(reply.test, 'the reply has come') done() }) - t.pass() + t.assert.ok('should pass') } catch (e) { - t.fail() + t.assert.fail() } try { fastify.addHook('preValidation', function (request, reply, done) { - t.equal(request.preParsing, true) - t.equal(request.test, 'the request is coming') - t.equal(reply.test, 'the reply has come') + t.assert.strictEqual(request.preParsing, true) + t.assert.strictEqual(request.test, 'the request is coming') + t.assert.strictEqual(reply.test, 'the reply has come') done() }) - t.pass() + t.assert.ok('should pass') } catch (e) { - t.fail() + t.assert.fail() } try { fastify.addHook('preSerialization', function (request, reply, payload, done) { - t.ok('preSerialization called') + t.assert.ok('preSerialization called') done() }) - t.pass() + t.assert.ok('should pass') } catch (e) { - t.fail() + t.assert.fail() } try { @@ -97,18 +106,18 @@ test('hooks', t => { done() } }) - t.pass() + t.assert.ok('should pass') } catch (e) { - t.fail() + t.assert.fail() } fastify.addHook('onResponse', function (request, reply, done) { - t.ok('onResponse called') + t.assert.ok('onResponse called') done() }) fastify.addHook('onSend', function (req, reply, thePayload, done) { - t.ok('onSend called') + t.assert.ok('onSend called') done() }) @@ -116,12 +125,12 @@ test('hooks', t => { method: 'GET', url: '/', handler: function (req, reply) { - t.equal(req.test, 'the request is coming') - t.equal(reply.test, 'the reply has come') + t.assert.strictEqual(req.test, 'the request is coming') + t.assert.strictEqual(reply.test, 'the reply has come') reply.code(200).send(payload) }, onResponse: function (req, reply, done) { - t.ok('onResponse inside hook') + t.assert.ok('onResponse inside hook') }, response: { 200: { @@ -138,45 +147,32 @@ test('hooks', t => { reply.code(200).send(payload) }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) + const getResult = await fetch(fastifyServer) + t.assert.ok(getResult.ok) + t.assert.strictEqual(getResult.status, 200) + const getBody = await getResult.text() + t.assert.strictEqual(getResult.headers.get('content-length'), '' + getBody.length) + t.assert.deepStrictEqual(JSON.parse(getBody), { hello: 'world' }) - sget({ - method: 'HEAD', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 500) - }) + const headResult = await fetch(fastifyServer, { method: 'HEAD' }) + t.assert.ok(!headResult.ok) + t.assert.strictEqual(headResult.status, 500) - sget({ - method: 'DELETE', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 500) - }) - }) + const deleteResult = await fetch(fastifyServer, { method: 'DELETE' }) + t.assert.ok(!deleteResult.ok) + t.assert.strictEqual(deleteResult.status, 500) }) -test('onRequest hook should support encapsulation / 1', t => { +test('onRequest hook should support encapsulation / 1', (t, testDone) => { t.plan(5) const fastify = Fastify() fastify.register((instance, opts, done) => { instance.addHook('onRequest', (req, reply, done) => { - t.equal(req.raw.url, '/plugin') + t.assert.strictEqual(req.raw.url, '/plugin') done() }) @@ -192,44 +188,46 @@ test('onRequest hook should support encapsulation / 1', t => { }) fastify.inject('/root', (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - }) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) - fastify.inject('/plugin', (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) + fastify.inject('/plugin', (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + testDone() + }) }) }) -test('onRequest hook should support encapsulation / 2', t => { +test('onRequest hook should support encapsulation / 2', (t, testDone) => { t.plan(3) const fastify = Fastify() let pluginInstance - fastify.addHook('onRequest', () => {}) + fastify.addHook('onRequest', () => { }) fastify.register((instance, opts, done) => { - instance.addHook('onRequest', () => {}) + instance.addHook('onRequest', () => { }) pluginInstance = instance done() }) fastify.ready(err => { - t.error(err) - t.equal(fastify[symbols.kHooks].onRequest.length, 1) - t.equal(pluginInstance[symbols.kHooks].onRequest.length, 2) + t.assert.ifError(err) + t.assert.strictEqual(fastify[symbols.kHooks].onRequest.length, 1) + t.assert.strictEqual(pluginInstance[symbols.kHooks].onRequest.length, 2) + testDone() }) }) -test('onRequest hook should support encapsulation / 3', t => { - t.plan(20) +test('onRequest hook should support encapsulation / 3', async t => { + t.plan(19) const fastify = Fastify() fastify.decorate('hello', 'world') fastify.addHook('onRequest', function (req, reply, done) { - t.ok(this.hello) - t.ok(this.hello2) + t.assert.ok(this.hello) + t.assert.ok(this.hello2) req.first = true done() }) @@ -237,124 +235,108 @@ test('onRequest hook should support encapsulation / 3', t => { fastify.decorate('hello2', 'world') fastify.get('/first', (req, reply) => { - t.ok(req.first) - t.notOk(req.second) + t.assert.ok(req.first) + t.assert.ok(!req.second) reply.send({ hello: 'world' }) }) fastify.register((instance, opts, done) => { instance.decorate('hello3', 'world') instance.addHook('onRequest', function (req, reply, done) { - t.ok(this.hello) - t.ok(this.hello2) - t.ok(this.hello3) + t.assert.ok(this.hello) + t.assert.ok(this.hello2) + t.assert.ok(this.hello3) req.second = true done() }) instance.get('/second', (req, reply) => { - t.ok(req.first) - t.ok(req.second) + t.assert.ok(req.first) + t.assert.ok(req.second) reply.send({ hello: 'world' }) }) done() }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/first' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) + const firstResult = await fetch(fastifyServer + '/first', { method: 'GET' }) + t.assert.ok(firstResult.ok) + t.assert.strictEqual(firstResult.status, 200) + const firstBody = await firstResult.text() + t.assert.strictEqual(firstResult.headers.get('content-length'), '' + firstBody.length) + t.assert.deepStrictEqual(JSON.parse(firstBody), { hello: 'world' }) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/second' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) - }) + const secondResult = await fetch(fastifyServer + '/second', { method: 'GET' }) + t.assert.ok(secondResult.ok) + t.assert.strictEqual(secondResult.status, 200) + const secondBody = await secondResult.text() + t.assert.strictEqual(secondResult.headers.get('content-length'), '' + secondBody.length) + t.assert.deepStrictEqual(JSON.parse(secondBody), { hello: 'world' }) }) -test('preHandler hook should support encapsulation / 5', t => { - t.plan(17) +test('preHandler hook should support encapsulation / 5', async t => { + t.plan(16) const fastify = Fastify() + t.after(() => { fastify.close() }) fastify.decorate('hello', 'world') fastify.addHook('preHandler', function (req, res, done) { - t.ok(this.hello) + t.assert.ok(this.hello) req.first = true done() }) fastify.get('/first', (req, reply) => { - t.ok(req.first) - t.notOk(req.second) + t.assert.ok(req.first) + t.assert.ok(!req.second) reply.send({ hello: 'world' }) }) fastify.register((instance, opts, done) => { instance.decorate('hello2', 'world') instance.addHook('preHandler', function (req, res, done) { - t.ok(this.hello) - t.ok(this.hello2) + t.assert.ok(this.hello) + t.assert.ok(this.hello2) req.second = true done() }) instance.get('/second', (req, reply) => { - t.ok(req.first) - t.ok(req.second) + t.assert.ok(req.first) + t.assert.ok(req.second) reply.send({ hello: 'world' }) }) done() }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) + const fastifyServer = await fastify.listen({ port: 0 }) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/first' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) + const firstResult = await fetch(fastifyServer + '/first') + t.assert.ok(firstResult.ok) + t.assert.strictEqual(firstResult.status, 200) + const firstBody = await firstResult.text() + t.assert.strictEqual(firstResult.headers.get('content-length'), '' + firstBody.length) + t.assert.deepStrictEqual(JSON.parse(firstBody), { hello: 'world' }) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/second' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) - }) + const secondResult = await fetch(fastifyServer + '/second') + t.assert.ok(secondResult.ok) + t.assert.strictEqual(secondResult.status, 200) + const secondBody = await secondResult.text() + t.assert.strictEqual(secondResult.headers.get('content-length'), '' + secondBody.length) + t.assert.deepStrictEqual(JSON.parse(secondBody), { hello: 'world' }) }) -test('onRoute hook should be called / 1', t => { +test('onRoute hook should be called / 1', (t, testDone) => { t.plan(2) const fastify = Fastify({ exposeHeadRoutes: false }) fastify.register((instance, opts, done) => { instance.addHook('onRoute', () => { - t.pass() + t.assert.ok('should pass') }) instance.get('/', opts, function (req, reply) { reply.send() @@ -363,23 +345,24 @@ test('onRoute hook should be called / 1', t => { }) fastify.ready(err => { - t.error(err) + t.assert.ifError(err) + testDone() }) }) -test('onRoute hook should be called / 2', t => { +test('onRoute hook should be called / 2', (t, testDone) => { t.plan(5) let firstHandler = 0 let secondHandler = 0 const fastify = Fastify({ exposeHeadRoutes: false }) fastify.addHook('onRoute', (route) => { - t.pass() + t.assert.ok('should pass') firstHandler++ }) fastify.register((instance, opts, done) => { instance.addHook('onRoute', (route) => { - t.pass() + t.assert.ok('should pass') secondHandler++ }) instance.get('/', opts, function (req, reply) { @@ -388,16 +371,17 @@ test('onRoute hook should be called / 2', t => { done() }) .after(() => { - t.equal(firstHandler, 1) - t.equal(secondHandler, 1) + t.assert.strictEqual(firstHandler, 1) + t.assert.strictEqual(secondHandler, 1) }) fastify.ready(err => { - t.error(err) + t.assert.ifError(err) + testDone() }) }) -test('onRoute hook should be called / 3', t => { +test('onRoute hook should be called / 3', (t, testDone) => { t.plan(5) const fastify = Fastify({ exposeHeadRoutes: false }) @@ -406,18 +390,18 @@ test('onRoute hook should be called / 3', t => { } fastify.addHook('onRoute', (route) => { - t.pass() + t.assert.ok('should pass') }) fastify.register((instance, opts, done) => { instance.addHook('onRoute', (route) => { - t.pass() + t.assert.ok('should pass') }) instance.get('/a', handler) done() }) .after((err, done) => { - t.error(err) + t.assert.ifError(err) setTimeout(() => { fastify.get('/b', handler) done() @@ -425,21 +409,22 @@ test('onRoute hook should be called / 3', t => { }) fastify.ready(err => { - t.error(err) + t.assert.ifError(err) + testDone() }) }) -test('onRoute hook should be called (encapsulation support) / 4', t => { +test('onRoute hook should be called (encapsulation support) / 4', (t, testDone) => { t.plan(4) const fastify = Fastify({ exposeHeadRoutes: false }) fastify.addHook('onRoute', () => { - t.pass() + t.assert.ok('should pass') }) fastify.register((instance, opts, done) => { instance.addHook('onRoute', () => { - t.pass() + t.assert.ok('should pass') }) instance.get('/nested', opts, function (req, reply) { reply.send() @@ -452,11 +437,12 @@ test('onRoute hook should be called (encapsulation support) / 4', t => { }) fastify.ready(err => { - t.error(err) + t.assert.ifError(err) + testDone() }) }) -test('onRoute hook should be called (encapsulation support) / 5', t => { +test('onRoute hook should be called (encapsulation support) / 5', (t, testDone) => { t.plan(2) const fastify = Fastify({ exposeHeadRoutes: false }) @@ -466,7 +452,7 @@ test('onRoute hook should be called (encapsulation support) / 5', t => { fastify.register((instance, opts, done) => { instance.addHook('onRoute', () => { - t.pass() + t.assert.ok('should pass') }) instance.get('/nested', opts, function (req, reply) { reply.send() @@ -479,11 +465,12 @@ test('onRoute hook should be called (encapsulation support) / 5', t => { }) fastify.ready(err => { - t.error(err) + t.assert.ifError(err) + testDone() }) }) -test('onRoute hook should be called (encapsulation support) / 6', t => { +test('onRoute hook should be called (encapsulation support) / 6', (t, testDone) => { t.plan(1) const fastify = Fastify({ exposeHeadRoutes: false }) @@ -492,25 +479,26 @@ test('onRoute hook should be called (encapsulation support) / 6', t => { }) fastify.addHook('onRoute', () => { - t.fail('This should not be called') + t.assert.fail('This should not be called') }) fastify.ready(err => { - t.error(err) + t.assert.ifError(err) + testDone() }) }) -test('onRoute should keep the context', t => { +test('onRoute should keep the context', (t, testDone) => { t.plan(4) const fastify = Fastify({ exposeHeadRoutes: false }) fastify.register((instance, opts, done) => { instance.decorate('test', true) instance.addHook('onRoute', onRoute) - t.ok(instance.prototype === fastify.prototype) + t.assert.ok(instance.prototype === fastify.prototype) function onRoute (route) { - t.ok(this.test) - t.equal(this, instance) + t.assert.ok(this.test) + t.assert.strictEqual(this, instance) } instance.get('/', opts, function (req, reply) { @@ -521,26 +509,27 @@ test('onRoute should keep the context', t => { }) fastify.close((err) => { - t.error(err) + t.assert.ifError(err) + testDone() }) }) -test('onRoute hook should pass correct route', t => { +test('onRoute hook should pass correct route', (t, testDone) => { t.plan(9) const fastify = Fastify({ exposeHeadRoutes: false }) fastify.addHook('onRoute', (route) => { - t.equal(route.method, 'GET') - t.equal(route.url, '/') - t.equal(route.path, '/') - t.equal(route.routePath, '/') + t.assert.strictEqual(route.method, 'GET') + t.assert.strictEqual(route.url, '/') + t.assert.strictEqual(route.path, '/') + t.assert.strictEqual(route.routePath, '/') }) fastify.register((instance, opts, done) => { instance.addHook('onRoute', (route) => { - t.equal(route.method, 'GET') - t.equal(route.url, '/') - t.equal(route.path, '/') - t.equal(route.routePath, '/') + t.assert.strictEqual(route.method, 'GET') + t.assert.strictEqual(route.url, '/') + t.assert.strictEqual(route.path, '/') + t.assert.strictEqual(route.routePath, '/') }) instance.get('/', opts, function (req, reply) { reply.send() @@ -549,28 +538,29 @@ test('onRoute hook should pass correct route', t => { }) fastify.ready(err => { - t.error(err) + t.assert.ifError(err) + testDone() }) }) -test('onRoute hook should pass correct route with custom prefix', t => { +test('onRoute hook should pass correct route with custom prefix', (t, testDone) => { t.plan(11) const fastify = Fastify({ exposeHeadRoutes: false }) fastify.addHook('onRoute', function (route) { - t.equal(route.method, 'GET') - t.equal(route.url, '/v1/foo') - t.equal(route.path, '/v1/foo') - t.equal(route.routePath, '/foo') - t.equal(route.prefix, '/v1') + t.assert.strictEqual(route.method, 'GET') + t.assert.strictEqual(route.url, '/v1/foo') + t.assert.strictEqual(route.path, '/v1/foo') + t.assert.strictEqual(route.routePath, '/foo') + t.assert.strictEqual(route.prefix, '/v1') }) fastify.register((instance, opts, done) => { instance.addHook('onRoute', function (route) { - t.equal(route.method, 'GET') - t.equal(route.url, '/v1/foo') - t.equal(route.path, '/v1/foo') - t.equal(route.routePath, '/foo') - t.equal(route.prefix, '/v1') + t.assert.strictEqual(route.method, 'GET') + t.assert.strictEqual(route.url, '/v1/foo') + t.assert.strictEqual(route.path, '/v1/foo') + t.assert.strictEqual(route.routePath, '/foo') + t.assert.strictEqual(route.prefix, '/v1') }) instance.get('/foo', opts, function (req, reply) { reply.send() @@ -579,20 +569,21 @@ test('onRoute hook should pass correct route with custom prefix', t => { }, { prefix: '/v1' }) fastify.ready(err => { - t.error(err) + t.assert.ifError(err) + testDone() }) }) -test('onRoute hook should pass correct route with custom options', t => { +test('onRoute hook should pass correct route with custom options', (t, testDone) => { t.plan(6) const fastify = Fastify({ exposeHeadRoutes: false }) fastify.register((instance, opts, done) => { instance.addHook('onRoute', function (route) { - t.equal(route.method, 'GET') - t.equal(route.url, '/foo') - t.equal(route.logLevel, 'info') - t.equal(route.bodyLimit, 100) - t.type(route.logSerializers.test, 'function') + t.assert.strictEqual(route.method, 'GET') + t.assert.strictEqual(route.url, '/foo') + t.assert.strictEqual(route.logLevel, 'info') + t.assert.strictEqual(route.bodyLimit, 100) + t.assert.ok(typeof route.logSerializers.test === 'function') }) instance.get('/foo', { logLevel: 'info', @@ -607,19 +598,20 @@ test('onRoute hook should pass correct route with custom options', t => { }) fastify.ready(err => { - t.error(err) + t.assert.ifError(err) + testDone() }) }) -test('onRoute hook should receive any route option', t => { +test('onRoute hook should receive any route option', (t, testDone) => { t.plan(5) const fastify = Fastify({ exposeHeadRoutes: false }) fastify.register((instance, opts, done) => { instance.addHook('onRoute', function (route) { - t.equal(route.method, 'GET') - t.equal(route.url, '/foo') - t.equal(route.routePath, '/foo') - t.equal(route.auth, 'basic') + t.assert.strictEqual(route.method, 'GET') + t.assert.strictEqual(route.url, '/foo') + t.assert.strictEqual(route.routePath, '/foo') + t.assert.strictEqual(route.auth, 'basic') }) instance.get('/foo', { auth: 'basic' }, function (req, reply) { reply.send() @@ -628,19 +620,20 @@ test('onRoute hook should receive any route option', t => { }) fastify.ready(err => { - t.error(err) + t.assert.ifError(err) + testDone() }) }) -test('onRoute hook should preserve system route configuration', t => { +test('onRoute hook should preserve system route configuration', (t, testDone) => { t.plan(5) const fastify = Fastify({ exposeHeadRoutes: false }) fastify.register((instance, opts, done) => { instance.addHook('onRoute', function (route) { - t.equal(route.method, 'GET') - t.equal(route.url, '/foo') - t.equal(route.routePath, '/foo') - t.equal(route.handler.length, 2) + t.assert.strictEqual(route.method, 'GET') + t.assert.strictEqual(route.url, '/foo') + t.assert.strictEqual(route.routePath, '/foo') + t.assert.strictEqual(route.handler.length, 2) }) instance.get('/foo', { url: '/bar', method: 'POST' }, function (req, reply) { reply.send() @@ -649,31 +642,33 @@ test('onRoute hook should preserve system route configuration', t => { }) fastify.ready(err => { - t.error(err) + t.assert.ifError(err) + testDone() }) }) -test('onRoute hook should preserve handler function in options of shorthand route system configuration', t => { +test('onRoute hook should preserve handler function in options of shorthand route system configuration', (t, testDone) => { t.plan(2) - const handler = (req, reply) => {} + const handler = (req, reply) => { } const fastify = Fastify({ exposeHeadRoutes: false }) fastify.register((instance, opts, done) => { instance.addHook('onRoute', function (route) { - t.equal(route.handler, handler) + t.assert.strictEqual(route.handler, handler) }) instance.get('/foo', { handler }) done() }) fastify.ready(err => { - t.error(err) + t.assert.ifError(err) + testDone() }) }) // issue ref https://github.com/fastify/fastify-compress/issues/140 -test('onRoute hook should be called once when prefixTrailingSlash', t => { +test('onRoute hook should be called once when prefixTrailingSlash', (t, testDone) => { t.plan(3) let onRouteCalled = 0 @@ -709,20 +704,22 @@ test('onRoute hook should be called once when prefixTrailingSlash', t => { }, { prefix: '/prefix' }) fastify.ready(err => { - t.error(err) - t.equal(onRouteCalled, 1) // onRoute hook was called once - t.equal(routePatched, 1) // and plugin acted once and avoided redundaunt route patching + t.assert.ifError(err) + t.assert.strictEqual(onRouteCalled, 1) // onRoute hook was called once + t.assert.strictEqual(routePatched, 1) // and plugin acted once and avoided redundant route patching + testDone() }) }) -test('onRoute hook should able to change the route url', t => { - t.plan(5) +test('onRoute hook should able to change the route url', async t => { + t.plan(4) const fastify = Fastify({ exposeHeadRoutes: false }) + t.after(() => { fastify.close() }) fastify.register((instance, opts, done) => { instance.addHook('onRoute', (route) => { - t.equal(route.url, '/foo') + t.assert.strictEqual(route.url, '/foo') route.url = encodeURI(route.url) }) @@ -733,22 +730,15 @@ test('onRoute hook should able to change the route url', t => { done() }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) + const fastifyServer = await fastify.listen({ port: 0 }) - sget({ - method: 'GET', - url: getUrl(fastify) + encodeURI('/foo') - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(body.toString(), 'here /foo') - }) - }) + const result = await fetch(fastifyServer + encodeURI('/foo')) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.strictEqual(await result.text(), 'here /foo') }) -test('onRoute hook that throws should be caught', t => { +test('onRoute hook that throws should be caught', (t, testDone) => { t.plan(1) const fastify = Fastify({ exposeHeadRoutes: false }) @@ -762,30 +752,30 @@ test('onRoute hook that throws should be caught', t => { reply.send() }) - t.fail('onRoute should throw sync if error') + t.assert.fail('onRoute should throw sync if error') } catch (error) { - t.ok(error) + t.assert.ok(error) } done() }) - fastify.ready() + fastify.ready(testDone) }) -test('onRoute hook with many prefix', t => { +test('onRoute hook with many prefix', (t, testDone) => { t.plan(3) const fastify = Fastify({ exposeHeadRoutes: false }) const handler = (req, reply) => { reply.send({}) } const onRouteChecks = [ - { routePath: '/anotherPath', prefix: '/two', url: '/one/two/anotherPath' }, + { routePath: '/anotherPath', prefix: '/one/two', url: '/one/two/anotherPath' }, { routePath: '/aPath', prefix: '/one', url: '/one/aPath' } ] fastify.register((instance, opts, done) => { - instance.addHook('onRoute', (route) => { - t.match(route, onRouteChecks.pop()) + instance.addHook('onRoute', ({ routePath, prefix, url }) => { + t.assert.deepStrictEqual({ routePath, prefix, url }, onRouteChecks.pop()) }) instance.route({ method: 'GET', url: '/aPath', handler }) @@ -796,15 +786,18 @@ test('onRoute hook with many prefix', t => { done() }, { prefix: '/one' }) - fastify.ready(err => { t.error(err) }) + fastify.ready(err => { + t.assert.ifError(err) + testDone() + }) }) -test('onRoute hook should not be called when it registered after route', t => { +test('onRoute hook should not be called when it registered after route', (t, testDone) => { t.plan(3) const fastify = Fastify() fastify.addHook('onRoute', () => { - t.pass() + t.assert.ok('should pass') }) fastify.get('/', function (req, reply) { @@ -812,15 +805,16 @@ test('onRoute hook should not be called when it registered after route', t => { }) fastify.addHook('onRoute', () => { - t.fail('should not be called') + t.assert.fail('should not be called') }) fastify.ready(err => { - t.error(err) + t.assert.ifError(err) + testDone() }) }) -test('onResponse hook should log request error', t => { +test('onResponse hook should log request error', (t, testDone) => { t.plan(4) let fastify = null @@ -833,12 +827,12 @@ test('onResponse hook should log request error', t => { } }) } catch (e) { - t.fail() + t.assert.fail() } logStream.once('data', line => { - t.equal(line.msg, 'request errored') - t.equal(line.level, 50) + t.assert.strictEqual(line.msg, 'request errored') + t.assert.strictEqual(line.level, 50) }) fastify.addHook('onResponse', (request, reply, done) => { @@ -850,18 +844,19 @@ test('onResponse hook should log request error', t => { }) fastify.inject('/root', (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + testDone() }) }) -test('onResponse hook should support encapsulation / 1', t => { +test('onResponse hook should support encapsulation / 1', (t, testDone) => { t.plan(5) const fastify = Fastify() fastify.register((instance, opts, done) => { instance.addHook('onResponse', (request, reply, done) => { - t.equal(reply.plugin, true) + t.assert.strictEqual(reply.plugin, true) done() }) @@ -878,44 +873,47 @@ test('onResponse hook should support encapsulation / 1', t => { }) fastify.inject('/root', (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) }) fastify.inject('/plugin', (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + testDone() }) }) -test('onResponse hook should support encapsulation / 2', t => { +test('onResponse hook should support encapsulation / 2', (t, testDone) => { t.plan(3) const fastify = Fastify() let pluginInstance - fastify.addHook('onResponse', () => {}) + fastify.addHook('onResponse', () => { }) fastify.register((instance, opts, done) => { - instance.addHook('onResponse', () => {}) + instance.addHook('onResponse', () => { }) pluginInstance = instance done() }) fastify.ready(err => { - t.error(err) - t.equal(fastify[symbols.kHooks].onResponse.length, 1) - t.equal(pluginInstance[symbols.kHooks].onResponse.length, 2) + t.assert.ifError(err) + t.assert.strictEqual(fastify[symbols.kHooks].onResponse.length, 1) + t.assert.strictEqual(pluginInstance[symbols.kHooks].onResponse.length, 2) + testDone() }) }) -test('onResponse hook should support encapsulation / 3', t => { - t.plan(16) +test('onResponse hook should support encapsulation / 3', async t => { + t.plan(15) const fastify = Fastify() + t.after(() => { fastify.close() }) fastify.decorate('hello', 'world') fastify.addHook('onResponse', function (request, reply, done) { - t.ok(this.hello) - t.ok('onResponse called') + t.assert.ok(this.hello) + t.assert.ok('onResponse called') done() }) @@ -926,9 +924,9 @@ test('onResponse hook should support encapsulation / 3', t => { fastify.register((instance, opts, done) => { instance.decorate('hello2', 'world') instance.addHook('onResponse', function (request, reply, done) { - t.ok(this.hello) - t.ok(this.hello2) - t.ok('onResponse called') + t.assert.ok(this.hello) + t.assert.ok(this.hello2) + t.assert.ok('onResponse called') done() }) @@ -939,60 +937,53 @@ test('onResponse hook should support encapsulation / 3', t => { done() }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) + const fastifyServer = await fastify.listen({ port: 0 }) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/first' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) + const firstResult = await fetch(fastifyServer + '/first', { method: 'GET' }) + t.assert.ok(firstResult.ok) + t.assert.strictEqual(firstResult.status, 200) + const firstBody = await firstResult.text() + t.assert.strictEqual(firstResult.headers.get('content-length'), '' + firstBody.length) + t.assert.deepStrictEqual(JSON.parse(firstBody), { hello: 'world' }) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/second' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) - }) + const secondResult = await fetch(fastifyServer + '/second') + t.assert.ok(secondResult.ok) + t.assert.strictEqual(secondResult.status, 200) + const secondBody = await secondResult.text() + t.assert.strictEqual(secondResult.headers.get('content-length'), '' + secondBody.length) + t.assert.deepStrictEqual(JSON.parse(secondBody), { hello: 'world' }) }) -test('onSend hook should support encapsulation / 1', t => { +test('onSend hook should support encapsulation / 1', (t, testDone) => { t.plan(3) const fastify = Fastify() let pluginInstance - fastify.addHook('onSend', () => {}) + fastify.addHook('onSend', () => { }) fastify.register((instance, opts, done) => { - instance.addHook('onSend', () => {}) + instance.addHook('onSend', () => { }) pluginInstance = instance done() }) fastify.ready(err => { - t.error(err) - t.equal(fastify[symbols.kHooks].onSend.length, 1) - t.equal(pluginInstance[symbols.kHooks].onSend.length, 2) + t.assert.ifError(err) + t.assert.strictEqual(fastify[symbols.kHooks].onSend.length, 1) + t.assert.strictEqual(pluginInstance[symbols.kHooks].onSend.length, 2) + testDone() }) }) -test('onSend hook should support encapsulation / 2', t => { - t.plan(16) +test('onSend hook should support encapsulation / 2', async t => { + t.plan(15) const fastify = Fastify() + t.after(() => { fastify.close() }) fastify.decorate('hello', 'world') fastify.addHook('onSend', function (request, reply, thePayload, done) { - t.ok(this.hello) - t.ok('onSend called') + t.assert.ok(this.hello) + t.assert.ok('onSend called') done() }) @@ -1003,9 +994,9 @@ test('onSend hook should support encapsulation / 2', t => { fastify.register((instance, opts, done) => { instance.decorate('hello2', 'world') instance.addHook('onSend', function (request, reply, thePayload, done) { - t.ok(this.hello) - t.ok(this.hello2) - t.ok('onSend called') + t.assert.ok(this.hello) + t.assert.ok(this.hello2) + t.assert.ok('onSend called') done() }) @@ -1016,33 +1007,24 @@ test('onSend hook should support encapsulation / 2', t => { done() }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) + const fastifyServer = await fastify.listen({ port: 0 }) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/first' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) + const firstResult = await fetch(fastifyServer + '/first') + t.assert.ok(firstResult.ok) + t.assert.strictEqual(firstResult.status, 200) + const firstBody = await firstResult.text() + t.assert.strictEqual(firstResult.headers.get('content-length'), '' + firstBody.length) + t.assert.deepStrictEqual(JSON.parse(firstBody), { hello: 'world' }) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/second' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) - }) + const secondResult = await fetch(fastifyServer + '/second') + t.assert.ok(secondResult.ok) + t.assert.strictEqual(secondResult.status, 200) + const secondBody = await secondResult.text() + t.assert.strictEqual(secondResult.headers.get('content-length'), '' + secondBody.length) + t.assert.deepStrictEqual(JSON.parse(secondBody), { hello: 'world' }) }) -test('onSend hook is called after payload is serialized and headers are set', t => { +test('onSend hook is called after payload is serialized and headers are set', (t, testDone) => { t.plan(30) const fastify = Fastify() @@ -1050,8 +1032,8 @@ test('onSend hook is called after payload is serialized and headers are set', t const thePayload = { hello: 'world' } instance.addHook('onSend', function (request, reply, payload, done) { - t.same(JSON.parse(payload), thePayload) - t.equal(reply[symbols.kReplyHeaders]['content-type'], 'application/json; charset=utf-8') + t.assert.deepStrictEqual(JSON.parse(payload), thePayload) + t.assert.strictEqual(reply[symbols.kReplyHeaders]['content-type'], 'application/json; charset=utf-8') done() }) @@ -1064,8 +1046,8 @@ test('onSend hook is called after payload is serialized and headers are set', t fastify.register((instance, opts, done) => { instance.addHook('onSend', function (request, reply, payload, done) { - t.equal(payload, 'some text') - t.equal(reply[symbols.kReplyHeaders]['content-type'], 'text/plain; charset=utf-8') + t.assert.strictEqual(payload, 'some text') + t.assert.strictEqual(reply[symbols.kReplyHeaders]['content-type'], 'text/plain; charset=utf-8') done() }) @@ -1080,8 +1062,8 @@ test('onSend hook is called after payload is serialized and headers are set', t const thePayload = Buffer.from('buffer payload') instance.addHook('onSend', function (request, reply, payload, done) { - t.equal(payload, thePayload) - t.equal(reply[symbols.kReplyHeaders]['content-type'], 'application/octet-stream') + t.assert.strictEqual(payload, thePayload) + t.assert.strictEqual(reply[symbols.kReplyHeaders]['content-type'], 'application/octet-stream') done() }) @@ -1102,8 +1084,8 @@ test('onSend hook is called after payload is serialized and headers are set', t }) instance.addHook('onSend', function (request, reply, payload, done) { - t.equal(payload, thePayload) - t.equal(reply[symbols.kReplyHeaders]['content-type'], 'application/octet-stream') + t.assert.strictEqual(payload, thePayload) + t.assert.strictEqual(reply[symbols.kReplyHeaders]['content-type'], 'application/octet-stream') done() }) @@ -1119,8 +1101,8 @@ test('onSend hook is called after payload is serialized and headers are set', t const serializedPayload = 'serialized' instance.addHook('onSend', function (request, reply, payload, done) { - t.equal(payload, serializedPayload) - t.equal(reply[symbols.kReplyHeaders]['content-type'], 'text/custom') + t.assert.strictEqual(payload, serializedPayload) + t.assert.strictEqual(reply[symbols.kReplyHeaders]['content-type'], 'text/custom') done() }) @@ -1134,58 +1116,65 @@ test('onSend hook is called after payload is serialized and headers are set', t done() }) + const completion = waitForCb({ steps: 5 }) fastify.inject({ method: 'GET', url: '/json' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same(JSON.parse(res.payload), { hello: 'world' }) - t.equal(res.headers['content-length'], '17') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + t.assert.strictEqual(res.headers['content-length'], '17') + completion.stepIn() }) fastify.inject({ method: 'GET', url: '/text' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same(res.payload, 'some text') - t.equal(res.headers['content-length'], '9') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.payload, 'some text') + t.assert.strictEqual(res.headers['content-length'], '9') + completion.stepIn() }) fastify.inject({ method: 'GET', url: '/buffer' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same(res.payload, 'buffer payload') - t.equal(res.headers['content-length'], '14') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.payload, 'buffer payload') + t.assert.strictEqual(res.headers['content-length'], '14') + completion.stepIn() }) fastify.inject({ method: 'GET', url: '/stream' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same(res.payload, 'stream payload') - t.equal(res.headers['transfer-encoding'], 'chunked') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.payload, 'stream payload') + t.assert.strictEqual(res.headers['transfer-encoding'], 'chunked') + completion.stepIn() }) fastify.inject({ method: 'GET', url: '/custom-serializer' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same(res.payload, 'serialized') - t.equal(res.headers['content-type'], 'text/custom') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.payload, 'serialized') + t.assert.strictEqual(res.headers['content-type'], 'text/custom') + completion.stepIn() }) + completion.patience.then(testDone) }) -test('modify payload', t => { +test('modify payload', (t, testDone) => { t.plan(10) const fastify = Fastify() const payload = { hello: 'world' } @@ -1193,21 +1182,21 @@ test('modify payload', t => { const anotherPayload = '"winter is coming"' fastify.addHook('onSend', function (request, reply, thePayload, done) { - t.ok('onSend called') - t.same(JSON.parse(thePayload), payload) + t.assert.ok('onSend called') + t.assert.deepStrictEqual(JSON.parse(thePayload), payload) thePayload = thePayload.replace('world', 'modified') done(null, thePayload) }) fastify.addHook('onSend', function (request, reply, thePayload, done) { - t.ok('onSend called') - t.same(JSON.parse(thePayload), modifiedPayload) + t.assert.ok('onSend called') + t.assert.deepStrictEqual(JSON.parse(thePayload), modifiedPayload) done(null, anotherPayload) }) fastify.addHook('onSend', function (request, reply, thePayload, done) { - t.ok('onSend called') - t.equal(thePayload, anotherPayload) + t.assert.ok('onSend called') + t.assert.strictEqual(thePayload, anotherPayload) done() }) @@ -1219,19 +1208,20 @@ test('modify payload', t => { method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.equal(res.payload, anotherPayload) - t.equal(res.statusCode, 200) - t.equal(res.headers['content-length'], '18') + t.assert.ifError(err) + t.assert.strictEqual(res.payload, anotherPayload) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['content-length'], '18') + testDone() }) }) -test('clear payload', t => { +test('clear payload', (t, testDone) => { t.plan(6) const fastify = Fastify() fastify.addHook('onSend', function (request, reply, payload, done) { - t.ok('onSend called') + t.assert.ok('onSend called') reply.code(304) done(null, null) }) @@ -1244,17 +1234,26 @@ test('clear payload', t => { method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 304) - t.equal(res.payload, '') - t.equal(res.headers['content-length'], undefined) - t.equal(res.headers['content-type'], 'application/json; charset=utf-8') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 304) + t.assert.strictEqual(res.payload, '') + t.assert.strictEqual(res.headers['content-length'], undefined) + t.assert.strictEqual(res.headers['content-type'], 'application/json; charset=utf-8') + testDone() }) }) -test('onSend hook throws', t => { - t.plan(9) +test('onSend hook throws', async t => { + t.plan(10) + const Fastify = proxyquire('..', { + './lib/schemas.js': { + getSchemaSerializer: (param1, param2, param3) => { + t.assert.strictEqual(param3, 'application/json; charset=utf-8', 'param3 should be "application/json; charset=utf-8"') + } + } + }) const fastify = Fastify() + t.after(() => { fastify.close() }) fastify.addHook('onSend', function (request, reply, payload, done) { if (request.raw.method === 'DELETE') { done(new Error('some error')) @@ -1265,6 +1264,10 @@ test('onSend hook throws', t => { throw new Error('some error') } + if (request.raw.method === 'POST') { + throw new Error('some error') + } + done() }) @@ -1272,6 +1275,26 @@ test('onSend hook throws', t => { reply.send({ hello: 'world' }) }) + fastify.post('/', { + schema: { + response: { + 200: { + content: { + 'application/json': { + schema: { + name: { type: 'string' }, + image: { type: 'string' }, + address: { type: 'string' } + } + } + } + } + } + } + }, (req, reply) => { + reply.send({ hello: 'world' }) + }) + fastify.delete('/', (req, reply) => { reply.send({ hello: 'world' }) }) @@ -1280,37 +1303,29 @@ test('onSend hook throws', t => { reply.send({ hello: 'world' }) }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) + const fastifyServer = await fastify.listen({ port: 0 }) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) - sget({ - method: 'DELETE', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 500) - }) - sget({ - method: 'PUT', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 500) - }) - }) + const getResult = await fetch(fastifyServer) + t.assert.ok(getResult.ok) + t.assert.strictEqual(getResult.status, 200) + const getBody = await getResult.text() + t.assert.strictEqual(getResult.headers.get('content-length'), '' + getBody.length) + t.assert.deepStrictEqual(JSON.parse(getBody), { hello: 'world' }) + + const postResult = await fetch(fastifyServer, { method: 'POST' }) + t.assert.ok(!postResult.ok) + t.assert.strictEqual(postResult.status, 500) + + const deleteResult = await fetch(fastifyServer, { method: 'DELETE' }) + t.assert.ok(!deleteResult.ok) + t.assert.strictEqual(deleteResult.status, 500) + + const putResult = await fetch(fastifyServer, { method: 'PUT' }) + t.assert.ok(!putResult.ok) + t.assert.strictEqual(putResult.status, 500) }) -test('onSend hook should receive valid request and reply objects if onRequest hook fails', t => { +test('onSend hook should receive valid request and reply objects if onRequest hook fails', (t, testDone) => { t.plan(4) const fastify = Fastify() @@ -1322,8 +1337,8 @@ test('onSend hook should receive valid request and reply objects if onRequest ho }) fastify.addHook('onSend', function (request, reply, payload, done) { - t.equal(request.testDecorator, 'testDecoratorVal') - t.equal(reply.testDecorator, 'testDecoratorVal') + t.assert.strictEqual(request.testDecorator, 'testDecoratorVal') + t.assert.strictEqual(reply.testDecorator, 'testDecoratorVal') done() }) @@ -1335,12 +1350,13 @@ test('onSend hook should receive valid request and reply objects if onRequest ho method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 500) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 500) + testDone() }) }) -test('onSend hook should receive valid request and reply objects if a custom content type parser fails', t => { +test('onSend hook should receive valid request and reply objects if a custom content type parser fails', (t, testDone) => { t.plan(4) const fastify = Fastify() @@ -1352,8 +1368,8 @@ test('onSend hook should receive valid request and reply objects if a custom con }) fastify.addHook('onSend', function (request, reply, payload, done) { - t.equal(request.testDecorator, 'testDecoratorVal') - t.equal(reply.testDecorator, 'testDecoratorVal') + t.assert.strictEqual(request.testDecorator, 'testDecoratorVal') + t.assert.strictEqual(reply.testDecorator, 'testDecoratorVal') done() }) @@ -1366,12 +1382,13 @@ test('onSend hook should receive valid request and reply objects if a custom con url: '/', payload: 'body' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 500) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 500) + testDone() }) }) -test('Content-Length header should be updated if onSend hook modifies the payload', t => { +test('Content-Length header should be updated if onSend hook modifies the payload', (t, testDone) => { t.plan(2) const instance = Fastify() @@ -1387,36 +1404,37 @@ test('Content-Length header should be updated if onSend hook modifies the payloa method: 'GET', url: '/' }, (err, res) => { - t.error(err) + t.assert.ifError(err) const payloadLength = Buffer.byteLength(res.body) const contentLength = Number(res.headers['content-length']) - t.equal(payloadLength, contentLength) + t.assert.strictEqual(payloadLength, contentLength) + testDone() }) }) -test('cannot add hook after binding', t => { - t.plan(2) +test('cannot add hook after binding', (t, testDone) => { + t.plan(1) const instance = Fastify() + t.after(() => instance.close()) instance.get('/', function (request, reply) { reply.send({ hello: 'world' }) }) instance.listen({ port: 0 }, err => { - t.error(err) - t.teardown(instance.server.close.bind(instance.server)) + t.assert.ifError(err) try { - instance.addHook('onRequest', () => {}) - t.fail() + instance.addHook('onRequest', () => { }) + t.assert.fail() } catch (e) { - t.pass() + testDone() } }) }) -test('onRequest hooks should be able to block a request', t => { +test('onRequest hooks should be able to block a request', (t, testDone) => { t.plan(5) const fastify = Fastify() @@ -1426,38 +1444,39 @@ test('onRequest hooks should be able to block a request', t => { }) fastify.addHook('onRequest', (req, reply, done) => { - t.fail('this should not be called') + t.assert.fail('this should not be called') }) fastify.addHook('preHandler', (req, reply, done) => { - t.fail('this should not be called') + t.assert.fail('this should not be called') }) fastify.addHook('onSend', (req, reply, payload, done) => { - t.ok('called') + t.assert.ok('called') done() }) fastify.addHook('onResponse', (request, reply, done) => { - t.ok('called') + t.assert.ok('called') done() }) fastify.get('/', function (request, reply) { - t.fail('we should not be here') + t.assert.fail('we should not be here') }) fastify.inject({ url: '/', method: 'GET' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.equal(res.payload, 'hello') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload, 'hello') + testDone() }) }) -test('preValidation hooks should be able to block a request', t => { +test('preValidation hooks should be able to block a request', (t, testDone) => { t.plan(5) const fastify = Fastify() @@ -1467,38 +1486,39 @@ test('preValidation hooks should be able to block a request', t => { }) fastify.addHook('preValidation', (req, reply, done) => { - t.fail('this should not be called') + t.assert.fail('this should not be called') }) fastify.addHook('preHandler', (req, reply, done) => { - t.fail('this should not be called') + t.assert.fail('this should not be called') }) fastify.addHook('onSend', (req, reply, payload, done) => { - t.ok('called') + t.assert.ok('called') done() }) fastify.addHook('onResponse', (request, reply, done) => { - t.ok('called') + t.assert.ok('called') done() }) fastify.get('/', function (request, reply) { - t.fail('we should not be here') + t.assert.fail('we should not be here') }) fastify.inject({ url: '/', method: 'GET' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.equal(res.payload, 'hello') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload, 'hello') + testDone() }) }) -test('preValidation hooks should be able to change request body before validation', t => { +test('preValidation hooks should be able to change request body before validation', (t, testDone) => { t.plan(4) const fastify = Fastify() @@ -1527,7 +1547,7 @@ test('preValidation hooks should be able to change request body before validatio } }, (req, reply) => { - t.pass() + t.assert.ok('should pass') reply.status(200).send('hello') } ) @@ -1539,13 +1559,14 @@ test('preValidation hooks should be able to change request body before validatio message: Buffer.from(JSON.stringify({ foo: 'example', bar: 1 })).toString('base64') } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.equal(res.payload, 'hello') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload, 'hello') + testDone() }) }) -test('preParsing hooks should be able to block a request', t => { +test('preParsing hooks should be able to block a request', (t, testDone) => { t.plan(5) const fastify = Fastify() @@ -1555,38 +1576,39 @@ test('preParsing hooks should be able to block a request', t => { }) fastify.addHook('preParsing', (req, reply, payload, done) => { - t.fail('this should not be called') + t.assert.fail('this should not be called') }) fastify.addHook('preHandler', (req, reply, done) => { - t.fail('this should not be called') + t.assert.fail('this should not be called') }) fastify.addHook('onSend', (req, reply, payload, done) => { - t.ok('called') + t.assert.ok('called') done() }) fastify.addHook('onResponse', (request, reply, done) => { - t.ok('called') + t.assert.ok('called') done() }) fastify.get('/', function (request, reply) { - t.fail('we should not be here') + t.assert.fail('we should not be here') }) fastify.inject({ url: '/', method: 'GET' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.equal(res.payload, 'hello') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload, 'hello') + testDone() }) }) -test('preHandler hooks should be able to block a request', t => { +test('preHandler hooks should be able to block a request', (t, testDone) => { t.plan(5) const fastify = Fastify() @@ -1596,34 +1618,35 @@ test('preHandler hooks should be able to block a request', t => { }) fastify.addHook('preHandler', (req, reply, done) => { - t.fail('this should not be called') + t.assert.fail('this should not be called') }) fastify.addHook('onSend', (req, reply, payload, done) => { - t.equal(payload, 'hello') + t.assert.strictEqual(payload, 'hello') done() }) fastify.addHook('onResponse', (request, reply, done) => { - t.ok('called') + t.assert.ok('called') done() }) fastify.get('/', function (request, reply) { - t.fail('we should not be here') + t.assert.fail('we should not be here') }) fastify.inject({ url: '/', method: 'GET' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.equal(res.payload, 'hello') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload, 'hello') + testDone() }) }) -test('onRequest hooks should be able to block a request (last hook)', t => { +test('onRequest hooks should be able to block a request (last hook)', (t, testDone) => { t.plan(5) const fastify = Fastify() @@ -1633,34 +1656,35 @@ test('onRequest hooks should be able to block a request (last hook)', t => { }) fastify.addHook('preHandler', (req, reply, done) => { - t.fail('this should not be called') + t.assert.fail('this should not be called') }) fastify.addHook('onSend', (req, reply, payload, done) => { - t.ok('called') + t.assert.ok('called') done() }) fastify.addHook('onResponse', (request, reply, done) => { - t.ok('called') + t.assert.ok('called') done() }) fastify.get('/', function (request, reply) { - t.fail('we should not be here') + t.assert.fail('we should not be here') }) fastify.inject({ url: '/', method: 'GET' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.equal(res.payload, 'hello') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload, 'hello') + testDone() }) }) -test('preHandler hooks should be able to block a request (last hook)', t => { +test('preHandler hooks should be able to block a request (last hook)', (t, testDone) => { t.plan(5) const fastify = Fastify() @@ -1670,30 +1694,31 @@ test('preHandler hooks should be able to block a request (last hook)', t => { }) fastify.addHook('onSend', (req, reply, payload, done) => { - t.equal(payload, 'hello') + t.assert.strictEqual(payload, 'hello') done() }) fastify.addHook('onResponse', (request, reply, done) => { - t.ok('called') + t.assert.ok('called') done() }) fastify.get('/', function (request, reply) { - t.fail('we should not be here') + t.assert.fail('we should not be here') }) fastify.inject({ url: '/', method: 'GET' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.equal(res.payload, 'hello') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload, 'hello') + testDone() }) }) -test('preParsing hooks should handle errors', t => { +test('preParsing hooks should handle errors', (t, testDone) => { t.plan(3) const fastify = Fastify() @@ -1712,60 +1737,62 @@ test('preParsing hooks should handle errors', t => { url: '/', payload: { hello: 'world' } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 501) - t.same(JSON.parse(res.payload), { error: 'Not Implemented', message: 'kaboom', statusCode: 501 }) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 501) + t.assert.deepStrictEqual(JSON.parse(res.payload), { error: 'Not Implemented', message: 'kaboom', statusCode: 501 }) + testDone() }) }) -test('onRequest respond with a stream', t => { +test('onRequest respond with a stream', (t, testDone) => { t.plan(4) const fastify = Fastify() fastify.addHook('onRequest', (req, reply, done) => { - const stream = fs.createReadStream(process.cwd() + '/test/stream.test.js', 'utf8') + const stream = fs.createReadStream(__filename, 'utf8') // stream.pipe(res) // res.once('finish', done) reply.send(stream) }) fastify.addHook('onRequest', (req, res, done) => { - t.fail('this should not be called') + t.assert.fail('this should not be called') }) fastify.addHook('preHandler', (req, reply, done) => { - t.fail('this should not be called') + t.assert.fail('this should not be called') }) fastify.addHook('onSend', (req, reply, payload, done) => { - t.ok('called') + t.assert.ok('called') done() }) fastify.addHook('onResponse', (request, reply, done) => { - t.ok('called') + t.assert.ok('called') done() }) fastify.get('/', function (request, reply) { - t.fail('we should not be here') + t.assert.fail('we should not be here') }) fastify.inject({ url: '/', method: 'GET' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + testDone() }) }) -test('preHandler respond with a stream', t => { +test('preHandler respond with a stream', (t, testDone) => { t.plan(7) const fastify = Fastify() fastify.addHook('onRequest', (req, reply, done) => { - t.ok('called') + t.assert.ok('called') done() }) @@ -1774,49 +1801,50 @@ test('preHandler respond with a stream', t => { const order = [1, 2] fastify.addHook('preHandler', (req, reply, done) => { - const stream = fs.createReadStream(process.cwd() + '/test/stream.test.js', 'utf8') + const stream = fs.createReadStream(__filename, 'utf8') reply.send(stream) reply.raw.once('finish', () => { - t.equal(order.shift(), 2) + t.assert.strictEqual(order.shift(), 2) done() }) }) fastify.addHook('preHandler', (req, reply, done) => { - t.fail('this should not be called') + t.assert.fail('this should not be called') }) fastify.addHook('onSend', (req, reply, payload, done) => { - t.equal(order.shift(), 1) - t.equal(typeof payload.pipe, 'function') + t.assert.strictEqual(order.shift(), 1) + t.assert.strictEqual(typeof payload.pipe, 'function') done() }) fastify.addHook('onResponse', (request, reply, done) => { - t.ok('called') + t.assert.ok('called') done() }) fastify.get('/', function (request, reply) { - t.fail('we should not be here') + t.assert.fail('we should not be here') }) fastify.inject({ url: '/', method: 'GET' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + testDone() }) }) -test('Register an hook after a plugin inside a plugin', t => { +test('Register an hook after a plugin inside a plugin', (t, testDone) => { t.plan(6) const fastify = Fastify() fastify.register(fp(function (instance, opts, done) { instance.addHook('preHandler', function (req, reply, done) { - t.ok('called') + t.assert.ok('called') done() }) @@ -1829,12 +1857,12 @@ test('Register an hook after a plugin inside a plugin', t => { fastify.register(fp(function (instance, opts, done) { instance.addHook('preHandler', function (req, reply, done) { - t.ok('called') + t.assert.ok('called') done() }) instance.addHook('preHandler', function (req, reply, done) { - t.ok('called') + t.assert.ok('called') done() }) @@ -1845,25 +1873,26 @@ test('Register an hook after a plugin inside a plugin', t => { url: '/', method: 'GET' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same(JSON.parse(res.payload), { hello: 'world' }) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + testDone() }) }) -test('Register an hook after a plugin inside a plugin (with preHandler option)', t => { +test('Register an hook after a plugin inside a plugin (with preHandler option)', (t, testDone) => { t.plan(7) const fastify = Fastify() fastify.register(fp(function (instance, opts, done) { instance.addHook('preHandler', function (req, reply, done) { - t.ok('called') + t.assert.ok('called') done() }) instance.get('/', { preHandler: (req, reply, done) => { - t.ok('called') + t.assert.ok('called') done() } }, function (request, reply) { @@ -1875,12 +1904,12 @@ test('Register an hook after a plugin inside a plugin (with preHandler option)', fastify.register(fp(function (instance, opts, done) { instance.addHook('preHandler', function (req, reply, done) { - t.ok('called') + t.assert.ok('called') done() }) instance.addHook('preHandler', function (req, reply, done) { - t.ok('called') + t.assert.ok('called') done() }) @@ -1891,13 +1920,14 @@ test('Register an hook after a plugin inside a plugin (with preHandler option)', url: '/', method: 'GET' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same(JSON.parse(res.payload), { hello: 'world' }) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + testDone() }) }) -test('Register hooks inside a plugin after an encapsulated plugin', t => { +test('Register hooks inside a plugin after an encapsulated plugin', (t, testDone) => { t.plan(7) const fastify = Fastify() @@ -1911,22 +1941,22 @@ test('Register hooks inside a plugin after an encapsulated plugin', t => { fastify.register(fp(function (instance, opts, done) { instance.addHook('onRequest', function (req, reply, done) { - t.ok('called') + t.assert.ok('called') done() }) instance.addHook('preHandler', function (request, reply, done) { - t.ok('called') + t.assert.ok('called') done() }) instance.addHook('onSend', function (request, reply, payload, done) { - t.ok('called') + t.assert.ok('called') done() }) instance.addHook('onResponse', function (request, reply, done) { - t.ok('called') + t.assert.ok('called') done() }) @@ -1934,31 +1964,32 @@ test('Register hooks inside a plugin after an encapsulated plugin', t => { })) fastify.inject('/', (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same(JSON.parse(res.payload), { hello: 'world' }) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + testDone() }) }) -test('onRequest hooks should run in the order in which they are defined', t => { +test('onRequest hooks should run in the order in which they are defined', (t, testDone) => { t.plan(9) const fastify = Fastify() fastify.register(function (instance, opts, done) { instance.addHook('onRequest', function (req, reply, done) { - t.equal(req.previous, undefined) + t.assert.strictEqual(req.previous, undefined) req.previous = 1 done() }) instance.get('/', function (request, reply) { - t.equal(request.previous, 5) + t.assert.strictEqual(request.previous, 5) reply.send({ hello: 'world' }) }) instance.register(fp(function (i, opts, done) { i.addHook('onRequest', function (req, reply, done) { - t.equal(req.previous, 1) + t.assert.strictEqual(req.previous, 1) req.previous = 2 done() }) @@ -1970,14 +2001,14 @@ test('onRequest hooks should run in the order in which they are defined', t => { fastify.register(fp(function (instance, opts, done) { instance.addHook('onRequest', function (req, reply, done) { - t.equal(req.previous, 2) + t.assert.strictEqual(req.previous, 2) req.previous = 3 done() }) instance.register(fp(function (i, opts, done) { i.addHook('onRequest', function (req, reply, done) { - t.equal(req.previous, 3) + t.assert.strictEqual(req.previous, 3) req.previous = 4 done() }) @@ -1985,7 +2016,7 @@ test('onRequest hooks should run in the order in which they are defined', t => { })) instance.addHook('onRequest', function (req, reply, done) { - t.equal(req.previous, 4) + t.assert.strictEqual(req.previous, 4) req.previous = 5 done() }) @@ -1994,31 +2025,32 @@ test('onRequest hooks should run in the order in which they are defined', t => { })) fastify.inject('/', (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same(JSON.parse(res.payload), { hello: 'world' }) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + testDone() }) }) -test('preHandler hooks should run in the order in which they are defined', t => { +test('preHandler hooks should run in the order in which they are defined', (t, testDone) => { t.plan(9) const fastify = Fastify() fastify.register(function (instance, opts, done) { instance.addHook('preHandler', function (request, reply, done) { - t.equal(request.previous, undefined) + t.assert.strictEqual(request.previous, undefined) request.previous = 1 done() }) instance.get('/', function (request, reply) { - t.equal(request.previous, 5) + t.assert.strictEqual(request.previous, 5) reply.send({ hello: 'world' }) }) instance.register(fp(function (i, opts, done) { i.addHook('preHandler', function (request, reply, done) { - t.equal(request.previous, 1) + t.assert.strictEqual(request.previous, 1) request.previous = 2 done() }) @@ -2030,14 +2062,14 @@ test('preHandler hooks should run in the order in which they are defined', t => fastify.register(fp(function (instance, opts, done) { instance.addHook('preHandler', function (request, reply, done) { - t.equal(request.previous, 2) + t.assert.strictEqual(request.previous, 2) request.previous = 3 done() }) instance.register(fp(function (i, opts, done) { i.addHook('preHandler', function (request, reply, done) { - t.equal(request.previous, 3) + t.assert.strictEqual(request.previous, 3) request.previous = 4 done() }) @@ -2045,7 +2077,7 @@ test('preHandler hooks should run in the order in which they are defined', t => })) instance.addHook('preHandler', function (request, reply, done) { - t.equal(request.previous, 4) + t.assert.strictEqual(request.previous, 4) request.previous = 5 done() }) @@ -2054,19 +2086,20 @@ test('preHandler hooks should run in the order in which they are defined', t => })) fastify.inject('/', (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same(JSON.parse(res.payload), { hello: 'world' }) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + testDone() }) }) -test('onSend hooks should run in the order in which they are defined', t => { +test('onSend hooks should run in the order in which they are defined', (t, testDone) => { t.plan(8) const fastify = Fastify() fastify.register(function (instance, opts, done) { instance.addHook('onSend', function (request, reply, payload, done) { - t.equal(request.previous, undefined) + t.assert.strictEqual(request.previous, undefined) request.previous = 1 done() }) @@ -2077,7 +2110,7 @@ test('onSend hooks should run in the order in which they are defined', t => { instance.register(fp(function (i, opts, done) { i.addHook('onSend', function (request, reply, payload, done) { - t.equal(request.previous, 1) + t.assert.strictEqual(request.previous, 1) request.previous = 2 done() }) @@ -2089,14 +2122,14 @@ test('onSend hooks should run in the order in which they are defined', t => { fastify.register(fp(function (instance, opts, done) { instance.addHook('onSend', function (request, reply, payload, done) { - t.equal(request.previous, 2) + t.assert.strictEqual(request.previous, 2) request.previous = 3 done() }) instance.register(fp(function (i, opts, done) { i.addHook('onSend', function (request, reply, payload, done) { - t.equal(request.previous, 3) + t.assert.strictEqual(request.previous, 3) request.previous = 4 done() }) @@ -2104,7 +2137,7 @@ test('onSend hooks should run in the order in which they are defined', t => { })) instance.addHook('onSend', function (request, reply, payload, done) { - t.equal(request.previous, 4) + t.assert.strictEqual(request.previous, 4) done(null, '5') }) @@ -2112,19 +2145,20 @@ test('onSend hooks should run in the order in which they are defined', t => { })) fastify.inject('/', (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same(JSON.parse(res.payload), 5) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(JSON.parse(res.payload), 5) + testDone() }) }) -test('onResponse hooks should run in the order in which they are defined', t => { +test('onResponse hooks should run in the order in which they are defined', (t, testDone) => { t.plan(8) const fastify = Fastify() fastify.register(function (instance, opts, done) { instance.addHook('onResponse', function (request, reply, done) { - t.equal(reply.previous, undefined) + t.assert.strictEqual(reply.previous, undefined) reply.previous = 1 done() }) @@ -2135,7 +2169,7 @@ test('onResponse hooks should run in the order in which they are defined', t => instance.register(fp(function (i, opts, done) { i.addHook('onResponse', function (request, reply, done) { - t.equal(reply.previous, 1) + t.assert.strictEqual(reply.previous, 1) reply.previous = 2 done() }) @@ -2147,14 +2181,14 @@ test('onResponse hooks should run in the order in which they are defined', t => fastify.register(fp(function (instance, opts, done) { instance.addHook('onResponse', function (request, reply, done) { - t.equal(reply.previous, 2) + t.assert.strictEqual(reply.previous, 2) reply.previous = 3 done() }) instance.register(fp(function (i, opts, done) { i.addHook('onResponse', function (request, reply, done) { - t.equal(reply.previous, 3) + t.assert.strictEqual(reply.previous, 3) reply.previous = 4 done() }) @@ -2162,7 +2196,7 @@ test('onResponse hooks should run in the order in which they are defined', t => })) instance.addHook('onResponse', function (request, reply, done) { - t.equal(reply.previous, 4) + t.assert.strictEqual(reply.previous, 4) done() }) @@ -2170,13 +2204,14 @@ test('onResponse hooks should run in the order in which they are defined', t => })) fastify.inject('/', (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same(JSON.parse(res.payload), { hello: 'world' }) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + testDone() }) }) -test('onRequest, preHandler, and onResponse hooks that resolve to a value do not cause an error', t => { +test('onRequest, preHandler, and onResponse hooks that resolve to a value do not cause an error', (t, testDone) => { t.plan(3) const fastify = Fastify() @@ -2195,13 +2230,14 @@ test('onRequest, preHandler, and onResponse hooks that resolve to a value do not }) fastify.inject('/', (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.equal(res.payload, 'hello') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload, 'hello') + testDone() }) }) -test('If a response header has been set inside an hook it shoulod not be overwritten by the final response handler', t => { +test('If a response header has been set inside an hook it should not be overwritten by the final response handler', (t, testDone) => { t.plan(5) const fastify = Fastify() @@ -2215,15 +2251,16 @@ test('If a response header has been set inside an hook it shoulod not be overwri }) fastify.inject('/', (err, res) => { - t.error(err) - t.equal(res.headers['x-custom-header'], 'hello') - t.equal(res.headers['content-type'], 'text/plain; charset=utf-8') - t.equal(res.statusCode, 200) - t.equal(res.payload, 'hello') + t.assert.ifError(err) + t.assert.strictEqual(res.headers['x-custom-header'], 'hello') + t.assert.strictEqual(res.headers['content-type'], 'text/plain; charset=utf-8') + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload, 'hello') + testDone() }) }) -test('If the content type has been set inside an hook it should not be changed', t => { +test('If the content type has been set inside an hook it should not be changed', (t, testDone) => { t.plan(5) const fastify = Fastify() @@ -2233,27 +2270,28 @@ test('If the content type has been set inside an hook it should not be changed', }) fastify.get('/', (request, reply) => { - t.ok(reply[symbols.kReplyHeaders]['content-type']) + t.assert.ok(reply[symbols.kReplyHeaders]['content-type']) reply.send('hello') }) fastify.inject('/', (err, res) => { - t.error(err) - t.equal(res.headers['content-type'], 'text/html') - t.equal(res.statusCode, 200) - t.equal(res.payload, 'hello') + t.assert.ifError(err) + t.assert.strictEqual(res.headers['content-type'], 'text/html') + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload, 'hello') + testDone() }) }) -test('request in onRequest, preParsing, preValidation and onResponse', t => { +test('request in onRequest, preParsing, preValidation and onResponse', (t, testDone) => { t.plan(18) const fastify = Fastify() fastify.addHook('onRequest', function (request, reply, done) { - t.same(request.body, undefined) - t.same(request.query, { key: 'value' }) - t.same(request.params, { greeting: 'hello' }) - t.same(request.headers, { + t.assert.deepStrictEqual(request.body, undefined) + t.assert.deepStrictEqual(request.query.key, 'value') + t.assert.deepStrictEqual(request.params.greeting, 'hello') + t.assert.deepStrictEqual(request.headers, { 'content-length': '17', 'content-type': 'application/json', host: 'localhost:80', @@ -2264,10 +2302,10 @@ test('request in onRequest, preParsing, preValidation and onResponse', t => { }) fastify.addHook('preParsing', function (request, reply, payload, done) { - t.same(request.body, undefined) - t.same(request.query, { key: 'value' }) - t.same(request.params, { greeting: 'hello' }) - t.same(request.headers, { + t.assert.deepStrictEqual(request.body, undefined) + t.assert.deepStrictEqual(request.query.key, 'value') + t.assert.deepStrictEqual(request.params.greeting, 'hello') + t.assert.deepStrictEqual(request.headers, { 'content-length': '17', 'content-type': 'application/json', host: 'localhost:80', @@ -2278,10 +2316,10 @@ test('request in onRequest, preParsing, preValidation and onResponse', t => { }) fastify.addHook('preValidation', function (request, reply, done) { - t.same(request.body, { hello: 'world' }) - t.same(request.query, { key: 'value' }) - t.same(request.params, { greeting: 'hello' }) - t.same(request.headers, { + t.assert.deepStrictEqual(request.body, { hello: 'world' }) + t.assert.deepStrictEqual(request.query.key, 'value') + t.assert.deepStrictEqual(request.params.greeting, 'hello') + t.assert.deepStrictEqual(request.headers, { 'content-length': '17', 'content-type': 'application/json', host: 'localhost:80', @@ -2292,10 +2330,10 @@ test('request in onRequest, preParsing, preValidation and onResponse', t => { }) fastify.addHook('onResponse', function (request, reply, done) { - t.same(request.body, { hello: 'world' }) - t.same(request.query, { key: 'value' }) - t.same(request.params, { greeting: 'hello' }) - t.same(request.headers, { + t.assert.deepStrictEqual(request.body, { hello: 'world' }) + t.assert.deepStrictEqual(request.query.key, 'value') + t.assert.deepStrictEqual(request.params.greeting, 'hello') + t.assert.deepStrictEqual(request.headers, { 'content-length': '17', 'content-type': 'application/json', host: 'localhost:80', @@ -2315,18 +2353,19 @@ test('request in onRequest, preParsing, preValidation and onResponse', t => { headers: { 'x-custom': 'hello' }, payload: { hello: 'world' } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + testDone() }) }) -test('preValidation hook should support encapsulation / 1', t => { +test('preValidation hook should support encapsulation / 1', (t, testDone) => { t.plan(5) const fastify = Fastify() fastify.register((instance, opts, done) => { instance.addHook('preValidation', (req, reply, done) => { - t.equal(req.raw.url, '/plugin') + t.assert.strictEqual(req.raw.url, '/plugin') done() }) @@ -2342,44 +2381,46 @@ test('preValidation hook should support encapsulation / 1', t => { }) fastify.inject('/root', (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - }) - - fastify.inject('/plugin', (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + fastify.inject('/plugin', (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + testDone() + }) }) }) -test('preValidation hook should support encapsulation / 2', t => { +test('preValidation hook should support encapsulation / 2', (t, testDone) => { t.plan(3) const fastify = Fastify() let pluginInstance - fastify.addHook('preValidation', () => {}) + fastify.addHook('preValidation', () => { }) fastify.register((instance, opts, done) => { - instance.addHook('preValidation', () => {}) + instance.addHook('preValidation', () => { }) pluginInstance = instance done() }) fastify.ready(err => { - t.error(err) - t.equal(fastify[symbols.kHooks].preValidation.length, 1) - t.equal(pluginInstance[symbols.kHooks].preValidation.length, 2) + t.assert.ifError(err) + t.assert.strictEqual(fastify[symbols.kHooks].preValidation.length, 1) + t.assert.strictEqual(pluginInstance[symbols.kHooks].preValidation.length, 2) + testDone() }) }) -test('preValidation hook should support encapsulation / 3', t => { - t.plan(20) +test('preValidation hook should support encapsulation / 3', async t => { + t.plan(19) const fastify = Fastify() + t.after(() => { fastify.close() }) fastify.decorate('hello', 'world') fastify.addHook('preValidation', function (req, reply, done) { - t.ok(this.hello) - t.ok(this.hello2) + t.assert.ok(this.hello) + t.assert.ok(this.hello2) req.first = true done() }) @@ -2387,57 +2428,48 @@ test('preValidation hook should support encapsulation / 3', t => { fastify.decorate('hello2', 'world') fastify.get('/first', (req, reply) => { - t.ok(req.first) - t.notOk(req.second) + t.assert.ok(req.first) + t.assert.ok(!req.second) reply.send({ hello: 'world' }) }) fastify.register((instance, opts, done) => { instance.decorate('hello3', 'world') instance.addHook('preValidation', function (req, reply, done) { - t.ok(this.hello) - t.ok(this.hello2) - t.ok(this.hello3) + t.assert.ok(this.hello) + t.assert.ok(this.hello2) + t.assert.ok(this.hello3) req.second = true done() }) instance.get('/second', (req, reply) => { - t.ok(req.first) - t.ok(req.second) + t.assert.ok(req.first) + t.assert.ok(req.second) reply.send({ hello: 'world' }) }) done() }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) + const fastifyServer = await fastify.listen({ port: 0 }) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/first' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) + const result1 = await fetch(fastifyServer + '/first') + t.assert.ok(result1.ok) + t.assert.strictEqual(result1.status, 200) + const body1 = await result1.text() + t.assert.strictEqual(result1.headers.get('content-length'), '' + body1.length) + t.assert.deepStrictEqual(JSON.parse(body1), { hello: 'world' }) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/second' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) - }) + const result2 = await fetch(fastifyServer + '/second') + t.assert.ok(result2.ok) + t.assert.strictEqual(result2.status, 200) + const body2 = await result2.text() + t.assert.strictEqual(result2.headers.get('content-length'), '' + body2.length) + t.assert.deepStrictEqual(JSON.parse(body2), { hello: 'world' }) }) -test('onError hook', t => { +test('onError hook', (t, testDone) => { t.plan(3) const fastify = Fastify() @@ -2445,7 +2477,7 @@ test('onError hook', t => { const err = new Error('kaboom') fastify.addHook('onError', (request, reply, error, done) => { - t.match(error, err) + t.assert.deepStrictEqual(error, err) done() }) @@ -2457,16 +2489,17 @@ test('onError hook', t => { method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { error: 'Internal Server Error', message: 'kaboom', statusCode: 500 }) + testDone() }) }) -test('reply.send should throw if called inside the onError hook', t => { +test('reply.send should throw if called inside the onError hook', (t, testDone) => { t.plan(3) const fastify = Fastify() @@ -2476,9 +2509,9 @@ test('reply.send should throw if called inside the onError hook', t => { fastify.addHook('onError', (request, reply, error, done) => { try { reply.send() - t.fail('Should throw') + t.assert.fail('Should throw') } catch (err) { - t.equal(err.code, 'FST_ERR_SEND_INSIDE_ONERR') + t.assert.strictEqual(err.code, 'FST_ERR_SEND_INSIDE_ONERR') } done() }) @@ -2491,56 +2524,55 @@ test('reply.send should throw if called inside the onError hook', t => { method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { error: 'Internal Server Error', message: 'kaboom', statusCode: 500 }) + testDone() }) }) -test('onError hook with setErrorHandler', t => { - t.test('Send error', t => { - t.plan(3) +test('onError hook with setErrorHandler', (t, testDone) => { + t.plan(3) - const fastify = Fastify() + const fastify = Fastify() - const external = new Error('ouch') - const internal = new Error('kaboom') + const external = new Error('ouch') + const internal = new Error('kaboom') - fastify.setErrorHandler((_, req, reply) => { - reply.send(external) - }) + fastify.setErrorHandler((_, req, reply) => { + reply.send(external) + }) - fastify.addHook('onError', (request, reply, error, done) => { - t.match(error, internal) - done() - }) + fastify.addHook('onError', (request, reply, error, done) => { + t.assert.deepStrictEqual(error, internal) + done() + }) - fastify.get('/', (req, reply) => { - reply.send(internal) - }) + fastify.get('/', (req, reply) => { + reply.send(internal) + }) - fastify.inject({ - method: 'GET', - url: '/' - }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { - error: 'Internal Server Error', - message: 'ouch', - statusCode: 500 - }) + fastify.inject({ + method: 'GET', + url: '/' + }, (err, res) => { + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { + error: 'Internal Server Error', + message: 'ouch', + statusCode: 500 }) + testDone() }) - - t.end() }) -test('preParsing hook should run before parsing and be able to modify the payload', t => { - t.plan(5) +test('preParsing hook should run before parsing and be able to modify the payload', async t => { + t.plan(4) const fastify = Fastify() + t.after(() => { fastify.close() }) fastify.addHook('preParsing', function (req, reply, payload, done) { const modified = new stream.Readable() @@ -2558,27 +2590,24 @@ test('preParsing hook should run before parsing and be able to modify the payloa } }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) + const fastifyServer = await fastify.listen({ port: 0 }) - sget({ - method: 'POST', - url: 'http://localhost:' + fastify.server.address().port + '/first', - body: { hello: 'world' }, - json: true - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + JSON.stringify(body).length) - t.same(body, { hello: 'another world' }) - }) + const result = await fetch(fastifyServer + '/first', { + method: 'POST', + body: JSON.stringify({ hello: 'world' }), + headers: { 'Content-Type': 'application/json' } }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { hello: 'another world' }) }) -test('preParsing hooks should run in the order in which they are defined', t => { - t.plan(5) +test('preParsing hooks should run in the order in which they are defined', async t => { + t.plan(4) const fastify = Fastify() + t.after(() => { fastify.close() }) fastify.addHook('preParsing', function (req, reply, payload, done) { const modified = new stream.Readable() @@ -2601,27 +2630,24 @@ test('preParsing hooks should run in the order in which they are defined', t => } }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) + const fastifyServer = await fastify.listen({ port: 0 }) - sget({ - method: 'POST', - url: 'http://localhost:' + fastify.server.address().port + '/first', - body: { hello: 'world' }, - json: true - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + JSON.stringify(body).length) - t.same(body, { hello: 'another world' }) - }) + const result = await fetch(fastifyServer + '/first', { + method: 'POST', + body: JSON.stringify({ hello: 'world' }), + headers: { 'Content-Type': 'application/json' } }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { hello: 'another world' }) }) -test('preParsing hooks should support encapsulation', t => { - t.plan(9) +test('preParsing hooks should support encapsulation', async t => { + t.plan(8) const fastify = Fastify() + t.after(() => { fastify.close() }) fastify.addHook('preParsing', function (req, reply, payload, done) { const modified = new stream.Readable() @@ -2651,43 +2677,38 @@ test('preParsing hooks should support encapsulation', t => { done() }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) + const fastifyServer = await fastify.listen({ port: 0 }) - sget({ - method: 'POST', - url: 'http://localhost:' + fastify.server.address().port + '/first', - body: { hello: 'world' }, - json: true - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + JSON.stringify(body).length) - t.same(body, { hello: 'another world' }) - }) + const result1 = await fetch(fastifyServer + '/first', { + method: 'POST', + body: JSON.stringify({ hello: 'world' }), + headers: { 'Content-Type': 'application/json' } + }) + t.assert.ok(result1.ok) + t.assert.strictEqual(result1.status, 200) + const body1 = await result1.text() + t.assert.strictEqual(result1.headers.get('content-length'), '' + body1.length) + t.assert.deepStrictEqual(JSON.parse(body1), { hello: 'another world' }) - sget({ - method: 'POST', - url: 'http://localhost:' + fastify.server.address().port + '/second', - body: { hello: 'world' }, - json: true - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + JSON.stringify(body).length) - t.same(body, { hello: 'encapsulated world' }) - }) + const result2 = await fetch(fastifyServer + '/second', { + method: 'POST', + body: JSON.stringify({ hello: 'world' }), + headers: { 'Content-Type': 'application/json' } }) + t.assert.ok(result2.ok) + t.assert.strictEqual(result2.status, 200) + const body2 = await result2.text() + t.assert.strictEqual(result2.headers.get('content-length'), '' + body2.length) + t.assert.deepStrictEqual(JSON.parse(body2), { hello: 'encapsulated world' }) }) -test('preParsing hook should support encapsulation / 1', t => { +test('preParsing hook should support encapsulation / 1', (t, testDone) => { t.plan(5) const fastify = Fastify() fastify.register((instance, opts, done) => { instance.addHook('preParsing', (req, reply, payload, done) => { - t.equal(req.raw.url, '/plugin') + t.assert.strictEqual(req.raw.url, '/plugin') done() }) @@ -2703,44 +2724,46 @@ test('preParsing hook should support encapsulation / 1', t => { }) fastify.inject('/root', (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - }) - - fastify.inject('/plugin', (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + fastify.inject('/plugin', (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + testDone() + }) }) }) -test('preParsing hook should support encapsulation / 2', t => { +test('preParsing hook should support encapsulation / 2', (t, testDone) => { t.plan(3) const fastify = Fastify() let pluginInstance - fastify.addHook('preParsing', function a () {}) + fastify.addHook('preParsing', function a () { }) fastify.register((instance, opts, done) => { - instance.addHook('preParsing', function b () {}) + instance.addHook('preParsing', function b () { }) pluginInstance = instance done() }) fastify.ready(err => { - t.error(err) - t.equal(fastify[symbols.kHooks].preParsing.length, 1) - t.equal(pluginInstance[symbols.kHooks].preParsing.length, 2) + t.assert.ifError(err) + t.assert.strictEqual(fastify[symbols.kHooks].preParsing.length, 1) + t.assert.strictEqual(pluginInstance[symbols.kHooks].preParsing.length, 2) + testDone() }) }) -test('preParsing hook should support encapsulation / 3', t => { - t.plan(20) +test('preParsing hook should support encapsulation / 3', async t => { + t.plan(19) const fastify = Fastify() + t.after(() => { fastify.close() }) fastify.decorate('hello', 'world') fastify.addHook('preParsing', function (req, reply, payload, done) { - t.ok(this.hello) - t.ok(this.hello2) + t.assert.ok(this.hello) + t.assert.ok(this.hello2) req.first = true done() }) @@ -2748,59 +2771,51 @@ test('preParsing hook should support encapsulation / 3', t => { fastify.decorate('hello2', 'world') fastify.get('/first', (req, reply) => { - t.ok(req.first) - t.notOk(req.second) + t.assert.ok(req.first) + t.assert.ok(!req.second) reply.send({ hello: 'world' }) }) fastify.register((instance, opts, done) => { instance.decorate('hello3', 'world') instance.addHook('preParsing', function (req, reply, payload, done) { - t.ok(this.hello) - t.ok(this.hello2) - t.ok(this.hello3) + t.assert.ok(this.hello) + t.assert.ok(this.hello2) + t.assert.ok(this.hello3) req.second = true done() }) instance.get('/second', (req, reply) => { - t.ok(req.first) - t.ok(req.second) + t.assert.ok(req.first) + t.assert.ok(req.second) reply.send({ hello: 'world' }) }) done() }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) + const fastifyServer = await fastify.listen({ port: 0 }) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/first' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) + const result1 = await fetch(fastifyServer + '/first') + t.assert.ok(result1.ok) + t.assert.strictEqual(result1.status, 200) + const body1 = await result1.text() + t.assert.strictEqual(result1.headers.get('content-length'), '' + body1.length) + t.assert.deepStrictEqual(JSON.parse(body1), { hello: 'world' }) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/second' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) - }) + const result2 = await fetch(fastifyServer + '/second') + t.assert.ok(result2.ok) + t.assert.strictEqual(result2.status, 200) + const body2 = await result2.text() + t.assert.strictEqual(result2.headers.get('content-length'), '' + body2.length) + t.assert.deepStrictEqual(JSON.parse(body2), { hello: 'world' }) }) -test('preSerialization hook should run before serialization and be able to modify the payload', t => { - t.plan(5) +test('preSerialization hook should run before serialization and be able to modify the payload', async t => { + t.plan(4) const fastify = Fastify() + t.after(() => { fastify.close() }) fastify.addHook('preSerialization', function (req, reply, payload, done) { payload.hello += '1' @@ -2834,31 +2849,27 @@ test('preSerialization hook should run before serialization and be able to modif } }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) + const fastifyServer = await fastify.listen({ port: 0 }) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/first' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world1', world: 'ok' }) - }) - }) + const result = await fetch(fastifyServer + '/first') + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { hello: 'world1', world: 'ok' }) }) -test('preSerialization hook should be able to throw errors which are validated against schema response', t => { +test('preSerialization hook should be able to throw errors which are validated against schema response', async t => { + t.plan(5) const fastify = Fastify() + t.after(() => { fastify.close() }) fastify.addHook('preSerialization', function (req, reply, payload, done) { done(new Error('preSerialization aborted')) }) fastify.setErrorHandler((err, request, reply) => { - t.equal(err.message, 'preSerialization aborted') + t.assert.strictEqual(err.message, 'preSerialization aborted') err.world = 'error' reply.send(err) }) @@ -2885,33 +2896,27 @@ test('preSerialization hook should be able to throw errors which are validated a } }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) + const fastifyServer = await fastify.listen({ port: 0 }) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/first' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 500) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { world: 'error' }) - t.end() - }) - }) + const result = await fetch(fastifyServer + '/first') + t.assert.ok(!result.ok) + t.assert.strictEqual(result.status, 500) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { world: 'error' }) }) -test('preSerialization hook which returned error should still run onError hooks', t => { - t.plan(4) +test('preSerialization hook which returned error should still run onError hooks', async t => { + t.plan(3) const fastify = Fastify() + t.after(() => { fastify.close() }) fastify.addHook('preSerialization', function (req, reply, payload, done) { done(new Error('preSerialization aborted')) }) fastify.addHook('onError', function (req, reply, payload, done) { - t.pass() + t.assert.ok('should pass') done() }) @@ -2919,23 +2924,17 @@ test('preSerialization hook which returned error should still run onError hooks' reply.send({ hello: 'world' }) }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) + const fastifyServer = await fastify.listen({ port: 0 }) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/first' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 500) - }) - }) + const result = await fetch(fastifyServer + '/first') + t.assert.ok(!result.ok) + t.assert.strictEqual(result.status, 500) }) -test('preSerialization hooks should run in the order in which they are defined', t => { - t.plan(5) +test('preSerialization hooks should run in the order in which they are defined', async t => { + t.plan(4) const fastify = Fastify() + t.after(() => { fastify.close() }) fastify.addHook('preSerialization', function (req, reply, payload, done) { payload.hello += '2' @@ -2950,28 +2949,23 @@ test('preSerialization hooks should run in the order in which they are defined', }) fastify.get('/first', (req, reply) => { - reply.send(payload) + reply.send({ hello: 'world' }) }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) + const fastifyServer = await fastify.listen({ port: 0 }) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/first' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world21' }) - }) - }) + const result = await fetch(fastifyServer + '/first') + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { hello: 'world21' }) }) -test('preSerialization hooks should support encapsulation', t => { - t.plan(9) +test('preSerialization hooks should support encapsulation', async t => { + t.plan(8) const fastify = Fastify() + t.after(() => { fastify.close() }) fastify.addHook('preSerialization', function (req, reply, payload, done) { payload.hello += '1' @@ -2997,40 +2991,32 @@ test('preSerialization hooks should support encapsulation', t => { done() }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) + const fastifyServer = await fastify.listen({ port: 0 }) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/first' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world1' }) - }) + const result1 = await fetch(fastifyServer + '/first') + t.assert.ok(result1.ok) + t.assert.strictEqual(result1.status, 200) + const body1 = await result1.text() + t.assert.strictEqual(result1.headers.get('content-length'), '' + body1.length) + t.assert.deepStrictEqual(JSON.parse(body1), { hello: 'world1' }) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/second' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world12' }) - }) - }) + const result2 = await fetch(fastifyServer + '/second') + t.assert.ok(result2.ok) + t.assert.strictEqual(result2.status, 200) + const body2 = await result2.text() + t.assert.strictEqual(result2.headers.get('content-length'), '' + body2.length) + t.assert.deepStrictEqual(JSON.parse(body2), { hello: 'world12' }) }) -test('onRegister hook should be called / 1', t => { - t.plan(3) +test('onRegister hook should be called / 1', (t, testDone) => { + t.plan(5) const fastify = Fastify() - fastify.addHook('onRegister', (instance, opts) => { - // duck typing for the win! - t.ok(instance.addHook) - t.same(opts, pluginOpts) + fastify.addHook('onRegister', function (instance, opts, done) { + t.assert.ok(this.addHook) + t.assert.ok(instance.addHook) + t.assert.deepStrictEqual(opts, pluginOpts) + t.assert.ok(!done) }) const pluginOpts = { prefix: 'hello', custom: 'world' } @@ -3038,16 +3024,19 @@ test('onRegister hook should be called / 1', t => { done() }, pluginOpts) - fastify.ready(err => { t.error(err) }) + fastify.ready(err => { + t.assert.ifError(err) + testDone() + }) }) -test('onRegister hook should be called / 2', t => { - t.plan(4) +test('onRegister hook should be called / 2', (t, testDone) => { + t.plan(7) const fastify = Fastify() - fastify.addHook('onRegister', instance => { - // duck typing for the win! - t.ok(instance.addHook) + fastify.addHook('onRegister', function (instance) { + t.assert.ok(this.addHook) + t.assert.ok(instance.addHook) }) fastify.register((instance, opts, done) => { @@ -3062,11 +3051,12 @@ test('onRegister hook should be called / 2', t => { }) fastify.ready(err => { - t.error(err) + t.assert.ifError(err) + testDone() }) }) -test('onRegister hook should be called / 3', t => { +test('onRegister hook should be called / 3', (t, testDone) => { t.plan(4) const fastify = Fastify() @@ -3080,24 +3070,25 @@ test('onRegister hook should be called / 3', t => { instance.data.push(1) instance.register((instance, opts, done) => { instance.data.push(2) - t.same(instance.data, [1, 2]) + t.assert.deepStrictEqual(instance.data, [1, 2]) done() }) - t.same(instance.data, [1]) + t.assert.deepStrictEqual(instance.data, [1]) done() }) fastify.register((instance, opts, done) => { - t.same(instance.data, []) + t.assert.deepStrictEqual(instance.data, []) done() }) fastify.ready(err => { - t.error(err) + t.assert.ifError(err) + testDone() }) }) -test('onRegister hook should be called (encapsulation)', t => { +test('onRegister hook should be called (encapsulation)', (t, testDone) => { t.plan(1) const fastify = Fastify() @@ -3107,17 +3098,18 @@ test('onRegister hook should be called (encapsulation)', t => { plugin[Symbol.for('skip-override')] = true fastify.addHook('onRegister', (instance, opts) => { - t.fail('This should not be called') + t.assert.fail('This should not be called') }) fastify.register(plugin) fastify.ready(err => { - t.error(err) + t.assert.ifError(err) + testDone() }) }) -test('early termination, onRequest', t => { +test('early termination, onRequest', (t, testDone) => { t.plan(3) const app = Fastify() @@ -3128,17 +3120,18 @@ test('early termination, onRequest', t => { }) app.get('/', (req, reply) => { - t.fail('should not happen') + t.assert.fail('should not happen') }) app.inject('/', function (err, res) { - t.error(err) - t.equal(res.statusCode, 200) - t.equal(res.body.toString(), 'hello world') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.body.toString(), 'hello world') + testDone() }) }) -test('reply.send should throw if undefined error is thrown', t => { +test('reply.send should throw if undefined error is thrown', (t, testDone) => { /* eslint prefer-promise-reject-errors: ["error", {"allowEmptyReject": true}] */ t.plan(3) @@ -3156,20 +3149,19 @@ test('reply.send should throw if undefined error is thrown', t => { method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 500) - t.same(JSON.parse(res.payload), { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 500) + t.assert.deepStrictEqual(JSON.parse(res.payload), { error: 'Internal Server Error', code: 'FST_ERR_SEND_UNDEFINED_ERR', message: 'Undefined error has occurred', statusCode: 500 }) + testDone() }) }) -test('reply.send should throw if undefined error is thrown at preParsing hook', t => { - /* eslint prefer-promise-reject-errors: ["error", {"allowEmptyReject": true}] */ - +test('reply.send should throw if undefined error is thrown at preParsing hook', (t, testDone) => { t.plan(3) const fastify = Fastify() @@ -3185,20 +3177,19 @@ test('reply.send should throw if undefined error is thrown at preParsing hook', method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 500) - t.same(JSON.parse(res.payload), { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 500) + t.assert.deepStrictEqual(JSON.parse(res.payload), { error: 'Internal Server Error', code: 'FST_ERR_SEND_UNDEFINED_ERR', message: 'Undefined error has occurred', statusCode: 500 }) + testDone() }) }) -test('reply.send should throw if undefined error is thrown at onSend hook', t => { - /* eslint prefer-promise-reject-errors: ["error", {"allowEmptyReject": true}] */ - +test('reply.send should throw if undefined error is thrown at onSend hook', (t, testDone) => { t.plan(3) const fastify = Fastify() @@ -3214,23 +3205,25 @@ test('reply.send should throw if undefined error is thrown at onSend hook', t => method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 500) - t.same(JSON.parse(res.payload), { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 500) + t.assert.deepStrictEqual(JSON.parse(res.payload), { error: 'Internal Server Error', code: 'FST_ERR_SEND_UNDEFINED_ERR', message: 'Undefined error has occurred', statusCode: 500 }) + testDone() }) }) -test('onTimeout should be triggered', t => { - t.plan(6) +test('onTimeout should be triggered', async t => { + t.plan(4) const fastify = Fastify({ connectionTimeout: 500 }) + t.after(() => { fastify.close() }) fastify.addHook('onTimeout', function (req, res, done) { - t.ok('called', 'onTimeout') + t.assert.ok('called', 'onTimeout') done() }) @@ -3242,33 +3235,22 @@ test('onTimeout should be triggered', t => { return reply }) - fastify.listen({ port: 0 }, (err, address) => { - t.error(err) - t.teardown(() => fastify.close()) + const fastifyServer = await fastify.listen({ port: 0 }) - sget({ - method: 'GET', - url: address - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - }) - sget({ - method: 'GET', - url: `${address}/timeout` - }, (err, response, body) => { - t.type(err, Error) - t.equal(err.message, 'socket hang up') - }) - }) + const result1 = await fetch(fastifyServer) + t.assert.ok(result1.ok) + t.assert.strictEqual(result1.status, 200) + + await t.assert.rejects(() => fetch(fastifyServer + '/timeout')) }) -test('onTimeout should be triggered and socket _meta is set', t => { - t.plan(6) +test('onTimeout should be triggered and socket _meta is set', async t => { + t.plan(4) const fastify = Fastify({ connectionTimeout: 500 }) + t.after(() => { fastify.close() }) fastify.addHook('onTimeout', function (req, res, done) { - t.ok('called', 'onTimeout') + t.assert.ok('called', 'onTimeout') done() }) @@ -3281,23 +3263,316 @@ test('onTimeout should be triggered and socket _meta is set', t => { return reply }) - fastify.listen({ port: 0 }, (err, address) => { - t.error(err) - t.teardown(() => fastify.close()) + const fastifyServer = await fastify.listen({ port: 0 }) + + const result1 = await fetch(fastifyServer) + t.assert.ok(result1.ok) + t.assert.strictEqual(result1.status, 200) + + try { + await fetch(fastifyServer + '/timeout') + t.fail('Should have thrown an error') + } catch (err) { + t.assert.ok(err instanceof Error) + } +}) - sget({ +test('registering invalid hooks should throw an error', async t => { + t.plan(3) + + const fastify = Fastify() + + t.assert.throws(() => { + fastify.route({ method: 'GET', - url: address - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) + path: '/invalidHook', + onRequest: [undefined], + async handler () { + return 'hello world' + } }) - sget({ + }, { + message: 'onRequest hook should be a function, instead got [object Undefined]' + }) + + t.assert.throws(() => { + fastify.route({ method: 'GET', - url: `${address}/timeout` - }, (err, response, body) => { - t.type(err, Error) - t.equal(err.message, 'socket hang up') + path: '/invalidHook', + onRequest: null, + async handler () { + return 'hello world' + } + }) + }, { message: 'onRequest hook should be a function, instead got [object Null]' }) + + // undefined is ok + fastify.route({ + method: 'GET', + path: '/validhook', + onRequest: undefined, + async handler () { + return 'hello world' + } + }) + + t.assert.throws(() => { + fastify.addHook('onRoute', (routeOptions) => { + routeOptions.onSend = [undefined] + }) + + fastify.get('/', function (request, reply) { + reply.send('hello world') + }) + }, { message: 'onSend hook should be a function, instead got [object Undefined]' }) +}) + +test('onRequestAbort should be triggered', (t, testDone) => { + const fastify = Fastify() + let order = 0 + + t.plan(7) + t.after(() => fastify.close()) + + const completion = waitForCb({ steps: 2 }) + completion.patience.then(testDone) + + fastify.addHook('onRequestAbort', function (req, done) { + t.assert.strictEqual(++order, 1, 'called in hook') + t.assert.ok(req.pendingResolve, 'request has pendingResolve') + req.pendingResolve() + completion.stepIn() + done() + }) + + fastify.addHook('onError', function hook (request, reply, error, done) { + t.assert.fail('onError should not be called') + done() + }) + + fastify.addHook('onSend', function hook (request, reply, payload, done) { + t.assert.strictEqual(payload, '{"hello":"world"}', 'onSend should be called') + done(null, payload) + }) + + fastify.addHook('onResponse', function hook (request, reply, done) { + t.assert.fail('onResponse should not be called') + done() + }) + + fastify.route({ + method: 'GET', + path: '/', + async handler (request, reply) { + t.assert.ok('handler called') + let resolvePromise + const promise = new Promise(resolve => { resolvePromise = resolve }) + request.pendingResolve = resolvePromise + await promise + t.assert.ok('handler promise resolved') + return { hello: 'world' } + }, + async onRequestAbort (req) { + t.assert.strictEqual(++order, 2, 'called in route') + completion.stepIn() + } + }) + + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + + const socket = connect(fastify.server.address().port) + + socket.write('GET / HTTP/1.1\r\nHost: fastify.test\r\n\r\n') + + sleep(500).then(() => socket.destroy()) + }) +}) + +test('onRequestAbort should support encapsulation', (t, testDone) => { + const fastify = Fastify() + let order = 0 + let child + + t.plan(6) + t.after(() => fastify.close()) + + const completion = waitForCb({ steps: 2 }) + completion.patience.then(testDone) + + fastify.addHook('onRequestAbort', function (req, done) { + t.assert.strictEqual(++order, 1, 'called in root') + t.assert.deepStrictEqual(this.pluginName, child.pluginName) + completion.stepIn() + done() + }) + + fastify.register(async function (_child, _) { + child = _child + + fastify.addHook('onRequestAbort', async function (req) { + t.assert.strictEqual(++order, 2, 'called in child') + t.assert.deepStrictEqual(this.pluginName, child.pluginName) + completion.stepIn() }) + + child.route({ + method: 'GET', + path: '/', + async handler (request, reply) { + await sleep(1000) + return { hello: 'world' } + }, + async onRequestAbort (_req) { + t.assert.strictEqual(++order, 3, 'called in route') + } + }) + }) + + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + + const socket = connect(fastify.server.address().port) + + socket.write('GET / HTTP/1.1\r\nHost: fastify.test\r\n\r\n') + + sleep(500).then(() => socket.destroy()) + }) +}) + +test('onRequestAbort should handle errors / 1', (t, testDone) => { + const fastify = Fastify() + + t.plan(2) + t.after(() => fastify.close()) + + fastify.addHook('onRequestAbort', function (req, done) { + process.nextTick(() => { + t.assert.ok('should pass') + testDone() + }) + done(new Error('KABOOM!')) + }) + + fastify.route({ + method: 'GET', + path: '/', + async handler (request, reply) { + await sleep(1000) + return { hello: 'world' } + } + }) + + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + + const socket = connect(fastify.server.address().port) + + socket.write('GET / HTTP/1.1\r\nHost: fastify.test\r\n\r\n') + + sleep(500).then(() => socket.destroy()) + }) +}) + +test('onRequestAbort should handle errors / 2', (t, testDone) => { + const fastify = Fastify() + + t.plan(2) + t.after(() => fastify.close()) + + fastify.addHook('onRequestAbort', function (req, done) { + process.nextTick(() => { + t.assert.ok('should pass') + testDone() + }) + throw new Error('KABOOM!') + }) + + fastify.route({ + method: 'GET', + path: '/', + async handler (request, reply) { + await sleep(1000) + return { hello: 'world' } + } + }) + + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + + const socket = connect(fastify.server.address().port) + + socket.write('GET / HTTP/1.1\r\nHost: fastify.test\r\n\r\n') + + sleep(500).then(() => socket.destroy()) + }) +}) + +test('onRequestAbort should handle async errors / 1', (t, testDone) => { + const fastify = Fastify() + + t.plan(2) + t.after(() => fastify.close()) + + fastify.addHook('onRequestAbort', async function (req) { + process.nextTick(() => { + t.assert.ok('should pass') + testDone() + }) + throw new Error('KABOOM!') + }) + + fastify.route({ + method: 'GET', + path: '/', + async handler (request, reply) { + await sleep(1000) + return { hello: 'world' } + } + }) + + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + + const socket = connect(fastify.server.address().port) + + socket.write('GET / HTTP/1.1\r\nHost: fastify.test\r\n\r\n') + + sleep(500).then(() => socket.destroy()) + }) +}) + +test('onRequestAbort should handle async errors / 2', (t, testDone) => { + const fastify = Fastify() + + t.plan(2) + t.after(() => fastify.close()) + + fastify.addHook('onRequestAbort', async function (req) { + process.nextTick(() => { + t.assert.ok('should pass') + testDone() + }) + + return Promise.reject() + }) + + fastify.route({ + method: 'GET', + path: '/', + async handler (request, reply) { + await sleep(1000) + return { hello: 'world' } + } + }) + + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + + const socket = connect(fastify.server.address().port) + + socket.write('GET / HTTP/1.1\r\nHost: fastify.test\r\n\r\n') + + sleep(500).then(() => socket.destroy()) }) }) diff --git a/test/http-methods/copy.test.js b/test/http-methods/copy.test.js new file mode 100644 index 00000000000..15a4fc807ef --- /dev/null +++ b/test/http-methods/copy.test.js @@ -0,0 +1,35 @@ +'use strict' + +const { test } = require('node:test') +const fastify = require('../../fastify')() +fastify.addHttpMethod('COPY') + +test('can be created - copy', async t => { + t.plan(3) + + t.after(() => fastify.close()) + + try { + fastify.route({ + method: 'COPY', + url: '*', + handler: function (req, reply) { + reply.code(204).send() + } + }) + t.assert.ok(true) + } catch (e) { + t.assert.fail() + } + + const fastifyServer = await fastify.listen({ port: 0 }) + + const result = await fetch(`${fastifyServer}/test.txt`, { + method: 'COPY', + headers: { + Destination: '/test2.txt' + } + }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 204) +}) diff --git a/test/http-methods/custom-http-methods.test.js b/test/http-methods/custom-http-methods.test.js new file mode 100644 index 00000000000..61feed4e318 --- /dev/null +++ b/test/http-methods/custom-http-methods.test.js @@ -0,0 +1,114 @@ +'use strict' + +const http = require('node:http') +const { test } = require('node:test') +const Fastify = require('../../fastify') + +function addEcho (fastify, method) { + fastify.route({ + method, + url: '/', + handler: function (req, reply) { + reply.send(req.body) + } + }) +} + +test('missing method from http client', (t, done) => { + t.plan(2) + const fastify = Fastify() + + fastify.listen({ port: 3000 }, (err) => { + t.assert.ifError(err) + + const port = fastify.server.address().port + const req = http.request({ + port, + method: 'REBIND', + path: '/' + }, (res) => { + t.assert.strictEqual(res.statusCode, 404) + fastify.close() + done() + }) + + req.end() + }) +}) + +test('addHttpMethod increase the supported HTTP methods supported', (t, done) => { + t.plan(8) + const app = Fastify() + + t.assert.throws(() => { addEcho(app, 'REBIND') }, /REBIND method is not supported./) + t.assert.ok(!app.supportedMethods.includes('REBIND')) + t.assert.ok(!app.rebind) + + app.addHttpMethod('REBIND') + t.assert.doesNotThrow(() => { addEcho(app, 'REBIND') }, 'REBIND method is supported.') + t.assert.ok(app.supportedMethods.includes('REBIND')) + t.assert.ok(app.rebind) + + app.rebind('/foo', () => 'hello') + + app.inject({ + method: 'REBIND', + url: '/foo' + }, (err, response) => { + t.assert.ifError(err) + t.assert.strictEqual(response.payload, 'hello') + done() + }) +}) + +test('addHttpMethod adds a new custom method without body', t => { + t.plan(3) + const app = Fastify() + + t.assert.throws(() => { addEcho(app, 'REBIND') }, /REBIND method is not supported./) + + app.addHttpMethod('REBIND') + t.assert.doesNotThrow(() => { addEcho(app, 'REBIND') }, 'REBIND method is supported.') + + t.assert.throws(() => { + app.route({ + url: '/', + method: 'REBIND', + schema: { + body: { + type: 'object', + properties: { + hello: { type: 'string' } + } + } + }, + handler: function (req, reply) { + reply.send(req.body) + } + }) + }, /Body validation schema for REBIND:\/ route is not supported!/) +}) + +test('addHttpMethod adds a new custom method with body', (t, done) => { + t.plan(3) + const app = Fastify() + + app.addHttpMethod('REBIND', { hasBody: true }) + t.assert.doesNotThrow(() => { addEcho(app, 'REBIND') }, 'REBIND method is supported.') + + app.inject({ + method: 'REBIND', + url: '/', + payload: { hello: 'world' } + }, (err, response) => { + t.assert.ifError(err) + t.assert.deepStrictEqual(response.json(), { hello: 'world' }) + done() + }) +}) + +test('addHttpMethod rejects fake http method', t => { + t.plan(1) + const fastify = Fastify() + t.assert.throws(() => { fastify.addHttpMethod('FOOO') }, /Provided method is invalid!/) +}) diff --git a/test/http-methods/get.test.js b/test/http-methods/get.test.js new file mode 100644 index 00000000000..8522a083c64 --- /dev/null +++ b/test/http-methods/get.test.js @@ -0,0 +1,412 @@ +'use strict' + +const { test } = require('node:test') +const { Client } = require('undici') +const fastify = require('../../fastify')() + +const schema = { + schema: { + response: { + '2xx': { + type: 'object', + properties: { + hello: { + type: 'string' + } + } + } + } + } +} + +const nullSchema = { + schema: { + response: { + '2xx': { + type: 'null' + } + } + } +} + +const numberSchema = { + schema: { + response: { + '2xx': { + type: 'object', + properties: { + hello: { + type: 'number' + } + } + } + } + } +} + +const querySchema = { + schema: { + querystring: { + type: 'object', + properties: { + hello: { + type: 'integer' + } + } + } + } +} + +const paramsSchema = { + schema: { + params: { + type: 'object', + properties: { + foo: { + type: 'string' + }, + test: { + type: 'integer' + } + } + } + } +} + +const headersSchema = { + schema: { + headers: { + type: 'object', + properties: { + 'x-test': { + type: 'number' + }, + 'Y-Test': { + type: 'number' + } + } + } + } +} + +test('shorthand - get', t => { + t.plan(1) + try { + fastify.get('/', schema, function (req, reply) { + reply.code(200).send({ hello: 'world' }) + }) + t.assert.ok(true) + } catch (e) { + t.assert.fail() + } +}) + +test('shorthand - get (return null)', t => { + t.plan(1) + try { + fastify.get('/null', nullSchema, function (req, reply) { + reply.code(200).send(null) + }) + t.assert.ok(true) + } catch (e) { + t.assert.fail() + } +}) + +test('shorthand - get params', t => { + t.plan(1) + try { + fastify.get('/params/:foo/:test', paramsSchema, function (req, reply) { + reply.code(200).send(req.params) + }) + t.assert.ok(true) + } catch (e) { + t.assert.fail() + } +}) + +test('shorthand - get, querystring schema', t => { + t.plan(1) + try { + fastify.get('/query', querySchema, function (req, reply) { + reply.code(200).send(req.query) + }) + t.assert.ok(true) + } catch (e) { + t.assert.fail() + } +}) + +test('shorthand - get, headers schema', t => { + t.plan(1) + try { + fastify.get('/headers', headersSchema, function (req, reply) { + reply.code(200).send(req.headers) + }) + t.assert.ok(true) + } catch (e) { + t.assert.fail() + } +}) + +test('missing schema - get', t => { + t.plan(1) + try { + fastify.get('/missing', function (req, reply) { + reply.code(200).send({ hello: 'world' }) + }) + t.assert.ok(true) + } catch (e) { + t.assert.fail() + } +}) + +test('custom serializer - get', t => { + t.plan(1) + + function customSerializer (data) { + return JSON.stringify(data) + } + + try { + fastify.get('/custom-serializer', numberSchema, function (req, reply) { + reply.code(200).serializer(customSerializer).send({ hello: 'world' }) + }) + t.assert.ok(true) + } catch (e) { + t.assert.fail() + } +}) + +test('empty response', t => { + t.plan(1) + try { + fastify.get('/empty', function (req, reply) { + reply.code(200).send() + }) + t.assert.ok(true) + } catch (e) { + t.assert.fail() + } +}) + +test('send a falsy boolean', t => { + t.plan(1) + try { + fastify.get('/boolean', function (req, reply) { + reply.code(200).send(false) + }) + t.assert.ok(true) + } catch (e) { + t.assert.fail() + } +}) + +test('shorthand - get, set port', t => { + t.plan(1) + try { + fastify.get('/port', headersSchema, function (req, reply) { + reply.code(200).send({ port: req.port }) + }) + t.assert.ok(true) + } catch (e) { + t.assert.fail() + } +}) + +test('get test', async t => { + t.after(() => { fastify.close() }) + + await fastify.listen({ port: 0 }) + + await t.test('shorthand - request get', async t => { + t.plan(4) + + const response = await fetch('http://localhost:' + fastify.server.address().port, { + method: 'GET' + }) + t.assert.ok(response.ok) + t.assert.strictEqual(response.status, 200) + const body = await response.text() + t.assert.strictEqual(response.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { hello: 'world' }) + }) + + await t.test('shorthand - request get params schema', async t => { + t.plan(4) + + const response = await fetch('http://localhost:' + fastify.server.address().port + '/params/world/123', { + method: 'GET' + }) + t.assert.ok(response.ok) + t.assert.strictEqual(response.status, 200) + const body = await response.text() + t.assert.strictEqual(response.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { foo: 'world', test: 123 }) + }) + + await t.test('shorthand - request get params schema error', async t => { + t.plan(3) + + const response = await fetch('http://localhost:' + fastify.server.address().port + '/params/world/string', { + method: 'GET' + }) + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 400) + const body = await response.text() + t.assert.deepStrictEqual(JSON.parse(body), { + error: 'Bad Request', + code: 'FST_ERR_VALIDATION', + message: 'params/test must be integer', + statusCode: 400 + }) + }) + + await t.test('shorthand - request get headers schema', async t => { + t.plan(4) + + const response = await fetch('http://localhost:' + fastify.server.address().port + '/headers', { + method: 'GET', + headers: { + 'x-test': '1', + 'Y-Test': '3' + } + }) + t.assert.ok(response.ok) + t.assert.strictEqual(response.status, 200) + const body = await response.json() + t.assert.strictEqual(body['x-test'], 1) + t.assert.strictEqual(body['y-test'], 3) + }) + + await t.test('shorthand - request get headers schema error', async t => { + t.plan(3) + + const response = await fetch('http://localhost:' + fastify.server.address().port + '/headers', { + method: 'GET', + headers: { + 'x-test': 'abc' + } + }) + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 400) + const body = await response.text() + t.assert.deepStrictEqual(JSON.parse(body), { + error: 'Bad Request', + code: 'FST_ERR_VALIDATION', + message: 'headers/x-test must be number', + statusCode: 400 + }) + }) + + await t.test('shorthand - request get querystring schema', async t => { + t.plan(4) + + const response = await fetch('http://localhost:' + fastify.server.address().port + '/query?hello=123', { + method: 'GET' + }) + t.assert.ok(response.ok) + t.assert.strictEqual(response.status, 200) + const body = await response.text() + t.assert.strictEqual(response.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { hello: 123 }) + }) + + await t.test('shorthand - request get querystring schema error', async t => { + t.plan(3) + + const response = await fetch('http://localhost:' + fastify.server.address().port + '/query?hello=world', { + method: 'GET' + }) + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 400) + const body = await response.text() + t.assert.deepStrictEqual(JSON.parse(body), { + error: 'Bad Request', + code: 'FST_ERR_VALIDATION', + message: 'querystring/hello must be integer', + statusCode: 400 + }) + }) + + await t.test('shorthand - request get missing schema', async t => { + t.plan(4) + + const response = await fetch('http://localhost:' + fastify.server.address().port + '/missing', { + method: 'GET' + }) + t.assert.ok(response.ok) + t.assert.strictEqual(response.status, 200) + const body = await response.text() + t.assert.strictEqual(response.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { hello: 'world' }) + }) + + await t.test('shorthand - custom serializer', async t => { + t.plan(4) + + const response = await fetch('http://localhost:' + fastify.server.address().port + '/custom-serializer', { + method: 'GET' + }) + t.assert.ok(response.ok) + t.assert.strictEqual(response.status, 200) + const body = await response.text() + t.assert.strictEqual(response.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { hello: 'world' }) + }) + + await t.test('shorthand - empty response', async t => { + t.plan(4) + + const response = await fetch('http://localhost:' + fastify.server.address().port + '/empty', { + method: 'GET' + }) + t.assert.ok(response.ok) + t.assert.strictEqual(response.status, 200) + const body = await response.text() + t.assert.strictEqual(response.headers.get('content-length'), '0') + t.assert.deepStrictEqual(body.toString(), '') + }) + + await t.test('shorthand - send a falsy boolean', async t => { + t.plan(3) + + const response = await fetch('http://localhost:' + fastify.server.address().port + '/boolean', { + method: 'GET' + }) + t.assert.ok(response.ok) + t.assert.strictEqual(response.status, 200) + const body = await response.text() + t.assert.deepStrictEqual(body.toString(), 'false') + }) + + await t.test('shorthand - send null value', async t => { + t.plan(3) + + const response = await fetch('http://localhost:' + fastify.server.address().port + '/null', { + method: 'GET' + }) + t.assert.ok(response.ok) + t.assert.strictEqual(response.status, 200) + const body = await response.text() + t.assert.deepStrictEqual(body.toString(), 'null') + }) + + await t.test('shorthand - request get headers - test fall back port', async t => { + t.plan(2) + + const instance = new Client('http://localhost:' + fastify.server.address().port) + + const response = await instance.request({ + path: '/port', + method: 'GET', + headers: { + host: 'fastify.test' + } + }) + + t.assert.strictEqual(response.statusCode, 200) + const body = JSON.parse(await response.body.text()) + t.assert.strictEqual(body.port, null) + }) +}) diff --git a/test/http-methods/head.test.js b/test/http-methods/head.test.js new file mode 100644 index 00000000000..fe68694ef37 --- /dev/null +++ b/test/http-methods/head.test.js @@ -0,0 +1,263 @@ +'use strict' + +const { test } = require('node:test') +const fastify = require('../../fastify')() + +const schema = { + schema: { + response: { + '2xx': { + type: 'null' + } + } + } +} + +const querySchema = { + schema: { + querystring: { + type: 'object', + properties: { + hello: { + type: 'integer' + } + } + } + } +} + +const paramsSchema = { + schema: { + params: { + type: 'object', + properties: { + foo: { + type: 'string' + }, + test: { + type: 'integer' + } + } + } + } +} + +test('shorthand - head', t => { + t.plan(1) + try { + fastify.head('/', schema, function (req, reply) { + reply.code(200).send(null) + }) + t.assert.ok(true) + } catch (e) { + t.assert.fail() + } +}) + +test('shorthand - custom head', t => { + t.plan(1) + try { + fastify.head('/proxy/*', function (req, reply) { + reply.headers({ 'x-foo': 'bar' }) + reply.code(200).send(null) + }) + + fastify.get('/proxy/*', function (req, reply) { + reply.code(200).send(null) + }) + + t.assert.ok(true) + } catch (e) { + t.assert.fail() + } +}) + +test('shorthand - custom head with constraints', t => { + t.plan(1) + try { + fastify.head('/proxy/*', { constraints: { version: '1.0.0' } }, function (req, reply) { + reply.headers({ 'x-foo': 'bar' }) + reply.code(200).send(null) + }) + + fastify.get('/proxy/*', { constraints: { version: '1.0.0' } }, function (req, reply) { + reply.code(200).send(null) + }) + + t.assert.ok(true) + } catch (e) { + t.assert.fail() + } +}) + +test('shorthand - should not reset a head route', t => { + t.plan(1) + try { + fastify.get('/query1', function (req, reply) { + reply.code(200).send(null) + }) + + fastify.put('/query1', function (req, reply) { + reply.code(200).send(null) + }) + + t.assert.ok(true) + } catch (e) { + t.assert.fail() + } +}) + +test('shorthand - should set get and head route in the same api call', t => { + t.plan(1) + try { + fastify.route({ + method: ['HEAD', 'GET'], + url: '/query4', + handler: function (req, reply) { + reply.headers({ 'x-foo': 'bar' }) + reply.code(200).send(null) + } + }) + + t.assert.ok(true) + } catch (e) { + t.assert.fail() + } +}) + +test('shorthand - head params', t => { + t.plan(1) + try { + fastify.head('/params/:foo/:test', paramsSchema, function (req, reply) { + reply.send(null) + }) + t.assert.ok(true) + } catch (e) { + t.assert.fail() + } +}) + +test('shorthand - head, querystring schema', t => { + t.plan(1) + try { + fastify.head('/query', querySchema, function (req, reply) { + reply.code(200).send(null) + }) + t.assert.ok(true) + } catch (e) { + t.assert.fail() + } +}) + +test('missing schema - head', t => { + t.plan(1) + try { + fastify.head('/missing', function (req, reply) { + reply.code(200).send(null) + }) + t.assert.ok(true) + } catch (e) { + t.assert.fail() + } +}) + +test('head test', async t => { + t.after(() => { fastify.close() }) + const fastifyServer = await fastify.listen({ port: 0 }) + + await t.test('shorthand - request head', async t => { + t.plan(2) + const result = await fetch(fastifyServer, { + method: 'HEAD' + }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + }) + + await t.test('shorthand - request head params schema', async t => { + t.plan(2) + const result = await fetch(`${fastifyServer}/params/world/123`, { + method: 'HEAD' + }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + }) + + await t.test('shorthand - request head params schema error', async t => { + t.plan(2) + const result = await fetch(`${fastifyServer}/params/world/string`, { + method: 'HEAD' + }) + t.assert.ok(!result.ok) + t.assert.strictEqual(result.status, 400) + }) + + await t.test('shorthand - request head querystring schema', async t => { + t.plan(2) + const result = await fetch(`${fastifyServer}/query?hello=123`, { + method: 'HEAD' + }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + }) + + await t.test('shorthand - request head querystring schema error', async t => { + t.plan(2) + const result = await fetch(`${fastifyServer}/query?hello=world`, { + method: 'HEAD' + }) + t.assert.ok(!result.ok) + t.assert.strictEqual(result.status, 400) + }) + + await t.test('shorthand - request head missing schema', async t => { + t.plan(2) + const result = await fetch(`${fastifyServer}/missing`, { + method: 'HEAD' + }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + }) + + await t.test('shorthand - request head custom head', async t => { + t.plan(3) + const result = await fetch(`${fastifyServer}/proxy/test`, { + method: 'HEAD' + }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.headers.get('x-foo'), 'bar') + t.assert.strictEqual(result.status, 200) + }) + + await t.test('shorthand - request head custom head with constraints', async t => { + t.plan(3) + const result = await fetch(`${fastifyServer}/proxy/test`, { + method: 'HEAD', + headers: { + version: '1.0.0' + } + }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.headers.get('x-foo'), 'bar') + t.assert.strictEqual(result.status, 200) + }) + + await t.test('shorthand - should not reset a head route', async t => { + t.plan(2) + const result = await fetch(`${fastifyServer}/query1`, { + method: 'HEAD' + }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + }) + + await t.test('shorthand - should set get and head route in the same api call', async t => { + t.plan(3) + const result = await fetch(`${fastifyServer}/query4`, { + method: 'HEAD' + }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.headers.get('x-foo'), 'bar') + t.assert.strictEqual(result.status, 200) + }) +}) diff --git a/test/http-methods/lock.test.js b/test/http-methods/lock.test.js new file mode 100644 index 00000000000..6df050207fa --- /dev/null +++ b/test/http-methods/lock.test.js @@ -0,0 +1,108 @@ +'use strict' + +const { test } = require('node:test') +const fastify = require('../../fastify')() +fastify.addHttpMethod('LOCK', { hasBody: true }) + +const bodySample = ` + + + + + http://fastify.test/~ejw/contact.html + + ` + +test('can be created - lock', t => { + t.plan(1) + try { + fastify.route({ + method: 'LOCK', + url: '*', + handler: function (req, reply) { + reply + .code(200) + .send(` + + + + + + + + + + infinity + + http://fastify.test/~ejw/contact.html + + Second-604800 + + urn:uuid:e71d4fae-5dec-22d6-fea5-00a0c91e6be4 + + + http://fastify.test/workspace/webdav/proposal.oc + + + + ` + ) + } + }) + t.assert.ok(true) + } catch (e) { + t.assert.fail() + } +}) + +test('lock test', async t => { + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => { + fastify.close() + }) + // the body test uses a text/plain content type instead of application/xml because it requires + // a specific content type parser + await t.test('request with body - lock', async (t) => { + t.plan(3) + + const result = await fetch(`${fastifyServer}/test/a.txt`, { + method: 'LOCK', + headers: { 'content-type': 'text/plain' }, + body: bodySample + }) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + }) + + await t.test('request with body and no content type (415 error) - lock', async (t) => { + t.plan(3) + + const result = await fetch(`${fastifyServer}/test/a.txt`, { + method: 'LOCK', + body: bodySample, + headers: { 'content-type': undefined } + }) + + t.assert.ok(!result.ok) + t.assert.strictEqual(result.status, 415) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + }) + + await t.test('request without body - lock', async (t) => { + t.plan(3) + + const result = await fetch(`${fastifyServer}/test/a.txt`, { + method: 'LOCK', + headers: { 'content-type': 'text/plain' } + }) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + }) +}) diff --git a/test/http-methods/mkcalendar.test.js b/test/http-methods/mkcalendar.test.js new file mode 100644 index 00000000000..d2221884432 --- /dev/null +++ b/test/http-methods/mkcalendar.test.js @@ -0,0 +1,143 @@ +'use strict' + +const { test } = require('node:test') +const fastify = require('../../fastify')() +fastify.addHttpMethod('MKCALENDAR', { hasBody: true }) + +const bodySample = ` + + + + + + + 0 + + + + CALENDAR_NAME + BEGIN:VCALENDAR + VERSION:2.0 + + + + + ` + +test('can be created - mkcalendar', (t) => { + t.plan(1) + try { + fastify.route({ + method: 'MKCALENDAR', + url: '*', + handler: function (req, reply) { + return reply.code(207).send(` + + + / + + + + + + 2022-04-13T12:35:30Z + Wed, 13 Apr 2022 12:35:30 GMT + "e0-5dc8869b53ef1" + + + + + + + + + + + + + + + + + + + + httpd/unix-directory + + HTTP/1.1 200 OK + + + `) + } + }) + t.assert.ok(true) + } catch (e) { + t.assert.fail() + } +}) + +test('mkcalendar test', async t => { + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => { + fastify.close() + }) + + await t.test('request - mkcalendar', async t => { + t.plan(2) + const result = await fetch(`${fastifyServer}/`, { + method: 'MKCALENDAR' + }) + t.assert.strictEqual(result.status, 207) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + }) + + await t.test('request with other path - mkcalendar', async t => { + t.plan(2) + const result = await fetch(`${fastifyServer}/test`, { + method: 'MKCALENDAR' + }) + t.assert.strictEqual(result.status, 207) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + }) + + // the body test uses a text/plain content type instead of application/xml because it requires + // a specific content type parser + await t.test('request with body - mkcalendar', async t => { + t.plan(3) + const result = await fetch(`${fastifyServer}/test`, { + method: 'MKCALENDAR', + headers: { 'content-type': 'text/plain' }, + body: bodySample + }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 207) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + }) + + await t.test('request with body and no content type (415 error) - mkcalendar', async t => { + t.plan(3) + const result = await fetch(`${fastifyServer}/test`, { + method: 'MKCALENDAR', + body: bodySample, + headers: { 'content-type': undefined } + }) + t.assert.ok(!result.ok) + t.assert.strictEqual(result.status, 415) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + }) + + await t.test('request without body - mkcalendar', async t => { + t.plan(3) + const result = await fetch(`${fastifyServer}/test`, { + method: 'MKCALENDAR' + }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 207) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + }) +}) diff --git a/test/http-methods/mkcol.test.js b/test/http-methods/mkcol.test.js new file mode 100644 index 00000000000..3ef662a938f --- /dev/null +++ b/test/http-methods/mkcol.test.js @@ -0,0 +1,35 @@ +'use strict' + +const { test } = require('node:test') +const fastify = require('../../')() +fastify.addHttpMethod('MKCOL') + +test('can be created - mkcol', t => { + t.plan(1) + try { + fastify.route({ + method: 'MKCOL', + url: '*', + handler: function (req, reply) { + reply.code(201).send() + } + }) + t.assert.ok(true) + } catch (e) { + t.assert.fail() + } +}) + +test('mkcol test', async t => { + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) + + await t.test('request - mkcol', async t => { + t.plan(2) + const result = await fetch(`${fastifyServer}/test/`, { + method: 'MKCOL' + }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 201) + }) +}) diff --git a/test/http-methods/move.test.js b/test/http-methods/move.test.js new file mode 100644 index 00000000000..b1a566b49d7 --- /dev/null +++ b/test/http-methods/move.test.js @@ -0,0 +1,42 @@ +'use strict' + +const { test } = require('node:test') +const fastify = require('../../')() +fastify.addHttpMethod('MOVE') + +test('shorthand - move', t => { + t.plan(1) + try { + fastify.route({ + method: 'MOVE', + url: '*', + handler: function (req, reply) { + const destination = req.headers.destination + reply.code(201) + .header('location', destination) + .send() + } + }) + t.assert.ok(true) + } catch (e) { + t.assert.fail() + } +}) +test('move test', async t => { + const fastifyServer = await fastify.listen({ port: 0 }) + + t.after(() => { fastify.close() }) + + await t.test('request - move', async t => { + t.plan(3) + const result = await fetch(`${fastifyServer}/test.txt`, { + method: 'MOVE', + headers: { + Destination: '/test2.txt' + } + }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 201) + t.assert.strictEqual(result.headers.get('location'), '/test2.txt') + }) +}) diff --git a/test/http-methods/propfind.test.js b/test/http-methods/propfind.test.js new file mode 100644 index 00000000000..1d6dd22ec8c --- /dev/null +++ b/test/http-methods/propfind.test.js @@ -0,0 +1,136 @@ +'use strict' + +const { test } = require('node:test') +const fastify = require('../../')() +fastify.addHttpMethod('PROPFIND', { hasBody: true }) + +const bodySample = ` + + + + + + ` + +test('can be created - propfind', t => { + t.plan(1) + try { + fastify.route({ + method: 'PROPFIND', + url: '*', + handler: function (req, reply) { + return reply.code(207) + .send(` + + + / + + + + + + 2022-04-13T12:35:30Z + Wed, 13 Apr 2022 12:35:30 GMT + "e0-5dc8869b53ef1" + + + + + + + + + + + + + + + + + + + + httpd/unix-directory + + HTTP/1.1 200 OK + + + ` + ) + } + }) + t.assert.ok(true) + } catch (e) { + t.assert.fail() + } +}) + +test('propfind test', async t => { + await fastify.listen({ port: 0 }) + + t.after(() => { + fastify.close() + }) + + await t.test('request - propfind', async t => { + t.plan(3) + const result = await fetch(`http://localhost:${fastify.server.address().port}/`, { + method: 'PROPFIND' + }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 207) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + }) + + await t.test('request with other path - propfind', async t => { + t.plan(3) + const result = await fetch(`http://localhost:${fastify.server.address().port}/test`, { + method: 'PROPFIND' + }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 207) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + }) + + // the body test uses a text/plain content type instead of application/xml because it requires + // a specific content type parser + await t.test('request with body - propfind', async t => { + t.plan(3) + const result = await fetch(`http://localhost:${fastify.server.address().port}/test`, { + method: 'PROPFIND', + headers: { 'content-type': 'text/plain' }, + body: bodySample + }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 207) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + }) + + await t.test('request with body and no content type (415 error) - propfind', async t => { + t.plan(3) + const result = await fetch(`http://localhost:${fastify.server.address().port}/test`, { + method: 'PROPFIND', + body: bodySample, + headers: { 'content-type': '' } + }) + t.assert.ok(!result.ok) + t.assert.strictEqual(result.status, 415) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + }) + + await t.test('request without body - propfind', async t => { + t.plan(3) + const result = await fetch(`http://localhost:${fastify.server.address().port}/test`, { + method: 'PROPFIND' + }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 207) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + }) +}) diff --git a/test/http-methods/proppatch.test.js b/test/http-methods/proppatch.test.js new file mode 100644 index 00000000000..c2ec2180b7d --- /dev/null +++ b/test/http-methods/proppatch.test.js @@ -0,0 +1,105 @@ +'use strict' + +const { test } = require('node:test') +const fastify = require('../../')() +fastify.addHttpMethod('PROPPATCH', { hasBody: true }) + +const bodySample = ` + + + + + Jim Whitehead + Roy Fielding + + + + + + + + + ` + +test('shorthand - proppatch', t => { + t.plan(1) + try { + fastify.route({ + method: 'PROPPATCH', + url: '*', + handler: function (req, reply) { + reply + .code(207) + .send(` + + + http://www.fastify.test/bar.html + + + + + HTTP/1.1 424 Failed Dependency + + + + + + HTTP/1.1 409 Conflict + + Copyright Owner cannot be deleted or altered. + + ` + ) + } + }) + t.assert.ok(true) + } catch (e) { + t.assert.fail() + } +}) + +test('proppatch test', async t => { + const fastifyServer = await fastify.listen({ port: 0 }) + + t.after(() => { fastify.close() }) + // the body test uses a text/plain content type instead of application/xml because it requires + // a specific content type parser + await t.test('request with body - proppatch', async t => { + t.plan(3) + const result = await fetch(`${fastifyServer}/test/a.txt`, { + method: 'PROPPATCH', + headers: { 'content-type': 'text/plain' }, + body: bodySample + }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 207) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + }) + + await t.test('request with body and no content type (415 error) - proppatch', async t => { + t.plan(3) + const result = await fetch(`${fastifyServer}/test/a.txt`, { + method: 'PROPPATCH', + body: bodySample, + headers: { 'content-type': undefined } + }) + t.assert.ok(!result.ok) + t.assert.strictEqual(result.status, 415) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + }) + + await t.test('request without body - proppatch', async t => { + t.plan(3) + const result = await fetch(`${fastifyServer}/test/a.txt`, { + method: 'PROPPATCH' + }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 207) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + }) +}) diff --git a/test/http-methods/report.test.js b/test/http-methods/report.test.js new file mode 100644 index 00000000000..c8533fb2948 --- /dev/null +++ b/test/http-methods/report.test.js @@ -0,0 +1,142 @@ +'use strict' + +const { test } = require('node:test') +const fastify = require('../../fastify')() +fastify.addHttpMethod('REPORT', { hasBody: true }) + +const bodySample = ` + + + + + + + + + + + + + + ` + +test('can be created - report', (t) => { + t.plan(1) + try { + fastify.route({ + method: 'REPORT', + url: '*', + handler: function (req, reply) { + return reply.code(207).send(` + + + / + + + + + + 2022-04-13T12:35:30Z + Wed, 13 Apr 2022 12:35:30 GMT + "e0-5dc8869b53ef1" + + + + + + + + + + + + + + + + + + + + httpd/unix-directory + + HTTP/1.1 200 OK + + + `) + } + }) + t.assert.ok(true) + } catch (e) { + t.assert.fail() + } +}) + +test('report test', async t => { + await fastify.listen({ port: 0 }) + + t.after(() => { + fastify.close() + }) + + await t.test('request - report', async (t) => { + t.plan(3) + const result = await fetch(`http://localhost:${fastify.server.address().port}/`, { + method: 'REPORT' + }) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 207) + t.assert.strictEqual(result.headers.get('content-length'), '' + (await result.text()).length) + }) + + await t.test('request with other path - report', async (t) => { + t.plan(3) + const result = await fetch(`http://localhost:${fastify.server.address().port}/test`, { + method: 'REPORT' + }) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 207) + t.assert.strictEqual(result.headers.get('content-length'), '' + (await result.text()).length) + }) + + // the body test uses a text/plain content type instead of application/xml because it requires + // a specific content type parser + await t.test('request with body - report', async (t) => { + t.plan(3) + const result = await fetch(`http://localhost:${fastify.server.address().port}/test`, { + method: 'REPORT', + headers: { 'content-type': 'text/plain' }, + body: bodySample + }) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 207) + t.assert.strictEqual(result.headers.get('content-length'), '' + (await result.text()).length) + }) + + await t.test('request with body and no content type (415 error) - report', async (t) => { + t.plan(3) + const result = await fetch(`http://localhost:${fastify.server.address().port}/test`, { + method: 'REPORT', + body: bodySample, + headers: { 'content-type': '' } + }) + + t.assert.ok(!result.ok) + t.assert.strictEqual(result.status, 415) + t.assert.strictEqual(result.headers.get('content-length'), '' + (await result.text()).length) + }) + + await t.test('request without body - report', async (t) => { + t.plan(3) + const result = await fetch(`http://localhost:${fastify.server.address().port}/test`, { + method: 'REPORT' + }) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 207) + t.assert.strictEqual(result.headers.get('content-length'), '' + (await result.text()).length) + }) +}) diff --git a/test/http-methods/search.test.js b/test/http-methods/search.test.js new file mode 100644 index 00000000000..7e0cdcf58b9 --- /dev/null +++ b/test/http-methods/search.test.js @@ -0,0 +1,233 @@ +'use strict' + +const { test } = require('node:test') +const fastify = require('../../fastify')() +fastify.addHttpMethod('SEARCH', { hasBody: true }) + +const schema = { + response: { + '2xx': { + type: 'object', + properties: { + hello: { + type: 'string' + } + } + } + } +} + +const querySchema = { + querystring: { + type: 'object', + properties: { + hello: { + type: 'integer' + } + } + } +} + +const paramsSchema = { + params: { + type: 'object', + properties: { + foo: { + type: 'string' + }, + test: { + type: 'integer' + } + } + } +} + +const bodySchema = { + body: { + type: 'object', + properties: { + foo: { + type: 'string' + }, + test: { + type: 'integer' + } + } + } +} + +test('search', t => { + t.plan(1) + try { + fastify.route({ + method: 'SEARCH', + url: '/', + schema, + handler: function (request, reply) { + reply.code(200).send({ hello: 'world' }) + } + }) + t.assert.ok(true) + } catch (e) { + t.assert.fail() + } +}) + +test('search, params schema', t => { + t.plan(1) + try { + fastify.route({ + method: 'SEARCH', + url: '/params/:foo/:test', + schema: paramsSchema, + handler: function (request, reply) { + reply.code(200).send(request.params) + } + }) + t.assert.ok(true) + } catch (e) { + t.assert.fail() + } +}) + +test('search, querystring schema', t => { + t.plan(1) + try { + fastify.route({ + method: 'SEARCH', + url: '/query', + schema: querySchema, + handler: function (request, reply) { + reply.code(200).send(request.query) + } + }) + t.assert.ok(true) + } catch (e) { + t.assert.fail() + } +}) + +test('search, body schema', t => { + t.plan(1) + try { + fastify.route({ + method: 'SEARCH', + url: '/body', + schema: bodySchema, + handler: function (request, reply) { + reply.code(200).send(request.body) + } + }) + t.assert.ok(true) + } catch (e) { + t.assert.fail() + } +}) + +test('search test', async t => { + await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) + const url = `http://localhost:${fastify.server.address().port}` + + await t.test('request - search', async t => { + t.plan(4) + const result = await fetch(url, { + method: 'SEARCH' + }) + const body = await result.text() + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { hello: 'world' }) + }) + + await t.test('request search params schema', async t => { + t.plan(4) + const result = await fetch(`${url}/params/world/123`, { + method: 'SEARCH' + }) + const body = await result.text() + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { foo: 'world', test: 123 }) + }) + + await t.test('request search params schema error', async t => { + t.plan(3) + const result = await fetch(`${url}/params/world/string`, { + method: 'SEARCH' + }) + const body = await result.text() + t.assert.strictEqual(result.status, 400) + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { + error: 'Bad Request', + code: 'FST_ERR_VALIDATION', + message: 'params/test must be integer', + statusCode: 400 + }) + }) + + await t.test('request search querystring schema', async t => { + t.plan(4) + const result = await fetch(`${url}/query?hello=123`, { + method: 'SEARCH' + }) + const body = await result.text() + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { hello: 123 }) + }) + + await t.test('request search querystring schema error', async t => { + t.plan(3) + const result = await fetch(`${url}/query?hello=world`, { + method: 'SEARCH' + }) + const body = await result.text() + t.assert.strictEqual(result.status, 400) + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { + error: 'Bad Request', + code: 'FST_ERR_VALIDATION', + message: 'querystring/hello must be integer', + statusCode: 400 + }) + }) + + await t.test('request search body schema', async t => { + t.plan(4) + const replyBody = { foo: 'bar', test: 5 } + const result = await fetch(`${url}/body`, { + method: 'SEARCH', + body: JSON.stringify(replyBody), + headers: { 'content-type': 'application/json' } + }) + const body = await result.text() + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), replyBody) + }) + + await t.test('request search body schema error', async t => { + t.plan(4) + const result = await fetch(`${url}/body`, { + method: 'SEARCH', + body: JSON.stringify({ foo: 'bar', test: 'test' }), + headers: { 'content-type': 'application/json' } + }) + const body = await result.text() + t.assert.ok(!result.ok) + t.assert.strictEqual(result.status, 400) + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { + error: 'Bad Request', + code: 'FST_ERR_VALIDATION', + message: 'body/test must be integer', + statusCode: 400 + }) + }) +}) diff --git a/test/http-methods/trace.test.js b/test/http-methods/trace.test.js new file mode 100644 index 00000000000..07f869c2675 --- /dev/null +++ b/test/http-methods/trace.test.js @@ -0,0 +1,21 @@ +'use strict' + +const { test } = require('node:test') +const fastify = require('../../fastify')() +fastify.addHttpMethod('TRACE') + +test('shorthand - trace', t => { + t.plan(1) + try { + fastify.route({ + method: 'TRACE', + url: '/', + handler: function (request, reply) { + reply.code(200).send('TRACE OK') + } + }) + t.assert.ok(true) + } catch (e) { + t.assert.fail() + } +}) diff --git a/test/http-methods/unlock.test.js b/test/http-methods/unlock.test.js new file mode 100644 index 00000000000..7260ba4f07a --- /dev/null +++ b/test/http-methods/unlock.test.js @@ -0,0 +1,38 @@ +'use strict' + +const { test } = require('node:test') +const fastify = require('../../fastify')() +fastify.addHttpMethod('UNLOCK') + +test('can be created - unlock', t => { + t.plan(1) + try { + fastify.route({ + method: 'UNLOCK', + url: '*', + handler: function (req, reply) { + reply.code(204).send() + } + }) + t.assert.ok(true) + } catch (e) { + t.assert.fail() + } +}) + +test('unlock test', async t => { + const fastifyServer = await fastify.listen({ port: 0 }) + + t.after(() => { fastify.close() }) + await t.test('request - unlock', async t => { + t.plan(2) + const result = await fetch(`${fastifyServer}/test/a.txt`, { + method: 'UNLOCK', + headers: { + 'Lock-Token': 'urn:uuid:a515cfa4-5da4-22e1-f5b5-00a0451e6bf7' + } + }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 204) + }) +}) diff --git a/test/http2/closing.test.js b/test/http2/closing.test.js index 37a8af693e2..8253b59fedc 100644 --- a/test/http2/closing.test.js +++ b/test/http2/closing.test.js @@ -1,118 +1,121 @@ 'use strict' -const t = require('tap') +const { test } = require('node:test') const Fastify = require('../..') -const http2 = require('http2') -const { promisify } = require('util') +const http2 = require('node:http2') +const { promisify } = require('node:util') const connect = promisify(http2.connect) -const { once } = require('events') - +const { once } = require('node:events') const { buildCertificate } = require('../build-certificate') -t.before(buildCertificate) - -function getUrl (app) { - const { address, port } = app.server.address() - if (address === '::1') { - return `http://[${address}]:${port}` - } else { - return `http://${address}:${port}` - } -} - -t.test('http/2 request while fastify closing', t => { - let fastify - try { - fastify = Fastify({ - http2: true - }) - t.pass('http2 successfully loaded') - } catch (e) { - t.fail('http2 loading failed', e) - } +const { getServerUrl } = require('../helper') +const { kHttp2ServerSessions } = require('../../lib/symbols') + +test.before(buildCertificate) + +const isNode24OrGreater = Number(process.versions.node.split('.')[0]) >= 24 + +test('http/2 request while fastify closing Node <24', { skip: isNode24OrGreater }, (t, done) => { + const fastify = Fastify({ + http2: true + }) + t.assert.ok('http2 successfully loaded') fastify.get('/', () => Promise.resolve({})) + t.after(() => { fastify.close() }) fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - t.test('return 200', t => { - const url = getUrl(fastify) - const session = http2.connect(url, function () { - this.request({ - ':method': 'GET', - ':path': '/' - }).on('response', headers => { - t.equal(headers[':status'], 503) - t.end() - this.destroy() - }).on('error', () => { - // Nothing to do here, - // we are not interested in this error that might - // happen or not - }) - fastify.close() + t.assert.ifError(err) + + const url = getServerUrl(fastify) + const session = http2.connect(url, function () { + this.request({ + ':method': 'GET', + ':path': '/' + }).on('response', headers => { + t.assert.strictEqual(headers[':status'], 503) + done() + this.destroy() + }).on('error', () => { + // Nothing to do here, + // we are not interested in this error that might + // happen or not }) session.on('error', () => { // Nothing to do here, // we are not interested in this error that might // happen or not - t.end() + done() }) + fastify.close() }) - - t.end() }) }) -t.test('http/2 request while fastify closing - return503OnClosing: false', t => { - let fastify - try { - fastify = Fastify({ - http2: true, - return503OnClosing: false - }) - t.pass('http2 successfully loaded') - } catch (e) { - t.fail('http2 loading failed', e) - } +test('http/2 request while fastify closing Node >=24', { skip: !isNode24OrGreater }, (t, done) => { + const fastify = Fastify({ + http2: true + }) + t.assert.ok('http2 successfully loaded') fastify.get('/', () => Promise.resolve({})) + t.after(() => { fastify.close() }) fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - t.test('return 200', t => { - const url = getUrl(fastify) - const session = http2.connect(url, function () { - this.request({ - ':method': 'GET', - ':path': '/' - }).on('response', headers => { - t.equal(headers[':status'], 200) - t.end() - this.destroy() - }).on('error', () => { - // Nothing to do here, - // we are not interested in this error that might - // happen or not - }) - fastify.close() - }) + t.assert.ifError(err) + + const url = getServerUrl(fastify) + const session = http2.connect(url, function () { session.on('error', () => { // Nothing to do here, // we are not interested in this error that might // happen or not - t.end() }) + session.on('close', () => { + done() + }) + fastify.close() }) + }) +}) + +test('http/2 request while fastify closing - return503OnClosing: false', { skip: isNode24OrGreater }, (t, done) => { + const fastify = Fastify({ + http2: true, + return503OnClosing: false + }) + + t.after(() => { fastify.close() }) + + fastify.get('/', () => Promise.resolve({})) - t.end() + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + const url = getServerUrl(fastify) + const session = http2.connect(url, function () { + this.request({ + ':method': 'GET', + ':path': '/' + }).on('response', headers => { + t.assert.strictEqual(headers[':status'], 200) + done() + this.destroy() + }).on('error', () => { + // Nothing to do here, + // we are not interested in this error that might + // happen or not + }) + fastify.close() + }) + session.on('error', () => { + // Nothing to do here, + // we are not interested in this error that might + // happen or not + done() + }) }) }) -t.test('http/2 closes successfully with async await', async t => { +test('http/2 closes successfully with async await', async t => { const fastify = Fastify({ http2SessionTimeout: 100, http2: true @@ -120,14 +123,14 @@ t.test('http/2 closes successfully with async await', async t => { await fastify.listen({ port: 0 }) - const url = getUrl(fastify) + const url = getServerUrl(fastify) const session = await connect(url) // An error might or might not happen, as it's OS dependent. session.on('error', () => {}) await fastify.close() }) -t.test('https/2 closes successfully with async await', async t => { +test('https/2 closes successfully with async await', async t => { const fastify = Fastify({ http2SessionTimeout: 100, http2: true, @@ -139,14 +142,14 @@ t.test('https/2 closes successfully with async await', async t => { await fastify.listen({ port: 0 }) - const url = getUrl(fastify) + const url = getServerUrl(fastify) const session = await connect(url) // An error might or might not happen, as it's OS dependent. session.on('error', () => {}) await fastify.close() }) -t.test('http/2 server side session emits a timeout event', async t => { +test('http/2 server side session emits a timeout event', async t => { let _resolve const p = new Promise((resolve) => { _resolve = resolve }) @@ -162,7 +165,7 @@ t.test('http/2 server side session emits a timeout event', async t => { await fastify.listen({ port: 0 }) - const url = getUrl(fastify) + const url = getServerUrl(fastify) const session = await connect(url) const req = session.request({ ':method': 'GET', @@ -170,7 +173,7 @@ t.test('http/2 server side session emits a timeout event', async t => { }).end() const [headers] = await once(req, 'response') - t.equal(headers[':status'], 200) + t.assert.strictEqual(headers[':status'], 200) req.resume() // An error might or might not happen, as it's OS dependent. @@ -178,3 +181,90 @@ t.test('http/2 server side session emits a timeout event', async t => { await p await fastify.close() }) + +test('http/2 sessions closed after closing server', async t => { + t.plan(1) + const fastify = Fastify({ + http2: true, + http2SessionTimeout: 100 + }) + await fastify.listen() + const url = getServerUrl(fastify) + const waitSessionConnect = once(fastify.server, 'session') + const session = http2.connect(url) + await once(session, 'connect') + await waitSessionConnect + const waitSessionClosed = once(session, 'close') + await fastify.close() + await waitSessionClosed + t.assert.strictEqual(session.closed, true) +}) + +test('http/2 sessions should be closed when setting forceClosedConnections to true', async t => { + t.plan(2) + const fastify = Fastify({ http2: true, http2SessionTimeout: 100, forceCloseConnections: true }) + fastify.get('/', () => 'hello world') + await fastify.listen() + const client = await connect(getServerUrl(fastify)) + const req = client.request({ + [http2.HTTP2_HEADER_PATH]: '/', + [http2.HTTP2_HEADER_METHOD]: 'GET' + }) + await once(req, 'response') + fastify.close() + const r2 = client.request({ + [http2.HTTP2_HEADER_PATH]: '/', + [http2.TTP2_HEADER_METHOD]: 'GET' + }) + r2.on('error', (err) => { + t.assert.strictEqual(err.toString(), 'Error [ERR_HTTP2_STREAM_ERROR]: Stream closed with error code NGHTTP2_REFUSED_STREAM') + }) + await once(r2, 'error') + r2.end() + t.assert.strictEqual(client.closed, true) + client.destroy() +}) + +test('http/2 sessions should be removed from server[kHttp2ServerSessions] Set on goaway', async t => { + t.plan(2) + const fastify = Fastify({ http2: true, http2SessionTimeout: 100, forceCloseConnections: true }) + await fastify.listen() + const waitSession = once(fastify.server, 'session') + const client = http2.connect(getServerUrl(fastify)) + const [session] = await waitSession + const waitGoaway = once(session, 'goaway') + t.assert.strictEqual(fastify.server[kHttp2ServerSessions].size, 1) + client.goaway() + await waitGoaway + t.assert.strictEqual(fastify.server[kHttp2ServerSessions].size, 0) + client.destroy() + await fastify.close() +}) + +test('http/2 sessions should be removed from server[kHttp2ServerSessions] Set on frameError', async t => { + t.plan(2) + const fastify = Fastify({ http2: true, http2SessionTimeout: 100, forceCloseConnections: true }) + await fastify.listen() + const waitSession = once(fastify.server, 'session') + const client = http2.connect(getServerUrl(fastify)) + const [session] = await waitSession + t.assert.strictEqual(fastify.server[kHttp2ServerSessions].size, 1) + session.emit('frameError', 0, 0, 0) + t.assert.strictEqual(fastify.server[kHttp2ServerSessions].size, 0) + client.destroy() + await fastify.close() +}) + +test('http/2 sessions should not be removed from server[kHttp2ServerSessions] from Set if stream id passed on frameError', async t => { + t.plan(2) + const fastify = Fastify({ http2: true, http2SessionTimeout: 100, forceCloseConnections: true }) + await fastify.listen() + const waitSession = once(fastify.server, 'session') + const client = http2.connect(getServerUrl(fastify)) + const [session] = await waitSession + t.assert.strictEqual(fastify.server[kHttp2ServerSessions].size, 1) + session.emit('frameError', 0, 0, 1) + t.assert.strictEqual(fastify.server[kHttp2ServerSessions].size, 1) + client.destroy() + await fastify.close() +}) diff --git a/test/http2/constraint.test.js b/test/http2/constraint.test.js index 6c0162bf1dd..06f53b7903d 100644 --- a/test/http2/constraint.test.js +++ b/test/http2/constraint.test.js @@ -1,7 +1,6 @@ 'use strict' -const t = require('tap') -const test = t.test +const { test } = require('node:test') const Fastify = require('../..') const h2url = require('h2url') @@ -9,9 +8,9 @@ const alpha = { res: 'alpha' } const beta = { res: 'beta' } const { buildCertificate } = require('../build-certificate') -t.before(buildCertificate) +test.before(buildCertificate) -test('A route supports host constraints under http2 protocol and secure connection', (t) => { +test('A route supports host constraints under http2 protocol and secure connection', async (t) => { t.plan(5) let fastify @@ -23,12 +22,12 @@ test('A route supports host constraints under http2 protocol and secure connecti cert: global.context.cert } }) - t.pass('Key/cert successfully loaded') + t.assert.ok(true, 'Key/cert successfully loaded') } catch (e) { - t.fail('Key/cert loading failed', e) + t.assert.fail('Key/cert loading failed') } - const constrain = 'fastify.io' + const constrain = 'fastify.dev' fastify.route({ method: 'GET', @@ -45,47 +44,66 @@ test('A route supports host constraints under http2 protocol and secure connecti reply.code(200).send(beta) } }) + fastify.route({ + method: 'GET', + url: '/hostname_port', + constraints: { host: constrain }, + handler: function (req, reply) { + reply.code(200).send({ ...beta, hostname: req.hostname }) + } + }) + t.after(() => { fastify.close() }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - t.test('https get request - no constrain', async (t) => { - t.plan(3) + await fastify.listen({ port: 0 }) - const url = `https://localhost:${fastify.server.address().port}` - const res = await h2url.concat({ url }) + await t.test('https get request - no constrain', async (t) => { + t.plan(3) - t.equal(res.headers[':status'], 200) - t.equal(res.headers['content-length'], '' + JSON.stringify(alpha).length) - t.same(JSON.parse(res.body), alpha) - }) + const url = `https://localhost:${fastify.server.address().port}` + const res = await h2url.concat({ url }) - t.test('https get request - constrain', async (t) => { - t.plan(3) + t.assert.strictEqual(res.headers[':status'], 200) + t.assert.strictEqual(res.headers['content-length'], '' + JSON.stringify(alpha).length) + t.assert.deepStrictEqual(JSON.parse(res.body), alpha) + }) - const url = `https://localhost:${fastify.server.address().port}/beta` - const res = await h2url.concat({ - url, - headers: { - ':authority': constrain - } - }) + await t.test('https get request - constrain', async (t) => { + t.plan(3) - t.equal(res.headers[':status'], 200) - t.equal(res.headers['content-length'], '' + JSON.stringify(beta).length) - t.same(JSON.parse(res.body), beta) + const url = `https://localhost:${fastify.server.address().port}/beta` + const res = await h2url.concat({ + url, + headers: { + ':authority': constrain + } }) - t.test('https get request - constrain - not found', async (t) => { - t.plan(1) + t.assert.strictEqual(res.headers[':status'], 200) + t.assert.strictEqual(res.headers['content-length'], '' + JSON.stringify(beta).length) + t.assert.deepStrictEqual(JSON.parse(res.body), beta) + }) - const url = `https://localhost:${fastify.server.address().port}/beta` - const res = await h2url.concat({ - url - }) + await t.test('https get request - constrain - not found', async (t) => { + t.plan(1) - t.equal(res.headers[':status'], 404) + const url = `https://localhost:${fastify.server.address().port}/beta` + const res = await h2url.concat({ + url + }) + + t.assert.strictEqual(res.headers[':status'], 404) + }) + await t.test('https get request - constrain - verify hostname and port from request', async (t) => { + t.plan(1) + + const url = `https://localhost:${fastify.server.address().port}/hostname_port` + const res = await h2url.concat({ + url, + headers: { + ':authority': constrain + } }) + const body = JSON.parse(res.body) + t.assert.strictEqual(body.hostname, constrain) }) }) diff --git a/test/http2/head.test.js b/test/http2/head.test.js index 091d5888c45..bf5f254cc58 100644 --- a/test/http2/head.test.js +++ b/test/http2/head.test.js @@ -1,35 +1,34 @@ 'use strict' -const t = require('tap') -const test = t.test +const { test } = require('node:test') const Fastify = require('../..') const h2url = require('h2url') const msg = { hello: 'world' } -let fastify -try { - fastify = Fastify({ - http2: true - }) - t.pass('http2 successfully loaded') -} catch (e) { - t.fail('http2 loading failed', e) -} +test('http2 HEAD test', async (t) => { + let fastify + try { + fastify = Fastify({ + http2: true + }) + t.assert.ok(true, 'http2 successfully loaded') + } catch (e) { + t.assert.fail('http2 loading failed') + } -fastify.all('/', function (req, reply) { - reply.code(200).send(msg) -}) + fastify.all('/', function (req, reply) { + reply.code(200).send(msg) + }) + t.after(() => { fastify.close() }) -fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) + await fastify.listen({ port: 0 }) - test('http HEAD request', async (t) => { + await t.test('http HEAD request', async (t) => { t.plan(1) const url = `http://localhost:${fastify.server.address().port}` const res = await h2url.concat({ url, method: 'HEAD' }) - t.equal(res.headers[':status'], 200) + t.assert.strictEqual(res.headers[':status'], 200) }) }) diff --git a/test/http2/missing-http2-module.test.js b/test/http2/missing-http2-module.test.js deleted file mode 100644 index ab9a5caf779..00000000000 --- a/test/http2/missing-http2-module.test.js +++ /dev/null @@ -1,18 +0,0 @@ -'use strict' - -const t = require('tap') -const test = t.test -const proxyquire = require('proxyquire') -const server = proxyquire('../../lib/server', { http2: null }) -const Fastify = proxyquire('../..', { './lib/server.js': server }) - -test('should throw when http2 module cannot be found', t => { - t.plan(2) - try { - Fastify({ http2: true }) - t.fail('fastify did not throw expected error') - } catch (err) { - t.equal(err.code, 'FST_ERR_HTTP2_INVALID_VERSION') - t.equal(err.message, 'HTTP2 is available only from node >= 8.8.1') - } -}) diff --git a/test/http2/plain.test.js b/test/http2/plain.test.js index 75c5f09cb94..3e1d5b4c96e 100644 --- a/test/http2/plain.test.js +++ b/test/http2/plain.test.js @@ -1,53 +1,68 @@ 'use strict' -const t = require('tap') -const test = t.test +const { test } = require('node:test') const Fastify = require('../..') const h2url = require('h2url') const msg = { hello: 'world' } -let fastify -try { - fastify = Fastify({ - http2: true +test('http2 plain test', async t => { + let fastify + try { + fastify = Fastify({ + http2: true + }) + t.assert.ok(true, 'http2 successfully loaded') + } catch (e) { + t.assert.fail('http2 loading failed') + } + + fastify.get('/', function (req, reply) { + reply.code(200).send(msg) }) - t.pass('http2 successfully loaded') -} catch (e) { - t.fail('http2 loading failed', e) -} -fastify.get('/', function (req, reply) { - reply.code(200).send(msg) -}) + fastify.get('/host', function (req, reply) { + reply.code(200).send(req.host) + }) -fastify.get('/hostname', function (req, reply) { - reply.code(200).send(req.hostname) -}) + fastify.get('/hostname_port', function (req, reply) { + reply.code(200).send({ hostname: req.hostname, port: req.port }) + }) + + t.after(() => { fastify.close() }) -fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) + await fastify.listen({ port: 0 }) - test('http get request', async (t) => { + await t.test('http get request', async (t) => { t.plan(3) const url = `http://localhost:${fastify.server.address().port}` const res = await h2url.concat({ url }) - t.equal(res.headers[':status'], 200) - t.equal(res.headers['content-length'], '' + JSON.stringify(msg).length) + t.assert.strictEqual(res.headers[':status'], 200) + t.assert.strictEqual(res.headers['content-length'], '' + JSON.stringify(msg).length) - t.same(JSON.parse(res.body), msg) + t.assert.deepStrictEqual(JSON.parse(res.body), msg) }) - test('http hostname', async (t) => { + await t.test('http host', async (t) => { t.plan(1) - const hostname = `localhost:${fastify.server.address().port}` + const host = `localhost:${fastify.server.address().port}` + + const url = `http://${host}/host` + const res = await h2url.concat({ url }) + + t.assert.strictEqual(res.body, host) + }) + await t.test('http hostname and port', async (t) => { + t.plan(2) + + const host = `localhost:${fastify.server.address().port}` - const url = `http://${hostname}/hostname` + const url = `http://${host}/hostname_port` const res = await h2url.concat({ url }) - t.equal(res.body, hostname) + t.assert.strictEqual(JSON.parse(res.body).hostname, host.split(':')[0]) + t.assert.strictEqual(JSON.parse(res.body).port, parseInt(host.split(':')[1])) }) }) diff --git a/test/http2/secure-with-fallback.test.js b/test/http2/secure-with-fallback.test.js index 12f57fc6989..25566389226 100644 --- a/test/http2/secure-with-fallback.test.js +++ b/test/http2/secure-with-fallback.test.js @@ -1,17 +1,16 @@ 'use strict' -const t = require('tap') -const test = t.test +const { test } = require('node:test') const Fastify = require('../..') const h2url = require('h2url') -const sget = require('simple-get').concat const msg = { hello: 'world' } const { buildCertificate } = require('../build-certificate') -t.before(buildCertificate) +const { Agent } = require('undici') +test.before(buildCertificate) -test('secure with fallback', (t) => { - t.plan(7) +test('secure with fallback', async (t) => { + t.plan(6) let fastify try { @@ -23,9 +22,9 @@ test('secure with fallback', (t) => { cert: global.context.cert } }) - t.pass('Key/cert successfully loaded') + t.assert.ok(true, 'Key/cert successfully loaded') } catch (e) { - t.fail('Key/cert loading failed', e) + t.assert.fail('Key/cert loading failed') } fastify.get('/', function (req, reply) { @@ -40,71 +39,75 @@ test('secure with fallback', (t) => { throw new Error('kaboom') }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) + t.after(() => fastify.close()) - t.test('https get error', async (t) => { - t.plan(1) + const fastifyServer = await fastify.listen({ port: 0 }) - const url = `https://localhost:${fastify.server.address().port}/error` - const res = await h2url.concat({ url }) + await t.test('https get error', async (t) => { + t.plan(1) - t.equal(res.headers[':status'], 500) - }) + const url = `${fastifyServer}/error` + const res = await h2url.concat({ url }) - t.test('https post', async (t) => { - t.plan(2) + t.assert.strictEqual(res.headers[':status'], 500) + }) - const url = `https://localhost:${fastify.server.address().port}` - const res = await h2url.concat({ - url, - method: 'POST', - body: JSON.stringify({ hello: 'http2' }), - headers: { - 'content-type': 'application/json' - } - }) + await t.test('https post', async (t) => { + t.plan(2) - t.equal(res.headers[':status'], 200) - t.same(JSON.parse(res.body), { hello: 'http2' }) + const res = await h2url.concat({ + url: fastifyServer, + method: 'POST', + body: JSON.stringify({ hello: 'http2' }), + headers: { + 'content-type': 'application/json' + } }) - t.test('https get request', async (t) => { - t.plan(3) + t.assert.strictEqual(res.headers[':status'], 200) + t.assert.deepStrictEqual(JSON.parse(res.body), { hello: 'http2' }) + }) + + await t.test('https get request', async (t) => { + t.plan(3) - const url = `https://localhost:${fastify.server.address().port}` - const res = await h2url.concat({ url }) + const res = await h2url.concat({ url: fastifyServer }) - t.equal(res.headers[':status'], 200) - t.equal(res.headers['content-length'], '' + JSON.stringify(msg).length) - t.same(JSON.parse(res.body), msg) - }) + t.assert.strictEqual(res.headers[':status'], 200) + t.assert.strictEqual(res.headers['content-length'], '' + JSON.stringify(msg).length) + t.assert.deepStrictEqual(JSON.parse(res.body), msg) + }) + + await t.test('http1 get request', async t => { + t.plan(4) - t.test('http1 get request', t => { - t.plan(4) - sget({ - method: 'GET', - url: 'https://localhost:' + fastify.server.address().port, - rejectUnauthorized: false - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) + const result = await fetch(fastifyServer, { + dispatcher: new Agent({ + connect: { + rejectUnauthorized: false + } }) }) - t.test('http1 get error', (t) => { - t.plan(2) - sget({ - method: 'GET', - url: 'https://localhost:' + fastify.server.address().port + '/error', - rejectUnauthorized: false - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 500) + const body = await result.text() + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), msg) + }) + + await t.test('http1 get error', async t => { + t.plan(2) + + const result = await fetch(`${fastifyServer}/error`, { + dispatcher: new Agent({ + connect: { + rejectUnauthorized: false + } }) }) + + t.assert.ok(!result.ok) + t.assert.strictEqual(result.status, 500) }) }) diff --git a/test/http2/secure.test.js b/test/http2/secure.test.js index 75b0526788a..2a12ce8d0ea 100644 --- a/test/http2/secure.test.js +++ b/test/http2/secure.test.js @@ -1,15 +1,14 @@ 'use strict' -const t = require('tap') -const test = t.test +const { test } = require('node:test') const Fastify = require('../..') const h2url = require('h2url') const msg = { hello: 'world' } const { buildCertificate } = require('../build-certificate') -t.before(buildCertificate) +test.before(buildCertificate) -test('secure', (t) => { +test('secure', async (t) => { t.plan(4) let fastify @@ -21,9 +20,9 @@ test('secure', (t) => { cert: global.context.cert } }) - t.pass('Key/cert successfully loaded') + t.assert.ok(true, 'Key/cert successfully loaded') } catch (e) { - t.fail('Key/cert loading failed', e) + t.assert.fail('Key/cert loading failed') } fastify.get('/', function (req, reply) { @@ -32,28 +31,37 @@ test('secure', (t) => { fastify.get('/proto', function (req, reply) { reply.code(200).send({ proto: req.protocol }) }) + fastify.get('/hostname_port', function (req, reply) { + reply.code(200).send({ hostname: req.hostname, port: req.port }) + }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) + t.after(() => { fastify.close() }) + await fastify.listen({ port: 0 }) - t.test('https get request', async (t) => { - t.plan(3) + await t.test('https get request', async (t) => { + t.plan(3) - const url = `https://localhost:${fastify.server.address().port}` - const res = await h2url.concat({ url }) + const url = `https://localhost:${fastify.server.address().port}` + const res = await h2url.concat({ url }) - t.equal(res.headers[':status'], 200) - t.equal(res.headers['content-length'], '' + JSON.stringify(msg).length) - t.same(JSON.parse(res.body), msg) - }) + t.assert.strictEqual(res.headers[':status'], 200) + t.assert.strictEqual(res.headers['content-length'], '' + JSON.stringify(msg).length) + t.assert.deepStrictEqual(JSON.parse(res.body), msg) + }) - t.test('https get request without trust proxy - protocol', async (t) => { - t.plan(2) + await t.test('https get request without trust proxy - protocol', async (t) => { + t.plan(2) - const url = `https://localhost:${fastify.server.address().port}/proto` - t.same(JSON.parse((await h2url.concat({ url })).body), { proto: 'https' }) - t.same(JSON.parse((await h2url.concat({ url, headers: { 'X-Forwarded-Proto': 'lorem' } })).body), { proto: 'https' }) - }) + const url = `https://localhost:${fastify.server.address().port}/proto` + t.assert.deepStrictEqual(JSON.parse((await h2url.concat({ url })).body), { proto: 'https' }) + t.assert.deepStrictEqual(JSON.parse((await h2url.concat({ url, headers: { 'X-Forwarded-Proto': 'lorem' } })).body), { proto: 'https' }) + }) + await t.test('https get request - test hostname and port', async (t) => { + t.plan(2) + + const url = `https://localhost:${fastify.server.address().port}/hostname_port` + const parsedbody = JSON.parse((await h2url.concat({ url })).body) + t.assert.strictEqual(parsedbody.hostname, 'localhost') + t.assert.strictEqual(parsedbody.port, fastify.server.address().port) }) }) diff --git a/test/http2/unknown-http-method.test.js b/test/http2/unknown-http-method.test.js index fdc53fca277..56941a2d497 100644 --- a/test/http2/unknown-http-method.test.js +++ b/test/http2/unknown-http-method.test.js @@ -1,32 +1,32 @@ 'use strict' -const t = require('tap') -const test = t.test +const { test } = require('node:test') const Fastify = require('../..') const h2url = require('h2url') const msg = { hello: 'world' } -const fastify = Fastify({ - http2: true -}) +test('http2 unknown http method', async t => { + const fastify = Fastify({ + http2: true + }) -fastify.get('/', function (req, reply) { - reply.code(200).send(msg) -}) + fastify.get('/', function (req, reply) { + reply.code(200).send(msg) + }) -fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) + t.after(() => { fastify.close() }) + await fastify.listen({ port: 0 }) - test('http UNKNOWN_METHOD request', async (t) => { + await t.test('http UNKNOWN_METHOD request', async (t) => { t.plan(2) const url = `http://localhost:${fastify.server.address().port}` const res = await h2url.concat({ url, method: 'UNKNOWN_METHOD' }) - t.equal(res.headers[':status'], 404) - t.same(JSON.parse(res.body), { + t.assert.strictEqual(res.headers[':status'], 404) + t.assert.deepStrictEqual(JSON.parse(res.body), { statusCode: 404, + code: 'FST_ERR_NOT_FOUND', error: 'Not Found', message: 'Not Found' }) diff --git a/test/https/custom-https-server.test.js b/test/https/custom-https-server.test.js index ea05e639206..ba7a7b65fc3 100644 --- a/test/https/custom-https-server.test.js +++ b/test/https/custom-https-server.test.js @@ -1,59 +1,58 @@ 'use strict' -const t = require('tap') -const test = t.test +const { test } = require('node:test') const Fastify = require('../..') -const https = require('https') -const sget = require('simple-get').concat -const dns = require('dns').promises - +const https = require('node:https') +const dns = require('node:dns').promises const { buildCertificate } = require('../build-certificate') -t.before(buildCertificate) +const { Agent } = require('undici') + +async function setup () { + await buildCertificate() -test('Should support a custom https server', async t => { const localAddresses = await dns.lookup('localhost', { all: true }) - t.plan(localAddresses.length + 3) + test('Should support a custom https server', { skip: localAddresses.length < 1 }, async t => { + t.plan(5) - const serverFactory = (handler, opts) => { - t.ok(opts.serverFactory, 'it is called twice for every HOST interface') + const fastify = Fastify({ + serverFactory: (handler, opts) => { + t.assert.ok(opts.serverFactory, 'it is called once for localhost') - const options = { - key: global.context.key, - cert: global.context.cert - } + const options = { + key: global.context.key, + cert: global.context.cert + } - const server = https.createServer(options, (req, res) => { - req.custom = true - handler(req, res) - }) + const server = https.createServer(options, (req, res) => { + req.custom = true + handler(req, res) + }) - return server - } - - const fastify = Fastify({ serverFactory }) + return server + } + }) - t.teardown(fastify.close.bind(fastify)) + t.after(() => { fastify.close() }) - fastify.get('/', (req, reply) => { - t.ok(req.raw.custom) - reply.send({ hello: 'world' }) - }) + fastify.get('/', (req, reply) => { + t.assert.ok(req.raw.custom) + reply.send({ hello: 'world' }) + }) - await fastify.listen({ port: 0 }) + await fastify.listen({ port: 0 }) - await new Promise((resolve, reject) => { - sget({ - method: 'GET', - url: 'https://localhost:' + fastify.server.address().port, - rejectUnauthorized: false - }, (err, response, body) => { - if (err) { - return reject(err) - } - t.equal(response.statusCode, 200) - t.same(JSON.parse(body), { hello: 'world' }) - resolve() + const result = await fetch('https://localhost:' + fastify.server.address().port, { + dispatcher: new Agent({ + connect: { + rejectUnauthorized: false + } + }) }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.deepStrictEqual(await result.json(), { hello: 'world' }) }) -}) +} + +setup() diff --git a/test/https/https.test.js b/test/https/https.test.js index cc6b40f8e2f..7e0a9f3d5d1 100644 --- a/test/https/https.test.js +++ b/test/https/https.test.js @@ -1,15 +1,15 @@ 'use strict' -const t = require('tap') -const test = t.test -const sget = require('simple-get').concat +const { test } = require('node:test') +const { request } = require('undici') const Fastify = require('../..') const { buildCertificate } = require('../build-certificate') -t.before(buildCertificate) +const { Agent } = require('undici') +test.before(buildCertificate) -test('https', (t) => { - t.plan(4) +test('https', async (t) => { + t.plan(3) let fastify try { @@ -19,9 +19,9 @@ test('https', (t) => { cert: global.context.cert } }) - t.pass('Key/cert successfully loaded') + t.assert.ok('Key/cert successfully loaded') } catch (e) { - t.fail('Key/cert loading failed', e) + t.assert.fail('Key/cert loading failed') } fastify.get('/', function (req, reply) { @@ -32,45 +32,105 @@ test('https', (t) => { reply.code(200).send({ proto: req.protocol }) }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - t.test('https get request', t => { - t.plan(4) - sget({ - method: 'GET', - url: 'https://localhost:' + fastify.server.address().port, - rejectUnauthorized: false - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) + await fastify.listen({ port: 0 }) + + t.after(() => { fastify.close() }) + + await t.test('https get request', async t => { + t.plan(4) + const result = await fetch('https://localhost:' + fastify.server.address().port, { + dispatcher: new Agent({ + connect: { + rejectUnauthorized: false + } }) }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { hello: 'world' }) + }) + + await t.test('https get request without trust proxy - protocol', async t => { + t.plan(3) + const result1 = await fetch(`${'https://localhost:' + fastify.server.address().port}/proto`, { + dispatcher: new Agent({ + connect: { + rejectUnauthorized: false + } + }) + }) + t.assert.ok(result1.ok) + t.assert.deepStrictEqual(await result1.json(), { proto: 'https' }) + + const result2 = await fetch(`${'https://localhost:' + fastify.server.address().port}/proto`, { + dispatcher: new Agent({ + connect: { + rejectUnauthorized: false + } + }), + headers: { + 'x-forwarded-proto': 'lorem' + } + }) + t.assert.deepStrictEqual(await result2.json(), { proto: 'https' }) + }) +}) + +test('https - headers', async (t) => { + t.plan(3) + let fastify + try { + fastify = Fastify({ + https: { + key: global.context.key, + cert: global.context.cert + } + }) + t.assert.ok('Key/cert successfully loaded') + } catch (e) { + t.assert.fail('Key/cert loading failed') + } + + fastify.get('/', function (req, reply) { + reply.code(200).send({ hello: 'world', hostname: req.hostname, port: req.port }) + }) + + t.after(async () => { await fastify.close() }) + + await fastify.listen({ port: 0 }) - t.test('https get request without trust proxy - protocol', t => { - t.plan(4) - sget({ - method: 'GET', - url: 'https://localhost:' + fastify.server.address().port + '/proto', - rejectUnauthorized: false - }, (err, response, body) => { - t.error(err) - t.same(JSON.parse(body), { proto: 'https' }) + await t.test('https get request', async t => { + t.plan(3) + const result = await fetch('https://localhost:' + fastify.server.address().port, { + dispatcher: new Agent({ + connect: { + rejectUnauthorized: false + } }) - sget({ - method: 'GET', - url: 'https://localhost:' + fastify.server.address().port + '/proto', - rejectUnauthorized: false, - headers: { - 'x-forwarded-proto': 'lorem' + }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.deepStrictEqual(await result.json(), { hostname: 'localhost', port: fastify.server.address().port, hello: 'world' }) + }) + + await t.test('https get request - test port fall back', async t => { + t.plan(2) + + const result = await request('https://localhost:' + fastify.server.address().port, { + method: 'GET', + headers: { + host: 'fastify.test' + }, + dispatcher: new Agent({ + connect: { + rejectUnauthorized: false } - }, (err, response, body) => { - t.error(err) - t.same(JSON.parse(body), { proto: 'https' }) }) }) + + t.assert.strictEqual(result.statusCode, 200) + t.assert.deepStrictEqual(await result.body.json(), { hello: 'world', hostname: 'fastify.test', port: null }) }) }) diff --git a/test/imports.test.js b/test/imports.test.js index 791cc7117fc..62f6d9a752c 100644 --- a/test/imports.test.js +++ b/test/imports.test.js @@ -1,18 +1,17 @@ 'use strict' -const t = require('tap') -const test = t.test +const { test } = require('node:test') test('should import as default', t => { t.plan(2) const fastify = require('..') - t.ok(fastify) - t.equal(typeof fastify, 'function') + t.assert.ok(fastify) + t.assert.strictEqual(typeof fastify, 'function') }) test('should import as esm', t => { t.plan(2) const { fastify } = require('..') - t.ok(fastify) - t.equal(typeof fastify, 'function') + t.assert.ok(fastify) + t.assert.strictEqual(typeof fastify, 'function') }) diff --git a/test/inject.test.js b/test/inject.test.js index 34469d66b93..9cf3802d2a5 100644 --- a/test/inject.test.js +++ b/test/inject.test.js @@ -1,20 +1,19 @@ 'use strict' -const t = require('tap') -const test = t.test -const Stream = require('stream') -const util = require('util') +const { test } = require('node:test') +const Stream = require('node:stream') +const util = require('node:util') const Fastify = require('..') -const FormData = require('form-data') +const { Readable } = require('node:stream') test('inject should exist', t => { t.plan(2) const fastify = Fastify() - t.ok(fastify.inject) - t.equal(typeof fastify.inject, 'function') + t.assert.ok(fastify.inject) + t.assert.strictEqual(typeof fastify.inject, 'function') }) -test('should wait for the ready event', t => { +test('should wait for the ready event', (t, done) => { t.plan(4) const fastify = Fastify() const payload = { hello: 'world' } @@ -23,7 +22,6 @@ test('should wait for the ready event', t => { instance.get('/', (req, reply) => { reply.send(payload) }) - setTimeout(done, 500) }) @@ -31,14 +29,15 @@ test('should wait for the ready event', t => { method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.same(payload, JSON.parse(res.payload)) - t.equal(res.statusCode, 200) - t.equal(res.headers['content-length'], '17') + t.assert.ifError(err) + t.assert.deepStrictEqual(payload, JSON.parse(res.payload)) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['content-length'], '17') + done() }) }) -test('inject get request', t => { +test('inject get request', (t, done) => { t.plan(4) const fastify = Fastify() const payload = { hello: 'world' } @@ -51,14 +50,15 @@ test('inject get request', t => { method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.same(payload, JSON.parse(res.payload)) - t.equal(res.statusCode, 200) - t.equal(res.headers['content-length'], '17') + t.assert.ifError(err) + t.assert.deepStrictEqual(payload, JSON.parse(res.payload)) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['content-length'], '17') + done() }) }) -test('inject get request - code check', t => { +test('inject get request - code check', (t, done) => { t.plan(4) const fastify = Fastify() const payload = { hello: 'world' } @@ -71,14 +71,15 @@ test('inject get request - code check', t => { method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.same(payload, JSON.parse(res.payload)) - t.equal(res.statusCode, 201) - t.equal(res.headers['content-length'], '17') + t.assert.ifError(err) + t.assert.deepStrictEqual(payload, JSON.parse(res.payload)) + t.assert.strictEqual(res.statusCode, 201) + t.assert.strictEqual(res.headers['content-length'], '17') + done() }) }) -test('inject get request - headers check', t => { +test('inject get request - headers check', (t, done) => { t.plan(4) const fastify = Fastify() @@ -90,14 +91,15 @@ test('inject get request - headers check', t => { method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.equal('', res.payload) - t.equal(res.headers['content-type'], 'text/plain') - t.equal(res.headers['content-length'], '0') + t.assert.ifError(err) + t.assert.strictEqual('', res.payload) + t.assert.strictEqual(res.headers['content-type'], 'text/plain') + t.assert.strictEqual(res.headers['content-length'], '0') + done() }) }) -test('inject get request - querystring', t => { +test('inject get request - querystring', (t, done) => { t.plan(4) const fastify = Fastify() @@ -109,14 +111,15 @@ test('inject get request - querystring', t => { method: 'GET', url: '/?hello=world' }, (err, res) => { - t.error(err) - t.same({ hello: 'world' }, JSON.parse(res.payload)) - t.equal(res.statusCode, 200) - t.equal(res.headers['content-length'], '17') + t.assert.ifError(err) + t.assert.deepStrictEqual({ hello: 'world' }, JSON.parse(res.payload)) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['content-length'], '17') + done() }) }) -test('inject get request - params', t => { +test('inject get request - params', (t, done) => { t.plan(4) const fastify = Fastify() @@ -128,14 +131,15 @@ test('inject get request - params', t => { method: 'GET', url: '/world' }, (err, res) => { - t.error(err) - t.same({ hello: 'world' }, JSON.parse(res.payload)) - t.equal(res.statusCode, 200) - t.equal(res.headers['content-length'], '17') + t.assert.ifError(err) + t.assert.deepStrictEqual({ hello: 'world' }, JSON.parse(res.payload)) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['content-length'], '17') + done() }) }) -test('inject get request - wildcard', t => { +test('inject get request - wildcard', (t, done) => { t.plan(4) const fastify = Fastify() @@ -147,14 +151,15 @@ test('inject get request - wildcard', t => { method: 'GET', url: '/test/wildcard' }, (err, res) => { - t.error(err) - t.same({ '*': 'wildcard' }, JSON.parse(res.payload)) - t.equal(res.statusCode, 200) - t.equal(res.headers['content-length'], '16') + t.assert.ifError(err) + t.assert.deepStrictEqual({ '*': 'wildcard' }, JSON.parse(res.payload)) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['content-length'], '16') + done() }) }) -test('inject get request - headers', t => { +test('inject get request - headers', (t, done) => { t.plan(4) const fastify = Fastify() @@ -167,14 +172,15 @@ test('inject get request - headers', t => { url: '/', headers: { hello: 'world' } }, (err, res) => { - t.error(err) - t.equal('world', JSON.parse(res.payload).hello) - t.equal(res.statusCode, 200) - t.equal(res.headers['content-length'], '69') + t.assert.ifError(err) + t.assert.strictEqual('world', JSON.parse(res.payload).hello) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['content-length'], '69') + done() }) }) -test('inject post request', t => { +test('inject post request', (t, done) => { t.plan(4) const fastify = Fastify() const payload = { hello: 'world' } @@ -188,14 +194,15 @@ test('inject post request', t => { url: '/', payload }, (err, res) => { - t.error(err) - t.same(payload, JSON.parse(res.payload)) - t.equal(res.statusCode, 200) - t.equal(res.headers['content-length'], '17') + t.assert.ifError(err) + t.assert.deepStrictEqual(payload, JSON.parse(res.payload)) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['content-length'], '17') + done() }) }) -test('inject post request - send stream', t => { +test('inject post request - send stream', (t, done) => { t.plan(4) const fastify = Fastify() @@ -209,14 +216,15 @@ test('inject post request - send stream', t => { headers: { 'content-type': 'application/json' }, payload: getStream() }, (err, res) => { - t.error(err) - t.same('{"hello":"world"}', res.payload) - t.equal(res.statusCode, 200) - t.equal(res.headers['content-length'], '17') + t.assert.ifError(err) + t.assert.deepStrictEqual('{"hello":"world"}', res.payload) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['content-length'], '17') + done() }) }) -test('inject get request - reply stream', t => { +test('inject get request - reply stream', (t, done) => { t.plan(3) const fastify = Fastify() @@ -228,13 +236,14 @@ test('inject get request - reply stream', t => { method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.same('{"hello":"world"}', res.payload) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.deepStrictEqual('{"hello":"world"}', res.payload) + t.assert.strictEqual(res.statusCode, 200) + done() }) }) -test('inject promisify - waiting for ready event', t => { +test('inject promisify - waiting for ready event', (t, done) => { t.plan(1) const fastify = Fastify() const payload = { hello: 'world' } @@ -249,12 +258,13 @@ test('inject promisify - waiting for ready event', t => { } fastify.inject(injectParams) .then(res => { - t.equal(res.statusCode, 200) + t.assert.strictEqual(res.statusCode, 200) + done() }) - .catch(t.fail) + .catch(t.assert.fail) }) -test('inject promisify - after the ready event', t => { +test('inject promisify - after the ready event', (t, done) => { t.plan(2) const fastify = Fastify() const payload = { hello: 'world' } @@ -264,7 +274,7 @@ test('inject promisify - after the ready event', t => { }) fastify.ready(err => { - t.error(err) + t.assert.ifError(err) const injectParams = { method: 'GET', @@ -272,13 +282,14 @@ test('inject promisify - after the ready event', t => { } fastify.inject(injectParams) .then(res => { - t.equal(res.statusCode, 200) + t.assert.strictEqual(res.statusCode, 200) + done() }) - .catch(t.fail) + .catch(t.assert.fail) }) }) -test('inject promisify - when the server is up', t => { +test('inject promisify - when the server is up', (t, done) => { t.plan(2) const fastify = Fastify() const payload = { hello: 'world' } @@ -288,7 +299,7 @@ test('inject promisify - when the server is up', t => { }) fastify.ready(err => { - t.error(err) + t.assert.ifError(err) // setTimeout because the ready event don't set "started" flag // in this iteration of the 'event loop' @@ -299,14 +310,15 @@ test('inject promisify - when the server is up', t => { } fastify.inject(injectParams) .then(res => { - t.equal(res.statusCode, 200) + t.assert.strictEqual(res.statusCode, 200) + done() }) - .catch(t.fail) + .catch(t.assert.fail) }, 10) }) }) -test('should reject in error case', t => { +test('should reject in error case', (t, done) => { t.plan(1) const fastify = Fastify() @@ -320,11 +332,12 @@ test('should reject in error case', t => { url: '/' }) .catch(e => { - t.equal(e, error) + t.assert.strictEqual(e, error) + done() }) }) -test('inject a multipart request using form-body', t => { +test('inject a multipart request using form-body', (t, done) => { t.plan(2) const fastify = Fastify() @@ -342,7 +355,7 @@ test('inject a multipart request using form-body', t => { }) const form = new FormData() - form.append('my_field', 'my value') + form.set('my_field', 'my value') fastify.inject({ method: 'POST', @@ -350,8 +363,9 @@ test('inject a multipart request using form-body', t => { payload: form }) .then(response => { - t.equal(response.statusCode, 200) - t.ok(/Content-Disposition: form-data; name="my_field"/.test(response.payload)) + t.assert.strictEqual(response.statusCode, 200) + t.assert.ok(/Content-Disposition: form-data; name="my_field"/.test(response.payload)) + done() }) }) @@ -371,28 +385,29 @@ function getStream () { return new Read() } -test('should error the promise if ready errors', t => { +test('should error the promise if ready errors', (t, done) => { t.plan(3) const fastify = Fastify() fastify.register((instance, opts) => { return Promise.reject(new Error('kaboom')) }).after(function () { - t.pass('after is called') + t.assert.ok('after is called') }) fastify.inject({ method: 'GET', url: '/' }).then(() => { - t.fail('this should not be called') + t.assert.fail('this should not be called') }).catch(err => { - t.ok(err) - t.equal(err.message, 'kaboom') + t.assert.ok(err) + t.assert.strictEqual(err.message, 'kaboom') + done() }) }) -test('should throw error if callback specified and if ready errors', t => { +test('should throw error if callback specified and if ready errors', (t, done) => { t.plan(2) const fastify = Fastify() const error = new Error('kaboom') @@ -405,8 +420,9 @@ test('should throw error if callback specified and if ready errors', t => { method: 'GET', url: '/' }, err => { - t.ok(err) - t.equal(err, error) + t.assert.ok(err) + t.assert.strictEqual(err, error) + done() }) }) @@ -421,9 +437,9 @@ test('should support builder-style injection with ready app', async (t) => { await fastify.ready() const res = await fastify.inject().get('/').end() - t.same(payload, JSON.parse(res.payload)) - t.equal(res.statusCode, 200) - t.equal(res.headers['content-length'], '17') + t.assert.deepStrictEqual(payload, JSON.parse(res.payload)) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['content-length'], '17') }) test('should support builder-style injection with non-ready app', async (t) => { @@ -436,9 +452,9 @@ test('should support builder-style injection with non-ready app', async (t) => { }) const res = await fastify.inject().get('/').end() - t.same(payload, JSON.parse(res.payload)) - t.equal(res.statusCode, 200) - t.equal(res.headers['content-length'], '17') + t.assert.deepStrictEqual(payload, JSON.parse(res.payload)) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['content-length'], '17') }) test('should handle errors in builder-style injection correctly', async (t) => { @@ -451,7 +467,36 @@ test('should handle errors in builder-style injection correctly', async (t) => { try { await fastify.inject().get('/') } catch (err) { - t.ok(err) - t.equal(err.message, 'Kaboom') + t.assert.ok(err) + t.assert.strictEqual(err.message, 'Kaboom') } }) + +test('Should not throw on access to routeConfig frameworkErrors handler - FST_ERR_BAD_URL', (t, done) => { + t.plan(5) + + const fastify = Fastify({ + frameworkErrors: function (err, req, res) { + t.assert.ok(typeof req.id === 'string') + t.assert.ok(req.raw instanceof Readable) + t.assert.deepStrictEqual(req.routeOptions.url, undefined) + res.send(`${err.message} - ${err.code}`) + } + }) + + fastify.get('/test/:id', (req, res) => { + res.send('{ hello: \'world\' }') + }) + + fastify.inject( + { + method: 'GET', + url: '/test/%world' + }, + (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.body, '\'/test/%world\' is not a valid url component - FST_ERR_BAD_URL') + done() + } + ) +}) diff --git a/test/input-validation.js b/test/input-validation.js index 62b837b1248..52ae26784bb 100644 --- a/test/input-validation.js +++ b/test/input-validation.js @@ -1,10 +1,9 @@ - 'use strict' -const sget = require('simple-get').concat const Ajv = require('ajv') const Joi = require('joi') const yup = require('yup') +const assert = require('node:assert') module.exports.payloadMethod = function (method, t) { const test = t.test @@ -73,7 +72,7 @@ module.exports.payloadMethod = function (method, t) { const result = schema.validateSync(data, yupOptions) return { value: result } } catch (e) { - return { error: e } + return { error: [e] } } } } @@ -128,204 +127,208 @@ module.exports.payloadMethod = function (method, t) { done() }) - t.pass() + t.assert.ok(true) } catch (e) { - t.fail() + t.assert.fail() } }) fastify.listen({ port: 0 }, function (err) { - if (err) { - t.error(err) - } + assert.ifError(err) - t.teardown(() => { fastify.close() }) + t.after(() => { fastify.close() }) - test(`${upMethod} - correctly replies`, t => { + test(`${upMethod} - correctly replies`, async (t) => { if (upMethod === 'HEAD') { t.plan(2) - sget({ - method: upMethod, - url: 'http://localhost:' + fastify.server.address().port - }, (err, response) => { - t.error(err) - t.equal(response.statusCode, 200) + const result = await fetch('http://localhost:' + fastify.server.address().port, { + method: upMethod }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) } else { t.plan(3) - sget({ + + const result = await fetch('http://localhost:' + fastify.server.address().port, { method: upMethod, - url: 'http://localhost:' + fastify.server.address().port, - body: { - hello: 42 - }, - json: true - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body, { hello: 42 }) + body: JSON.stringify({ hello: 42 }), + headers: { + 'Content-Type': 'application/json' + } }) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.deepStrictEqual(await result.json(), { hello: 42 }) } }) - test(`${upMethod} - 400 on bad parameters`, t => { + test(`${upMethod} - 400 on bad parameters`, async (t) => { t.plan(3) - sget({ + + const result = await fetch('http://localhost:' + fastify.server.address().port, { method: upMethod, - url: 'http://localhost:' + fastify.server.address().port, - body: { - hello: 'world' - }, - json: true - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 400) - t.same(body, { - error: 'Bad Request', - message: 'body/hello must be integer', - statusCode: 400 - }) + body: JSON.stringify({ hello: 'world' }), + headers: { + 'Content-Type': 'application/json' + } + }) + + t.assert.ok(!result.ok) + t.assert.strictEqual(result.status, 400) + t.assert.deepStrictEqual(await result.json(), { + error: 'Bad Request', + message: 'body/hello must be integer', + statusCode: 400, + code: 'FST_ERR_VALIDATION' }) }) - test(`${upMethod} - input-validation coerce`, t => { + test(`${upMethod} - input-validation coerce`, async (t) => { t.plan(3) - sget({ + + const restult = await fetch('http://localhost:' + fastify.server.address().port, { method: upMethod, - url: 'http://localhost:' + fastify.server.address().port, - body: { - hello: '42' - }, - json: true - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body, { hello: 42 }) + body: JSON.stringify({ hello: '42' }), + headers: { + 'Content-Type': 'application/json' + } }) + + t.assert.ok(restult.ok) + t.assert.strictEqual(restult.status, 200) + t.assert.deepStrictEqual(await restult.json(), { hello: 42 }) }) - test(`${upMethod} - input-validation custom schema compiler`, t => { + test(`${upMethod} - input-validation custom schema compiler`, async (t) => { t.plan(3) - sget({ + + const result = await fetch('http://localhost:' + fastify.server.address().port + '/custom', { method: upMethod, - url: 'http://localhost:' + fastify.server.address().port + '/custom', - body: { - hello: '42', - world: 55 - }, - json: true - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body, { hello: 42 }) + body: JSON.stringify({ hello: '42', world: 55 }), + headers: { + 'Content-Type': 'application/json' + } }) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.deepStrictEqual(await result.json(), { hello: 42 }) }) - test(`${upMethod} - input-validation joi schema compiler ok`, t => { + test(`${upMethod} - input-validation joi schema compiler ok`, async (t) => { t.plan(3) - sget({ + + const result = await fetch('http://localhost:' + fastify.server.address().port + '/joi', { method: upMethod, - url: 'http://localhost:' + fastify.server.address().port + '/joi', - body: { - hello: '42' - }, - json: true - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body, { hello: 42 }) + body: JSON.stringify({ hello: '42' }), + headers: { + 'Content-Type': 'application/json' + } }) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.deepStrictEqual(await result.json(), { hello: '42' }) }) - test(`${upMethod} - input-validation joi schema compiler ko`, t => { + test(`${upMethod} - input-validation joi schema compiler ko`, async (t) => { t.plan(3) - sget({ + + const result = await fetch('http://localhost:' + fastify.server.address().port + '/joi', { method: upMethod, - url: 'http://localhost:' + fastify.server.address().port + '/joi', - body: { - hello: 44 - }, - json: true - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 400) - t.same(body, { - error: 'Bad Request', - message: '"hello" must be a string', - statusCode: 400 - }) + body: JSON.stringify({ hello: 44 }), + headers: { + 'Content-Type': 'application/json' + } + }) + + t.assert.ok(!result.ok) + t.assert.strictEqual(result.status, 400) + t.assert.deepStrictEqual(await result.json(), { + error: 'Bad Request', + message: '"hello" must be a string', + statusCode: 400, + code: 'FST_ERR_VALIDATION' }) }) - test(`${upMethod} - input-validation yup schema compiler ok`, t => { + test(`${upMethod} - input-validation yup schema compiler ok`, async (t) => { t.plan(3) - sget({ + + const result = await fetch('http://localhost:' + fastify.server.address().port + '/yup', { method: upMethod, - url: 'http://localhost:' + fastify.server.address().port + '/yup', - body: { - hello: '42' - }, - json: true - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body, { hello: 42 }) + body: JSON.stringify({ hello: '42' }), + headers: { + 'Content-Type': 'application/json' + } }) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.deepStrictEqual(await result.json(), { hello: '42' }) }) - test(`${upMethod} - input-validation yup schema compiler ko`, t => { + test(`${upMethod} - input-validation yup schema compiler ko`, async (t) => { t.plan(3) - sget({ + + const result = await fetch('http://localhost:' + fastify.server.address().port + '/yup', { method: upMethod, - url: 'http://localhost:' + fastify.server.address().port + '/yup', - body: { - hello: 44 - }, - json: true - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 400) - t.same(body, { - error: 'Bad Request', - message: 'hello must be a `string` type, but the final value was: `44`.', - statusCode: 400 - }) + body: JSON.stringify({ hello: 44 }), + headers: { + 'Content-Type': 'application/json' + } + }) + + t.assert.ok(!result.ok) + t.assert.strictEqual(result.status, 400) + t.assert.deepStrictEqual(await result.json(), { + error: 'Bad Request', + message: 'body hello must be a `string` type, but the final value was: `44`.', + statusCode: 400, + code: 'FST_ERR_VALIDATION' }) }) - test(`${upMethod} - input-validation instance custom schema compiler encapsulated`, t => { + test(`${upMethod} - input-validation instance custom schema compiler encapsulated`, async (t) => { t.plan(3) - sget({ + + const result = await fetch('http://localhost:' + fastify.server.address().port + '/plugin', { method: upMethod, - url: 'http://localhost:' + fastify.server.address().port + '/plugin', - body: { }, - json: true - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 400) - t.same(body, { - error: 'Bad Request', - message: 'From custom schema compiler!', - statusCode: '400' - }) + body: JSON.stringify({}), + headers: { + 'Content-Type': 'application/json' + } + }) + + t.assert.ok(!result.ok) + t.assert.strictEqual(result.status, 400) + t.assert.deepStrictEqual(await result.json(), { + error: 'Bad Request', + message: 'From custom schema compiler!', + statusCode: 400, + code: 'FST_ERR_VALIDATION' }) }) - test(`${upMethod} - input-validation custom schema compiler encapsulated`, t => { + test(`${upMethod} - input-validation custom schema compiler encapsulated`, async (t) => { t.plan(3) - sget({ + + const result = await fetch('http://localhost:' + fastify.server.address().port + '/plugin/custom', { method: upMethod, - url: 'http://localhost:' + fastify.server.address().port + '/plugin/custom', - body: { }, - json: true - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 400) - t.same(body, { - error: 'Bad Request', - message: 'Always fail!', - statusCode: '400' - }) + body: JSON.stringify({}), + headers: { + 'Content-Type': 'application/json' + } + }) + + t.assert.ok(!result.ok) + t.assert.strictEqual(result.status, 400) + t.assert.deepStrictEqual(await result.json(), { + error: 'Bad Request', + message: 'Always fail!', + statusCode: 400, + code: 'FST_ERR_VALIDATION' }) }) }) diff --git a/test/internals/all.test.js b/test/internals/all.test.js index 72756481269..14d3370145a 100644 --- a/test/internals/all.test.js +++ b/test/internals/all.test.js @@ -1,35 +1,38 @@ 'use strict' -const t = require('tap') -const test = t.test +const { test } = require('node:test') const Fastify = require('../..') -const supportedMethods = ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT', 'OPTIONS'] - -test('fastify.all should add all the methods to the same url', t => { - t.plan(supportedMethods.length * 2) +test('fastify.all should add all the methods to the same url', async t => { const fastify = Fastify() + const requirePayload = [ + 'POST', + 'PUT', + 'PATCH' + ] + + const supportedMethods = fastify.supportedMethods + t.plan(supportedMethods.length) + fastify.all('/', (req, reply) => { reply.send({ method: req.raw.method }) }) - supportedMethods.forEach(injectRequest) + await Promise.all(supportedMethods.map(async method => injectRequest(method))) - function injectRequest (method) { + async function injectRequest (method) { const options = { url: '/', method } - if (method === 'POST' || method === 'PUT' || method === 'PATCH') { + if (requirePayload.includes(method)) { options.payload = { hello: 'world' } } - fastify.inject(options, (err, res) => { - t.error(err) - const payload = JSON.parse(res.payload) - t.same(payload, { method }) - }) + const res = await fastify.inject(options) + const payload = JSON.parse(res.payload) + t.assert.deepStrictEqual(payload, { method }) } }) diff --git a/test/internals/contentTypeParser.test.js b/test/internals/content-type-parser.test.js similarity index 75% rename from test/internals/contentTypeParser.test.js rename to test/internals/content-type-parser.test.js index a24f67e15fd..aadff2dedc1 100644 --- a/test/internals/contentTypeParser.test.js +++ b/test/internals/content-type-parser.test.js @@ -1,24 +1,23 @@ 'use strict' -const t = require('tap') +const { test } = require('node:test') const proxyquire = require('proxyquire') -const test = t.test -const { Readable } = require('stream') -const { kTestInternals } = require('../../lib/symbols') +const { Readable } = require('node:stream') +const { kTestInternals, kRouteContext } = require('../../lib/symbols') const Request = require('../../lib/request') const Reply = require('../../lib/reply') test('rawBody function', t => { t.plan(2) - const internals = require('../../lib/contentTypeParser')[kTestInternals] + const internals = require('../../lib/content-type-parser')[kTestInternals] const body = Buffer.from('你好 世界') const parser = { asString: true, asBuffer: false, fn (req, bodyInString, done) { - t.equal(bodyInString, body.toString()) - t.equal(typeof done, 'function') + t.assert.strictEqual(bodyInString, body.toString()) + t.assert.strictEqual(typeof done, 'function') return { then (cb) { cb() @@ -50,7 +49,7 @@ test('rawBody function', t => { internals.rawBody( request, reply, - reply.context._parserOptions, + reply[kRouteContext]._parserOptions, parser, done ) @@ -61,8 +60,8 @@ test('rawBody function', t => { test('Should support Webpack and faux modules', t => { t.plan(2) - const internals = proxyquire('../../lib/contentTypeParser', { - 'tiny-lru': { default: () => { } } + const internals = proxyquire('../../lib/content-type-parser', { + 'toad-cache': { default: () => { } } })[kTestInternals] const body = Buffer.from('你好 世界') @@ -70,8 +69,8 @@ test('Should support Webpack and faux modules', t => { asString: true, asBuffer: false, fn (req, bodyInString, done) { - t.equal(bodyInString, body.toString()) - t.equal(typeof done, 'function') + t.assert.strictEqual(bodyInString, body.toString()) + t.assert.strictEqual(typeof done, 'function') return { then (cb) { cb() @@ -103,7 +102,7 @@ test('Should support Webpack and faux modules', t => { internals.rawBody( request, reply, - reply.context._parserOptions, + reply[kRouteContext]._parserOptions, parser, done ) diff --git a/test/internals/context.test.js b/test/internals/context.test.js new file mode 100644 index 00000000000..1bab2bf3762 --- /dev/null +++ b/test/internals/context.test.js @@ -0,0 +1,31 @@ +'use strict' + +const { test } = require('node:test') +const { kRouteContext } = require('../../lib/symbols') +const Context = require('../../lib/context') + +const Fastify = require('../..') + +test('context', async context => { + context.plan(1) + + await context.test('Should not contain undefined as key prop', async t => { + t.plan(4) + const app = Fastify() + + app.get('/', (req, reply) => { + t.assert.ok(req[kRouteContext] instanceof Context) + t.assert.ok(reply[kRouteContext] instanceof Context) + t.assert.ok(!('undefined' in reply[kRouteContext])) + t.assert.ok(!('undefined' in req[kRouteContext])) + + reply.send('hello world!') + }) + + try { + await app.inject('/') + } catch (e) { + t.assert.fail(e) + } + }) +}) diff --git a/test/internals/decorator.test.js b/test/internals/decorator.test.js index adadac1c2d2..c83f2c926ae 100644 --- a/test/internals/decorator.test.js +++ b/test/internals/decorator.test.js @@ -1,9 +1,6 @@ 'use strict' -/* eslint no-prototype-builtins: 0 */ - -const t = require('tap') -const test = t.test +const { test } = require('node:test') const decorator = require('../../lib/decorate') const { kState @@ -24,7 +21,7 @@ test('decorate should add the given method to its instance', t => { const server = build() server.add('test', () => {}) - t.ok(server.test) + t.assert.ok(server.test) }) test('decorate is chainable', t => { @@ -46,15 +43,15 @@ test('decorate is chainable', t => { .add('test2', () => {}) .add('test3', () => {}) - t.ok(server.test1) - t.ok(server.test2) - t.ok(server.test3) + t.assert.ok(server.test1) + t.assert.ok(server.test2) + t.assert.ok(server.test3) }) test('checkExistence should check if a property is part of the given instance', t => { t.plan(1) const instance = { test: () => {} } - t.ok(decorator.exist(instance, 'test')) + t.assert.ok(decorator.exist(instance, 'test')) }) test('checkExistence should find the instance if not given', t => { @@ -73,7 +70,7 @@ test('checkExistence should find the instance if not given', t => { const server = build() server.add('test', () => {}) - t.ok(server.check('test')) + t.assert.ok(server.check('test')) }) test('checkExistence should check the prototype as well', t => { @@ -82,7 +79,7 @@ test('checkExistence should check the prototype as well', t => { Instance.prototype.test = () => {} const instance = new Instance() - t.ok(decorator.exist(instance, 'test')) + t.assert.ok(decorator.exist(instance, 'test')) }) test('checkDependencies should throw if a dependency is not present', t => { @@ -90,10 +87,10 @@ test('checkDependencies should throw if a dependency is not present', t => { const instance = {} try { decorator.dependencies(instance, 'foo', ['test']) - t.fail() + t.assert.fail() } catch (e) { - t.equal(e.code, 'FST_ERR_DEC_MISSING_DEPENDENCY') - t.equal(e.message, 'The decorator is missing dependency \'test\'.') + t.assert.strictEqual(e.code, 'FST_ERR_DEC_MISSING_DEPENDENCY') + t.assert.strictEqual(e.message, 'The decorator is missing dependency \'test\'.') } }) @@ -114,10 +111,10 @@ test('decorate should internally call checkDependencies', t => { try { server.add('method', () => {}, ['test']) - t.fail() + t.assert.fail() } catch (e) { - t.equal(e.code, 'FST_ERR_DEC_MISSING_DEPENDENCY') - t.equal(e.message, 'The decorator is missing dependency \'test\'.') + t.assert.strictEqual(e.code, 'FST_ERR_DEC_MISSING_DEPENDENCY') + t.assert.strictEqual(e.message, 'The decorator is missing dependency \'test\'.') } }) @@ -134,14 +131,14 @@ test('decorate should recognize getter/setter objects', t => { decorator.add.call(one, 'foo', { getter: () => this._a, setter: (val) => { - t.pass() + t.assert.ok(true) this._a = val } }) - t.equal(one.hasOwnProperty('foo'), true) - t.equal(one.foo, undefined) + t.assert.strictEqual(Object.hasOwn(one, 'foo'), true) + t.assert.strictEqual(one.foo, undefined) one.foo = 'a' - t.equal(one.foo, 'a') + t.assert.strictEqual(one.foo, 'a') // getter only const two = { @@ -154,6 +151,6 @@ test('decorate should recognize getter/setter objects', t => { decorator.add.call(two, 'foo', { getter: () => 'a getter' }) - t.equal(two.hasOwnProperty('foo'), true) - t.equal(two.foo, 'a getter') + t.assert.strictEqual(Object.hasOwn(two, 'foo'), true) + t.assert.strictEqual(two.foo, 'a getter') }) diff --git a/test/internals/errors.test.js b/test/internals/errors.test.js new file mode 100644 index 00000000000..e132f385cdf --- /dev/null +++ b/test/internals/errors.test.js @@ -0,0 +1,982 @@ +'use strict' + +const { test } = require('node:test') +const errors = require('../../lib/errors') +const { readFileSync } = require('node:fs') +const { resolve } = require('node:path') + +const expectedErrors = 88 + +test(`should expose ${expectedErrors} errors`, t => { + t.plan(1) + const exportedKeys = Object.keys(errors) + let counter = 0 + for (const key of exportedKeys) { + if (errors[key].name === 'FastifyError') { + counter++ + } + } + t.assert.strictEqual(counter, expectedErrors) +}) + +test('ensure name and codes of Errors are identical', t => { + t.plan(expectedErrors) + + const exportedKeys = Object.keys(errors) + for (const key of exportedKeys) { + if (errors[key].name === 'FastifyError') { + t.assert.strictEqual(key, new errors[key]().code, key) + } + } +}) + +test('FST_ERR_NOT_FOUND', t => { + t.plan(5) + const error = new errors.FST_ERR_NOT_FOUND() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_NOT_FOUND') + t.assert.strictEqual(error.message, 'Not Found') + t.assert.strictEqual(error.statusCode, 404) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_OPTIONS_NOT_OBJ', t => { + t.plan(5) + const error = new errors.FST_ERR_OPTIONS_NOT_OBJ() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_OPTIONS_NOT_OBJ') + t.assert.strictEqual(error.message, 'Options must be an object') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof TypeError) +}) + +test('FST_ERR_QSP_NOT_FN', t => { + t.plan(5) + const error = new errors.FST_ERR_QSP_NOT_FN() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_QSP_NOT_FN') + t.assert.strictEqual(error.message, "querystringParser option should be a function, instead got '%s'") + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof TypeError) +}) + +test('FST_ERR_SCHEMA_CONTROLLER_BUCKET_OPT_NOT_FN', t => { + t.plan(5) + const error = new errors.FST_ERR_SCHEMA_CONTROLLER_BUCKET_OPT_NOT_FN() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_SCHEMA_CONTROLLER_BUCKET_OPT_NOT_FN') + t.assert.strictEqual(error.message, "schemaController.bucket option should be a function, instead got '%s'") + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof TypeError) +}) + +test('FST_ERR_SCHEMA_ERROR_FORMATTER_NOT_FN', t => { + t.plan(5) + const error = new errors.FST_ERR_SCHEMA_ERROR_FORMATTER_NOT_FN() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_SCHEMA_ERROR_FORMATTER_NOT_FN') + t.assert.strictEqual(error.message, "schemaErrorFormatter option should be a non async function. Instead got '%s'.") + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof TypeError) +}) + +test('FST_ERR_AJV_CUSTOM_OPTIONS_OPT_NOT_OBJ', t => { + t.plan(5) + const error = new errors.FST_ERR_AJV_CUSTOM_OPTIONS_OPT_NOT_OBJ() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_AJV_CUSTOM_OPTIONS_OPT_NOT_OBJ') + t.assert.strictEqual(error.message, "ajv.customOptions option should be an object, instead got '%s'") + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof TypeError) +}) + +test('FST_ERR_AJV_CUSTOM_OPTIONS_OPT_NOT_ARR', t => { + t.plan(5) + const error = new errors.FST_ERR_AJV_CUSTOM_OPTIONS_OPT_NOT_ARR() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_AJV_CUSTOM_OPTIONS_OPT_NOT_ARR') + t.assert.strictEqual(error.message, "ajv.plugins option should be an array, instead got '%s'") + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof TypeError) +}) + +test('FST_ERR_CTP_ALREADY_PRESENT', t => { + t.plan(5) + const error = new errors.FST_ERR_CTP_ALREADY_PRESENT() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_CTP_ALREADY_PRESENT') + t.assert.strictEqual(error.message, "Content type parser '%s' already present.") + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_CTP_INVALID_TYPE', t => { + t.plan(5) + const error = new errors.FST_ERR_CTP_INVALID_TYPE() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_CTP_INVALID_TYPE') + t.assert.strictEqual(error.message, 'The content type should be a string or a RegExp') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof TypeError) +}) + +test('FST_ERR_CTP_EMPTY_TYPE', t => { + t.plan(5) + const error = new errors.FST_ERR_CTP_EMPTY_TYPE() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_CTP_EMPTY_TYPE') + t.assert.strictEqual(error.message, 'The content type cannot be an empty string') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof TypeError) +}) + +test('FST_ERR_CTP_INVALID_HANDLER', t => { + t.plan(5) + const error = new errors.FST_ERR_CTP_INVALID_HANDLER() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_CTP_INVALID_HANDLER') + t.assert.strictEqual(error.message, 'The content type handler should be a function') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof TypeError) +}) + +test('FST_ERR_CTP_INVALID_PARSE_TYPE', t => { + t.plan(5) + const error = new errors.FST_ERR_CTP_INVALID_PARSE_TYPE() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_CTP_INVALID_PARSE_TYPE') + t.assert.strictEqual(error.message, "The body parser can only parse your data as 'string' or 'buffer', you asked '%s' which is not supported.") + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof TypeError) +}) + +test('FST_ERR_CTP_BODY_TOO_LARGE', t => { + t.plan(5) + const error = new errors.FST_ERR_CTP_BODY_TOO_LARGE() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_CTP_BODY_TOO_LARGE') + t.assert.strictEqual(error.message, 'Request body is too large') + t.assert.strictEqual(error.statusCode, 413) + t.assert.ok(error instanceof RangeError) +}) + +test('FST_ERR_CTP_INVALID_MEDIA_TYPE', t => { + t.plan(5) + const error = new errors.FST_ERR_CTP_INVALID_MEDIA_TYPE() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_CTP_INVALID_MEDIA_TYPE') + t.assert.strictEqual(error.message, 'Unsupported Media Type') + t.assert.strictEqual(error.statusCode, 415) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_CTP_INVALID_CONTENT_LENGTH', t => { + t.plan(5) + const error = new errors.FST_ERR_CTP_INVALID_CONTENT_LENGTH() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_CTP_INVALID_CONTENT_LENGTH') + t.assert.strictEqual(error.message, 'Request body size did not match Content-Length') + t.assert.strictEqual(error.statusCode, 400) + t.assert.ok(error instanceof RangeError) +}) + +test('FST_ERR_CTP_EMPTY_JSON_BODY', t => { + t.plan(5) + const error = new errors.FST_ERR_CTP_EMPTY_JSON_BODY() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_CTP_EMPTY_JSON_BODY') + t.assert.strictEqual(error.message, "Body cannot be empty when content-type is set to 'application/json'") + t.assert.strictEqual(error.statusCode, 400) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_CTP_INVALID_JSON_BODY', t => { + t.plan(5) + const error = new errors.FST_ERR_CTP_INVALID_JSON_BODY() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_CTP_INVALID_JSON_BODY') + t.assert.strictEqual(error.message, "Body is not valid JSON but content-type is set to 'application/json'") + t.assert.strictEqual(error.statusCode, 400) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_CTP_INSTANCE_ALREADY_STARTED', t => { + t.plan(5) + const error = new errors.FST_ERR_CTP_INSTANCE_ALREADY_STARTED() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_CTP_INSTANCE_ALREADY_STARTED') + t.assert.strictEqual(error.message, 'Cannot call "%s" when fastify instance is already started!') + t.assert.strictEqual(error.statusCode, 400) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_DEC_ALREADY_PRESENT', t => { + t.plan(5) + const error = new errors.FST_ERR_DEC_ALREADY_PRESENT() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_DEC_ALREADY_PRESENT') + t.assert.strictEqual(error.message, "The decorator '%s' has already been added!") + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_DEC_DEPENDENCY_INVALID_TYPE', t => { + t.plan(5) + const error = new errors.FST_ERR_DEC_DEPENDENCY_INVALID_TYPE() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_DEC_DEPENDENCY_INVALID_TYPE') + t.assert.strictEqual(error.message, "The dependencies of decorator '%s' must be of type Array.") + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof TypeError) +}) + +test('FST_ERR_DEC_MISSING_DEPENDENCY', t => { + t.plan(5) + const error = new errors.FST_ERR_DEC_MISSING_DEPENDENCY() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_DEC_MISSING_DEPENDENCY') + t.assert.strictEqual(error.message, "The decorator is missing dependency '%s'.") + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_DEC_AFTER_START', t => { + t.plan(5) + const error = new errors.FST_ERR_DEC_AFTER_START() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_DEC_AFTER_START') + t.assert.strictEqual(error.message, "The decorator '%s' has been added after start!") + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_DEC_REFERENCE_TYPE', t => { + t.plan(5) + const error = new errors.FST_ERR_DEC_REFERENCE_TYPE() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_DEC_REFERENCE_TYPE') + t.assert.strictEqual(error.message, "The decorator '%s' of type '%s' is a reference type. Use the { getter, setter } interface instead.") + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_DEC_UNDECLARED', t => { + t.plan(5) + const error = new errors.FST_ERR_DEC_UNDECLARED('myDecorator', 'request') + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_DEC_UNDECLARED') + t.assert.strictEqual(error.message, "No decorator 'myDecorator' has been declared on request.") + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_HOOK_INVALID_TYPE', t => { + t.plan(5) + const error = new errors.FST_ERR_HOOK_INVALID_TYPE() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_HOOK_INVALID_TYPE') + t.assert.strictEqual(error.message, 'The hook name must be a string') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof TypeError) +}) + +test('FST_ERR_HOOK_INVALID_HANDLER', t => { + t.plan(5) + const error = new errors.FST_ERR_HOOK_INVALID_HANDLER() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_HOOK_INVALID_HANDLER') + t.assert.strictEqual(error.message, '%s hook should be a function, instead got %s') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof TypeError) +}) + +test('FST_ERR_HOOK_INVALID_ASYNC_HANDLER', t => { + t.plan(5) + const error = new errors.FST_ERR_HOOK_INVALID_ASYNC_HANDLER() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_HOOK_INVALID_ASYNC_HANDLER') + t.assert.strictEqual(error.message, "Async function has too many arguments. Async hooks should not use the 'done' argument.") + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof TypeError) +}) + +test('FST_ERR_HOOK_NOT_SUPPORTED', t => { + t.plan(5) + const error = new errors.FST_ERR_HOOK_NOT_SUPPORTED() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_HOOK_NOT_SUPPORTED') + t.assert.strictEqual(error.message, '%s hook not supported!') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof TypeError) +}) + +test('FST_ERR_MISSING_MIDDLEWARE', t => { + t.plan(5) + const error = new errors.FST_ERR_MISSING_MIDDLEWARE() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_MISSING_MIDDLEWARE') + t.assert.strictEqual(error.message, 'You must register a plugin for handling middlewares, visit fastify.dev/docs/latest/Reference/Middleware/ for more info.') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_HOOK_TIMEOUT', t => { + t.plan(5) + const error = new errors.FST_ERR_HOOK_TIMEOUT() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_HOOK_TIMEOUT') + t.assert.strictEqual(error.message, "A callback for '%s' hook%s timed out. You may have forgotten to call 'done' function or to resolve a Promise") + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_LOG_INVALID_DESTINATION', t => { + t.plan(5) + const error = new errors.FST_ERR_LOG_INVALID_DESTINATION() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_LOG_INVALID_DESTINATION') + t.assert.strictEqual(error.message, 'Cannot specify both logger.stream and logger.file options') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_LOG_INVALID_LOGGER', t => { + t.plan(5) + const error = new errors.FST_ERR_LOG_INVALID_LOGGER() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_LOG_INVALID_LOGGER') + t.assert.strictEqual(error.message, "Invalid logger object provided. The logger instance should have these functions(s): '%s'.") + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof TypeError) +}) + +test('FST_ERR_LOG_INVALID_LOGGER_INSTANCE', t => { + t.plan(5) + const error = new errors.FST_ERR_LOG_INVALID_LOGGER_INSTANCE() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_LOG_INVALID_LOGGER_INSTANCE') + t.assert.strictEqual(error.message, 'loggerInstance only accepts a logger instance.') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof TypeError) +}) + +test('FST_ERR_LOG_INVALID_LOGGER_CONFIG', t => { + t.plan(5) + const error = new errors.FST_ERR_LOG_INVALID_LOGGER_CONFIG() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_LOG_INVALID_LOGGER_CONFIG') + t.assert.strictEqual(error.message, 'logger options only accepts a configuration object.') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof TypeError) +}) + +test('FST_ERR_LOG_LOGGER_AND_LOGGER_INSTANCE_PROVIDED', t => { + t.plan(5) + const error = new errors.FST_ERR_LOG_LOGGER_AND_LOGGER_INSTANCE_PROVIDED() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_LOG_LOGGER_AND_LOGGER_INSTANCE_PROVIDED') + t.assert.strictEqual(error.message, 'You cannot provide both logger and loggerInstance. Please provide only one.') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof TypeError) +}) + +test('FST_ERR_REP_INVALID_PAYLOAD_TYPE', t => { + t.plan(5) + const error = new errors.FST_ERR_REP_INVALID_PAYLOAD_TYPE() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_REP_INVALID_PAYLOAD_TYPE') + t.assert.strictEqual(error.message, "Attempted to send payload of invalid type '%s'. Expected a string or Buffer.") + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof TypeError) +}) + +test('FST_ERR_REP_RESPONSE_BODY_CONSUMED', t => { + t.plan(5) + const error = new errors.FST_ERR_REP_RESPONSE_BODY_CONSUMED() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_REP_RESPONSE_BODY_CONSUMED') + t.assert.strictEqual(error.message, 'Response.body is already consumed.') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_REP_READABLE_STREAM_LOCKED', t => { + t.plan(5) + const error = new errors.FST_ERR_REP_READABLE_STREAM_LOCKED() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_REP_READABLE_STREAM_LOCKED') + t.assert.strictEqual(error.message, 'ReadableStream was locked. You should call releaseLock() method on reader before sending.') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_REP_ALREADY_SENT', t => { + t.plan(5) + const error = new errors.FST_ERR_REP_ALREADY_SENT('/hello', 'GET') + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_REP_ALREADY_SENT') + t.assert.strictEqual(error.message, 'Reply was already sent, did you forget to "return reply" in "/hello" (GET)?') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_REP_SENT_VALUE', t => { + t.plan(5) + const error = new errors.FST_ERR_REP_SENT_VALUE() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_REP_SENT_VALUE') + t.assert.strictEqual(error.message, 'The only possible value for reply.sent is true.') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof TypeError) +}) + +test('FST_ERR_SEND_INSIDE_ONERR', t => { + t.plan(5) + const error = new errors.FST_ERR_SEND_INSIDE_ONERR() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_SEND_INSIDE_ONERR') + t.assert.strictEqual(error.message, 'You cannot use `send` inside the `onError` hook') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_SEND_UNDEFINED_ERR', t => { + t.plan(5) + const error = new errors.FST_ERR_SEND_UNDEFINED_ERR() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_SEND_UNDEFINED_ERR') + t.assert.strictEqual(error.message, 'Undefined error has occurred') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_BAD_STATUS_CODE', t => { + t.plan(5) + const error = new errors.FST_ERR_BAD_STATUS_CODE() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_BAD_STATUS_CODE') + t.assert.strictEqual(error.message, 'Called reply with an invalid status code: %s') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_BAD_TRAILER_NAME', t => { + t.plan(5) + const error = new errors.FST_ERR_BAD_TRAILER_NAME() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_BAD_TRAILER_NAME') + t.assert.strictEqual(error.message, 'Called reply.trailer with an invalid header name: %s') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_BAD_TRAILER_VALUE', t => { + t.plan(5) + const error = new errors.FST_ERR_BAD_TRAILER_VALUE() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_BAD_TRAILER_VALUE') + t.assert.strictEqual(error.message, "Called reply.trailer('%s', fn) with an invalid type: %s. Expected a function.") + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_FAILED_ERROR_SERIALIZATION', t => { + t.plan(5) + const error = new errors.FST_ERR_FAILED_ERROR_SERIALIZATION() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_FAILED_ERROR_SERIALIZATION') + t.assert.strictEqual(error.message, 'Failed to serialize an error. Error: %s. Original error: %s') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_MISSING_SERIALIZATION_FN', t => { + t.plan(5) + const error = new errors.FST_ERR_MISSING_SERIALIZATION_FN() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_MISSING_SERIALIZATION_FN') + t.assert.strictEqual(error.message, 'Missing serialization function. Key "%s"') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_MISSING_CONTENTTYPE_SERIALIZATION_FN', t => { + t.plan(5) + const error = new errors.FST_ERR_MISSING_CONTENTTYPE_SERIALIZATION_FN() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_MISSING_CONTENTTYPE_SERIALIZATION_FN') + t.assert.strictEqual(error.message, 'Missing serialization function. Key "%s:%s"') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_REQ_INVALID_VALIDATION_INVOCATION', t => { + t.plan(5) + const error = new errors.FST_ERR_REQ_INVALID_VALIDATION_INVOCATION() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_REQ_INVALID_VALIDATION_INVOCATION') + t.assert.strictEqual(error.message, 'Invalid validation invocation. Missing validation function for HTTP part "%s" nor schema provided.') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_SCH_MISSING_ID', t => { + t.plan(5) + const error = new errors.FST_ERR_SCH_MISSING_ID() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_SCH_MISSING_ID') + t.assert.strictEqual(error.message, 'Missing schema $id property') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_SCH_ALREADY_PRESENT', t => { + t.plan(5) + const error = new errors.FST_ERR_SCH_ALREADY_PRESENT() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_SCH_ALREADY_PRESENT') + t.assert.strictEqual(error.message, "Schema with id '%s' already declared!") + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_SCH_CONTENT_MISSING_SCHEMA', t => { + t.plan(5) + const error = new errors.FST_ERR_SCH_CONTENT_MISSING_SCHEMA() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_SCH_CONTENT_MISSING_SCHEMA') + t.assert.strictEqual(error.message, "Schema is missing for the content type '%s'") + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_SCH_DUPLICATE', t => { + t.plan(5) + const error = new errors.FST_ERR_SCH_DUPLICATE() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_SCH_DUPLICATE') + t.assert.strictEqual(error.message, "Schema with '%s' already present!") + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_SCH_VALIDATION_BUILD', t => { + t.plan(5) + const error = new errors.FST_ERR_SCH_VALIDATION_BUILD() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_SCH_VALIDATION_BUILD') + t.assert.strictEqual(error.message, 'Failed building the validation schema for %s: %s, due to error %s') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_SCH_SERIALIZATION_BUILD', t => { + t.plan(5) + const error = new errors.FST_ERR_SCH_SERIALIZATION_BUILD() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_SCH_SERIALIZATION_BUILD') + t.assert.strictEqual(error.message, 'Failed building the serialization schema for %s: %s, due to error %s') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_SCH_RESPONSE_SCHEMA_NOT_NESTED_2XX', t => { + t.plan(5) + const error = new errors.FST_ERR_SCH_RESPONSE_SCHEMA_NOT_NESTED_2XX() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_SCH_RESPONSE_SCHEMA_NOT_NESTED_2XX') + t.assert.strictEqual(error.message, 'response schemas should be nested under a valid status code, e.g { 2xx: { type: "object" } }') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_INIT_OPTS_INVALID', t => { + t.plan(5) + const error = new errors.FST_ERR_INIT_OPTS_INVALID() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_INIT_OPTS_INVALID') + t.assert.strictEqual(error.message, "Invalid initialization options: '%s'") + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_FORCE_CLOSE_CONNECTIONS_IDLE_NOT_AVAILABLE', t => { + t.plan(5) + const error = new errors.FST_ERR_FORCE_CLOSE_CONNECTIONS_IDLE_NOT_AVAILABLE() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_FORCE_CLOSE_CONNECTIONS_IDLE_NOT_AVAILABLE') + t.assert.strictEqual(error.message, "Cannot set forceCloseConnections to 'idle' as your HTTP server does not support closeIdleConnections method") + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_DUPLICATED_ROUTE', t => { + t.plan(5) + const error = new errors.FST_ERR_DUPLICATED_ROUTE() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_DUPLICATED_ROUTE') + t.assert.strictEqual(error.message, "Method '%s' already declared for route '%s'") + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_BAD_URL', t => { + t.plan(5) + const error = new errors.FST_ERR_BAD_URL() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_BAD_URL') + t.assert.strictEqual(error.message, "'%s' is not a valid url component") + t.assert.strictEqual(error.statusCode, 400) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_ASYNC_CONSTRAINT', t => { + t.plan(5) + const error = new errors.FST_ERR_ASYNC_CONSTRAINT() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_ASYNC_CONSTRAINT') + t.assert.strictEqual(error.message, 'Unexpected error from async constraint') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_INVALID_URL', t => { + t.plan(5) + const error = new errors.FST_ERR_INVALID_URL() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_INVALID_URL') + t.assert.strictEqual(error.message, "URL must be a string. Received '%s'") + t.assert.strictEqual(error.statusCode, 400) + t.assert.ok(error instanceof TypeError) +}) + +test('FST_ERR_ROUTE_OPTIONS_NOT_OBJ', t => { + t.plan(5) + const error = new errors.FST_ERR_ROUTE_OPTIONS_NOT_OBJ() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_ROUTE_OPTIONS_NOT_OBJ') + t.assert.strictEqual(error.message, 'Options for "%s:%s" route must be an object') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof TypeError) +}) + +test('FST_ERR_ROUTE_DUPLICATED_HANDLER', t => { + t.plan(5) + const error = new errors.FST_ERR_ROUTE_DUPLICATED_HANDLER() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_ROUTE_DUPLICATED_HANDLER') + t.assert.strictEqual(error.message, 'Duplicate handler for "%s:%s" route is not allowed!') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_ROUTE_HANDLER_NOT_FN', t => { + t.plan(5) + const error = new errors.FST_ERR_ROUTE_HANDLER_NOT_FN() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_ROUTE_HANDLER_NOT_FN') + t.assert.strictEqual(error.message, 'Error Handler for %s:%s route, if defined, must be a function') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof TypeError) +}) + +test('FST_ERR_ROUTE_MISSING_HANDLER', t => { + t.plan(5) + const error = new errors.FST_ERR_ROUTE_MISSING_HANDLER() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_ROUTE_MISSING_HANDLER') + t.assert.strictEqual(error.message, 'Missing handler function for "%s:%s" route.') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_ROUTE_METHOD_INVALID', t => { + t.plan(5) + const error = new errors.FST_ERR_ROUTE_METHOD_INVALID() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_ROUTE_METHOD_INVALID') + t.assert.strictEqual(error.message, 'Provided method is invalid!') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof TypeError) +}) + +test('FST_ERR_ROUTE_METHOD_NOT_SUPPORTED', t => { + t.plan(5) + const error = new errors.FST_ERR_ROUTE_METHOD_NOT_SUPPORTED() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_ROUTE_METHOD_NOT_SUPPORTED') + t.assert.strictEqual(error.message, '%s method is not supported.') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_ROUTE_BODY_VALIDATION_SCHEMA_NOT_SUPPORTED', t => { + t.plan(5) + const error = new errors.FST_ERR_ROUTE_BODY_VALIDATION_SCHEMA_NOT_SUPPORTED() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_ROUTE_BODY_VALIDATION_SCHEMA_NOT_SUPPORTED') + t.assert.strictEqual(error.message, 'Body validation schema for %s:%s route is not supported!') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_ROUTE_BODY_LIMIT_OPTION_NOT_INT', t => { + t.plan(5) + const error = new errors.FST_ERR_ROUTE_BODY_LIMIT_OPTION_NOT_INT() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_ROUTE_BODY_LIMIT_OPTION_NOT_INT') + t.assert.strictEqual(error.message, "'bodyLimit' option must be an integer > 0. Got '%s'") + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof TypeError) +}) + +test('FST_ERR_ROUTE_BODY_LIMIT_OPTION_NOT_INT', t => { + t.plan(5) + const error = new errors.FST_ERR_ROUTE_BODY_LIMIT_OPTION_NOT_INT() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_ROUTE_BODY_LIMIT_OPTION_NOT_INT') + t.assert.strictEqual(error.message, "'bodyLimit' option must be an integer > 0. Got '%s'") + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof TypeError) +}) + +test('FST_ERR_ROUTE_REWRITE_NOT_STR', t => { + t.plan(5) + const error = new errors.FST_ERR_ROUTE_REWRITE_NOT_STR() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_ROUTE_REWRITE_NOT_STR') + t.assert.strictEqual(error.message, 'Rewrite url for "%s" needs to be of type "string" but received "%s"') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof TypeError) +}) + +test('FST_ERR_REOPENED_CLOSE_SERVER', t => { + t.plan(5) + const error = new errors.FST_ERR_REOPENED_CLOSE_SERVER() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_REOPENED_CLOSE_SERVER') + t.assert.strictEqual(error.message, 'Fastify has already been closed and cannot be reopened') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_REOPENED_SERVER', t => { + t.plan(5) + const error = new errors.FST_ERR_REOPENED_SERVER() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_REOPENED_SERVER') + t.assert.strictEqual(error.message, 'Fastify is already listening') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_INSTANCE_ALREADY_LISTENING', t => { + t.plan(5) + const error = new errors.FST_ERR_INSTANCE_ALREADY_LISTENING() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_INSTANCE_ALREADY_LISTENING') + t.assert.strictEqual(error.message, 'Fastify instance is already listening. %s') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_PLUGIN_VERSION_MISMATCH', t => { + t.plan(5) + const error = new errors.FST_ERR_PLUGIN_VERSION_MISMATCH() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_PLUGIN_VERSION_MISMATCH') + t.assert.strictEqual(error.message, "fastify-plugin: %s - expected '%s' fastify version, '%s' is installed") + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_PLUGIN_NOT_PRESENT_IN_INSTANCE', t => { + t.plan(5) + const error = new errors.FST_ERR_PLUGIN_NOT_PRESENT_IN_INSTANCE() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_PLUGIN_NOT_PRESENT_IN_INSTANCE') + t.assert.strictEqual(error.message, "The decorator '%s'%s is not present in %s") + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_PLUGIN_INVALID_ASYNC_HANDLER', t => { + t.plan(5) + const error = new errors.FST_ERR_PLUGIN_INVALID_ASYNC_HANDLER('easter-egg') + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_PLUGIN_INVALID_ASYNC_HANDLER') + t.assert.strictEqual(error.message, 'The easter-egg plugin being registered mixes async and callback styles. Async plugin should not mix async and callback style.') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof TypeError) +}) + +test('FST_ERR_PLUGIN_CALLBACK_NOT_FN', t => { + t.plan(5) + const error = new errors.FST_ERR_PLUGIN_CALLBACK_NOT_FN() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_PLUGIN_CALLBACK_NOT_FN') + t.assert.strictEqual(error.message, 'fastify-plugin: %s') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof TypeError) +}) + +test('FST_ERR_PLUGIN_NOT_VALID', t => { + t.plan(5) + const error = new errors.FST_ERR_PLUGIN_NOT_VALID() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_PLUGIN_NOT_VALID') + t.assert.strictEqual(error.message, 'fastify-plugin: %s') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_ROOT_PLG_BOOTED', t => { + t.plan(5) + const error = new errors.FST_ERR_ROOT_PLG_BOOTED() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_ROOT_PLG_BOOTED') + t.assert.strictEqual(error.message, 'fastify-plugin: %s') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_PARENT_PLUGIN_BOOTED', t => { + t.plan(5) + const error = new errors.FST_ERR_PARENT_PLUGIN_BOOTED() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_PARENT_PLUGIN_BOOTED') + t.assert.strictEqual(error.message, 'fastify-plugin: %s') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_PLUGIN_TIMEOUT', t => { + t.plan(5) + const error = new errors.FST_ERR_PLUGIN_TIMEOUT() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_PLUGIN_TIMEOUT') + t.assert.strictEqual(error.message, 'fastify-plugin: %s') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_VALIDATION', t => { + t.plan(5) + const error = new errors.FST_ERR_VALIDATION() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_VALIDATION') + t.assert.strictEqual(error.message, '%s') + t.assert.strictEqual(error.statusCode, 400) + t.assert.ok(error instanceof Error) +}) + +test('FST_ERR_LISTEN_OPTIONS_INVALID', t => { + t.plan(5) + const error = new errors.FST_ERR_LISTEN_OPTIONS_INVALID() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_LISTEN_OPTIONS_INVALID') + t.assert.strictEqual(error.message, "Invalid listen options: '%s'") + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof TypeError) +}) + +test('FST_ERR_ERROR_HANDLER_NOT_FN', t => { + t.plan(5) + const error = new errors.FST_ERR_ERROR_HANDLER_NOT_FN() + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.code, 'FST_ERR_ERROR_HANDLER_NOT_FN') + t.assert.strictEqual(error.message, 'Error Handler must be a function') + t.assert.strictEqual(error.statusCode, 500) + t.assert.ok(error instanceof TypeError) +}) + +test('Ensure that all errors are in Errors.md TOC', t => { + t.plan(expectedErrors) + + const errorsMd = readFileSync(resolve(__dirname, '../../docs/Reference/Errors.md'), 'utf8') + + const exportedKeys = Object.keys(errors) + for (const key of exportedKeys) { + if (errors[key].name === 'FastifyError') { + t.assert.ok(errorsMd.includes(` - [${key.toUpperCase()}](#${key.toLowerCase()})`), key) + } + } +}) + +test('Ensure that non-existing errors are not in Errors.md TOC', t => { + t.plan(expectedErrors) + const errorsMd = readFileSync(resolve(__dirname, '../../docs/Reference/Errors.md'), 'utf8') + + const matchRE = / {4}- \[([A-Z0-9_]+)\]\(#[a-z0-9_]+\)/g + const matches = errorsMd.matchAll(matchRE) + const exportedKeys = Object.keys(errors) + + for (const match of matches) { + t.assert.ok(exportedKeys.indexOf(match[1]) !== -1, match[1]) + } +}) + +test('Ensure that all errors are in Errors.md documented', t => { + t.plan(expectedErrors) + const errorsMd = readFileSync(resolve(__dirname, '../../docs/Reference/Errors.md'), 'utf8') + + const exportedKeys = Object.keys(errors) + for (const key of exportedKeys) { + if (errors[key].name === 'FastifyError') { + t.assert.ok(errorsMd.includes(`${key.toUpperCase()}`), key) + } + } +}) + +test('Ensure that non-existing errors are not in Errors.md documented', t => { + t.plan(expectedErrors) + + const errorsMd = readFileSync(resolve(__dirname, '../../docs/Reference/Errors.md'), 'utf8') + + const matchRE = /([0-9a-zA-Z_]+)<\/a>/g + const matches = errorsMd.matchAll(matchRE) + const exportedKeys = Object.keys(errors) + + for (const match of matches) { + t.assert.ok(exportedKeys.indexOf(match[1]) !== -1, match[1]) + } +}) + +test('Ensure that all errors are in errors.d.ts', t => { + t.plan(expectedErrors) + + const errorsDts = readFileSync(resolve(__dirname, '../../types/errors.d.ts'), 'utf8') + + const FastifyErrorCodesRE = /export type FastifyErrorCodes = Record<([^>]+),\s*FastifyErrorConstructor>/m + + const [, errorCodeType] = errorsDts.match(FastifyErrorCodesRE) + + const errorCodeRE = /'([A-Z0-9_]+)'/g + const matches = errorCodeType.matchAll(errorCodeRE) + const errorTypes = [...matches].map(match => match[1]) + const exportedKeys = Object.keys(errors) + + for (const key of exportedKeys) { + if (errors[key].name === 'FastifyError') { + t.assert.ok(errorTypes.includes(key), key) + } + } +}) + +test('Ensure that non-existing errors are not in errors.d.ts', t => { + t.plan(expectedErrors) + + const errorsDts = readFileSync(resolve(__dirname, '../../types/errors.d.ts'), 'utf8') + + const FastifyErrorCodesRE = /export type FastifyErrorCodes = Record<([^>]+),\s*FastifyErrorConstructor>/m + + const [, errorCodeType] = errorsDts.match(FastifyErrorCodesRE) + + const errorCodeRE = /'([A-Z0-9_]+)'/g + const matches = errorCodeType.matchAll(errorCodeRE) + const exportedKeys = Object.keys(errors) + + for (const match of matches) { + t.assert.ok(exportedKeys.indexOf(match[1]) !== -1, match[1]) + } +}) diff --git a/test/internals/handle-request.test.js b/test/internals/handle-request.test.js new file mode 100644 index 00000000000..2a6962d7b24 --- /dev/null +++ b/test/internals/handle-request.test.js @@ -0,0 +1,270 @@ +'use strict' + +const { test } = require('node:test') +const handleRequest = require('../../lib/handle-request') +const internals = require('../../lib/handle-request')[Symbol.for('internals')] +const Request = require('../../lib/request') +const Reply = require('../../lib/reply') +const { kRouteContext } = require('../../lib/symbols') +const buildSchema = require('../../lib/validation').compileSchemasForValidation + +const Ajv = require('ajv') +const ajv = new Ajv({ coerceTypes: true }) + +function schemaValidator ({ schema, method, url, httpPart }) { + const validateFunction = ajv.compile(schema) + const fn = function (body) { + const isOk = validateFunction(body) + if (isOk) return + return false + } + fn.errors = [] + return fn +} + +test('handleRequest function - sent reply', t => { + t.plan(1) + const request = {} + const reply = { sent: true } + const res = handleRequest(null, request, reply) + t.assert.strictEqual(res, undefined) +}) + +test('handleRequest function - invoke with error', t => { + t.plan(1) + const request = {} + const reply = {} + reply.send = (err) => t.assert.strictEqual(err.message, 'Kaboom') + handleRequest(new Error('Kaboom'), request, reply) +}) + +test('handler function - invalid schema', t => { + t.plan(1) + const res = {} + res.log = { error: () => {}, info: () => {} } + const context = { + config: { + method: 'GET', + url: '/an-url' + }, + schema: { + body: { + type: 'object', + properties: { + hello: { type: 'number' } + } + } + }, + errorHandler: { func: () => { t.assert.ok('errorHandler called') } }, + handler: () => {}, + Reply, + Request, + preValidation: [], + preHandler: [], + onSend: [], + onError: [], + attachValidation: false, + schemaErrorFormatter: () => new Error() + } + buildSchema(context, schemaValidator) + const request = { + body: { hello: 'world' }, + [kRouteContext]: context + } + internals.handler(request, new Reply(res, request)) +}) + +test('handler function - reply', t => { + t.plan(3) + const res = {} + res.end = () => { + t.assert.strictEqual(res.statusCode, 204) + t.assert.ok(true) + } + res.writeHead = () => {} + const context = { + handler: (req, reply) => { + t.assert.strictEqual(typeof reply, 'object') + reply.code(204) + reply.send(undefined) + }, + Reply, + Request, + preValidation: [], + preHandler: [], + onSend: [], + onError: [], + config: { + url: '', + method: '' + } + } + buildSchema(context, schemaValidator) + internals.handler({ [kRouteContext]: context }, new Reply(res, { [kRouteContext]: context })) +}) + +test('handler function - preValidationCallback with finished response', t => { + t.plan(0) + const res = {} + // Be sure to check only `writableEnded` where is available + res.writableEnded = true + res.end = () => { + t.assert.fail() + } + res.writeHead = () => {} + const context = { + handler: (req, reply) => { + t.assert.fail() + reply.send(undefined) + }, + Reply, + Request, + preValidation: null, + preHandler: [], + onSend: [], + onError: [] + } + buildSchema(context, schemaValidator) + internals.handler({ [kRouteContext]: context }, new Reply(res, { [kRouteContext]: context })) +}) + +test('request should be defined in onSend Hook on post request with content type application/json', async t => { + t.plan(6) + const fastify = require('../..')() + + t.after(() => { + fastify.close() + }) + + fastify.addHook('onSend', (request, reply, payload, done) => { + t.assert.ok(request) + t.assert.ok(request.raw) + t.assert.ok(request.id) + t.assert.ok(request.params) + t.assert.ok(request.query) + done() + }) + fastify.post('/', (request, reply) => { + reply.send(200) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + const result = await fetch(fastifyServer, { + method: 'POST', + headers: { + 'content-type': 'application/json' + } + }) + + t.assert.strictEqual(result.status, 400) +}) + +test('request should be defined in onSend Hook on post request with content type application/x-www-form-urlencoded', async t => { + t.plan(5) + const fastify = require('../..')() + + t.after(() => { + fastify.close() + }) + + fastify.addHook('onSend', (request, reply, payload, done) => { + t.assert.ok(request) + t.assert.ok(request.raw) + t.assert.ok(request.params) + t.assert.ok(request.query) + done() + }) + fastify.post('/', (request, reply) => { + reply.send(200) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + const result = await fetch(fastifyServer, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded' + } + }) + + // a 415 error is expected because of missing content type parser + t.assert.strictEqual(result.status, 415) +}) + +test('request should be defined in onSend Hook on options request with content type application/x-www-form-urlencoded', async t => { + t.plan(15) + const fastify = require('../..')() + + t.after(() => { + fastify.close() + }) + + fastify.addHook('onSend', (request, reply, payload, done) => { + t.assert.ok(request) + t.assert.ok(request.raw) + t.assert.ok(request.params) + t.assert.ok(request.query) + done() + }) + fastify.options('/', (request, reply) => { + reply.send(200) + }) + + // Test 1: OPTIONS with body and content-type header + const result1 = await fastify.inject({ + method: 'OPTIONS', + url: '/', + body: 'first-name=OPTIONS&last-name=METHOD', + headers: { + 'content-type': 'application/x-www-form-urlencoded' + } + }) + + // Content-Type is not supported + t.assert.strictEqual(result1.statusCode, 415) + + // Test 2: OPTIONS with content-type header only (no body) + const result2 = await fastify.inject({ + method: 'OPTIONS', + url: '/', + headers: { + 'content-type': 'application/x-www-form-urlencoded' + } + }) + + // Content-Type is not supported + t.assert.strictEqual(result2.statusCode, 415) + + // Test 3: OPTIONS with body but no content-type header + const result3 = await fastify.inject({ + method: 'OPTIONS', + url: '/', + body: 'first-name=OPTIONS&last-name=METHOD' + }) + + // No content-type with payload + t.assert.strictEqual(result3.statusCode, 415) +}) + +test('request should respond with an error if an unserialized payload is sent inside an async handler', async t => { + t.plan(2) + + const fastify = require('../..')() + + fastify.get('/', (request, reply) => { + reply.type('text/html') + return Promise.resolve(request.headers) + }) + + const res = await fastify.inject({ + method: 'GET', + url: '/' + }) + + t.assert.strictEqual(res.statusCode, 500) + t.assert.deepStrictEqual(JSON.parse(res.payload), { + error: 'Internal Server Error', + code: 'FST_ERR_REP_INVALID_PAYLOAD_TYPE', + message: 'Attempted to send payload of invalid type \'object\'. Expected a string or Buffer.', + statusCode: 500 + }) +}) diff --git a/test/internals/handleRequest.test.js b/test/internals/handleRequest.test.js deleted file mode 100644 index ac16b9cc33f..00000000000 --- a/test/internals/handleRequest.test.js +++ /dev/null @@ -1,247 +0,0 @@ -'use strict' - -const { test } = require('tap') -const handleRequest = require('../../lib/handleRequest') -const internals = require('../../lib/handleRequest')[Symbol.for('internals')] -const Request = require('../../lib/request') -const Reply = require('../../lib/reply') -const buildSchema = require('../../lib/validation').compileSchemasForValidation -const sget = require('simple-get').concat - -const Ajv = require('ajv') -const ajv = new Ajv({ coerceTypes: true }) - -function schemaValidator ({ schema, method, url, httpPart }) { - const validateFuncion = ajv.compile(schema) - const fn = function (body) { - const isOk = validateFuncion(body) - if (isOk) return - return false - } - fn.errors = [] - return fn -} - -test('handleRequest function - sent reply', t => { - t.plan(1) - const request = {} - const reply = { sent: true } - const res = handleRequest(null, request, reply) - t.equal(res, undefined) -}) - -test('handleRequest function - invoke with error', t => { - t.plan(1) - const request = {} - const reply = {} - reply.send = (err) => t.equal(err.message, 'Kaboom') - handleRequest(new Error('Kaboom'), request, reply) -}) - -test('handler function - invalid schema', t => { - t.plan(1) - const res = {} - res.log = { error: () => {}, info: () => {} } - const context = { - config: { - method: 'GET', - url: '/an-url' - }, - schema: { - body: { - type: 'object', - properties: { - hello: { type: 'number' } - } - } - }, - errorHandler: { func: () => { t.pass('errorHandler called') } }, - handler: () => {}, - Reply, - Request, - preValidation: [], - preHandler: [], - onSend: [], - onError: [], - attachValidation: false, - schemaErrorFormatter: () => new Error() - } - buildSchema(context, schemaValidator) - const request = { - body: { hello: 'world' }, - context - } - internals.handler(request, new Reply(res, request)) -}) - -test('handler function - reply', t => { - t.plan(3) - const res = {} - res.end = () => { - t.equal(res.statusCode, 204) - t.pass() - } - res.writeHead = () => {} - const context = { - handler: (req, reply) => { - t.equal(typeof reply, 'object') - reply.code(204) - reply.send(undefined) - }, - Reply, - Request, - preValidation: [], - preHandler: [], - onSend: [], - onError: [] - } - buildSchema(context, schemaValidator) - internals.handler({}, new Reply(res, { context })) -}) - -test('handler function - preValidationCallback with finished response', t => { - t.plan(0) - const res = {} - // Be sure to check only `writableEnded` where is available - res.writableEnded = true - res.end = () => { - t.fail() - } - res.writeHead = () => {} - const context = { - handler: (req, reply) => { - t.fail() - reply.send(undefined) - }, - Reply, - Request, - preValidation: null, - preHandler: [], - onSend: [], - onError: [] - } - buildSchema(context, schemaValidator) - internals.handler({}, new Reply(res, { context })) -}) - -test('request should be defined in onSend Hook on post request with content type application/json', t => { - t.plan(8) - const fastify = require('../..')() - - fastify.addHook('onSend', (request, reply, payload, done) => { - t.ok(request) - t.ok(request.raw) - t.ok(request.id) - t.ok(request.params) - t.ok(request.query) - done() - }) - fastify.post('/', (request, reply) => { - reply.send(200) - }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - sget({ - method: 'POST', - url: 'http://localhost:' + fastify.server.address().port, - headers: { - 'content-type': 'application/json' - } - }, (err, response, body) => { - t.error(err) - // a 400 error is expected because of no body - t.equal(response.statusCode, 400) - }) - }) -}) - -test('request should be defined in onSend Hook on post request with content type application/x-www-form-urlencoded', t => { - t.plan(7) - const fastify = require('../..')() - - fastify.addHook('onSend', (request, reply, payload, done) => { - t.ok(request) - t.ok(request.raw) - t.ok(request.params) - t.ok(request.query) - done() - }) - fastify.post('/', (request, reply) => { - reply.send(200) - }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - sget({ - method: 'POST', - url: 'http://localhost:' + fastify.server.address().port, - headers: { - 'content-type': 'application/x-www-form-urlencoded' - } - }, (err, response, body) => { - t.error(err) - // a 415 error is expected because of missing content type parser - t.equal(response.statusCode, 415) - }) - }) -}) - -test('request should be defined in onSend Hook on options request with content type application/x-www-form-urlencoded', t => { - t.plan(7) - const fastify = require('../..')() - - fastify.addHook('onSend', (request, reply, payload, done) => { - t.ok(request) - t.ok(request.raw) - t.ok(request.params) - t.ok(request.query) - done() - }) - fastify.options('/', (request, reply) => { - reply.send(200) - }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - sget({ - method: 'OPTIONS', - url: 'http://localhost:' + fastify.server.address().port, - headers: { - 'content-type': 'application/x-www-form-urlencoded' - } - }, (err, response, body) => { - t.error(err) - // Body parsing skipped, so no body sent - t.equal(response.statusCode, 200) - }) - }) -}) - -test('request should respond with an error if an unserialized payload is sent inside an async handler', t => { - t.plan(3) - - const fastify = require('../..')() - - fastify.get('/', (request, reply) => { - reply.type('text/html') - return Promise.resolve(request.headers) - }) - - fastify.inject({ - method: 'GET', - url: '/' - }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 500) - t.strictSame(JSON.parse(res.payload), { - error: 'Internal Server Error', - code: 'FST_ERR_REP_INVALID_PAYLOAD_TYPE', - message: 'Attempted to send payload of invalid type \'object\'. Expected a string or Buffer.', - statusCode: 500 - }) - }) -}) diff --git a/test/internals/hookRunner.test.js b/test/internals/hook-runner.test.js similarity index 55% rename from test/internals/hookRunner.test.js rename to test/internals/hook-runner.test.js index 438e55c74d0..f8319fe29a4 100644 --- a/test/internals/hookRunner.test.js +++ b/test/internals/hook-runner.test.js @@ -1,178 +1,189 @@ 'use strict' -const t = require('tap') -const test = t.test -const { hookRunner, onSendHookRunner } = require('../../lib/hooks') +const { test } = require('node:test') +const { hookRunnerGenerator, onSendHookRunner } = require('../../lib/hooks') test('hookRunner - Basic', t => { t.plan(9) - hookRunner([fn1, fn2, fn3], iterator, 'a', 'b', done) + const hookRunner = hookRunnerGenerator(iterator) + + hookRunner([fn1, fn2, fn3], 'a', 'b', done) function iterator (fn, a, b, done) { return fn(a, b, done) } function fn1 (a, b, done) { - t.equal(a, 'a') - t.equal(b, 'b') + t.assert.strictEqual(a, 'a') + t.assert.strictEqual(b, 'b') done() } function fn2 (a, b, done) { - t.equal(a, 'a') - t.equal(b, 'b') + t.assert.strictEqual(a, 'a') + t.assert.strictEqual(b, 'b') done() } function fn3 (a, b, done) { - t.equal(a, 'a') - t.equal(b, 'b') + t.assert.strictEqual(a, 'a') + t.assert.strictEqual(b, 'b') done() } function done (err, a, b) { - t.error(err) - t.equal(a, 'a') - t.equal(b, 'b') + t.assert.ifError(err) + t.assert.strictEqual(a, 'a') + t.assert.strictEqual(b, 'b') } }) test('hookRunner - In case of error should skip to done', t => { t.plan(7) - hookRunner([fn1, fn2, fn3], iterator, 'a', 'b', done) + const hookRunner = hookRunnerGenerator(iterator) + + hookRunner([fn1, fn2, fn3], 'a', 'b', done) function iterator (fn, a, b, done) { return fn(a, b, done) } function fn1 (a, b, done) { - t.equal(a, 'a') - t.equal(b, 'b') + t.assert.strictEqual(a, 'a') + t.assert.strictEqual(b, 'b') done() } function fn2 (a, b, done) { - t.equal(a, 'a') - t.equal(b, 'b') + t.assert.strictEqual(a, 'a') + t.assert.strictEqual(b, 'b') done(new Error('kaboom')) } function fn3 () { - t.fail('We should not be here') + t.assert.fail('We should not be here') } function done (err, a, b) { - t.equal(err.message, 'kaboom') - t.equal(a, 'a') - t.equal(b, 'b') + t.assert.strictEqual(err.message, 'kaboom') + t.assert.strictEqual(a, 'a') + t.assert.strictEqual(b, 'b') } }) test('hookRunner - Should handle throw', t => { t.plan(7) - hookRunner([fn1, fn2, fn3], iterator, 'a', 'b', done) + const hookRunner = hookRunnerGenerator(iterator) + + hookRunner([fn1, fn2, fn3], 'a', 'b', done) function iterator (fn, a, b, done) { return fn(a, b, done) } function fn1 (a, b, done) { - t.equal(a, 'a') - t.equal(b, 'b') + t.assert.strictEqual(a, 'a') + t.assert.strictEqual(b, 'b') done() } function fn2 (a, b, done) { - t.equal(a, 'a') - t.equal(b, 'b') + t.assert.strictEqual(a, 'a') + t.assert.strictEqual(b, 'b') throw new Error('kaboom') } function fn3 () { - t.fail('We should not be here') + t.assert.fail('We should not be here') } function done (err, a, b) { - t.equal(err.message, 'kaboom') - t.equal(a, 'a') - t.equal(b, 'b') + t.assert.strictEqual(err.message, 'kaboom') + t.assert.strictEqual(a, 'a') + t.assert.strictEqual(b, 'b') } }) test('hookRunner - Should handle promises', t => { t.plan(9) - hookRunner([fn1, fn2, fn3], iterator, 'a', 'b', done) + const hookRunner = hookRunnerGenerator(iterator) + + hookRunner([fn1, fn2, fn3], 'a', 'b', done) function iterator (fn, a, b, done) { return fn(a, b, done) } function fn1 (a, b) { - t.equal(a, 'a') - t.equal(b, 'b') + t.assert.strictEqual(a, 'a') + t.assert.strictEqual(b, 'b') return Promise.resolve() } function fn2 (a, b) { - t.equal(a, 'a') - t.equal(b, 'b') + t.assert.strictEqual(a, 'a') + t.assert.strictEqual(b, 'b') return Promise.resolve() } function fn3 (a, b) { - t.equal(a, 'a') - t.equal(b, 'b') + t.assert.strictEqual(a, 'a') + t.assert.strictEqual(b, 'b') return Promise.resolve() } function done (err, a, b) { - t.error(err) - t.equal(a, 'a') - t.equal(b, 'b') + t.assert.ifError(err) + t.assert.strictEqual(a, 'a') + t.assert.strictEqual(b, 'b') } }) test('hookRunner - In case of error should skip to done (with promises)', t => { t.plan(7) - hookRunner([fn1, fn2, fn3], iterator, 'a', 'b', done) + const hookRunner = hookRunnerGenerator(iterator) + + hookRunner([fn1, fn2, fn3], 'a', 'b', done) function iterator (fn, a, b, done) { return fn(a, b, done) } function fn1 (a, b) { - t.equal(a, 'a') - t.equal(b, 'b') + t.assert.strictEqual(a, 'a') + t.assert.strictEqual(b, 'b') return Promise.resolve() } function fn2 (a, b) { - t.equal(a, 'a') - t.equal(b, 'b') + t.assert.strictEqual(a, 'a') + t.assert.strictEqual(b, 'b') return Promise.reject(new Error('kaboom')) } function fn3 () { - t.fail('We should not be here') + t.assert.fail('We should not be here') } function done (err, a, b) { - t.equal(err.message, 'kaboom') - t.equal(a, 'a') - t.equal(b, 'b') + t.assert.strictEqual(err.message, 'kaboom') + t.assert.strictEqual(a, 'a') + t.assert.strictEqual(b, 'b') } }) test('hookRunner - Be able to exit before its natural end', t => { t.plan(4) + const hookRunner = hookRunnerGenerator(iterator) + let shouldStop = false - hookRunner([fn1, fn2, fn3], iterator, 'a', 'b', done) + hookRunner([fn1, fn2, fn3], 'a', 'b', done) function iterator (fn, a, b, done) { if (shouldStop) { @@ -182,24 +193,24 @@ test('hookRunner - Be able to exit before its natural end', t => { } function fn1 (a, b, done) { - t.equal(a, 'a') - t.equal(b, 'b') + t.assert.strictEqual(a, 'a') + t.assert.strictEqual(b, 'b') done() } function fn2 (a, b) { - t.equal(a, 'a') - t.equal(b, 'b') + t.assert.strictEqual(a, 'a') + t.assert.strictEqual(b, 'b') shouldStop = true return Promise.resolve() } function fn3 () { - t.fail('this should not be called') + t.assert.fail('this should not be called') } function done () { - t.fail('this should not be called') + t.assert.fail('this should not be called') } }) @@ -208,30 +219,32 @@ test('hookRunner - Promises that resolve to a value do not change the state', t const originalState = { a: 'a', b: 'b' } - hookRunner([fn1, fn2, fn3], iterator, originalState, 'b', done) + const hookRunner = hookRunnerGenerator(iterator) + + hookRunner([fn1, fn2, fn3], originalState, 'b', done) function iterator (fn, state, b, done) { return fn(state, b, done) } function fn1 (state, b, done) { - t.equal(state, originalState) + t.assert.strictEqual(state, originalState) return Promise.resolve(null) } function fn2 (state, b, done) { - t.equal(state, originalState) + t.assert.strictEqual(state, originalState) return Promise.resolve('string') } function fn3 (state, b, done) { - t.equal(state, originalState) + t.assert.strictEqual(state, originalState) return Promise.resolve({ object: true }) } function done (err, state, b) { - t.error(err) - t.equal(state, originalState) + t.assert.ifError(err) + t.assert.strictEqual(state, originalState) } }) @@ -245,31 +258,31 @@ test('onSendHookRunner - Basic', t => { onSendHookRunner([fn1, fn2, fn3], originalRequest, originalReply, originalPayload, done) function fn1 (request, reply, payload, done) { - t.same(request, originalRequest) - t.same(reply, originalReply) - t.equal(payload, originalPayload) + t.assert.deepStrictEqual(request, originalRequest) + t.assert.deepStrictEqual(reply, originalReply) + t.assert.strictEqual(payload, originalPayload) done() } function fn2 (request, reply, payload, done) { - t.same(request, originalRequest) - t.same(reply, originalReply) - t.equal(payload, originalPayload) + t.assert.deepStrictEqual(request, originalRequest) + t.assert.deepStrictEqual(reply, originalReply) + t.assert.strictEqual(payload, originalPayload) done() } function fn3 (request, reply, payload, done) { - t.same(request, originalRequest) - t.same(reply, originalReply) - t.equal(payload, originalPayload) + t.assert.deepStrictEqual(request, originalRequest) + t.assert.deepStrictEqual(reply, originalReply) + t.assert.strictEqual(payload, originalPayload) done() } function done (err, request, reply, payload) { - t.error(err) - t.same(request, originalRequest) - t.same(reply, originalReply) - t.equal(payload, originalPayload) + t.assert.ifError(err) + t.assert.deepStrictEqual(request, originalRequest) + t.assert.deepStrictEqual(reply, originalReply) + t.assert.strictEqual(payload, originalPayload) } }) @@ -286,25 +299,25 @@ test('onSendHookRunner - Can change the payload', t => { onSendHookRunner([fn1, fn2, fn3], originalRequest, originalReply, v1, done) function fn1 (request, reply, payload, done) { - t.same(payload, v1) + t.assert.deepStrictEqual(payload, v1) done(null, v2) } function fn2 (request, reply, payload, done) { - t.same(payload, v2) + t.assert.deepStrictEqual(payload, v2) done(null, v3) } function fn3 (request, reply, payload, done) { - t.same(payload, v3) + t.assert.deepStrictEqual(payload, v3) done(null, v4) } function done (err, request, reply, payload) { - t.error(err) - t.same(request, originalRequest) - t.same(reply, originalReply) - t.same(payload, v4) + t.assert.ifError(err) + t.assert.deepStrictEqual(request, originalRequest) + t.assert.deepStrictEqual(reply, originalReply) + t.assert.deepStrictEqual(payload, v4) } }) @@ -319,24 +332,24 @@ test('onSendHookRunner - In case of error should skip to done', t => { onSendHookRunner([fn1, fn2, fn3], originalRequest, originalReply, v1, done) function fn1 (request, reply, payload, done) { - t.same(payload, v1) + t.assert.deepStrictEqual(payload, v1) done(null, v2) } function fn2 (request, reply, payload, done) { - t.same(payload, v2) + t.assert.deepStrictEqual(payload, v2) done(new Error('kaboom')) } function fn3 () { - t.fail('We should not be here') + t.assert.fail('We should not be here') } function done (err, request, reply, payload) { - t.equal(err.message, 'kaboom') - t.same(request, originalRequest) - t.same(reply, originalReply) - t.same(payload, v2) + t.assert.strictEqual(err.message, 'kaboom') + t.assert.deepStrictEqual(request, originalRequest) + t.assert.deepStrictEqual(reply, originalReply) + t.assert.deepStrictEqual(payload, v2) } }) @@ -353,25 +366,25 @@ test('onSendHookRunner - Should handle promises', t => { onSendHookRunner([fn1, fn2, fn3], originalRequest, originalReply, v1, done) function fn1 (request, reply, payload) { - t.same(payload, v1) + t.assert.deepStrictEqual(payload, v1) return Promise.resolve(v2) } function fn2 (request, reply, payload) { - t.same(payload, v2) + t.assert.deepStrictEqual(payload, v2) return Promise.resolve(v3) } function fn3 (request, reply, payload) { - t.same(payload, v3) + t.assert.deepStrictEqual(payload, v3) return Promise.resolve(v4) } function done (err, request, reply, payload) { - t.error(err) - t.same(request, originalRequest) - t.same(reply, originalReply) - t.same(payload, v4) + t.assert.ifError(err) + t.assert.deepStrictEqual(request, originalRequest) + t.assert.deepStrictEqual(reply, originalReply) + t.assert.deepStrictEqual(payload, v4) } }) @@ -386,24 +399,24 @@ test('onSendHookRunner - In case of error should skip to done (with promises)', onSendHookRunner([fn1, fn2, fn3], originalRequest, originalReply, v1, done) function fn1 (request, reply, payload) { - t.same(payload, v1) + t.assert.deepStrictEqual(payload, v1) return Promise.resolve(v2) } function fn2 (request, reply, payload) { - t.same(payload, v2) + t.assert.deepStrictEqual(payload, v2) return Promise.reject(new Error('kaboom')) } function fn3 () { - t.fail('We should not be here') + t.assert.fail('We should not be here') } function done (err, request, reply, payload) { - t.equal(err.message, 'kaboom') - t.same(request, originalRequest) - t.same(reply, originalReply) - t.same(payload, v2) + t.assert.strictEqual(err.message, 'kaboom') + t.assert.deepStrictEqual(request, originalRequest) + t.assert.deepStrictEqual(reply, originalReply) + t.assert.deepStrictEqual(payload, v2) } }) @@ -418,19 +431,19 @@ test('onSendHookRunner - Be able to exit before its natural end', t => { onSendHookRunner([fn1, fn2, fn3], originalRequest, originalReply, v1, done) function fn1 (request, reply, payload, done) { - t.same(payload, v1) + t.assert.deepStrictEqual(payload, v1) done(null, v2) } function fn2 (request, reply, payload) { - t.same(payload, v2) + t.assert.deepStrictEqual(payload, v2) } function fn3 () { - t.fail('this should not be called') + t.assert.fail('this should not be called') } function done () { - t.fail('this should not be called') + t.assert.fail('this should not be called') } }) diff --git a/test/internals/hooks.test.js b/test/internals/hooks.test.js index 38594aa82f4..a0811a97b02 100644 --- a/test/internals/hooks.test.js +++ b/test/internals/hooks.test.js @@ -1,54 +1,51 @@ 'use strict' -const t = require('tap') -const test = t.test - +const { test } = require('node:test') const { Hooks } = require('../../lib/hooks') +const { default: fastify } = require('../../fastify') const noop = () => {} test('hooks should have 4 array with the registered hooks', t => { const hooks = new Hooks() - t.equal(typeof hooks, 'object') - t.ok(Array.isArray(hooks.onRequest)) - t.ok(Array.isArray(hooks.onSend)) - t.ok(Array.isArray(hooks.preParsing)) - t.ok(Array.isArray(hooks.preValidation)) - t.ok(Array.isArray(hooks.preHandler)) - t.ok(Array.isArray(hooks.onResponse)) - t.ok(Array.isArray(hooks.onError)) - t.end() + t.assert.strictEqual(typeof hooks, 'object') + t.assert.ok(Array.isArray(hooks.onRequest)) + t.assert.ok(Array.isArray(hooks.onSend)) + t.assert.ok(Array.isArray(hooks.preParsing)) + t.assert.ok(Array.isArray(hooks.preValidation)) + t.assert.ok(Array.isArray(hooks.preHandler)) + t.assert.ok(Array.isArray(hooks.onResponse)) + t.assert.ok(Array.isArray(hooks.onError)) }) test('hooks.add should add a hook to the given hook', t => { const hooks = new Hooks() hooks.add('onRequest', noop) - t.equal(hooks.onRequest.length, 1) - t.equal(typeof hooks.onRequest[0], 'function') + t.assert.strictEqual(hooks.onRequest.length, 1) + t.assert.strictEqual(typeof hooks.onRequest[0], 'function') hooks.add('preParsing', noop) - t.equal(hooks.preParsing.length, 1) - t.equal(typeof hooks.preParsing[0], 'function') + t.assert.strictEqual(hooks.preParsing.length, 1) + t.assert.strictEqual(typeof hooks.preParsing[0], 'function') hooks.add('preValidation', noop) - t.equal(hooks.preValidation.length, 1) - t.equal(typeof hooks.preValidation[0], 'function') + t.assert.strictEqual(hooks.preValidation.length, 1) + t.assert.strictEqual(typeof hooks.preValidation[0], 'function') hooks.add('preHandler', noop) - t.equal(hooks.preHandler.length, 1) - t.equal(typeof hooks.preHandler[0], 'function') + t.assert.strictEqual(hooks.preHandler.length, 1) + t.assert.strictEqual(typeof hooks.preHandler[0], 'function') hooks.add('onResponse', noop) - t.equal(hooks.onResponse.length, 1) - t.equal(typeof hooks.onResponse[0], 'function') + t.assert.strictEqual(hooks.onResponse.length, 1) + t.assert.strictEqual(typeof hooks.onResponse[0], 'function') hooks.add('onSend', noop) - t.equal(hooks.onSend.length, 1) - t.equal(typeof hooks.onSend[0], 'function') + t.assert.strictEqual(hooks.onSend.length, 1) + t.assert.strictEqual(typeof hooks.onSend[0], 'function') hooks.add('onError', noop) - t.equal(hooks.onError.length, 1) - t.equal(typeof hooks.onError[0], 'function') - t.end() + t.assert.strictEqual(hooks.onError.length, 1) + t.assert.strictEqual(typeof hooks.onError[0], 'function') }) test('hooks should throw on unexisting handler', t => { @@ -56,9 +53,9 @@ test('hooks should throw on unexisting handler', t => { const hooks = new Hooks() try { hooks.add('onUnexistingHook', noop) - t.fail() + t.assert.fail() } catch (e) { - t.pass() + t.assert.ok(true) } }) @@ -66,18 +63,34 @@ test('should throw on wrong parameters', t => { const hooks = new Hooks() t.plan(4) try { - hooks.add(null, noop) - t.fail() + hooks.add(null, () => {}) + t.assert.fail() } catch (e) { - t.equal(e.code, 'FST_ERR_HOOK_INVALID_TYPE') - t.equal(e.message, 'The hook name must be a string') + t.assert.strictEqual(e.code, 'FST_ERR_HOOK_INVALID_TYPE') + t.assert.strictEqual(e.message, 'The hook name must be a string') } try { - hooks.add('', null) - t.fail() + hooks.add('onSend', null) + t.assert.fail() } catch (e) { - t.equal(e.code, 'FST_ERR_HOOK_INVALID_HANDLER') - t.equal(e.message, 'The hook callback must be a function') + t.assert.strictEqual(e.code, 'FST_ERR_HOOK_INVALID_HANDLER') + t.assert.strictEqual(e.message, 'onSend hook should be a function, instead got [object Null]') + } +}) + +test('Integration test: internal function _addHook should be turned into app.ready() rejection', async (t) => { + const app = fastify() + + app.register(async function () { + app.addHook('notRealHook', async () => {}) + }) + + try { + await app.ready() + t.assert.fail('Expected ready() to throw') + } catch (err) { + t.assert.strictEqual(err.code, 'FST_ERR_HOOK_NOT_SUPPORTED') + t.assert.match(err.message, /hook not supported/i) } }) diff --git a/test/internals/initialConfig.test.js b/test/internals/initial-config.test.js similarity index 52% rename from test/internals/initialConfig.test.js rename to test/internals/initial-config.test.js index c90b91c552b..5cd5229aea4 100644 --- a/test/internals/initialConfig.test.js +++ b/test/internals/initial-config.test.js @@ -1,13 +1,13 @@ 'use strict' -const { test, before } = require('tap') +const { test, before } = require('node:test') const Fastify = require('../..') const helper = require('../helper') -const http = require('http') +const http = require('node:http') const pino = require('pino') const split = require('split2') const deepClone = require('rfdc')({ circles: true, proto: false }) -const { deepFreezeObject } = require('../../lib/initialConfigValidation').utils +const { deepFreezeObject } = require('../../lib/initial-config-validation').utils const { buildCertificate } = require('../build-certificate') @@ -23,7 +23,7 @@ before(async function () { test('Fastify.initialConfig is an object', t => { t.plan(1) - t.type(Fastify().initialConfig, 'object') + t.assert.ok(typeof Fastify().initialConfig === 'object') }) test('without options passed to Fastify, initialConfig should expose default values', t => { @@ -34,28 +34,29 @@ test('without options passed to Fastify, initialConfig should expose default val keepAliveTimeout: 72000, maxRequestsPerSocket: 0, requestTimeout: 0, + handlerTimeout: 0, bodyLimit: 1024 * 1024, caseSensitive: true, allowUnsafeRegex: false, disableRequestLogging: false, - jsonShorthand: true, ignoreTrailingSlash: false, ignoreDuplicateSlashes: false, maxParamLength: 100, onProtoPoisoning: 'error', onConstructorPoisoning: 'error', pluginTimeout: 10000, - requestIdHeader: 'request-id', + requestIdHeader: false, requestIdLogLabel: 'reqId', http2SessionTimeout: 72000, - exposeHeadRoutes: true + exposeHeadRoutes: true, + useSemicolonDelimiter: false } - t.same(Fastify().initialConfig, fastifyDefaultOptions) + t.assert.deepStrictEqual(Fastify().initialConfig, fastifyDefaultOptions) }) test('Fastify.initialConfig should expose all options', t => { - t.plan(20) + t.plan(22) const serverFactory = (handler, opts) => { const server = http.createServer((req, res) => { @@ -99,11 +100,12 @@ test('Fastify.initialConfig should expose all options', t => { allowUnsafeRegex: false, requestIdHeader: 'request-id-alt', pluginTimeout: 20000, + useSemicolonDelimiter: false, querystringParser: str => str, genReqId: function (req) { return reqId++ }, - logger: pino({ level: 'info' }), + loggerInstance: pino({ level: 'info' }), constraints: { version: versionStrategy }, @@ -113,28 +115,30 @@ test('Fastify.initialConfig should expose all options', t => { } const fastify = Fastify(options) - t.equal(fastify.initialConfig.http2, true) - t.equal(fastify.initialConfig.https, true, 'for security reason the key cert is hidden') - t.equal(fastify.initialConfig.ignoreTrailingSlash, true) - t.equal(fastify.initialConfig.ignoreDuplicateSlashes, true) - t.equal(fastify.initialConfig.maxParamLength, 200) - t.equal(fastify.initialConfig.connectionTimeout, 0) - t.equal(fastify.initialConfig.keepAliveTimeout, 72000) - t.equal(fastify.initialConfig.bodyLimit, 1049600) - t.equal(fastify.initialConfig.onProtoPoisoning, 'remove') - t.equal(fastify.initialConfig.caseSensitive, true) - t.equal(fastify.initialConfig.allowUnsafeRegex, false) - t.equal(fastify.initialConfig.requestIdHeader, 'request-id-alt') - t.equal(fastify.initialConfig.pluginTimeout, 20000) - t.ok(fastify.initialConfig.constraints.version) + t.assert.strictEqual(fastify.initialConfig.http2, true) + t.assert.strictEqual(fastify.initialConfig.https, true, 'for security reason the key cert is hidden') + t.assert.strictEqual(fastify.initialConfig.ignoreTrailingSlash, true) + t.assert.strictEqual(fastify.initialConfig.ignoreDuplicateSlashes, true) + t.assert.strictEqual(fastify.initialConfig.maxParamLength, 200) + t.assert.strictEqual(fastify.initialConfig.connectionTimeout, 0) + t.assert.strictEqual(fastify.initialConfig.keepAliveTimeout, 72000) + t.assert.strictEqual(fastify.initialConfig.bodyLimit, 1049600) + t.assert.strictEqual(fastify.initialConfig.onProtoPoisoning, 'remove') + t.assert.strictEqual(fastify.initialConfig.caseSensitive, true) + t.assert.strictEqual(fastify.initialConfig.useSemicolonDelimiter, false) + t.assert.strictEqual(fastify.initialConfig.allowUnsafeRegex, false) + t.assert.strictEqual(fastify.initialConfig.requestIdHeader, 'request-id-alt') + t.assert.strictEqual(fastify.initialConfig.pluginTimeout, 20000) + t.assert.ok(fastify.initialConfig.constraints.version) // obfuscated options: - t.equal(fastify.initialConfig.serverFactory, undefined) - t.equal(fastify.initialConfig.trustProxy, undefined) - t.equal(fastify.initialConfig.genReqId, undefined) - t.equal(fastify.initialConfig.querystringParser, undefined) - t.equal(fastify.initialConfig.logger, undefined) - t.equal(fastify.initialConfig.trustProxy, undefined) + t.assert.strictEqual(fastify.initialConfig.serverFactory, undefined) + t.assert.strictEqual(fastify.initialConfig.trustProxy, undefined) + t.assert.strictEqual(fastify.initialConfig.genReqId, undefined) + t.assert.strictEqual(fastify.initialConfig.childLoggerFactory, undefined) + t.assert.strictEqual(fastify.initialConfig.querystringParser, undefined) + t.assert.strictEqual(fastify.initialConfig.logger, undefined) + t.assert.strictEqual(fastify.initialConfig.trustProxy, undefined) }) test('Should throw if you try to modify Fastify.initialConfig', t => { @@ -143,12 +147,12 @@ test('Should throw if you try to modify Fastify.initialConfig', t => { const fastify = Fastify({ ignoreTrailingSlash: true }) try { fastify.initialConfig.ignoreTrailingSlash = false - t.fail() + t.assert.fail() } catch (error) { - t.type(error, TypeError) - t.equal(error.message, "Cannot assign to read only property 'ignoreTrailingSlash' of object '#'") - t.ok(error.stack) - t.pass() + t.assert.ok(error instanceof TypeError) + t.assert.strictEqual(error.message, "Cannot assign to read only property 'ignoreTrailingSlash' of object '#'") + t.assert.ok(error.stack) + t.assert.ok(true) } }) @@ -165,12 +169,12 @@ test('We must avoid shallow freezing and ensure that the whole object is freezed try { fastify.initialConfig.https.allowHTTP1 = false - t.fail() + t.assert.fail() } catch (error) { - t.type(error, TypeError) - t.equal(error.message, "Cannot assign to read only property 'allowHTTP1' of object '#'") - t.ok(error.stack) - t.same(fastify.initialConfig.https, { + t.assert.ok(error instanceof TypeError) + t.assert.strictEqual(error.message, "Cannot assign to read only property 'allowHTTP1' of object '#'") + t.assert.ok(error.stack) + t.assert.deepStrictEqual(fastify.initialConfig.https, { allowHTTP1: true }, 'key cert removed') } @@ -180,7 +184,7 @@ test('https value check', t => { t.plan(1) const fastify = Fastify({}) - t.notOk(fastify.initialConfig.https) + t.assert.ok(!fastify.initialConfig.https) }) test('Return an error if options do not match the validation schema', t => { @@ -189,14 +193,14 @@ test('Return an error if options do not match the validation schema', t => { try { Fastify({ ignoreTrailingSlash: 'string instead of boolean' }) - t.fail() + t.assert.fail() } catch (error) { - t.type(error, Error) - t.equal(error.name, 'FastifyError') - t.equal(error.message, 'Invalid initialization options: \'["must be boolean"]\'') - t.equal(error.code, 'FST_ERR_INIT_OPTS_INVALID') - t.ok(error.stack) - t.pass() + t.assert.ok(error instanceof Error) + t.assert.strictEqual(error.name, 'FastifyError') + t.assert.strictEqual(error.message, 'Invalid initialization options: \'["must be boolean"]\'') + t.assert.strictEqual(error.code, 'FST_ERR_INIT_OPTS_INVALID') + t.assert.ok(error.stack) + t.assert.ok(true) } }) @@ -213,10 +217,10 @@ test('Original options must not be frozen', t => { const fastify = Fastify(originalOptions) - t.equal(Object.isFrozen(originalOptions), false) - t.equal(Object.isFrozen(originalOptions.https), false) - t.equal(Object.isFrozen(fastify.initialConfig), true) - t.equal(Object.isFrozen(fastify.initialConfig.https), true) + t.assert.strictEqual(Object.isFrozen(originalOptions), false) + t.assert.strictEqual(Object.isFrozen(originalOptions.https), false) + t.assert.strictEqual(Object.isFrozen(fastify.initialConfig), true) + t.assert.strictEqual(Object.isFrozen(fastify.initialConfig.https), true) }) test('Original options must not be altered (test deep cloning)', t => { @@ -235,15 +239,15 @@ test('Original options must not be altered (test deep cloning)', t => { const fastify = Fastify(originalOptions) // initialConfig has been triggered - t.equal(Object.isFrozen(fastify.initialConfig), true) + t.assert.strictEqual(Object.isFrozen(fastify.initialConfig), true) // originalOptions must not have been altered - t.same(originalOptions.https.key, originalOptionsClone.https.key) - t.same(originalOptions.https.cert, originalOptionsClone.https.cert) + t.assert.deepStrictEqual(originalOptions.https.key, originalOptionsClone.https.key) + t.assert.deepStrictEqual(originalOptions.https.cert, originalOptionsClone.https.cert) }) -test('Should not have issues when passing stream options to Pino.js', t => { - t.plan(15) +test('Should not have issues when passing stream options to Pino.js', (t, done) => { + t.plan(17) const stream = split(JSON.parse) @@ -259,64 +263,73 @@ test('Should not have issues when passing stream options to Pino.js', t => { try { fastify = Fastify(originalOptions) + fastify.setChildLoggerFactory(function (logger, bindings, opts) { + bindings.someBinding = 'value' + return logger.child(bindings, opts) + }) - t.type(fastify, 'object') - t.same(fastify.initialConfig, { + t.assert.ok(typeof fastify === 'object') + t.assert.deepStrictEqual(fastify.initialConfig, { connectionTimeout: 0, keepAliveTimeout: 72000, maxRequestsPerSocket: 0, requestTimeout: 0, + handlerTimeout: 0, bodyLimit: 1024 * 1024, caseSensitive: true, allowUnsafeRegex: false, disableRequestLogging: false, - jsonShorthand: true, ignoreTrailingSlash: true, ignoreDuplicateSlashes: false, maxParamLength: 100, onProtoPoisoning: 'error', onConstructorPoisoning: 'error', pluginTimeout: 10000, - requestIdHeader: 'request-id', + requestIdHeader: false, requestIdLogLabel: 'reqId', http2SessionTimeout: 72000, - exposeHeadRoutes: true + exposeHeadRoutes: true, + useSemicolonDelimiter: false }) } catch (error) { - t.fail() + t.assert.fail() } fastify.get('/', function (req, reply) { - t.ok(req.log) + t.assert.ok(req.log) reply.send({ hello: 'world' }) }) stream.once('data', listenAtLogLine => { - t.ok(listenAtLogLine, 'listen at log message is ok') + t.assert.ok(listenAtLogLine, 'listen at log message is ok') stream.once('data', line => { const id = line.reqId - t.ok(line.reqId, 'reqId is defined') - t.ok(line.req, 'req is defined') - t.equal(line.msg, 'incoming request', 'message is set') - t.equal(line.req.method, 'GET', 'method is get') + t.assert.ok(line.reqId, 'reqId is defined') + t.assert.strictEqual(line.someBinding, 'value', 'child logger binding is set') + t.assert.ok(line.req, 'req is defined') + t.assert.strictEqual(line.msg, 'incoming request', 'message is set') + t.assert.strictEqual(line.req.method, 'GET', 'method is get') stream.once('data', line => { - t.equal(line.reqId, id) - t.ok(line.reqId, 'reqId is defined') - t.ok(line.res, 'res is defined') - t.equal(line.msg, 'request completed', 'message is set') - t.equal(line.res.statusCode, 200, 'statusCode is 200') - t.ok(line.responseTime, 'responseTime is defined') + t.assert.strictEqual(line.reqId, id) + t.assert.ok(line.reqId, 'reqId is defined') + t.assert.strictEqual(line.someBinding, 'value', 'child logger binding is set') + t.assert.ok(line.res, 'res is defined') + t.assert.strictEqual(line.msg, 'request completed', 'message is set') + t.assert.strictEqual(line.res.statusCode, 200, 'statusCode is 200') + t.assert.ok(line.responseTime, 'responseTime is defined') }) }) }) fastify.listen({ port: 0, host: localhost }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) + t.assert.ifError(err) + t.after(() => { fastify.close() }) - http.get(`http://${localhostForURL}:${fastify.server.address().port}`) + http.get(`http://${localhostForURL}:${fastify.server.address().port}`, () => { + done() + }) }) }) @@ -339,51 +352,32 @@ test('deepFreezeObject() should not throw on TypedArray', t => { const frozenObject = deepFreezeObject(object) // Buffers should not be frozen, as they are Uint8Array inherited instances - t.equal(Object.isFrozen(frozenObject.buffer), false) + t.assert.strictEqual(Object.isFrozen(frozenObject.buffer), false) - t.equal(Object.isFrozen(frozenObject), true) - t.equal(Object.isFrozen(frozenObject.object), true) - t.equal(Object.isFrozen(frozenObject.object.nested), true) + t.assert.strictEqual(Object.isFrozen(frozenObject), true) + t.assert.strictEqual(Object.isFrozen(frozenObject.object), true) + t.assert.strictEqual(Object.isFrozen(frozenObject.object.nested), true) - t.pass() + t.assert.ok(true) } catch (error) { - t.fail() + t.assert.fail() } }) -test('Fastify.initialConfig should accept the deprecated versioning option', t => { - t.plan(1) - - function onWarning (warning) { - t.equal(warning.code, 'FSTDEP009') - } - - process.on('warning', onWarning) - - const versioning = { - storage: function () { - const versions = {} - return { - get: (version) => { return versions[version] || null }, - set: (version, store) => { versions[version] = store } - } - }, - deriveVersion: (req, ctx) => { - return req.headers.accept - } - } - - Fastify({ versioning }) - setImmediate(function () { - process.removeListener('warning', onWarning) - t.end() - }) -}) - test('pluginTimeout should be parsed correctly', t => { const withDisabledTimeout = Fastify({ pluginTimeout: '0' }) - t.equal(withDisabledTimeout.initialConfig.pluginTimeout, 0) + t.assert.strictEqual(withDisabledTimeout.initialConfig.pluginTimeout, 0) const withInvalidTimeout = Fastify({ pluginTimeout: undefined }) - t.equal(withInvalidTimeout.initialConfig.pluginTimeout, 10000) - t.end() + t.assert.strictEqual(withInvalidTimeout.initialConfig.pluginTimeout, 10000) +}) + +test('Should not mutate the options object outside Fastify', async t => { + const options = Object.freeze({}) + + try { + Fastify(options) + t.assert.ok(true) + } catch (error) { + t.assert.fail(error.message) + } }) diff --git a/test/internals/logger.test.js b/test/internals/logger.test.js index dec0863fe6b..3660b828c8f 100644 --- a/test/internals/logger.test.js +++ b/test/internals/logger.test.js @@ -1,34 +1,34 @@ 'use strict' -const t = require('tap') -const test = t.test +const { test } = require('node:test') const Fastify = require('../..') -const loggerUtils = require('../../lib/logger') +const loggerUtils = require('../../lib/logger-factory') +const { serializers } = require('../../lib/logger-pino') test('time resolution', t => { t.plan(2) - t.equal(typeof loggerUtils.now, 'function') - t.equal(typeof loggerUtils.now(), 'number') + t.assert.strictEqual(typeof loggerUtils.now, 'function') + t.assert.strictEqual(typeof loggerUtils.now(), 'number') }) -test('The logger should add a unique id for every request', t => { +test('The logger should add a unique id for every request', (t, done) => { const ids = [] const fastify = Fastify() fastify.get('/', (req, reply) => { - t.ok(req.id) + t.assert.ok(req.id) reply.send({ id: req.id }) }) fastify.listen({ port: 0 }, err => { - t.error(err) + t.assert.ifError(err) const queue = new Queue() for (let i = 0; i < 10; i++) { queue.add(checkId) } queue.add(() => { fastify.close() - t.end() + done() }) }) @@ -37,24 +37,24 @@ test('The logger should add a unique id for every request', t => { method: 'GET', url: 'http://localhost:' + fastify.server.address().port }, (err, res) => { - t.error(err) + t.assert.ifError(err) const payload = JSON.parse(res.payload) - t.ok(ids.indexOf(payload.id) === -1, 'the id should not be duplicated') + t.assert.ok(ids.indexOf(payload.id) === -1, 'the id should not be duplicated') ids.push(payload.id) done() }) } }) -test('The logger should reuse request id header for req.id', t => { +test('The logger should not reuse request id header for req.id', (t, done) => { const fastify = Fastify() fastify.get('/', (req, reply) => { - t.ok(req.id) + t.assert.ok(req.id) reply.send({ id: req.id }) }) fastify.listen({ port: 0 }, err => { - t.error(err) + t.assert.ifError(err) fastify.inject({ method: 'GET', @@ -63,11 +63,40 @@ test('The logger should reuse request id header for req.id', t => { 'Request-Id': 'request-id-1' } }, (err, res) => { - t.error(err) + t.assert.ifError(err) const payload = JSON.parse(res.payload) - t.ok(payload.id === 'request-id-1', 'the request id from the header should be returned') + t.assert.ok(payload.id !== 'request-id-1', 'the request id from the header should not be returned with default configuration') + t.assert.ok(payload.id === 'req-1') // first request id when using the default configuration fastify.close() - t.end() + done() + }) + }) +}) + +test('The logger should reuse request id header for req.id if requestIdHeader is set', (t, done) => { + const fastify = Fastify({ + requestIdHeader: 'request-id' + }) + fastify.get('/', (req, reply) => { + t.assert.ok(req.id) + reply.send({ id: req.id }) + }) + + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + + fastify.inject({ + method: 'GET', + url: 'http://localhost:' + fastify.server.address().port, + headers: { + 'Request-Id': 'request-id-1' + } + }, (err, res) => { + t.assert.ifError(err) + const payload = JSON.parse(res.payload) + t.assert.ok(payload.id === 'request-id-1', 'the request id from the header should be returned') + fastify.close() + done() }) }) }) @@ -97,7 +126,7 @@ Queue.prototype.run = function run () { test('The logger should error if both stream and file destination are given', t => { t.plan(2) - const stream = require('stream').Writable + const stream = require('node:stream').Writable try { Fastify({ @@ -108,26 +137,26 @@ test('The logger should error if both stream and file destination are given', t } }) } catch (err) { - t.equal(err.code, 'FST_ERR_LOG_INVALID_DESTINATION') - t.equal(err.message, 'Cannot specify both logger.stream and logger.file options') + t.assert.strictEqual(err.code, 'FST_ERR_LOG_INVALID_DESTINATION') + t.assert.strictEqual(err.message, 'Cannot specify both logger.stream and logger.file options') } }) test('The serializer prevent fails if the request socket is undefined', t => { t.plan(1) - const serialized = loggerUtils.serializers.req({ + const serialized = serializers.req({ method: 'GET', url: '/', socket: undefined, headers: {} }) - t.same(serialized, { + t.assert.deepStrictEqual(serialized, { method: 'GET', url: '/', version: undefined, - hostname: undefined, + host: undefined, remoteAddress: undefined, remotePort: undefined }) diff --git a/test/internals/plugin.test.js b/test/internals/plugin.test.js index 0d03f234f5a..d3bd3415b24 100644 --- a/test/internals/plugin.test.js +++ b/test/internals/plugin.test.js @@ -1,19 +1,18 @@ 'use strict' -const t = require('tap') -const test = t.test +const { test } = require('node:test') -const pluginUtilsPublic = require('../../lib/pluginUtils.js') +const pluginUtilsPublic = require('../../lib/plugin-utils.js') const symbols = require('../../lib/symbols.js') -const pluginUtils = require('../../lib/pluginUtils')[symbols.kTestInternals] +const pluginUtils = require('../../lib/plugin-utils')[symbols.kTestInternals] test("shouldSkipOverride should check the 'skip-override' symbol", t => { t.plan(2) yes[Symbol.for('skip-override')] = true - t.ok(pluginUtils.shouldSkipOverride(yes)) - t.notOk(pluginUtils.shouldSkipOverride(no)) + t.assert.ok(pluginUtils.shouldSkipOverride(yes)) + t.assert.ok(!pluginUtils.shouldSkipOverride(no)) function yes () {} function no () {} @@ -26,7 +25,22 @@ test('getPluginName should return plugin name if the file is cached', t => { require.cache[expectedPluginName] = { exports: fn } const pluginName = pluginUtilsPublic.getPluginName(fn) - t.equal(pluginName, expectedPluginName) + t.assert.strictEqual(pluginName, expectedPluginName) +}) + +test('getPluginName should not throw when require.cache is undefined', t => { + t.plan(1) + function example () { + console.log('is just an example') + } + const cache = require.cache + require.cache = undefined + t.after(() => { + require.cache = cache + }) + const pluginName = pluginUtilsPublic.getPluginName(example) + + t.assert.strictEqual(pluginName, 'example') }) test("getMeta should return the object stored with the 'plugin-meta' symbol", t => { @@ -35,7 +49,7 @@ test("getMeta should return the object stored with the 'plugin-meta' symbol", t const meta = { hello: 'world' } fn[Symbol.for('plugin-meta')] = meta - t.same(meta, pluginUtils.getMeta(fn)) + t.assert.deepStrictEqual(meta, pluginUtils.getMeta(fn)) function fn () {} }) @@ -58,9 +72,9 @@ test('checkDecorators should check if the given decorator is present in the inst try { pluginUtils.checkDecorators.call(context, fn) - t.pass('Everything ok') + t.assert.ok('Everything ok') } catch (err) { - t.fail(err) + t.assert.fail(err) } function fn () {} @@ -84,9 +98,9 @@ test('checkDecorators should check if the given decorator is present in the inst try { pluginUtils.checkDecorators.call(context, fn) - t.fail('should throw') + t.assert.fail('should throw') } catch (err) { - t.equal(err.message, "The decorator 'plugin' is not present in Request") + t.assert.strictEqual(err.message, "The decorator 'plugin' is not present in Request") } function fn () {} @@ -106,9 +120,9 @@ test('checkDecorators should accept optional decorators', t => { try { pluginUtils.checkDecorators.call(context, fn) - t.pass('Everything ok') + t.assert.ok('Everything ok') } catch (err) { - t.fail(err) + t.assert.fail(err) } function fn () {} @@ -122,13 +136,13 @@ test('checkDependencies should check if the given dependency is present in the i } function context () {} - context[pluginUtilsPublic.registeredPlugins] = ['plugin'] + context[pluginUtilsPublic.kRegisteredPlugins] = ['plugin'] try { pluginUtils.checkDependencies.call(context, fn) - t.pass('Everything ok') + t.assert.ok('Everything ok') } catch (err) { - t.fail(err) + t.assert.fail(err) } function fn () {} @@ -143,13 +157,13 @@ test('checkDependencies should check if the given dependency is present in the i } function context () {} - context[pluginUtilsPublic.registeredPlugins] = [] + context[pluginUtilsPublic.kRegisteredPlugins] = [] try { pluginUtils.checkDependencies.call(context, fn) - t.fail('should throw') + t.assert.fail('should throw') } catch (err) { - t.equal(err.message, "The dependency 'plugin' of plugin 'test-plugin' is not registered") + t.assert.strictEqual(err.message, "The dependency 'plugin' of plugin 'test-plugin' is not registered") } function fn () {} diff --git a/test/internals/promise.test.js b/test/internals/promise.test.js new file mode 100644 index 00000000000..f1b06c5753e --- /dev/null +++ b/test/internals/promise.test.js @@ -0,0 +1,63 @@ +'use strict' + +const { test } = require('node:test') + +const { kTestInternals } = require('../../lib/symbols') +const PonyPromise = require('../../lib/promise') + +test('withResolvers', async (t) => { + t.plan(3) + await t.test('resolve', async (t) => { + t.plan(1) + const { promise, resolve } = PonyPromise.withResolvers() + resolve(true) + t.assert.ok(await promise) + }) + await t.test('reject', async (t) => { + t.plan(1) + const { promise, reject } = PonyPromise.withResolvers() + await t.assert.rejects(async () => { + reject(Error('reject')) + return promise + }, { + name: 'Error', + message: 'reject' + }) + }) + await t.test('thenable', async (t) => { + t.plan(1) + const { promise, resolve } = PonyPromise.withResolvers() + resolve(true) + promise.then((value) => { + t.assert.ok(value) + }) + }) +}) + +test('withResolvers - ponyfill', async (t) => { + await t.test('resolve', async (t) => { + t.plan(1) + const { promise, resolve } = PonyPromise[kTestInternals].withResolvers() + resolve(true) + t.assert.ok(await promise) + }) + await t.test('reject', async (t) => { + t.plan(1) + const { promise, reject } = PonyPromise[kTestInternals].withResolvers() + await t.assert.rejects(async () => { + reject(Error('reject')) + return promise + }, { + name: 'Error', + message: 'reject' + }) + }) + await t.test('thenable', async (t) => { + t.plan(1) + const { promise, resolve } = PonyPromise.withResolvers() + resolve(true) + promise.then((value) => { + t.assert.ok(value) + }) + }) +}) diff --git a/test/internals/reply-serialize.test.js b/test/internals/reply-serialize.test.js new file mode 100644 index 00000000000..24606d39808 --- /dev/null +++ b/test/internals/reply-serialize.test.js @@ -0,0 +1,714 @@ +'use strict' + +const { test } = require('node:test') +const { kReplyCacheSerializeFns, kRouteContext } = require('../../lib/symbols') +const Fastify = require('../../fastify') + +function getDefaultSchema () { + return { + type: 'object', + required: ['hello'], + properties: { + hello: { type: 'string' }, + world: { type: 'string' } + } + } +} + +function getResponseSchema () { + return { + 201: { + type: 'object', + required: ['status'], + properties: { + status: { + type: 'string', + enum: ['ok'] + }, + message: { + type: 'string' + } + } + }, + '4xx': { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['error'] + }, + code: { + type: 'integer', + minimum: 1 + }, + message: { + type: 'string' + } + } + }, + '3xx': { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + fullName: { type: 'string' }, + phone: { type: 'number' } + } + } + } + } + } + } +} + +test('Reply#compileSerializationSchema', async t => { + t.plan(4) + + await t.test('Should return a serialization function', async t => { + const fastify = Fastify() + + t.plan(4) + + fastify.get('/', (req, reply) => { + const serialize = reply.compileSerializationSchema(getDefaultSchema()) + const input = { hello: 'world' } + t.assert.ok(serialize instanceof Function) + t.assert.ok(typeof serialize(input) === 'string') + t.assert.strictEqual(serialize(input), JSON.stringify(input)) + + try { + serialize({ world: 'foo' }) + } catch (err) { + t.assert.strictEqual(err.message, '"hello" is required!') + } + + reply.send({ hello: 'world' }) + }) + + await fastify.inject({ + path: '/', + method: 'GET' + }) + }) + + await t.test('Should reuse the serialize fn across multiple invocations - Route without schema', + async t => { + const fastify = Fastify() + let serialize = null + let counter = 0 + + t.plan(17) + + const schemaObj = getDefaultSchema() + + fastify.get('/', (req, reply) => { + const input = { hello: 'world' } + counter++ + if (counter > 1) { + const newSerialize = reply.compileSerializationSchema(schemaObj) + t.assert.strictEqual(serialize, newSerialize, 'Are the same validate function') + serialize = newSerialize + } else { + t.assert.ok(true, 'build the schema compilation function') + serialize = reply.compileSerializationSchema(schemaObj) + } + + t.assert.ok(serialize instanceof Function) + t.assert.strictEqual(serialize(input), JSON.stringify(input)) + + try { + serialize({ world: 'foo' }) + } catch (err) { + t.assert.strictEqual(err.message, '"hello" is required!') + } + + reply.send({ hello: 'world' }) + }) + + await Promise.all([ + fastify.inject('/'), + fastify.inject('/'), + fastify.inject('/'), + fastify.inject('/') + ]) + + t.assert.strictEqual(counter, 4) + } + ) + + await t.test('Should use the custom serializer compiler for the route', + async t => { + const fastify = Fastify() + let called = 0 + const custom = ({ schema, httpStatus, url, method }) => { + t.assert.strictEqual(schema, schemaObj) + t.assert.strictEqual(url, '/') + t.assert.strictEqual(method, 'GET') + t.assert.strictEqual(httpStatus, '201') + + return input => { + called++ + t.assert.deepStrictEqual(input, { hello: 'world' }) + return JSON.stringify(input) + } + } + + const custom2 = ({ schema, httpStatus, url, method, contentType }) => { + t.assert.strictEqual(schema, schemaObj) + t.assert.strictEqual(url, '/user') + t.assert.strictEqual(method, 'GET') + t.assert.strictEqual(httpStatus, '3xx') + t.assert.strictEqual(contentType, 'application/json') + + return input => { + t.assert.deepStrictEqual(input, { fullName: 'Jone', phone: 1090243795 }) + return JSON.stringify(input) + } + } + + t.plan(17) + const schemaObj = getDefaultSchema() + + fastify.get('/', { serializerCompiler: custom }, (req, reply) => { + const input = { hello: 'world' } + const first = reply.compileSerializationSchema(schemaObj, '201') + const second = reply.compileSerializationSchema(schemaObj, '201') + + t.assert.strictEqual(first, second) + t.assert.ok(first(input), JSON.stringify(input)) + t.assert.ok(second(input), JSON.stringify(input)) + t.assert.strictEqual(called, 2) + + reply.send({ hello: 'world' }) + }) + + fastify.get('/user', { serializerCompiler: custom2 }, (req, reply) => { + const input = { fullName: 'Jone', phone: 1090243795 } + const first = reply.compileSerializationSchema(schemaObj, '3xx', 'application/json') + t.assert.ok(first(input), JSON.stringify(input)) + reply.send(input) + }) + + await fastify.inject({ + path: '/', + method: 'GET' + }) + + await fastify.inject({ + path: '/user', + method: 'GET' + }) + } + ) + + await t.test('Should build a WeakMap for cache when called', async t => { + const fastify = Fastify() + + t.plan(4) + + fastify.get('/', (req, reply) => { + const input = { hello: 'world' } + + t.assert.strictEqual(reply[kRouteContext][kReplyCacheSerializeFns], null) + t.assert.strictEqual(reply.compileSerializationSchema(getDefaultSchema())(input), JSON.stringify(input)) + t.assert.ok(reply[kRouteContext][kReplyCacheSerializeFns] instanceof WeakMap) + t.assert.strictEqual(reply.compileSerializationSchema(getDefaultSchema())(input), JSON.stringify(input)) + + reply.send({ hello: 'world' }) + }) + + await fastify.inject({ + path: '/', + method: 'GET' + }) + }) +}) + +test('Reply#getSerializationFunction', async t => { + t.plan(3) + + await t.test('Should retrieve the serialization function from the Schema definition', + async t => { + const fastify = Fastify() + const okInput201 = { + status: 'ok', + message: 'done!' + } + const notOkInput201 = { + message: 'created' + } + const okInput4xx = { + status: 'error', + code: 2, + message: 'oops!' + } + const notOkInput4xx = { + status: 'error', + code: 'something' + } + const okInput3xx = { + fullName: 'Jone', + phone: 0 + } + const noOkInput3xx = { + fullName: 'Jone', + phone: 'phone' + } + let cached4xx + let cached201 + let cachedJson3xx + + t.plan(13) + + const responseSchema = getResponseSchema() + + fastify.get( + '/:id', + { + params: { + type: 'object', + properties: { + id: { + type: 'integer' + } + } + }, + schema: { + response: responseSchema + } + }, + (req, reply) => { + const { id } = req.params + + if (Number(id) === 1) { + const serialize4xx = reply.getSerializationFunction('4xx') + const serialize201 = reply.getSerializationFunction(201) + const serializeJson3xx = reply.getSerializationFunction('3xx', 'application/json') + const serializeUndefined = reply.getSerializationFunction(undefined) + + cached4xx = serialize4xx + cached201 = serialize201 + cachedJson3xx = serializeJson3xx + + t.assert.ok(serialize4xx instanceof Function) + t.assert.ok(serialize201 instanceof Function) + t.assert.ok(serializeJson3xx instanceof Function) + t.assert.strictEqual(serialize4xx(okInput4xx), JSON.stringify(okInput4xx)) + t.assert.strictEqual(serialize201(okInput201), JSON.stringify(okInput201)) + t.assert.strictEqual(serializeJson3xx(okInput3xx), JSON.stringify(okInput3xx)) + t.assert.ok(!serializeUndefined) + + try { + serialize4xx(notOkInput4xx) + } catch (err) { + t.assert.strictEqual( + err.message, + 'The value "something" cannot be converted to an integer.' + ) + } + + try { + serialize201(notOkInput201) + } catch (err) { + t.assert.strictEqual(err.message, '"status" is required!') + } + + try { + serializeJson3xx(noOkInput3xx) + } catch (err) { + t.assert.strictEqual(err.message, 'The value "phone" cannot be converted to a number.') + } + + reply.status(201).send(okInput201) + } else { + const serialize201 = reply.getSerializationFunction(201) + const serialize4xx = reply.getSerializationFunction('4xx') + const serializeJson3xx = reply.getSerializationFunction('3xx', 'application/json') + + t.assert.strictEqual(serialize4xx, cached4xx) + t.assert.strictEqual(serialize201, cached201) + t.assert.strictEqual(serializeJson3xx, cachedJson3xx) + reply.status(401).send(okInput4xx) + } + } + ) + + await Promise.all([ + fastify.inject('/1'), + fastify.inject('/2') + ]) + } + ) + + await t.test('Should retrieve the serialization function from the cached one', + async t => { + const fastify = Fastify() + + const schemaObj = getDefaultSchema() + + const okInput = { + hello: 'world', + world: 'done!' + } + const notOkInput = { + world: 'done!' + } + let cached + + t.plan(6) + + fastify.get( + '/:id', + { + params: { + type: 'object', + properties: { + id: { + type: 'integer' + } + } + } + }, + (req, reply) => { + const { id } = req.params + + if (Number(id) === 1) { + const serialize = reply.compileSerializationSchema(schemaObj) + + t.assert.ok(serialize instanceof Function) + t.assert.strictEqual(serialize(okInput), JSON.stringify(okInput)) + + try { + serialize(notOkInput) + } catch (err) { + t.assert.strictEqual(err.message, '"hello" is required!') + } + + cached = serialize + } else { + const serialize = reply.getSerializationFunction(schemaObj) + + t.assert.strictEqual(serialize, cached) + t.assert.strictEqual(serialize(okInput), JSON.stringify(okInput)) + + try { + serialize(notOkInput) + } catch (err) { + t.assert.strictEqual(err.message, '"hello" is required!') + } + } + + reply.status(201).send(okInput) + } + ) + + await Promise.all([ + fastify.inject('/1'), + fastify.inject('/2') + ]) + } + ) + + await t.test('Should not instantiate a WeakMap if it is not needed', async t => { + const fastify = Fastify() + + t.plan(4) + + fastify.get('/', (req, reply) => { + t.assert.ok(!reply.getSerializationFunction(getDefaultSchema())) + t.assert.strictEqual(reply[kRouteContext][kReplyCacheSerializeFns], null) + t.assert.ok(!reply.getSerializationFunction('200')) + t.assert.strictEqual(reply[kRouteContext][kReplyCacheSerializeFns], null) + + reply.send({ hello: 'world' }) + }) + + await fastify.inject({ + path: '/', + method: 'GET' + }) + }) +}) + +test('Reply#serializeInput', async t => { + t.plan(6) + + await t.test( + 'Should throw if missed serialization function from HTTP status', + async t => { + const fastify = Fastify() + + t.plan(2) + + fastify.get('/', (req, reply) => { + reply.serializeInput({}, 201) + }) + + const result = await fastify.inject({ + path: '/', + method: 'GET' + }) + + t.assert.strictEqual(result.statusCode, 500) + t.assert.deepStrictEqual(result.json(), { + statusCode: 500, + code: 'FST_ERR_MISSING_SERIALIZATION_FN', + error: 'Internal Server Error', + message: 'Missing serialization function. Key "201"' + }) + } + ) + + await t.test( + 'Should throw if missed serialization function from HTTP status with specific content type', + async t => { + const fastify = Fastify() + + t.plan(2) + + fastify.get('/', { + schema: { + response: { + '3xx': { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + fullName: { type: 'string' }, + phone: { type: 'number' } + } + } + } + } + } + } + } + }, (req, reply) => { + reply.serializeInput({}, '3xx', 'application/vnd.v1+json') + }) + + const result = await fastify.inject({ + path: '/', + method: 'GET' + }) + + t.assert.strictEqual(result.statusCode, 500) + t.assert.deepStrictEqual(result.json(), { + statusCode: 500, + code: 'FST_ERR_MISSING_CONTENTTYPE_SERIALIZATION_FN', + error: 'Internal Server Error', + message: 'Missing serialization function. Key "3xx:application/vnd.v1+json"' + }) + } + ) + + await t.test('Should use a serializer fn from HTTP status', async t => { + const fastify = Fastify() + const okInput201 = { + status: 'ok', + message: 'done!' + } + const notOkInput201 = { + message: 'created' + } + const okInput4xx = { + status: 'error', + code: 2, + message: 'oops!' + } + const notOkInput4xx = { + status: 'error', + code: 'something' + } + const okInput3xx = { + fullName: 'Jone', + phone: 0 + } + const noOkInput3xx = { + fullName: 'Jone', + phone: 'phone' + } + + t.plan(6) + + fastify.get( + '/', + { + params: { + type: 'object', + properties: { + id: { + type: 'integer' + } + } + }, + schema: { + response: getResponseSchema() + } + }, + (req, reply) => { + t.assert.strictEqual( + reply.serializeInput(okInput4xx, '4xx'), + JSON.stringify(okInput4xx) + ) + t.assert.strictEqual( + reply.serializeInput(okInput201, 201), + JSON.stringify(okInput201) + ) + + t.assert.strictEqual( + reply.serializeInput(okInput3xx, {}, '3xx', 'application/json'), + JSON.stringify(okInput3xx) + ) + + try { + reply.serializeInput(noOkInput3xx, '3xx', 'application/json') + } catch (err) { + t.assert.strictEqual(err.message, 'The value "phone" cannot be converted to a number.') + } + + try { + reply.serializeInput(notOkInput4xx, '4xx') + } catch (err) { + t.assert.strictEqual( + err.message, + 'The value "something" cannot be converted to an integer.' + ) + } + + try { + reply.serializeInput(notOkInput201, 201) + } catch (err) { + t.assert.strictEqual(err.message, '"status" is required!') + } + + reply.status(204).send('') + } + ) + + await fastify.inject({ + path: '/', + method: 'GET' + }) + }) + + await t.test( + 'Should compile a serializer out of a schema if serializer fn missed', + async t => { + let compilerCalled = 0 + let serializerCalled = 0 + const testInput = { hello: 'world' } + const schemaObj = getDefaultSchema() + const fastify = Fastify() + const serializerCompiler = ({ schema, httpStatus, method, url }) => { + t.assert.strictEqual(schema, schemaObj) + t.assert.ok(!httpStatus) + t.assert.strictEqual(method, 'GET') + t.assert.strictEqual(url, '/') + + compilerCalled++ + return input => { + t.assert.strictEqual(input, testInput) + serializerCalled++ + return JSON.stringify(input) + } + } + + t.plan(10) + + fastify.get('/', { serializerCompiler }, (req, reply) => { + t.assert.strictEqual( + reply.serializeInput(testInput, schemaObj), + JSON.stringify(testInput) + ) + + t.assert.strictEqual( + reply.serializeInput(testInput, schemaObj), + JSON.stringify(testInput) + ) + + reply.status(201).send(testInput) + }) + + await fastify.inject({ + path: '/', + method: 'GET' + }) + + t.assert.strictEqual(compilerCalled, 1) + t.assert.strictEqual(serializerCalled, 2) + } + ) + + await t.test('Should use a cached serializer fn', async t => { + let compilerCalled = 0 + let serializerCalled = 0 + let cached + const testInput = { hello: 'world' } + const schemaObj = getDefaultSchema() + const fastify = Fastify() + const serializer = input => { + t.assert.strictEqual(input, testInput) + serializerCalled++ + return JSON.stringify(input) + } + const serializerCompiler = ({ schema, httpStatus, method, url }) => { + t.assert.strictEqual(schema, schemaObj) + t.assert.ok(!httpStatus) + t.assert.strictEqual(method, 'GET') + t.assert.strictEqual(url, '/') + + compilerCalled++ + return serializer + } + + t.plan(12) + + fastify.get('/', { serializerCompiler }, (req, reply) => { + t.assert.strictEqual( + reply.serializeInput(testInput, schemaObj), + JSON.stringify(testInput) + ) + + cached = reply.getSerializationFunction(schemaObj) + + t.assert.strictEqual( + reply.serializeInput(testInput, schemaObj), + cached(testInput) + ) + + reply.status(201).send(testInput) + }) + + await fastify.inject({ + path: '/', + method: 'GET' + }) + + t.assert.strictEqual(cached, serializer) + t.assert.strictEqual(compilerCalled, 1) + t.assert.strictEqual(serializerCalled, 3) + }) + + await t.test('Should instantiate a WeakMap after first call', async t => { + const fastify = Fastify() + + t.plan(3) + + fastify.get('/', (req, reply) => { + const input = { hello: 'world' } + t.assert.strictEqual(reply[kRouteContext][kReplyCacheSerializeFns], null) + t.assert.strictEqual(reply.serializeInput(input, getDefaultSchema()), JSON.stringify(input)) + t.assert.ok(reply[kRouteContext][kReplyCacheSerializeFns] instanceof WeakMap) + + reply.send({ hello: 'world' }) + }) + + await fastify.inject({ + path: '/', + method: 'GET' + }) + }) +}) diff --git a/test/internals/reply.test.js b/test/internals/reply.test.js index 3dfe2465617..adfc352dc31 100644 --- a/test/internals/reply.test.js +++ b/test/internals/reply.test.js @@ -1,61 +1,70 @@ 'use strict' -const t = require('tap') -const test = t.test -const sget = require('simple-get').concat -const http = require('http') +const { test } = require('node:test') +const http = require('node:http') const NotFound = require('http-errors').NotFound +const Request = require('../../lib/request') const Reply = require('../../lib/reply') -const { Readable, Writable } = require('stream') +const Fastify = require('../..') +const { Readable, Writable } = require('node:stream') const { kReplyErrorHandlerCalled, kReplyHeaders, kReplySerializer, kReplyIsError, - kReplySerializerDefault + kReplySerializerDefault, + kRouteContext } = require('../../lib/symbols') -const fs = require('fs') -const path = require('path') -const warning = require('../../lib/warnings') - -const doGet = function (url) { - return new Promise((resolve, reject) => { - sget({ method: 'GET', url, followRedirects: false }, (err, response, body) => { - if (err) { - reject(err) - } else { - resolve({ response, body }) - } - }) +const fs = require('node:fs') +const path = require('node:path') + +const doGet = async function (url) { + const result = await fetch(url, { + method: 'GET', + redirect: 'manual', + keepAlive: false }) + + return { + response: result, + body: await result.json().catch(() => undefined) + } } test('Once called, Reply should return an object with methods', t => { - t.plan(13) + t.plan(15) const response = { res: 'res' } - const context = {} - const request = { context } + const context = { + config: { onSend: [] }, + schema: {}, + _parserOptions: {}, + server: { hasConstraintStrategy: () => false, initialConfig: {} } + } + const request = new Request(null, null, null, null, null, context) const reply = new Reply(response, request) - t.equal(typeof reply, 'object') - t.equal(typeof reply[kReplyIsError], 'boolean') - t.equal(typeof reply[kReplyErrorHandlerCalled], 'boolean') - t.equal(typeof reply.send, 'function') - t.equal(typeof reply.code, 'function') - t.equal(typeof reply.status, 'function') - t.equal(typeof reply.header, 'function') - t.equal(typeof reply.serialize, 'function') - t.equal(typeof reply.getResponseTime, 'function') - t.equal(typeof reply[kReplyHeaders], 'object') - t.same(reply.raw, response) - t.equal(reply.context, context) - t.equal(reply.request, request) + t.assert.strictEqual(typeof reply, 'object') + t.assert.strictEqual(typeof reply[kReplyIsError], 'boolean') + t.assert.strictEqual(typeof reply[kReplyErrorHandlerCalled], 'boolean') + t.assert.strictEqual(typeof reply.send, 'function') + t.assert.strictEqual(typeof reply.code, 'function') + t.assert.strictEqual(typeof reply.status, 'function') + t.assert.strictEqual(typeof reply.header, 'function') + t.assert.strictEqual(typeof reply.serialize, 'function') + t.assert.strictEqual(typeof reply[kReplyHeaders], 'object') + t.assert.deepStrictEqual(reply.raw, response) + t.assert.strictEqual(reply[kRouteContext], context) + t.assert.strictEqual(reply.routeOptions.config, context.config) + t.assert.strictEqual(reply.routeOptions.schema, context.schema) + t.assert.strictEqual(reply.request, request) + // Aim to not bad property keys (including Symbols) + t.assert.ok(!('undefined' in reply)) }) test('reply.send will logStream error and destroy the stream', t => { t.plan(1) let destroyCalled const payload = new Readable({ - read () {}, + read () { }, destroy (err, cb) { destroyCalled = true cb(err) @@ -64,37 +73,37 @@ test('reply.send will logStream error and destroy the stream', t => { const response = new Writable() Object.assign(response, { - setHeader: () => {}, + setHeader: () => { }, hasHeader: () => false, getHeader: () => undefined, - writeHead: () => {}, - write: () => {}, + writeHead: () => { }, + write: () => { }, headersSent: true }) const log = { - warn: () => {} + warn: () => { } } - const reply = new Reply(response, { context: { onSend: null } }, log) + const reply = new Reply(response, { [kRouteContext]: { onSend: null } }, log) reply.send(payload) payload.destroy(new Error('stream error')) - t.equal(destroyCalled, true, 'Error not logged and not streamed') + t.assert.strictEqual(destroyCalled, true, 'Error not logged and not streamed') }) test('reply.send throw with circular JSON', t => { t.plan(1) const response = { - setHeader: () => {}, + setHeader: () => { }, hasHeader: () => false, getHeader: () => undefined, - writeHead: () => {}, - write: () => {}, - end: () => {} + writeHead: () => { }, + write: () => { }, + end: () => { } } - const reply = new Reply(response, { context: { onSend: [] } }) - t.throws(() => { + const reply = new Reply(response, { [kRouteContext]: { onSend: [] } }) + t.assert.throws(() => { const obj = {} obj.obj = obj reply.send(JSON.stringify(obj)) @@ -104,30 +113,31 @@ test('reply.send throw with circular JSON', t => { test('reply.send returns itself', t => { t.plan(1) const response = { - setHeader: () => {}, + setHeader: () => { }, hasHeader: () => false, getHeader: () => undefined, - writeHead: () => {}, - write: () => {}, - end: () => {} + writeHead: () => { }, + write: () => { }, + end: () => { } } - const reply = new Reply(response, { context: { onSend: [] } }) - t.equal(reply.send('hello'), reply) + const reply = new Reply(response, { [kRouteContext]: { onSend: [] } }) + t.assert.strictEqual(reply.send('hello'), reply) }) test('reply.serializer should set a custom serializer', t => { t.plan(2) const reply = new Reply(null, null, null) - t.equal(reply[kReplySerializer], null) + t.assert.strictEqual(reply[kReplySerializer], null) reply.serializer('serializer') - t.equal(reply[kReplySerializer], 'serializer') + t.assert.strictEqual(reply[kReplySerializer], 'serializer') }) -test('reply.serializer should support running preSerialization hooks', t => { +test('reply.serializer should support running preSerialization hooks', (t, done) => { t.plan(3) - const fastify = require('../..')() + const fastify = Fastify() + t.after(() => fastify.close()) - fastify.addHook('preSerialization', async (request, reply, payload) => { t.ok('called', 'preSerialization') }) + fastify.addHook('preSerialization', async (request, reply, payload) => { t.assert.ok('called', 'preSerialization') }) fastify.route({ method: 'GET', url: '/', @@ -143,8 +153,9 @@ test('reply.serializer should support running preSerialization hooks', t => { method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.equal(res.payload, '{"foo":"bar"}') + t.assert.ifError(err) + t.assert.strictEqual(res.payload, '{"foo":"bar"}') + done() }) }) @@ -152,8 +163,8 @@ test('reply.serialize should serialize payload', t => { t.plan(1) const response = { statusCode: 200 } const context = {} - const reply = new Reply(response, { context }) - t.equal(reply.serialize({ foo: 'bar' }), '{"foo":"bar"}') + const reply = new Reply(response, { [kRouteContext]: context }) + t.assert.strictEqual(reply.serialize({ foo: 'bar' }), '{"foo":"bar"}') }) test('reply.serialize should serialize payload with a custom serializer', t => { @@ -161,10 +172,10 @@ test('reply.serialize should serialize payload with a custom serializer', t => { let customSerializerCalled = false const response = { statusCode: 200 } const context = {} - const reply = new Reply(response, { context }) + const reply = new Reply(response, { [kRouteContext]: context }) reply.serializer((x) => (customSerializerCalled = true) && JSON.stringify(x)) - t.equal(reply.serialize({ foo: 'bar' }), '{"foo":"bar"}') - t.equal(customSerializerCalled, true, 'custom serializer not called') + t.assert.strictEqual(reply.serialize({ foo: 'bar' }), '{"foo":"bar"}') + t.assert.strictEqual(customSerializerCalled, true, 'custom serializer not called') }) test('reply.serialize should serialize payload with a context default serializer', t => { @@ -172,14 +183,15 @@ test('reply.serialize should serialize payload with a context default serializer let customSerializerCalled = false const response = { statusCode: 200 } const context = { [kReplySerializerDefault]: (x) => (customSerializerCalled = true) && JSON.stringify(x) } - const reply = new Reply(response, { context }) - t.equal(reply.serialize({ foo: 'bar' }), '{"foo":"bar"}') - t.equal(customSerializerCalled, true, 'custom serializer not called') + const reply = new Reply(response, { [kRouteContext]: context }) + t.assert.strictEqual(reply.serialize({ foo: 'bar' }), '{"foo":"bar"}') + t.assert.strictEqual(customSerializerCalled, true, 'custom serializer not called') }) -test('reply.serialize should serialize payload with Fastify instance', t => { +test('reply.serialize should serialize payload with Fastify instance', (t, done) => { t.plan(2) - const fastify = require('../..')() + const fastify = Fastify() + t.after(() => fastify.close()) fastify.route({ method: 'GET', url: '/', @@ -204,14 +216,15 @@ test('reply.serialize should serialize payload with Fastify instance', t => { method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.equal(res.payload, '{"foo":"bar"}') + t.assert.ifError(err) + t.assert.strictEqual(res.payload, '{"foo":"bar"}') + done() }) }) -test('within an instance', t => { - const fastify = require('../..')() - const test = t.test +test('within an instance', async t => { + const fastify = Fastify() + t.after(() => fastify.close()) fastify.get('/', function (req, reply) { reply.code(200) @@ -238,7 +251,7 @@ test('within an instance', t => { }) fastify.get('/redirect-code', function (req, reply) { - reply.redirect(301, '/') + reply.redirect('/', 301) }) fastify.get('/redirect-code-before-call', function (req, reply) { @@ -246,14 +259,14 @@ test('within an instance', t => { }) fastify.get('/redirect-code-before-call-overwrite', function (req, reply) { - reply.code(307).redirect(302, '/') + reply.code(307).redirect('/', 302) }) fastify.get('/custom-serializer', function (req, reply) { reply.code(200) reply.type('text/plain') reply.serializer(function (body) { - return require('querystring').stringify(body) + return require('node:querystring').stringify(body) }) reply.send({ hello: 'world!' }) }) @@ -269,218 +282,261 @@ test('within an instance', t => { done() }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(fastify.close.bind(fastify)) + const fastifyServer = await fastify.listen({ port: 0 }) - test('custom serializer should be used', t => { - t.plan(3) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/custom-serializer' - }, (err, response, body) => { - t.error(err) - t.equal(response.headers['content-type'], 'text/plain') - t.same(body.toString(), 'hello=world!') - }) - }) + await t.test('custom serializer should be used', async t => { + t.plan(3) + const result = await fetch(fastifyServer + '/custom-serializer') + t.assert.ok(result.ok) + t.assert.strictEqual(result.headers.get('content-type'), 'text/plain') + t.assert.deepStrictEqual(await result.text(), 'hello=world!') + }) - test('status code and content-type should be correct', t => { - t.plan(4) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-type'], 'text/plain') - t.same(body.toString(), 'hello world!') - }) - }) + await t.test('status code and content-type should be correct', async t => { + t.plan(3) + const result = await fetch(fastifyServer) + t.assert.ok(result.ok) + t.assert.strictEqual(result.headers.get('content-type'), 'text/plain') + t.assert.deepStrictEqual(await result.text(), 'hello world!') + }) - test('auto status code shoud be 200', t => { - t.plan(3) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/auto-status-code' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body.toString(), 'hello world!') - }) - }) + await t.test('auto status code should be 200', async t => { + t.plan(3) + const result = await fetch(fastifyServer + '/auto-status-code') + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.deepStrictEqual(await result.text(), 'hello world!') + }) - test('auto type shoud be text/plain', t => { - t.plan(3) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/auto-type' - }, (err, response, body) => { - t.error(err) - t.equal(response.headers['content-type'], 'text/plain') - t.same(body.toString(), 'hello world!') - }) - }) + await t.test('auto type should be text/plain', async t => { + t.plan(3) + const result = await fetch(fastifyServer + '/auto-type') + t.assert.ok(result.ok) + t.assert.strictEqual(result.headers.get('content-type'), 'text/plain') + t.assert.deepStrictEqual(await result.text(), 'hello world!') + }) - test('redirect to `/` - 1', t => { - t.plan(1) + await t.test('redirect to `/` - 1', (t, done) => { + t.plan(1) - http.get('http://localhost:' + fastify.server.address().port + '/redirect', function (response) { - t.equal(response.statusCode, 302) - }) + http.get(fastifyServer + '/redirect', function (response) { + t.assert.strictEqual(response.statusCode, 302) + done() }) + }) - test('redirect to `/` - 2', t => { - t.plan(1) + await t.test('redirect to `/` - 2', (t, done) => { + t.plan(1) - http.get('http://localhost:' + fastify.server.address().port + '/redirect-code', function (response) { - t.equal(response.statusCode, 301) - }) + http.get(fastifyServer + '/redirect-code', function (response) { + t.assert.strictEqual(response.statusCode, 301) + done() }) + }) - test('redirect to `/` - 3', t => { - t.plan(4) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/redirect' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-type'], 'text/plain') - t.same(body.toString(), 'hello world!') - }) - }) + await t.test('redirect to `/` - 3', async t => { + t.plan(4) + const result = await fetch(fastifyServer + '/redirect') + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.strictEqual(result.headers.get('content-type'), 'text/plain') + t.assert.deepStrictEqual(await result.text(), 'hello world!') + }) - test('redirect to `/` - 4', t => { - t.plan(4) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/redirect-code' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-type'], 'text/plain') - t.same(body.toString(), 'hello world!') - }) - }) + await t.test('redirect to `/` - 4', async t => { + t.plan(4) + const result = await fetch(fastifyServer + '/redirect-code') + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.strictEqual(result.headers.get('content-type'), 'text/plain') + t.assert.deepStrictEqual(await result.text(), 'hello world!') + }) - test('redirect to `/` - 5', t => { - t.plan(3) - const url = 'http://localhost:' + fastify.server.address().port + '/redirect-onsend' - http.get(url, (response) => { - t.equal(response.headers['x-onsend'], 'yes') - t.equal(response.headers['content-length'], '0') - t.equal(response.headers.location, '/') - }) + await t.test('redirect to `/` - 5', (t, done) => { + t.plan(3) + const url = fastifyServer + '/redirect-onsend' + http.get(url, (response) => { + t.assert.strictEqual(response.headers['x-onsend'], 'yes') + t.assert.strictEqual(response.headers['content-length'], '0') + t.assert.strictEqual(response.headers.location, '/') + done() }) + }) - test('redirect to `/` - 6', t => { - t.plan(4) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/redirect-code-before-call' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-type'], 'text/plain') - t.same(body.toString(), 'hello world!') - }) - }) + await t.test('redirect to `/` - 6', async t => { + t.plan(4) + const result = await fetch(fastifyServer + '/redirect-code-before-call') + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.strictEqual(result.headers.get('content-type'), 'text/plain') + t.assert.deepStrictEqual(await result.text(), 'hello world!') + }) - test('redirect to `/` - 7', t => { - t.plan(4) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/redirect-code-before-call-overwrite' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-type'], 'text/plain') - t.same(body.toString(), 'hello world!') - }) - }) + await t.test('redirect to `/` - 7', async t => { + t.plan(4) + const result = await fetch(fastifyServer + '/redirect-code-before-call-overwrite') + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.strictEqual(result.headers.get('content-type'), 'text/plain') + t.assert.deepStrictEqual(await result.text(), 'hello world!') + }) - test('redirect to `/` - 8', t => { - t.plan(1) + await t.test('redirect to `/` - 8', (t, done) => { + t.plan(1) - http.get('http://localhost:' + fastify.server.address().port + '/redirect-code-before-call', function (response) { - t.equal(response.statusCode, 307) - }) + http.get(fastifyServer + '/redirect-code-before-call', function (response) { + t.assert.strictEqual(response.statusCode, 307) + done() }) + }) - test('redirect to `/` - 9', t => { - t.plan(1) + await t.test('redirect to `/` - 9', (t, done) => { + t.plan(1) - http.get('http://localhost:' + fastify.server.address().port + '/redirect-code-before-call-overwrite', function (response) { - t.equal(response.statusCode, 302) - }) + http.get(fastifyServer + '/redirect-code-before-call-overwrite', function (response) { + t.assert.strictEqual(response.statusCode, 302) + done() }) + }) - test('redirect with async function to `/` - 10', t => { - t.plan(1) + await t.test('redirect with async function to `/` - 10', (t, done) => { + t.plan(1) - http.get('http://localhost:' + fastify.server.address().port + '/redirect-async', function (response) { - t.equal(response.statusCode, 302) - }) + http.get(fastifyServer + '/redirect-async', function (response) { + t.assert.strictEqual(response.statusCode, 302) + done() }) + }) +}) - t.end() +test('buffer without content type should send a application/octet-stream and raw buffer', async t => { + t.plan(3) + + const fastify = Fastify() + + fastify.get('/', function (req, reply) { + reply.send(Buffer.alloc(1024)) }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const result = await fetch(fastifyServer) + t.assert.ok(result.ok) + t.assert.strictEqual(result.headers.get('content-type'), 'application/octet-stream') + t.assert.deepStrictEqual(Buffer.from(await result.arrayBuffer()), Buffer.alloc(1024)) }) -test('buffer without content type should send a application/octet-stream and raw buffer', t => { +test('Uint8Array without content type should send a application/octet-stream and raw buffer', (t, done) => { t.plan(4) - const fastify = require('../..')() + const fastify = Fastify() fastify.get('/', function (req, reply) { - reply.send(Buffer.alloc(1024)) + reply.send(new Uint8Array(1024).fill(0xff)) }) fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(fastify.close.bind(fastify)) + t.assert.ifError(err) + t.after(() => fastify.close()) - sget({ + fastify.inject({ method: 'GET', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.equal(response.headers['content-type'], 'application/octet-stream') - t.same(body, Buffer.alloc(1024)) + url: '/' + }, (err, response) => { + t.assert.ifError(err) + t.assert.strictEqual(response.headers['content-type'], 'application/octet-stream') + t.assert.deepStrictEqual(new Uint8Array(response.rawPayload), new Uint8Array(1024).fill(0xff)) + done() }) }) }) +test('Uint16Array without content type should send a application/octet-stream and raw buffer', (t, done) => { + t.plan(4) + + const fastify = Fastify() + + fastify.get('/', function (req, reply) { + reply.send(new Uint16Array(50).fill(0xffffffff)) + }) -test('buffer with content type should not send application/octet-stream', t => { + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + t.after(() => fastify.close()) + + fastify.inject({ + method: 'GET', + url: '/' + }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.headers['content-type'], 'application/octet-stream') + t.assert.deepStrictEqual( + new Uint16Array( + res.rawPayload.buffer, + res.rawPayload.byteOffset, + res.rawPayload.byteLength / Uint16Array.BYTES_PER_ELEMENT + ), + new Uint16Array(50).fill(0xffffffff) + ) + done() + }) + }) +}) +test('TypedArray with content type should not send application/octet-stream', (t, done) => { t.plan(4) - const fastify = require('../..')() + const fastify = Fastify() fastify.get('/', function (req, reply) { reply.header('Content-Type', 'text/plain') - reply.send(Buffer.alloc(1024)) + reply.send(new Uint16Array(1024).fill(0xffffffff)) }) fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(fastify.close.bind(fastify)) + t.assert.ifError(err) + t.after(() => fastify.close()) - sget({ + fastify.inject({ method: 'GET', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.equal(response.headers['content-type'], 'text/plain') - t.same(body, Buffer.alloc(1024)) + url: '/' + }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.headers['content-type'], 'text/plain') + t.assert.deepStrictEqual( + new Uint16Array( + res.rawPayload.buffer, + res.rawPayload.byteOffset, + res.rawPayload.byteLength / Uint16Array.BYTES_PER_ELEMENT + ), + new Uint16Array(1024).fill(0xffffffff) + ) + done() }) }) }) +test('buffer with content type should not send application/octet-stream', async t => { + t.plan(3) -test('stream with content type should not send application/octet-stream', t => { - t.plan(4) + const fastify = Fastify() - const fastify = require('../..')() + fastify.get('/', function (req, reply) { + reply.header('Content-Type', 'text/plain') + reply.send(Buffer.alloc(1024)) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const result = await fetch(fastifyServer) + t.assert.ok(result.ok) + t.assert.strictEqual(result.headers.get('content-type'), 'text/plain') + t.assert.deepStrictEqual(Buffer.from(await result.arrayBuffer()), Buffer.alloc(1024)) +}) + +test('stream with content type should not send application/octet-stream', async t => { + t.plan(3) + + const fastify = Fastify() const streamPath = path.join(__dirname, '..', '..', 'package.json') const stream = fs.createReadStream(streamPath) @@ -490,24 +546,19 @@ test('stream with content type should not send application/octet-stream', t => { reply.header('Content-Type', 'text/plain').send(stream) }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(fastify.close.bind(fastify)) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.equal(response.headers['content-type'], 'text/plain') - t.same(body, buf) - }) - }) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const result = await fetch(fastifyServer) + t.assert.ok(result.ok) + t.assert.strictEqual(result.headers.get('content-type'), 'text/plain') + t.assert.deepStrictEqual(Buffer.from(await result.arrayBuffer()), buf) }) -test('stream without content type should not send application/octet-stream', t => { - t.plan(4) +test('stream without content type should not send application/octet-stream', async t => { + t.plan(3) - const fastify = require('../..')() + const fastify = Fastify() const stream = fs.createReadStream(__filename) const buf = fs.readFileSync(__filename) @@ -516,26 +567,21 @@ test('stream without content type should not send application/octet-stream', t = reply.send(stream) }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(fastify.close.bind(fastify)) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.equal(response.headers['content-type'], undefined) - t.same(body, buf) - }) - }) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const result = await fetch(fastifyServer) + t.assert.ok(result.ok) + t.assert.strictEqual(result.headers.get('content-type'), null) + t.assert.deepStrictEqual(Buffer.from(await result.arrayBuffer()), buf) }) -test('stream using reply.raw.writeHead should return customize headers', t => { - t.plan(6) +test('stream using reply.raw.writeHead should return customize headers', async t => { + t.plan(5) - const fastify = require('../..')() - const fs = require('fs') - const path = require('path') + const fastify = Fastify() + const fs = require('node:fs') + const path = require('node:path') const streamPath = path.join(__dirname, '..', '..', 'package.json') const stream = fs.createReadStream(streamPath) @@ -543,7 +589,7 @@ test('stream using reply.raw.writeHead should return customize headers', t => { fastify.get('/', function (req, reply) { reply.log.warn = function mockWarn (message) { - t.equal(message, 'response will send, but you shouldn\'t use res.writeHead in stream mode') + t.assert.strictEqual(message, 'response will send, but you shouldn\'t use res.writeHead in stream mode') } reply.raw.writeHead(200, { location: '/' @@ -551,73 +597,56 @@ test('stream using reply.raw.writeHead should return customize headers', t => { reply.send(stream) }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(fastify.close.bind(fastify)) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.equal(response.headers.location, '/') - t.equal(response.headers['Content-Type'], undefined) - t.same(body, buf) - }) - }) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const result = await fetch(fastifyServer) + t.assert.ok(result.ok) + t.assert.strictEqual(result.headers.get('location'), '/') + t.assert.strictEqual(result.headers.get('content-type'), null) + t.assert.deepStrictEqual(Buffer.from(await result.arrayBuffer()), buf) }) -test('plain string without content type should send a text/plain', t => { - t.plan(4) +test('plain string without content type should send a text/plain', async t => { + t.plan(3) - const fastify = require('../..')() + const fastify = Fastify() fastify.get('/', function (req, reply) { reply.send('hello world!') }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(fastify.close.bind(fastify)) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.equal(response.headers['content-type'], 'text/plain; charset=utf-8') - t.same(body.toString(), 'hello world!') - }) - }) + const result = await fetch(fastifyServer) + t.assert.ok(result.ok) + t.assert.strictEqual(result.headers.get('content-type'), 'text/plain; charset=utf-8') + t.assert.deepStrictEqual(await result.text(), 'hello world!') }) -test('plain string with content type should be sent unmodified', t => { - t.plan(4) +test('plain string with content type should be sent unmodified', async t => { + t.plan(3) - const fastify = require('../..')() + const fastify = Fastify() fastify.get('/', function (req, reply) { reply.type('text/css').send('hello world!') }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(fastify.close.bind(fastify)) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.equal(response.headers['content-type'], 'text/css') - t.same(body.toString(), 'hello world!') - }) - }) + const result = await fetch(fastifyServer) + t.assert.ok(result.ok) + t.assert.strictEqual(result.headers.get('content-type'), 'text/css') + t.assert.deepStrictEqual(await result.text(), 'hello world!') }) -test('plain string with content type and custom serializer should be serialized', t => { - t.plan(4) +test('plain string with content type and custom serializer should be serialized', async t => { + t.plan(3) - const fastify = require('../..')() + const fastify = Fastify() fastify.get('/', function (req, reply) { reply @@ -626,58 +655,48 @@ test('plain string with content type and custom serializer should be serialized' .send('hello world!') }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(fastify.close.bind(fastify)) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.equal(response.headers['content-type'], 'text/css') - t.same(body.toString(), 'serialized') - }) - }) + const result = await fetch(fastifyServer) + t.assert.ok(result.ok) + t.assert.strictEqual(result.headers.get('content-type'), 'text/css') + t.assert.deepStrictEqual(await result.text(), 'serialized') }) -test('plain string with content type application/json should NOT be serialized as json', t => { - t.plan(4) +test('plain string with content type application/json should NOT be serialized as json', async t => { + t.plan(3) - const fastify = require('../..')() + const fastify = Fastify() fastify.get('/', function (req, reply) { reply.type('application/json').send('{"key": "hello world!"}') }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(fastify.close.bind(fastify)) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.equal(response.headers['content-type'], 'application/json; charset=utf-8') - t.same(body.toString(), '{"key": "hello world!"}') - }) - }) + const result = await fetch(fastifyServer) + t.assert.ok(result.ok) + t.assert.strictEqual(result.headers.get('content-type'), 'application/json; charset=utf-8') + t.assert.deepStrictEqual(await result.text(), '{"key": "hello world!"}') }) -test('plain string with custom json content type should NOT be serialized as json', t => { - t.plan(19) +test('plain string with custom json content type should NOT be serialized as json', async t => { + t.plan(18) + + const fastify = Fastify() - const fastify = require('../..')() + t.after(() => fastify.close()) const customSamples = { collectionjson: { mimeType: 'application/vnd.collection+json', - sample: '{"collection":{"version":"1.0","href":"http://api.example.com/people/"}}' + sample: '{"collection":{"version":"1.0","href":"http://api.fastify.test/people/"}}' }, hal: { mimeType: 'application/hal+json', - sample: '{"_links":{"self":{"href":"https://api.example.com/people/1"}},"name":"John Doe"}' + sample: '{"_links":{"self":{"href":"https://api.fastify.test/people/1"}},"name":"John Doe"}' }, jsonapi: { mimeType: 'application/vnd.api+json', @@ -703,84 +722,66 @@ test('plain string with custom json content type should NOT be serialized as jso }) }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(fastify.close.bind(fastify)) + const fastifyServer = await fastify.listen({ port: 0 }) - Object.keys(customSamples).forEach((path) => { - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/' + path - }, (err, response, body) => { - t.error(err) - t.equal(response.headers['content-type'], customSamples[path].mimeType + '; charset=utf-8') - t.same(body.toString(), customSamples[path].sample) - }) - }) - }) + await Promise.all(Object.keys(customSamples).map(async path => { + const result = await fetch(fastifyServer + '/' + path) + t.assert.ok(result.ok) + t.assert.strictEqual(result.headers.get('content-type'), customSamples[path].mimeType + '; charset=utf-8') + t.assert.deepStrictEqual(await result.text(), customSamples[path].sample) + })) }) -test('non-string with content type application/json SHOULD be serialized as json', t => { - t.plan(4) +test('non-string with content type application/json SHOULD be serialized as json', async t => { + t.plan(3) - const fastify = require('../..')() + const fastify = Fastify() fastify.get('/', function (req, reply) { reply.type('application/json').send({ key: 'hello world!' }) }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(fastify.close.bind(fastify)) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.equal(response.headers['content-type'], 'application/json; charset=utf-8') - t.same(body.toString(), JSON.stringify({ key: 'hello world!' })) - }) - }) + const result = await fetch(fastifyServer) + t.assert.ok(result.ok) + t.assert.strictEqual(result.headers.get('content-type'), 'application/json; charset=utf-8') + t.assert.deepStrictEqual(await result.text(), JSON.stringify({ key: 'hello world!' })) }) -test('non-string with custom json\'s content-type SHOULD be serialized as json', t => { - t.plan(4) +test('non-string with custom json\'s content-type SHOULD be serialized as json', async t => { + t.plan(3) - const fastify = require('../..')() + const fastify = Fastify() fastify.get('/', function (req, reply) { reply.type('application/json; version=2; ').send({ key: 'hello world!' }) }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(fastify.close.bind(fastify)) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.equal(response.headers['content-type'], 'application/json; version=2; charset=utf-8') - t.same(body.toString(), JSON.stringify({ key: 'hello world!' })) - }) - }) + const result = await fetch(fastifyServer) + t.assert.ok(result.ok) + t.assert.strictEqual(result.headers.get('content-type'), 'application/json; version=2; charset=utf-8') + t.assert.deepStrictEqual(await result.text(), JSON.stringify({ key: 'hello world!' })) }) -test('non-string with custom json content type SHOULD be serialized as json', t => { - t.plan(16) +test('non-string with custom json content type SHOULD be serialized as json', async t => { + t.plan(15) - const fastify = require('../..')() + const fastify = Fastify() + t.after(() => fastify.close()) const customSamples = { collectionjson: { mimeType: 'application/vnd.collection+json', - sample: JSON.parse('{"collection":{"version":"1.0","href":"http://api.example.com/people/"}}') + sample: JSON.parse('{"collection":{"version":"1.0","href":"http://api.fastify.test/people/"}}') }, hal: { mimeType: 'application/hal+json', - sample: JSON.parse('{"_links":{"self":{"href":"https://api.example.com/people/1"}},"name":"John Doe"}') + sample: JSON.parse('{"_links":{"self":{"href":"https://api.fastify.test/people/1"}},"name":"John Doe"}') }, jsonapi: { mimeType: 'application/vnd.api+json', @@ -802,27 +803,20 @@ test('non-string with custom json content type SHOULD be serialized as json', t }) }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(fastify.close.bind(fastify)) + const fastifyServer = await fastify.listen({ port: 0 }) - Object.keys(customSamples).forEach((path) => { - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/' + path - }, (err, response, body) => { - t.error(err) - t.equal(response.headers['content-type'], customSamples[path].mimeType + '; charset=utf-8') - t.same(body.toString(), JSON.stringify(customSamples[path].sample)) - }) - }) - }) + await Promise.all(Object.keys(customSamples).map(async path => { + const result = await fetch(fastifyServer + '/' + path) + t.assert.ok(result.ok) + t.assert.strictEqual(result.headers.get('content-type'), customSamples[path].mimeType + '; charset=utf-8') + t.assert.deepStrictEqual(await result.text(), JSON.stringify(customSamples[path].sample)) + })) }) -test('error object with a content type that is not application/json should work', t => { - t.plan(6) +test('error object with a content type that is not application/json should work', async t => { + t.plan(4) - const fastify = require('../..')() + const fastify = Fastify() fastify.get('/text', function (req, reply) { reply.type('text/plain') @@ -834,32 +828,32 @@ test('error object with a content type that is not application/json should work' reply.send(new Error('some application error')) }) - fastify.inject({ - method: 'GET', - url: '/text' - }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 500) - t.equal(JSON.parse(res.payload).message, 'some application error') - }) + { + const res = await fastify.inject({ + method: 'GET', + url: '/text' + }) + t.assert.strictEqual(res.statusCode, 500) + t.assert.strictEqual(JSON.parse(res.payload).message, 'some application error') + } - fastify.inject({ - method: 'GET', - url: '/html' - }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 500) - t.equal(JSON.parse(res.payload).message, 'some application error') - }) + { + const res = await fastify.inject({ + method: 'GET', + url: '/html' + }) + t.assert.strictEqual(res.statusCode, 500) + t.assert.strictEqual(JSON.parse(res.payload).message, 'some application error') + } }) -test('undefined payload should be sent as-is', t => { - t.plan(6) +test('undefined payload should be sent as-is', async t => { + t.plan(5) - const fastify = require('../..')() + const fastify = Fastify() fastify.addHook('onSend', function (request, reply, payload, done) { - t.equal(payload, undefined) + t.assert.strictEqual(payload, undefined) done() }) @@ -867,32 +861,28 @@ test('undefined payload should be sent as-is', t => { reply.code(204).send() }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(fastify.close.bind(fastify)) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) - sget({ - method: 'GET', - url: `http://localhost:${fastify.server.address().port}` - }, (err, response, body) => { - t.error(err) - t.equal(response.headers['content-type'], undefined) - t.equal(response.headers['content-length'], undefined) - t.equal(body.length, 0) - }) - }) + const result = await fetch(fastifyServer) + t.assert.ok(result.ok) + t.assert.strictEqual(result.headers.get('content-type'), null) + t.assert.strictEqual(result.headers.get('content-length'), null) + const body = await result.text() + t.assert.strictEqual(body.length, 0) }) -test('for HEAD method, no body should be sent but content-length should be', t => { - t.plan(11) +test('for HEAD method, no body should be sent but content-length should be', async t => { + t.plan(10) - const fastify = require('../..')() + const fastify = Fastify() + t.after(() => fastify.close()) const contentType = 'application/json; charset=utf-8' const bodySize = JSON.stringify({ foo: 'bar' }).length fastify.head('/', { onSend: function (request, reply, payload, done) { - t.equal(payload, undefined) + t.assert.strictEqual(payload, undefined) done() } }, function (req, reply) { @@ -903,7 +893,7 @@ test('for HEAD method, no body should be sent but content-length should be', t = fastify.head('/with/null', { onSend: function (request, reply, payload, done) { - t.equal(payload, 'null') + t.assert.strictEqual(payload, 'null') done() } }, function (req, reply) { @@ -912,36 +902,32 @@ test('for HEAD method, no body should be sent but content-length should be', t = reply.code(200).send(null) }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(fastify.close.bind(fastify)) - - sget({ - method: 'HEAD', - url: `http://localhost:${fastify.server.address().port}` - }, (err, response, body) => { - t.error(err) - t.equal(response.headers['content-type'], contentType) - t.equal(response.headers['content-length'], bodySize.toString()) - t.equal(body.length, 0) - }) + const fastifyServer = await fastify.listen({ port: 0 }) - sget({ - method: 'HEAD', - url: `http://localhost:${fastify.server.address().port}/with/null` - }, (err, response, body) => { - t.error(err) - t.equal(response.headers['content-type'], contentType) - t.equal(response.headers['content-length'], bodySize.toString()) - t.equal(body.length, 0) - }) - }) + const promise1 = (async () => { + const result = await fetch(fastifyServer, { method: 'HEAD' }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.headers.get('content-type'), contentType) + t.assert.strictEqual(result.headers.get('content-length'), bodySize.toString()) + t.assert.strictEqual((await result.text()).length, 0) + })() + + const promise2 = (async () => { + const result = await fetch(fastifyServer + '/with/null', { method: 'HEAD' }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.headers.get('content-type'), contentType) + t.assert.strictEqual(result.headers.get('content-length'), bodySize.toString()) + t.assert.strictEqual((await result.text()).length, 0) + })() + + await Promise.all([promise1, promise2]) }) -test('reply.send(new NotFound()) should not invoke the 404 handler', t => { - t.plan(9) +test('reply.send(new NotFound()) should not invoke the 404 handler', async t => { + t.plan(6) - const fastify = require('../..')() + const fastify = Fastify() + t.after(() => fastify.close()) fastify.setNotFoundHandler((req, reply) => { t.fail('Should not be called') @@ -959,43 +945,35 @@ test('reply.send(new NotFound()) should not invoke the 404 handler', t => { done() }, { prefix: '/prefixed' }) - fastify.listen({ port: 0 }, err => { - t.error(err) - - t.teardown(fastify.close.bind(fastify)) + const fastifyServer = await fastify.listen({ port: 0 }) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/not-found' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 404) - t.equal(response.headers['content-type'], 'application/json; charset=utf-8') - t.same(JSON.parse(body.toString()), { - statusCode: 404, - error: 'Not Found', - message: 'Not Found' - }) + const promise1 = (async () => { + const result = await fetch(`${fastifyServer}/not-found`) + t.assert.strictEqual(result.status, 404) + t.assert.strictEqual(result.headers.get('content-type'), 'application/json; charset=utf-8') + t.assert.deepStrictEqual(JSON.parse(await result.text()), { + statusCode: 404, + error: 'Not Found', + message: 'Not Found' }) - - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/prefixed/not-found' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 404) - t.equal(response.headers['content-type'], 'application/json; charset=utf-8') - t.same(JSON.parse(body), { - error: 'Not Found', - message: 'Not Found', - statusCode: 404 - }) + })() + + const promise2 = (async () => { + const result = await fetch(`${fastifyServer}/prefixed/not-found`) + t.assert.strictEqual(result.status, 404) + t.assert.strictEqual(result.headers.get('content-type'), 'application/json; charset=utf-8') + t.assert.deepStrictEqual(JSON.parse(await result.text()), { + statusCode: 404, + error: 'Not Found', + message: 'Not Found' }) - }) + })() + + await Promise.all([promise1, promise2]) }) -test('reply can set multiple instances of same header', t => { - t.plan(4) +test('reply can set multiple instances of same header', async t => { + t.plan(3) const fastify = require('../../')() @@ -1006,86 +984,75 @@ test('reply can set multiple instances of same header', t => { .send({}) }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(fastify.close.bind(fastify)) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/headers' - }, (err, response, body) => { - t.error(err) - t.ok(response.headers['set-cookie']) - t.strictSame(response.headers['set-cookie'], ['one', 'two']) - }) - }) + const result = await fetch(`${fastifyServer}/headers`) + t.assert.ok(result.ok) + t.assert.ok(result.headers.get('set-cookie')) + t.assert.deepStrictEqual(result.headers.getSetCookie(), ['one', 'two']) }) -test('reply.hasHeader returns correct values', t => { - t.plan(3) +test('reply.hasHeader returns correct values', async t => { + t.plan(2) const fastify = require('../../')() fastify.get('/headers', function (req, reply) { reply.header('x-foo', 'foo') - t.equal(reply.hasHeader('x-foo'), true) - t.equal(reply.hasHeader('x-bar'), false) + t.assert.strictEqual(reply.hasHeader('x-foo'), true) + t.assert.strictEqual(reply.hasHeader('x-bar'), false) reply.send() }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(fastify.close.bind(fastify)) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/headers' - }, () => {}) - }) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + await fetch(`${fastifyServer}/headers`) }) -test('reply.getHeader returns correct values', t => { +test('reply.getHeader returns correct values', async t => { t.plan(4) const fastify = require('../../')() fastify.get('/headers', function (req, reply) { reply.header('x-foo', 'foo') - t.equal(reply.getHeader('x-foo'), 'foo') + t.assert.strictEqual(reply.getHeader('x-foo'), 'foo') reply.header('x-foo', 'bar') - t.strictSame(reply.getHeader('x-foo'), 'bar') + t.assert.deepStrictEqual(reply.getHeader('x-foo'), 'bar') + + reply.header('x-foo', 42) + t.assert.deepStrictEqual(reply.getHeader('x-foo'), 42) reply.header('set-cookie', 'one') reply.header('set-cookie', 'two') - t.strictSame(reply.getHeader('set-cookie'), ['one', 'two']) + t.assert.deepStrictEqual(reply.getHeader('set-cookie'), ['one', 'two']) reply.send() }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(fastify.close.bind(fastify)) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/headers' - }, () => {}) - }) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + await fetch(`${fastifyServer}/headers`) }) -test('reply.getHeader returns raw header if there is not in the reply headers', t => { +test('reply.getHeader returns raw header if there is not in the reply headers', (t) => { t.plan(1) const response = { - setHeader: () => {}, + setHeader: () => { }, hasHeader: () => true, getHeader: () => 'bar', - writeHead: () => {}, - end: () => {} + writeHead: () => { }, + end: () => { } } const reply = new Reply(response, { onSend: [] }, null) - t.equal(reply.getHeader('foo'), 'bar') + t.assert.strictEqual(reply.getHeader('foo'), 'bar') }) -test('reply.getHeaders returns correct values', t => { +test('reply.getHeaders returns correct values', (t, done) => { t.plan(3) const fastify = require('../../')() @@ -1093,7 +1060,7 @@ test('reply.getHeaders returns correct values', t => { fastify.get('/headers', function (req, reply) { reply.header('x-foo', 'foo') - t.strictSame(reply.getHeaders(), { + t.assert.deepStrictEqual(reply.getHeaders(), { 'x-foo': 'foo' }) @@ -1101,7 +1068,7 @@ test('reply.getHeaders returns correct values', t => { reply.raw.setHeader('x-foo', 'foo2') reply.raw.setHeader('x-baz', 'baz') - t.strictSame(reply.getHeaders(), { + t.assert.deepStrictEqual(reply.getHeaders(), { 'x-foo': 'foo', 'x-bar': 'bar', 'x-baz': 'baz' @@ -1111,97 +1078,78 @@ test('reply.getHeaders returns correct values', t => { }) fastify.inject('/headers', (err) => { - t.error(err) + t.assert.ifError(err) + done() }) }) -test('reply.removeHeader can remove the value', t => { - t.plan(5) +test('reply.removeHeader can remove the value', async t => { + t.plan(3) const fastify = require('../../')() - t.teardown(fastify.close.bind(fastify)) + t.after(() => fastify.close()) fastify.get('/headers', function (req, reply) { reply.header('x-foo', 'foo') - t.equal(reply.getHeader('x-foo'), 'foo') + t.assert.strictEqual(reply.getHeader('x-foo'), 'foo') - t.equal(reply.removeHeader('x-foo'), reply) - t.strictSame(reply.getHeader('x-foo'), undefined) + t.assert.strictEqual(reply.removeHeader('x-foo'), reply) + t.assert.deepStrictEqual(reply.getHeader('x-foo'), undefined) reply.send() }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(fastify.close.bind(fastify)) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/headers' - }, () => { - t.pass() - }) - }) + const fastifyServer = await fastify.listen({ port: 0 }) + + await fetch(`${fastifyServer}/headers`) }) -test('reply.header can reset the value', t => { - t.plan(3) +test('reply.header can reset the value', async t => { + t.plan(1) const fastify = require('../../')() - t.teardown(fastify.close.bind(fastify)) + t.after(() => fastify.close()) fastify.get('/headers', function (req, reply) { reply.header('x-foo', 'foo') reply.header('x-foo', undefined) - t.strictSame(reply.getHeader('x-foo'), '') + t.assert.deepStrictEqual(reply.getHeader('x-foo'), '') reply.send() }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(fastify.close.bind(fastify)) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/headers' - }, () => { - t.pass() - }) - }) + const fastifyServer = await fastify.listen({ port: 0 }) + + await fetch(`${fastifyServer}/headers`) }) // https://github.com/fastify/fastify/issues/3030 -test('reply.hasHeader computes raw and fastify headers', t => { - t.plan(4) +test('reply.hasHeader computes raw and fastify headers', async t => { + t.plan(2) const fastify = require('../../')() - t.teardown(fastify.close.bind(fastify)) + t.after(() => fastify.close()) fastify.get('/headers', function (req, reply) { reply.header('x-foo', 'foo') reply.raw.setHeader('x-bar', 'bar') - t.ok(reply.hasHeader('x-foo')) - t.ok(reply.hasHeader('x-bar')) + t.assert.ok(reply.hasHeader('x-foo')) + t.assert.ok(reply.hasHeader('x-bar')) reply.send() }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(fastify.close.bind(fastify)) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/headers' - }, () => { - t.pass() - }) - }) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + await fetch(`${fastifyServer}/headers`) }) -test('Reply should handle JSON content type with a charset', t => { - t.plan(16) +test('Reply should handle JSON content type with a charset', async t => { + t.plan(8) const fastify = require('../../')() @@ -1251,48 +1199,47 @@ test('Reply should handle JSON content type with a charset', t => { .send({ hello: 'world' }) }) - fastify.inject('/default', (err, res) => { - t.error(err) - t.equal(res.headers['content-type'], 'application/json; charset=utf-8') - }) - - fastify.inject('/utf8', (err, res) => { - t.error(err) - t.equal(res.headers['content-type'], 'application/json; charset=utf-8') - }) + { + const res = await fastify.inject('/default') + t.assert.strictEqual(res.headers['content-type'], 'application/json; charset=utf-8') + } - fastify.inject('/utf16', (err, res) => { - t.error(err) - t.equal(res.headers['content-type'], 'application/json; charset=utf-16') - }) + { + const res = await fastify.inject('/utf8') + t.assert.strictEqual(res.headers['content-type'], 'application/json; charset=utf-8') + } - fastify.inject('/utf32', (err, res) => { - t.error(err) - t.equal(res.headers['content-type'], 'application/json; charset=utf-32') - }) + { + const res = await fastify.inject('/utf16') + t.assert.strictEqual(res.headers['content-type'], 'application/json; charset=utf-16') + } - fastify.inject('/type-utf8', (err, res) => { - t.error(err) - t.equal(res.headers['content-type'], 'application/json; charset=utf-8') - }) + { + const res = await fastify.inject('/utf32') + t.assert.strictEqual(res.headers['content-type'], 'application/json; charset=utf-32') + } - fastify.inject('/type-utf16', (err, res) => { - t.error(err) - t.equal(res.headers['content-type'], 'application/json; charset=utf-16') - }) + { + const res = await fastify.inject('/type-utf8') + t.assert.strictEqual(res.headers['content-type'], 'application/json; charset=utf-8') + } - fastify.inject('/type-utf32', (err, res) => { - t.error(err) - t.equal(res.headers['content-type'], 'application/json; charset=utf-32') - }) + { + const res = await fastify.inject('/type-utf16') + t.assert.strictEqual(res.headers['content-type'], 'application/json; charset=utf-16') + } + { + const res = await fastify.inject('/type-utf32') + t.assert.strictEqual(res.headers['content-type'], 'application/json; charset=utf-32') + } - fastify.inject('/no-space-type-utf32', (err, res) => { - t.error(err) - t.equal(res.headers['content-type'], 'application/json;charset=utf-32') - }) + { + const res = await fastify.inject('/no-space-type-utf32') + t.assert.strictEqual(res.headers['content-type'], 'application/json;charset=utf-32') + } }) -test('Content type and charset set previously', t => { +test('Content type and charset set previously', (t, done) => { t.plan(2) const fastify = require('../../')() @@ -1307,46 +1254,50 @@ test('Content type and charset set previously', t => { }) fastify.inject('/', (err, res) => { - t.error(err) - t.equal(res.headers['content-type'], 'application/json; charset=utf-16') + t.assert.ifError(err) + t.assert.strictEqual(res.headers['content-type'], 'application/json; charset=utf-16') + done() }) }) -test('.status() is an alias for .code()', t => { +test('.status() is an alias for .code()', (t, done) => { t.plan(2) - const fastify = require('../..')() + const fastify = Fastify() fastify.get('/', function (req, reply) { reply.status(418).send() }) fastify.inject('/', (err, res) => { - t.error(err) - t.equal(res.statusCode, 418) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 418) + done() }) }) -test('.statusCode is getter and setter', t => { +test('.statusCode is getter and setter', (t, done) => { t.plan(4) - const fastify = require('../..')() + const fastify = Fastify() fastify.get('/', function (req, reply) { - t.ok(reply.statusCode, 200, 'default status value') + t.assert.strictEqual(reply.statusCode, 200, 'default status value') reply.statusCode = 418 - t.ok(reply.statusCode, 418) + t.assert.strictEqual(reply.statusCode, 418) reply.send() }) fastify.inject('/', (err, res) => { - t.error(err) - t.equal(res.statusCode, 418) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 418) + done() }) }) -test('reply.header setting multiple cookies as multiple Set-Cookie headers', t => { - t.plan(7) +test('reply.header setting multiple cookies as multiple Set-Cookie headers', async t => { + t.plan(5) const fastify = require('../../')() + t.after(() => fastify.close()) fastify.get('/headers', function (req, reply) { reply @@ -1357,124 +1308,48 @@ test('reply.header setting multiple cookies as multiple Set-Cookie headers', t = .send({}) }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(fastify.close.bind(fastify)) + const fastifyServer = await fastify.listen({ port: 0 }) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/headers' - }, (err, response, body) => { - t.error(err) - t.ok(response.headers['set-cookie']) - t.strictSame(response.headers['set-cookie'], ['one', 'two', 'three', 'four', 'five', 'six']) - }) - }) + const result = await fetch(`${fastifyServer}/headers`) + t.assert.ok(result.ok) + t.assert.ok(result.headers.get('set-cookie')) + t.assert.deepStrictEqual(result.headers.getSetCookie(), ['one', 'two', 'three', 'four', 'five', 'six']) - fastify.inject('/headers', (error, response) => { - t.error(error) - t.ok(response.headers['set-cookie']) - t.strictSame(response.headers['set-cookie'], ['one', 'two', 'three', 'four', 'five', 'six']) - }) + const response = await fastify.inject('/headers') + t.assert.ok(response.headers['set-cookie']) + t.assert.deepStrictEqual(response.headers['set-cookie'], ['one', 'two', 'three', 'four', 'five', 'six']) }) -test('should emit deprecation warning when trying to modify the reply.sent property', t => { - t.plan(4) - const fastify = require('../..')() - - const deprecationCode = 'FSTDEP010' - warning.emitted.delete(deprecationCode) - - process.removeAllListeners('warning') - process.on('warning', onWarning) - function onWarning (warning) { - t.equal(warning.name, 'FastifyDeprecation') - t.equal(warning.code, deprecationCode) - } - - fastify.get('/', (req, reply) => { - reply.sent = true - - reply.raw.end() - }) - - fastify.inject('/', (err, res) => { - t.error(err) - t.pass() - - process.removeListener('warning', onWarning) - }) -}) - -test('should throw error when passing falsy value to reply.sent', t => { - t.plan(4) - const fastify = require('../..')() - - fastify.get('/', function (req, reply) { - try { - reply.sent = false - } catch (err) { - t.equal(err.code, 'FST_ERR_REP_SENT_VALUE') - t.equal(err.message, 'The only possible value for reply.sent is true.') - reply.send() - } - }) - - fastify.inject('/', (err, res) => { - t.error(err) - t.pass() - }) -}) - -test('should throw error when attempting to set reply.sent more than once', t => { - t.plan(4) - const fastify = require('../..')() +test('should throw when trying to modify the reply.sent property', (t, done) => { + t.plan(3) + const fastify = Fastify() fastify.get('/', function (req, reply) { - reply.sent = true try { reply.sent = true - t.fail('must throw') } catch (err) { - t.equal(err.code, 'FST_ERR_REP_ALREADY_SENT') - t.equal(err.message, 'Reply was already sent.') + t.assert.ok(err) + reply.send() } - reply.raw.end() - }) - - fastify.inject('/', (err, res) => { - t.error(err) - t.pass() - }) -}) - -test('should not throw error when attempting to set reply.sent if the underlining request was sent', t => { - t.plan(3) - const fastify = require('../..')() - - fastify.get('/', function (req, reply) { - reply.raw.end() - t.doesNotThrow(() => { - reply.sent = true - }) }) fastify.inject('/', (err, res) => { - t.error(err) - t.pass() + t.assert.ifError(err) + t.assert.ok(true) + done() }) }) -test('reply.getResponseTime() should return 0 before the timer is initialised on the reply by setting up response listeners', t => { +test('reply.elapsedTime should return 0 before the timer is initialised on the reply by setting up response listeners', t => { t.plan(1) const response = { statusCode: 200 } const reply = new Reply(response, null) - t.equal(reply.getResponseTime(), 0) + t.assert.strictEqual(reply.elapsedTime, 0) }) -test('reply.getResponseTime() should return a number greater than 0 after the timer is initialised on the reply by setting up response listeners', t => { +test('reply.elapsedTime should return a number greater than 0 after the timer is initialised on the reply by setting up response listeners', async t => { t.plan(1) - const fastify = require('../..')() + const fastify = Fastify() fastify.route({ method: 'GET', url: '/', @@ -1484,16 +1359,15 @@ test('reply.getResponseTime() should return a number greater than 0 after the ti }) fastify.addHook('onResponse', (req, reply) => { - t.ok(reply.getResponseTime() > 0) - t.end() + t.assert.ok(reply.elapsedTime > 0) }) - fastify.inject({ method: 'GET', url: '/' }) + await fastify.inject({ method: 'GET', url: '/' }) }) -test('reply.getResponseTime() should return the time since a request started while inflight', t => { +test('reply.elapsedTime should return the time since a request started while inflight', async t => { t.plan(1) - const fastify = require('../..')() + const fastify = Fastify() fastify.route({ method: 'GET', url: '/', @@ -1502,21 +1376,24 @@ test('reply.getResponseTime() should return the time since a request started whi } }) + let preValidationElapsedTime + fastify.addHook('preValidation', (req, reply, done) => { - t.not(reply.getResponseTime(), reply.getResponseTime()) + preValidationElapsedTime = reply.elapsedTime + done() }) fastify.addHook('onResponse', (req, reply) => { - t.end() + t.assert.ok(reply.elapsedTime > preValidationElapsedTime) }) - fastify.inject({ method: 'GET', url: '/' }) + await fastify.inject({ method: 'GET', url: '/' }) }) -test('reply.getResponseTime() should return the same value after a request is finished', t => { +test('reply.elapsedTime should return the same value after a request is finished', async t => { t.plan(1) - const fastify = require('../..')() + const fastify = Fastify() fastify.route({ method: 'GET', url: '/', @@ -1526,19 +1403,18 @@ test('reply.getResponseTime() should return the same value after a request is fi }) fastify.addHook('onResponse', (req, reply) => { - t.equal(reply.getResponseTime(), reply.getResponseTime()) - t.end() + t.assert.strictEqual(reply.elapsedTime, reply.elapsedTime) }) - fastify.inject({ method: 'GET', url: '/' }) + await fastify.inject({ method: 'GET', url: '/' }) }) -test('reply should use the custom serializer', t => { +test('reply should use the custom serializer', (t, done) => { t.plan(4) - const fastify = require('../..')() + const fastify = Fastify() fastify.setReplySerializer((payload, statusCode) => { - t.same(payload, { foo: 'bar' }) - t.equal(statusCode, 200) + t.assert.deepStrictEqual(payload, { foo: 'bar' }) + t.assert.strictEqual(statusCode, 200) payload.foo = 'bar bar' return JSON.stringify(payload) }) @@ -1555,17 +1431,18 @@ test('reply should use the custom serializer', t => { method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.equal(res.payload, '{"foo":"bar bar"}') + t.assert.ifError(err) + t.assert.strictEqual(res.payload, '{"foo":"bar bar"}') + done() }) }) -test('reply should use the right serializer in encapsulated context', t => { - t.plan(9) +test('reply should use the right serializer in encapsulated context', async t => { + t.plan(6) - const fastify = require('../..')() + const fastify = Fastify() fastify.setReplySerializer((payload) => { - t.same(payload, { foo: 'bar' }) + t.assert.deepStrictEqual(payload, { foo: 'bar' }) payload.foo = 'bar bar' return JSON.stringify(payload) }) @@ -1583,7 +1460,7 @@ test('reply should use the right serializer in encapsulated context', t => { handler: (req, reply) => { reply.send({ john: 'doo' }) } }) instance.setReplySerializer((payload) => { - t.same(payload, { john: 'doo' }) + t.assert.deepStrictEqual(payload, { john: 'doo' }) payload.john = 'too too' return JSON.stringify(payload) }) @@ -1597,42 +1474,33 @@ test('reply should use the right serializer in encapsulated context', t => { handler: (req, reply) => { reply.send({ sweet: 'potato' }) } }) instance.setReplySerializer((payload) => { - t.same(payload, { sweet: 'potato' }) + t.assert.deepStrictEqual(payload, { sweet: 'potato' }) payload.sweet = 'potato potato' return JSON.stringify(payload) }) done() }, { prefix: 'sub' }) - fastify.inject({ - method: 'GET', - url: '/' - }, (err, res) => { - t.error(err) - t.equal(res.payload, '{"foo":"bar bar"}') - }) + { + const res = await fastify.inject('/') + t.assert.strictEqual(res.payload, '{"foo":"bar bar"}') + } - fastify.inject({ - method: 'GET', - url: '/sub' - }, (err, res) => { - t.error(err) - t.equal(res.payload, '{"john":"too too"}') - }) + { + const res = await fastify.inject('/sub') + t.assert.strictEqual(res.payload, '{"john":"too too"}') + } - fastify.inject({ - method: 'GET', - url: '/sub/sub' - }, (err, res) => { - t.error(err) - t.equal(res.payload, '{"sweet":"potato potato"}') - }) + { + const res = await fastify.inject('/sub/sub') + t.assert.strictEqual(res.payload, '{"sweet":"potato potato"}') + } }) -test('reply should use the right serializer in deep encapsulated context', t => { - t.plan(8) +test('reply should use the right serializer in deep encapsulated context', async t => { + t.plan(5) - const fastify = require('../..')() + const fastify = Fastify() fastify.route({ method: 'GET', @@ -1647,7 +1515,7 @@ test('reply should use the right serializer in deep encapsulated context', t => handler: (req, reply) => { reply.send({ john: 'doo' }) } }) instance.setReplySerializer((payload) => { - t.same(payload, { john: 'doo' }) + t.assert.deepStrictEqual(payload, { john: 'doo' }) payload.john = 'too too' return JSON.stringify(payload) }) @@ -1659,7 +1527,7 @@ test('reply should use the right serializer in deep encapsulated context', t => handler: (req, reply) => { reply.send({ john: 'deep' }) } }) subInstance.setReplySerializer((payload) => { - t.same(payload, { john: 'deep' }) + t.assert.deepStrictEqual(payload, { john: 'deep' }) payload.john = 'deep deep' return JSON.stringify(payload) }) @@ -1668,35 +1536,24 @@ test('reply should use the right serializer in deep encapsulated context', t => done() }) - fastify.inject({ - method: 'GET', - url: '/' - }, (err, res) => { - t.error(err) - t.equal(res.payload, '{"foo":"bar"}') - }) - - fastify.inject({ - method: 'GET', - url: '/sub' - }, (err, res) => { - t.error(err) - t.equal(res.payload, '{"john":"too too"}') - }) - - fastify.inject({ - method: 'GET', - url: '/deep' - }, (err, res) => { - t.error(err) - t.equal(res.payload, '{"john":"deep deep"}') - }) + { + const res = await fastify.inject('/') + t.assert.strictEqual(res.payload, '{"foo":"bar"}') + } + { + const res = await fastify.inject('/sub') + t.assert.strictEqual(res.payload, '{"john":"too too"}') + } + { + const res = await fastify.inject('/deep') + t.assert.strictEqual(res.payload, '{"john":"deep deep"}') + } }) -test('reply should use the route serializer', t => { +test('reply should use the route serializer', (t, done) => { t.plan(3) - const fastify = require('../..')() + const fastify = Fastify() fastify.setReplySerializer(() => { t.fail('this serializer should not be executed') }) @@ -1707,7 +1564,7 @@ test('reply should use the route serializer', t => { handler: (req, reply) => { reply .serializer((payload) => { - t.same(payload, { john: 'doo' }) + t.assert.deepStrictEqual(payload, { john: 'doo' }) payload.john = 'too too' return JSON.stringify(payload) }) @@ -1719,86 +1576,79 @@ test('reply should use the route serializer', t => { method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.equal(res.payload, '{"john":"too too"}') + t.assert.ifError(err) + t.assert.strictEqual(res.payload, '{"john":"too too"}') + done() }) }) -test('cannot set the replySerializer when the server is running', t => { +test('cannot set the replySerializer when the server is running', (t, done) => { t.plan(2) - const fastify = require('../..')() - t.teardown(fastify.close.bind(fastify)) + const fastify = Fastify() + t.after(() => fastify.close()) fastify.listen({ port: 0 }, err => { - t.error(err) + t.assert.ifError(err) try { - fastify.setReplySerializer(() => {}) - t.fail('this serializer should not be setup') + fastify.setReplySerializer(() => { }) + t.assert.fail('this serializer should not be setup') } catch (e) { - t.equal(e.message, 'Cannot call "setReplySerializer" when fastify instance is already started!') + t.assert.strictEqual(e.code, 'FST_ERR_INSTANCE_ALREADY_LISTENING') + } finally { + done() } }) }) -test('reply should not call the custom serializer for errors and not found', t => { - t.plan(9) +test('reply should not call the custom serializer for errors and not found', async t => { + t.plan(6) - const fastify = require('../..')() + const fastify = Fastify() fastify.setReplySerializer((payload, statusCode) => { - t.same(payload, { foo: 'bar' }) - t.equal(statusCode, 200) + t.assert.deepStrictEqual(payload, { foo: 'bar' }) + t.assert.strictEqual(statusCode, 200) return JSON.stringify(payload) }) fastify.get('/', (req, reply) => { reply.send({ foo: 'bar' }) }) fastify.get('/err', (req, reply) => { reply.send(new Error('an error')) }) - fastify.inject({ - method: 'GET', - url: '/' - }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.equal(res.payload, '{"foo":"bar"}') - }) - - fastify.inject({ - method: 'GET', - url: '/err' - }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 500) - }) - - fastify.inject({ - method: 'GET', - url: '/not-existing' - }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 404) - }) + { + const res = await fastify.inject('/') + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload, '{"foo":"bar"}') + } + { + const res = await fastify.inject('/err') + t.assert.strictEqual(res.statusCode, 500) + } + { + const res = await fastify.inject('/not-existing') + t.assert.strictEqual(res.statusCode, 404) + } }) -test('reply.then', t => { +test('reply.then', async t => { t.plan(4) - function request () {} + function request () { } - t.test('without an error', t => { + await t.test('without an error', (t, done) => { t.plan(1) const response = new Writable() const reply = new Reply(response, request) reply.then(function () { - t.pass('fulfilled called') + t.assert.ok(true) + done() }) response.destroy() }) - t.test('with an error', t => { + await t.test('with an error', (t, done) => { t.plan(1) const response = new Writable() @@ -1806,15 +1656,16 @@ test('reply.then', t => { const _err = new Error('kaboom') reply.then(function () { - t.fail('fulfilled called') + t.assert.fail('fulfilled called') }, function (err) { - t.equal(err, _err) + t.assert.strictEqual(err, _err) + done() }) response.destroy(_err) }) - t.test('with error but without reject callback', t => { + await t.test('with error but without reject callback', t => { t.plan(1) const response = new Writable() @@ -1822,15 +1673,15 @@ test('reply.then', t => { const _err = new Error('kaboom') reply.then(function () { - t.fail('fulfilled called') + t.assert.fail('fulfilled called') }) - t.pass() + t.assert.ok(true) response.destroy(_err) }) - t.test('with error, without reject callback, with logger', t => { + await t.test('with error, without reject callback, with logger', (t, done) => { t.plan(1) const response = new Writable() @@ -1838,13 +1689,14 @@ test('reply.then', t => { // spy logger reply.log = { warn: (message) => { - t.equal(message, 'unhandled rejection on reply.then') + t.assert.strictEqual(message, 'unhandled rejection on reply.then') + done() } } const _err = new Error('kaboom') reply.then(function () { - t.fail('fulfilled called') + t.assert.fail('fulfilled called') }) response.destroy(_err) @@ -1856,17 +1708,17 @@ test('reply.sent should read from response.writableEnded if it is defined', t => const reply = new Reply({ writableEnded: true }, {}, {}) - t.equal(reply.sent, true) + t.assert.strictEqual(reply.sent, true) }) test('redirect to an invalid URL should not crash the server', async t => { - const fastify = require('../..')() + const fastify = Fastify() fastify.route({ method: 'GET', url: '/redirect', handler: (req, reply) => { reply.log.warn = function mockWarn (obj, message) { - t.equal(message, 'Invalid character in header content ["location"]') + t.assert.strictEqual(message, 'Invalid character in header content ["location"]') } switch (req.query.useCase) { @@ -1885,12 +1737,12 @@ test('redirect to an invalid URL should not crash the server', async t => { } }) - await fastify.listen({ port: 0 }) + const fastifyServer = await fastify.listen({ port: 0 }) { - const { response, body } = await doGet(`http://localhost:${fastify.server.address().port}/redirect?useCase=1`) - t.equal(response.statusCode, 500) - t.same(JSON.parse(body), { + const { response, body } = await doGet(`${fastifyServer}/redirect?useCase=1`) + t.assert.strictEqual(response.status, 500) + t.assert.deepStrictEqual(body, { statusCode: 500, code: 'ERR_INVALID_CHAR', error: 'Internal Server Error', @@ -1898,28 +1750,28 @@ test('redirect to an invalid URL should not crash the server', async t => { }) } { - const { response } = await doGet(`http://localhost:${fastify.server.address().port}/redirect?useCase=2`) - t.equal(response.statusCode, 302) - t.equal(response.headers.location, '/?key=a%E2%80%99b') + const { response } = await doGet(`${fastifyServer}/redirect?useCase=2`) + t.assert.strictEqual(response.status, 302) + t.assert.strictEqual(response.headers.get('location'), '/?key=a%E2%80%99b') } { - const { response } = await doGet(`http://localhost:${fastify.server.address().port}/redirect?useCase=3`) - t.equal(response.statusCode, 302) - t.equal(response.headers.location, '/?key=ab') + const { response } = await doGet(`${fastifyServer}/redirect?useCase=3`) + t.assert.strictEqual(response.status, 302) + t.assert.strictEqual(response.headers.get('location'), '/?key=ab') } await fastify.close() }) test('invalid response headers should not crash the server', async t => { - const fastify = require('../..')() + const fastify = Fastify() fastify.route({ method: 'GET', url: '/bad-headers', handler: (req, reply) => { reply.log.warn = function mockWarn (obj, message) { - t.equal(message, 'Invalid character in header content ["smile-encoded"]', 'only the first invalid header is logged') + t.assert.strictEqual(message, 'Invalid character in header content ["smile-encoded"]', 'only the first invalid header is logged') } reply.header('foo', '$') @@ -1931,11 +1783,11 @@ test('invalid response headers should not crash the server', async t => { } }) - await fastify.listen({ port: 0 }) + const fastifyServer = await fastify.listen({ port: 0 }) - const { response, body } = await doGet(`http://localhost:${fastify.server.address().port}/bad-headers`) - t.equal(response.statusCode, 500) - t.same(JSON.parse(body), { + const { response, body } = await doGet(`${fastifyServer}/bad-headers`) + t.assert.strictEqual(response.status, 500) + t.assert.deepStrictEqual(body, { statusCode: 500, code: 'ERR_INVALID_CHAR', error: 'Internal Server Error', @@ -1946,13 +1798,13 @@ test('invalid response headers should not crash the server', async t => { }) test('invalid response headers when sending back an error', async t => { - const fastify = require('../..')() + const fastify = Fastify() fastify.route({ method: 'GET', url: '/bad-headers', handler: (req, reply) => { reply.log.warn = function mockWarn (obj, message) { - t.equal(message, 'Invalid character in header content ["smile"]', 'only the first invalid header is logged') + t.assert.strictEqual(message, 'Invalid character in header content ["smile"]', 'only the first invalid header is logged') } reply.header('smile', '😄') @@ -1960,11 +1812,11 @@ test('invalid response headers when sending back an error', async t => { } }) - await fastify.listen({ port: 0 }) + const fastifyServer = await fastify.listen({ port: 0 }) - const { response, body } = await doGet(`http://localhost:${fastify.server.address().port}/bad-headers`) - t.equal(response.statusCode, 500) - t.same(JSON.parse(body), { + const { response, body } = await doGet(`${fastifyServer}/bad-headers`) + t.assert.strictEqual(response.status, 500) + t.assert.deepStrictEqual(body, { statusCode: 500, code: 'ERR_INVALID_CHAR', error: 'Internal Server Error', @@ -1975,13 +1827,13 @@ test('invalid response headers when sending back an error', async t => { }) test('invalid response headers and custom error handler', async t => { - const fastify = require('../..')() + const fastify = Fastify() fastify.route({ method: 'GET', url: '/bad-headers', handler: (req, reply) => { reply.log.warn = function mockWarn (obj, message) { - t.equal(message, 'Invalid character in header content ["smile"]', 'only the first invalid header is logged') + t.assert.strictEqual(message, 'Invalid character in header content ["smile"]', 'only the first invalid header is logged') } reply.header('smile', '😄') @@ -1990,15 +1842,15 @@ test('invalid response headers and custom error handler', async t => { }) fastify.setErrorHandler(function (error, request, reply) { - t.equal(error.message, 'user land error', 'custom error handler receives the error') + t.assert.strictEqual(error.message, 'user land error', 'custom error handler receives the error') reply.status(500).send({ ops: true }) }) - await fastify.listen({ port: 0 }) + const fastifyServer = await fastify.listen({ port: 0 }) - const { response, body } = await doGet(`http://localhost:${fastify.server.address().port}/bad-headers`) - t.equal(response.statusCode, 500) - t.same(JSON.parse(body), { + const { response, body } = await doGet(`${fastifyServer}/bad-headers`) + t.assert.strictEqual(response.status, 500) + t.assert.deepStrictEqual(body, { statusCode: 500, code: 'ERR_INVALID_CHAR', error: 'Internal Server Error', @@ -2007,3 +1859,62 @@ test('invalid response headers and custom error handler', async t => { await fastify.close() }) + +test('reply.send will intercept ERR_HTTP_HEADERS_SENT and log an error message', t => { + t.plan(2) + + const response = new Writable() + Object.assign(response, { + setHeader: () => { }, + hasHeader: () => false, + getHeader: () => undefined, + writeHead: () => { + const err = new Error('kaboom') + err.code = 'ERR_HTTP_HEADERS_SENT' + throw err + }, + write: () => { }, + headersSent: true + }) + + const log = { + warn: (msg) => { + t.assert.strictEqual(msg, 'Reply was already sent, did you forget to "return reply" in the "/hello" (GET) route?') + } + } + + const reply = new Reply(response, { [kRouteContext]: { onSend: null }, raw: { url: '/hello', method: 'GET' } }, log) + + try { + reply.send('') + } catch (err) { + t.assert.strictEqual(err.code, 'ERR_HTTP_HEADERS_SENT') + } +}) + +test('Uint8Array view of ArrayBuffer returns correct byteLength', (t, done) => { + t.plan(5) + const fastify = Fastify() + + const arrBuf = new ArrayBuffer(100) + const arrView = new Uint8Array(arrBuf, 0, 10) + fastify.get('/', function (req, reply) { + return reply.send(arrView) + }) + + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + t.after(() => fastify.close()) + + fastify.inject({ + method: 'GET', + url: '/' + }, (err, response) => { + t.assert.ifError(err) + t.assert.strictEqual(response.headers['content-type'], 'application/octet-stream') + t.assert.strictEqual(response.headers['content-length'], '10') + t.assert.deepStrictEqual(response.rawPayload.byteLength, arrView.byteLength) + done() + }) + }) +}) diff --git a/test/internals/req-id-gen-factory.test.js b/test/internals/req-id-gen-factory.test.js new file mode 100644 index 00000000000..558841d565d --- /dev/null +++ b/test/internals/req-id-gen-factory.test.js @@ -0,0 +1,133 @@ +'use strict' + +const { test } = require('node:test') +const { reqIdGenFactory } = require('../../lib/req-id-gen-factory') + +test('should create incremental ids deterministically', t => { + t.plan(1) + const reqIdGen = reqIdGenFactory() + + for (let i = 1; i < 1e4; ++i) { + if (reqIdGen() !== 'req-' + i.toString(36)) { + t.assert.fail() + break + } + } + t.assert.ok(true) +}) + +test('should have prefix "req-"', t => { + t.plan(1) + const reqIdGen = reqIdGenFactory() + + t.assert.ok(reqIdGen().startsWith('req-')) +}) + +test('different id generator functions should have separate internal counters', t => { + t.plan(5) + const reqIdGenA = reqIdGenFactory() + const reqIdGenB = reqIdGenFactory() + + t.assert.strictEqual(reqIdGenA(), 'req-1') + t.assert.strictEqual(reqIdGenA(), 'req-2') + t.assert.strictEqual(reqIdGenB(), 'req-1') + t.assert.strictEqual(reqIdGenA(), 'req-3') + t.assert.strictEqual(reqIdGenB(), 'req-2') +}) + +test('should start counting with 1', t => { + t.plan(1) + const reqIdGen = reqIdGenFactory() + + t.assert.strictEqual(reqIdGen(), 'req-1') +}) + +test('should handle requestIdHeader and return provided id in header', t => { + t.plan(1) + + const reqIdGen = reqIdGenFactory('id') + + t.assert.strictEqual(reqIdGen({ headers: { id: '1337' } }), '1337') +}) + +test('should handle requestIdHeader and fallback if id is not provided in header', t => { + t.plan(1) + + const reqIdGen = reqIdGenFactory('id') + + t.assert.strictEqual(reqIdGen({ headers: { notId: '1337' } }), 'req-1') +}) + +test('should handle requestIdHeader and increment internal counter if no header was provided', t => { + t.plan(4) + + const reqIdGen = reqIdGenFactory('id') + + t.assert.strictEqual(reqIdGen({ headers: {} }), 'req-1') + t.assert.strictEqual(reqIdGen({ headers: {} }), 'req-2') + t.assert.strictEqual(reqIdGen({ headers: { id: '1337' } }), '1337') + t.assert.strictEqual(reqIdGen({ headers: {} }), 'req-3') +}) + +test('should use optGenReqId to generate ids', t => { + t.plan(4) + + let i = 1 + let gotCalled = false + function optGenReqId () { + gotCalled = true + return (i++).toString(16) + } + const reqIdGen = reqIdGenFactory(undefined, optGenReqId) + + t.assert.strictEqual(gotCalled, false) + t.assert.strictEqual(reqIdGen(), '1') + t.assert.strictEqual(gotCalled, true) + t.assert.strictEqual(reqIdGen(), '2') +}) + +test('should use optGenReqId to generate ids if requestIdHeader is used but not provided', t => { + t.plan(4) + + let i = 1 + let gotCalled = false + function optGenReqId () { + gotCalled = true + return (i++).toString(16) + } + const reqIdGen = reqIdGenFactory('reqId', optGenReqId) + + t.assert.strictEqual(gotCalled, false) + t.assert.strictEqual(reqIdGen({ headers: {} }), '1') + t.assert.strictEqual(gotCalled, true) + t.assert.strictEqual(reqIdGen({ headers: {} }), '2') +}) + +test('should not use optGenReqId to generate ids if requestIdHeader is used and provided', t => { + t.plan(2) + + function optGenReqId () { + t.assert.fail() + } + const reqIdGen = reqIdGenFactory('reqId', optGenReqId) + + t.assert.strictEqual(reqIdGen({ headers: { reqId: 'r1' } }), 'r1') + t.assert.strictEqual(reqIdGen({ headers: { reqId: 'r2' } }), 'r2') +}) + +test('should fallback to use optGenReqId to generate ids if requestIdHeader is sometimes provided', t => { + t.plan(4) + + let i = 1 + let gotCalled = false + function optGenReqId () { + gotCalled = true + return (i++).toString(16) + } + const reqIdGen = reqIdGenFactory('reqId', optGenReqId) + + t.assert.strictEqual(reqIdGen({ headers: { reqId: 'r1' } }), 'r1') + t.assert.strictEqual(gotCalled, false) + t.assert.strictEqual(reqIdGen({ headers: {} }), '1') + t.assert.strictEqual(gotCalled, true) +}) diff --git a/test/internals/request-validate.test.js b/test/internals/request-validate.test.js new file mode 100644 index 00000000000..914588136bf --- /dev/null +++ b/test/internals/request-validate.test.js @@ -0,0 +1,1402 @@ +'use strict' + +const { test } = require('node:test') +const Ajv = require('ajv') +const { kRequestCacheValidateFns, kRouteContext } = require('../../lib/symbols') +const Fastify = require('../../fastify') + +const defaultSchema = { + type: 'object', + required: ['hello'], + properties: { + hello: { type: 'string' }, + world: { type: 'string' } + } +} + +const requestSchema = { + params: { + type: 'object', + properties: { + id: { + type: 'integer', + minimum: 1 + } + } + }, + querystring: { + type: 'object', + properties: { + foo: { + type: 'string', + enum: ['bar'] + } + } + }, + body: defaultSchema, + headers: { + type: 'object', + properties: { + 'x-foo': { + type: 'string' + } + } + } +} + +test('#compileValidationSchema', async subtest => { + subtest.plan(7) + + await subtest.test('Should return a function - Route without schema', async t => { + const fastify = Fastify() + + t.plan(3) + + fastify.get('/', (req, reply) => { + const validate = req.compileValidationSchema(defaultSchema) + + t.assert.ok(validate instanceof Function) + t.assert.ok(validate({ hello: 'world' })) + t.assert.ok(!validate({ world: 'foo' })) + + reply.send({ hello: 'world' }) + }) + + await fastify.inject({ + path: '/', + method: 'GET' + }) + }) + + await subtest.test('Validate function errors property should be null after validation when input is valid', async t => { + const fastify = Fastify() + + t.plan(3) + + fastify.get('/', (req, reply) => { + const validate = req.compileValidationSchema(defaultSchema) + + t.assert.ok(validate({ hello: 'world' })) + t.assert.ok(Object.hasOwn(validate, 'errors')) + t.assert.strictEqual(validate.errors, null) + + reply.send({ hello: 'world' }) + }) + + await fastify.inject({ + path: '/', + method: 'GET' + }) + }) + + await subtest.test('Validate function errors property should be an array of errors after validation when input is valid', async t => { + const fastify = Fastify() + + t.plan(4) + + fastify.get('/', (req, reply) => { + const validate = req.compileValidationSchema(defaultSchema) + + t.assert.ok(!validate({ world: 'foo' })) + t.assert.ok(Object.hasOwn(validate, 'errors')) + t.assert.ok(Array.isArray(validate.errors)) + t.assert.ok(validate.errors.length > 0) + + reply.send({ hello: 'world' }) + }) + + await fastify.inject({ + path: '/', + method: 'GET' + }) + }) + + await subtest.test( + 'Should reuse the validate fn across multiple invocations - Route without schema', + async t => { + const fastify = Fastify() + let validate = null + let counter = 0 + + t.plan(16) + + fastify.get('/', (req, reply) => { + counter++ + if (counter > 1) { + const newValidate = req.compileValidationSchema(defaultSchema) + t.assert.strictEqual(validate, newValidate, 'Are the same validate function') + validate = newValidate + } else { + validate = req.compileValidationSchema(defaultSchema) + } + + t.assert.ok(validate instanceof Function) + t.assert.ok(validate({ hello: 'world' })) + t.assert.ok(!validate({ world: 'foo' })) + + reply.send({ hello: 'world' }) + }) + + await Promise.all([ + fastify.inject({ + path: '/', + method: 'GET' + }), + fastify.inject({ + path: '/', + method: 'GET' + }), + fastify.inject({ + path: '/', + method: 'GET' + }), + fastify.inject({ + path: '/', + method: 'GET' + }) + ]) + + t.assert.strictEqual(counter, 4) + } + ) + + await subtest.test('Should return a function - Route with schema', async t => { + const fastify = Fastify() + + t.plan(3) + + fastify.post( + '/', + { + schema: { + body: defaultSchema + } + }, + (req, reply) => { + const validate = req.compileValidationSchema(defaultSchema) + + t.assert.ok(validate instanceof Function) + t.assert.ok(validate({ hello: 'world' })) + t.assert.ok(!validate({ world: 'foo' })) + + reply.send({ hello: 'world' }) + } + ) + + await fastify.inject({ + path: '/', + method: 'POST', + payload: { + hello: 'world', + world: 'foo' + } + }) + }) + + await subtest.test( + 'Should use the custom validator compiler for the route', + async t => { + const fastify = Fastify() + let called = 0 + const custom = ({ schema, httpPart, url, method }) => { + t.assert.strictEqual(schema, defaultSchema) + t.assert.strictEqual(url, '/') + t.assert.strictEqual(method, 'GET') + t.assert.strictEqual(httpPart, 'querystring') + + return input => { + called++ + t.assert.deepStrictEqual(input, { hello: 'world' }) + return true + } + } + + t.plan(10) + + fastify.get('/', { validatorCompiler: custom }, (req, reply) => { + const first = req.compileValidationSchema(defaultSchema, 'querystring') + const second = req.compileValidationSchema(defaultSchema, 'querystring') + + t.assert.strictEqual(first, second) + t.assert.ok(first({ hello: 'world' })) + t.assert.ok(second({ hello: 'world' })) + t.assert.strictEqual(called, 2) + + reply.send({ hello: 'world' }) + }) + + await fastify.inject({ + path: '/', + method: 'GET' + }) + } + ) + + await subtest.test( + 'Should instantiate a WeakMap when executed for first time', + async t => { + const fastify = Fastify() + + t.plan(5) + + fastify.get('/', (req, reply) => { + t.assert.strictEqual(req[kRouteContext][kRequestCacheValidateFns], null) + t.assert.ok(req.compileValidationSchema(defaultSchema) instanceof Function) + t.assert.ok(req[kRouteContext][kRequestCacheValidateFns] instanceof WeakMap) + t.assert.ok(req.compileValidationSchema(Object.assign({}, defaultSchema)) instanceof Function) + t.assert.ok(req[kRouteContext][kRequestCacheValidateFns] instanceof WeakMap) + + reply.send({ hello: 'world' }) + }) + + await fastify.inject({ + path: '/', + method: 'GET' + }) + } + ) +}) + +test('#getValidationFunction', async subtest => { + subtest.plan(6) + + await subtest.test('Should return a validation function', async t => { + const fastify = Fastify() + + t.plan(1) + + fastify.get('/', (req, reply) => { + const original = req.compileValidationSchema(defaultSchema) + const referenced = req.getValidationFunction(defaultSchema) + + t.assert.strictEqual(original, referenced) + + reply.send({ hello: 'world' }) + }) + + await fastify.inject({ + path: '/', + method: 'GET' + }) + }) + + await subtest.test('Validate function errors property should be null after validation when input is valid', async t => { + const fastify = Fastify() + + t.plan(3) + + fastify.get('/', (req, reply) => { + req.compileValidationSchema(defaultSchema) + const validate = req.getValidationFunction(defaultSchema) + + t.assert.ok(validate({ hello: 'world' })) + t.assert.ok(Object.hasOwn(validate, 'errors')) + t.assert.strictEqual(validate.errors, null) + + reply.send({ hello: 'world' }) + }) + + await fastify.inject({ + path: '/', + method: 'GET' + }) + }) + + await subtest.test('Validate function errors property should be an array of errors after validation when input is valid', async t => { + const fastify = Fastify() + + t.plan(4) + + fastify.get('/', (req, reply) => { + req.compileValidationSchema(defaultSchema) + const validate = req.getValidationFunction(defaultSchema) + + t.assert.ok(!validate({ world: 'foo' })) + t.assert.ok(Object.hasOwn(validate, 'errors')) + t.assert.ok(Array.isArray(validate.errors)) + t.assert.ok(validate.errors.length > 0) + + reply.send({ hello: 'world' }) + }) + + await fastify.inject({ + path: '/', + method: 'GET' + }) + }) + + await subtest.test('Should return undefined if no schema compiled', async t => { + const fastify = Fastify() + + t.plan(2) + + fastify.get('/', (req, reply) => { + const validate = req.getValidationFunction(defaultSchema) + t.assert.ok(!validate) + + const validateFn = req.getValidationFunction(42) + t.assert.ok(!validateFn) + + reply.send({ hello: 'world' }) + }) + + await fastify.inject('/') + }) + + await subtest.test( + 'Should return the validation function from each HTTP part', + async t => { + const fastify = Fastify() + let headerValidation = null + let customValidation = null + + t.plan(15) + + fastify.post( + '/:id', + { + schema: requestSchema + }, + (req, reply) => { + const { params } = req + + switch (params.id) { + case 1: + customValidation = req.compileValidationSchema(defaultSchema) + t.assert.ok(req.getValidationFunction('body')) + t.assert.ok(req.getValidationFunction('body')({ hello: 'world' })) + t.assert.ok(!req.getValidationFunction('body')({ world: 'hello' })) + break + case 2: + headerValidation = req.getValidationFunction('headers') + t.assert.ok(headerValidation) + t.assert.ok(headerValidation({ 'x-foo': 'world' })) + t.assert.ok(!headerValidation({ 'x-foo': [] })) + break + case 3: + t.assert.ok(req.getValidationFunction('params')) + t.assert.ok(req.getValidationFunction('params')({ id: 123 })) + t.assert.ok(!req.getValidationFunction('params'({ id: 1.2 }))) + break + case 4: + t.assert.ok(req.getValidationFunction('querystring')) + t.assert.ok(req.getValidationFunction('querystring')({ foo: 'bar' })) + t.assert.ok(!req.getValidationFunction('querystring')({ foo: 'not-bar' }) + ) + break + case 5: + t.assert.strictEqual( + customValidation, + req.getValidationFunction(defaultSchema) + ) + t.assert.ok(customValidation({ hello: 'world' })) + t.assert.ok(!customValidation({})) + t.assert.strictEqual(headerValidation, req.getValidationFunction('headers')) + break + default: + t.assert.fail('Invalid id') + } + + reply.send({ hello: 'world' }) + } + ) + + const promises = [] + + for (let i = 1; i < 6; i++) { + promises.push( + fastify.inject({ + path: `/${i}`, + method: 'post', + query: { foo: 'bar' }, + payload: { + hello: 'world' + }, + headers: { + 'x-foo': 'x-bar' + } + }) + ) + } + + await Promise.all(promises) + } + ) + + await subtest.test('Should not set a WeakMap if there is no schema', async t => { + const fastify = Fastify() + + t.plan(1) + + fastify.get('/', (req, reply) => { + req.getValidationFunction(defaultSchema) + req.getValidationFunction('body') + + t.assert.strictEqual(req[kRouteContext][kRequestCacheValidateFns], null) + reply.send({ hello: 'world' }) + }) + + await fastify.inject({ + path: '/', + method: 'GET' + }) + }) +}) + +test('#validate', async subtest => { + subtest.plan(7) + + await subtest.test( + 'Should return true/false if input valid - Route without schema', + async t => { + const fastify = Fastify() + + t.plan(2) + + fastify.get('/', (req, reply) => { + const isNotValid = req.validateInput({ world: 'string' }, defaultSchema) + const isValid = req.validateInput({ hello: 'string' }, defaultSchema) + + t.assert.ok(!isNotValid) + t.assert.ok(isValid) + + reply.send({ hello: 'world' }) + }) + + await fastify.inject({ + path: '/', + method: 'GET' + }) + } + ) + + await subtest.test( + 'Should use the custom validator compiler for the route', + async t => { + const fastify = Fastify() + let called = 0 + const custom = ({ schema, httpPart, url, method }) => { + t.assert.strictEqual(schema, defaultSchema) + t.assert.strictEqual(url, '/') + t.assert.strictEqual(method, 'GET') + t.assert.strictEqual(httpPart, 'querystring') + + return input => { + called++ + t.assert.deepStrictEqual(input, { hello: 'world' }) + return true + } + } + + t.plan(9) + + fastify.get('/', { validatorCompiler: custom }, (req, reply) => { + const ok = req.validateInput( + { hello: 'world' }, + defaultSchema, + 'querystring' + ) + const ok2 = req.validateInput({ hello: 'world' }, defaultSchema) + + t.assert.ok(ok) + t.assert.ok(ok2) + t.assert.strictEqual(called, 2) + + reply.send({ hello: 'world' }) + }) + + await fastify.inject({ + path: '/', + method: 'GET' + }) + } + ) + + await subtest.test( + 'Should return true/false if input valid - With Schema for Route defined', + async t => { + const fastify = Fastify() + + t.plan(8) + + fastify.post( + '/:id', + { + schema: requestSchema + }, + (req, reply) => { + const { params } = req + + switch (params.id) { + case 1: + t.assert.ok(req.validateInput({ hello: 'world' }, 'body')) + t.assert.ok(!req.validateInput({ hello: [], world: 'foo' }, 'body')) + break + case 2: + t.assert.ok(!req.validateInput({ foo: 'something' }, 'querystring')) + t.assert.ok(req.validateInput({ foo: 'bar' }, 'querystring')) + break + case 3: + t.assert.ok(!req.validateInput({ 'x-foo': [] }, 'headers')) + t.assert.ok(req.validateInput({ 'x-foo': 'something' }, 'headers')) + break + case 4: + t.assert.ok(req.validateInput({ id: params.id }, 'params')) + t.assert.ok(!req.validateInput({ id: 0 }, 'params')) + break + default: + t.assert.fail('Invalid id') + } + + reply.send({ hello: 'world' }) + } + ) + + const promises = [] + + for (let i = 1; i < 5; i++) { + promises.push( + fastify.inject({ + path: `/${i}`, + method: 'post', + query: { foo: 'bar' }, + payload: { + hello: 'world' + }, + headers: { + 'x-foo': 'x-bar' + } + }) + ) + } + + await Promise.all(promises) + } + ) + + await subtest.test( + 'Should throw if missing validation fn for HTTP part and not schema provided', + async t => { + const fastify = Fastify() + + t.plan(10) + + fastify.get('/:id', (req, reply) => { + const { params } = req + + switch (parseInt(params.id)) { + case 1: + req.validateInput({}, 'body') + break + case 2: + req.validateInput({}, 'querystring') + break + case 3: + req.validateInput({}, 'query') + break + case 4: + req.validateInput({ 'x-foo': [] }, 'headers') + break + case 5: + req.validateInput({ id: 0 }, 'params') + break + default: + t.assert.fail('Invalid id') + } + }) + + const promises = [] + + for (let i = 1; i < 6; i++) { + promises.push( + (async j => { + const response = await fastify.inject(`/${j}`) + + const result = response.json() + t.assert.strictEqual(result.statusCode, 500) + t.assert.strictEqual(result.code, 'FST_ERR_REQ_INVALID_VALIDATION_INVOCATION') + })(i) + ) + } + + await Promise.all(promises) + } + ) + + await subtest.test( + 'Should throw if missing validation fn for HTTP part and not valid schema provided', + async t => { + const fastify = Fastify() + + t.plan(10) + + fastify.get('/:id', (req, reply) => { + const { params } = req + + switch (parseInt(params.id)) { + case 1: + req.validateInput({}, 1, 'body') + break + case 2: + req.validateInput({}, [], 'querystring') + break + case 3: + req.validateInput({}, '', 'query') + break + case 4: + req.validateInput({ 'x-foo': [] }, null, 'headers') + break + case 5: + req.validateInput({ id: 0 }, () => {}, 'params') + break + default: + t.assert.fail('Invalid id') + } + }) + + const promises = [] + + for (let i = 1; i < 6; i++) { + promises.push( + (async j => { + const response = await fastify.inject({ + path: `/${j}`, + method: 'GET' + }) + + const result = response.json() + t.assert.strictEqual(result.statusCode, 500) + t.assert.strictEqual(result.code, 'FST_ERR_REQ_INVALID_VALIDATION_INVOCATION') + })(i) + ) + } + + await Promise.all(promises) + } + ) + + await subtest.test('Should throw if invalid schema passed', async t => { + const fastify = Fastify() + + t.plan(10) + + fastify.get('/:id', (req, reply) => { + const { params } = req + + switch (parseInt(params.id)) { + case 1: + req.validateInput({}, 1) + break + case 2: + req.validateInput({}, '') + break + case 3: + req.validateInput({}, []) + break + case 4: + req.validateInput({ 'x-foo': [] }, null) + break + case 5: + req.validateInput({ id: 0 }, () => {}) + break + default: + t.assert.fail('Invalid id') + } + }) + + const promises = [] + + for (let i = 1; i < 6; i++) { + promises.push( + (async j => { + const response = await fastify.inject({ + path: `/${j}`, + method: 'GET' + }) + + const result = response.json() + t.assert.strictEqual(result.statusCode, 500) + t.assert.strictEqual(result.code, 'FST_ERR_REQ_INVALID_VALIDATION_INVOCATION') + })(i) + ) + } + + await Promise.all(promises) + }) + + await subtest.test( + 'Should set a WeakMap if compiling the very first schema', + async t => { + const fastify = Fastify() + + t.plan(3) + + fastify.get('/', (req, reply) => { + t.assert.strictEqual(req[kRouteContext][kRequestCacheValidateFns], null) + t.assert.strictEqual(req.validateInput({ hello: 'world' }, defaultSchema), true) + t.assert.ok(req[kRouteContext][kRequestCacheValidateFns] instanceof WeakMap) + + reply.send({ hello: 'world' }) + }) + + await fastify.inject({ + path: '/', + method: 'GET' + }) + } + ) +}) + +test('Nested Context', async subtest => { + subtest.plan(1) + + await subtest.test('Level_1', async tst => { + tst.plan(3) + await tst.test('#compileValidationSchema', async ntst => { + ntst.plan(5) + + await ntst.test('Should return a function - Route without schema', async t => { + const fastify = Fastify() + + fastify.register((instance, opts, next) => { + instance.get('/', (req, reply) => { + const validate = req.compileValidationSchema(defaultSchema) + + t.assert.ok(validate, Function) + t.assert.ok(validate({ hello: 'world' })) + t.assert.ok(!validate({ world: 'foo' })) + + reply.send({ hello: 'world' }) + }) + + next() + }) + + t.plan(3) + + await fastify.inject({ + path: '/', + method: 'GET' + }) + }) + + await ntst.test( + 'Should reuse the validate fn across multiple invocations - Route without schema', + async t => { + const fastify = Fastify() + let validate = null + let counter = 0 + + t.plan(16) + + fastify.register((instance, opts, next) => { + instance.get('/', (req, reply) => { + counter++ + if (counter > 1) { + const newValidate = req.compileValidationSchema(defaultSchema) + t.assert.strictEqual(validate, newValidate, 'Are the same validate function') + validate = newValidate + } else { + validate = req.compileValidationSchema(defaultSchema) + } + + t.assert.ok(validate, Function) + t.assert.ok(validate({ hello: 'world' })) + t.assert.ok(!validate({ world: 'foo' })) + + reply.send({ hello: 'world' }) + }) + + next() + }) + + await Promise.all([ + fastify.inject('/'), + fastify.inject('/'), + fastify.inject('/'), + fastify.inject('/') + ]) + + t.assert.strictEqual(counter, 4) + } + ) + + await ntst.test('Should return a function - Route with schema', async t => { + const fastify = Fastify() + + t.plan(3) + + fastify.register((instance, opts, next) => { + instance.post( + '/', + { + schema: { + body: defaultSchema + } + }, + (req, reply) => { + const validate = req.compileValidationSchema(defaultSchema) + + t.assert.ok(validate, Function) + t.assert.ok(validate({ hello: 'world' })) + t.assert.ok(!validate({ world: 'foo' })) + + reply.send({ hello: 'world' }) + } + ) + + next() + }) + + await fastify.inject({ + path: '/', + method: 'POST', + payload: { + hello: 'world', + world: 'foo' + } + }) + }) + + await ntst.test( + 'Should use the custom validator compiler for the route', + async t => { + const fastify = Fastify() + let called = 0 + + t.plan(10) + + fastify.register((instance, opts, next) => { + const custom = ({ schema, httpPart, url, method }) => { + t.assert.strictEqual(schema, defaultSchema) + t.assert.strictEqual(url, '/') + t.assert.strictEqual(method, 'GET') + t.assert.strictEqual(httpPart, 'querystring') + + return input => { + called++ + t.assert.deepStrictEqual(input, { hello: 'world' }) + return true + } + } + + fastify.get('/', { validatorCompiler: custom }, (req, reply) => { + const first = req.compileValidationSchema( + defaultSchema, + 'querystring' + ) + const second = req.compileValidationSchema( + defaultSchema, + 'querystring' + ) + + t.assert.strictEqual(first, second) + t.assert.ok(first({ hello: 'world' })) + t.assert.ok(second({ hello: 'world' })) + t.assert.strictEqual(called, 2) + + reply.send({ hello: 'world' }) + }) + + next() + }) + + await fastify.inject('/') + } + ) + + await ntst.test('Should compile the custom validation - nested with schema.headers', async t => { + const fastify = Fastify() + let called = false + + const schemaWithHeaders = { + headers: { + 'x-foo': { + type: 'string' + } + } + } + + const custom = ({ schema, httpPart, url, method }) => { + if (called) return () => true + // only custom validators keep the same headers object + t.assert.strictEqual(schema, schemaWithHeaders.headers) + t.assert.strictEqual(url, '/') + t.assert.strictEqual(httpPart, 'headers') + called = true + return () => true + } + + t.plan(4) + + fastify.setValidatorCompiler(custom) + + fastify.register((instance, opts, next) => { + instance.get('/', { schema: schemaWithHeaders }, (req, reply) => { + t.assert.strictEqual(called, true) + + reply.send({ hello: 'world' }) + }) + + next() + }) + + await fastify.inject('/') + }) + }) + + await tst.test('#getValidationFunction', async ntst => { + ntst.plan(6) + + await ntst.test('Should return a validation function', async t => { + const fastify = Fastify() + + t.plan(1) + + fastify.register((instance, opts, next) => { + instance.get('/', (req, reply) => { + const original = req.compileValidationSchema(defaultSchema) + const referenced = req.getValidationFunction(defaultSchema) + + t.assert.strictEqual(original, referenced) + + reply.send({ hello: 'world' }) + }) + + next() + }) + + await fastify.inject('/') + }) + + await ntst.test('Should return undefined if no schema compiled', async t => { + const fastify = Fastify() + + t.plan(1) + + fastify.register((instance, opts, next) => { + instance.get('/', (req, reply) => { + const validate = req.getValidationFunction(defaultSchema) + + t.assert.ok(!validate) + + reply.send({ hello: 'world' }) + }) + + next() + }) + + await fastify.inject('/') + }) + + await ntst.test( + 'Should return the validation function from each HTTP part', + async t => { + const fastify = Fastify() + let headerValidation = null + let customValidation = null + + t.plan(15) + + fastify.register((instance, opts, next) => { + instance.post( + '/:id', + { + schema: requestSchema + }, + (req, reply) => { + const { params } = req + + switch (params.id) { + case 1: + customValidation = req.compileValidationSchema( + defaultSchema + ) + t.assert.ok(req.getValidationFunction('body')) + t.assert.ok(req.getValidationFunction('body')({ hello: 'world' })) + t.assert.ok(!req.getValidationFunction('body')({ world: 'hello' }) + ) + break + case 2: + headerValidation = req.getValidationFunction('headers') + t.assert.ok(headerValidation) + t.assert.ok(headerValidation({ 'x-foo': 'world' })) + t.assert.ok(!headerValidation({ 'x-foo': [] })) + break + case 3: + t.assert.ok(req.getValidationFunction('params')) + t.assert.ok(req.getValidationFunction('params')({ id: 123 })) + t.assert.ok(!req.getValidationFunction('params'({ id: 1.2 }))) + break + case 4: + t.assert.ok(req.getValidationFunction('querystring')) + t.assert.ok( + req.getValidationFunction('querystring')({ foo: 'bar' }) + ) + t.assert.ok(!req.getValidationFunction('querystring')({ + foo: 'not-bar' + }) + ) + break + case 5: + t.assert.strictEqual( + customValidation, + req.getValidationFunction(defaultSchema) + ) + t.assert.ok(customValidation({ hello: 'world' })) + t.assert.ok(!customValidation({})) + t.assert.strictEqual( + headerValidation, + req.getValidationFunction('headers') + ) + break + default: + t.assert.fail('Invalid id') + } + + reply.send({ hello: 'world' }) + } + ) + + next() + }) + const promises = [] + + for (let i = 1; i < 6; i++) { + promises.push( + fastify.inject({ + path: `/${i}`, + method: 'post', + query: { foo: 'bar' }, + payload: { + hello: 'world' + }, + headers: { + 'x-foo': 'x-bar' + } + }) + ) + } + + await Promise.all(promises) + } + ) + + await ntst.test('Should return a validation function - nested', async t => { + const fastify = Fastify() + let called = false + const custom = ({ schema, httpPart, url, method }) => { + t.assert.strictEqual(schema, defaultSchema) + t.assert.strictEqual(url, '/') + t.assert.strictEqual(method, 'GET') + t.assert.ok(!httpPart) + + called = true + return () => true + } + + t.plan(6) + + fastify.setValidatorCompiler(custom) + + fastify.register((instance, opts, next) => { + instance.get('/', (req, reply) => { + const original = req.compileValidationSchema(defaultSchema) + const referenced = req.getValidationFunction(defaultSchema) + + t.assert.strictEqual(original, referenced) + t.assert.strictEqual(called, true) + + reply.send({ hello: 'world' }) + }) + + next() + }) + + await fastify.inject('/') + }) + + await ntst.test( + 'Should return undefined if no schema compiled - nested', + async t => { + const fastify = Fastify() + let called = 0 + const custom = ({ schema, httpPart, url, method }) => { + called++ + return () => true + } + + t.plan(3) + + fastify.setValidatorCompiler(custom) + + fastify.get('/', (req, reply) => { + const validate = req.compileValidationSchema(defaultSchema) + + t.assert.strictEqual(typeof validate, 'function') + + reply.send({ hello: 'world' }) + }) + + fastify.register( + (instance, opts, next) => { + instance.get('/', (req, reply) => { + const validate = req.getValidationFunction(defaultSchema) + + t.assert.ok(!validate) + t.assert.strictEqual(called, 1) + + reply.send({ hello: 'world' }) + }) + + next() + }, + { prefix: '/nested' } + ) + + await fastify.inject('/') + await fastify.inject('/nested') + } + ) + + await ntst.test('Should per-route defined validation compiler', async t => { + const fastify = Fastify() + let validateParent + let validateChild + let calledParent = 0 + let calledChild = 0 + const customParent = ({ schema, httpPart, url, method }) => { + calledParent++ + return () => true + } + + const customChild = ({ schema, httpPart, url, method }) => { + calledChild++ + return () => true + } + + t.plan(5) + + fastify.setValidatorCompiler(customParent) + + fastify.get('/', (req, reply) => { + validateParent = req.compileValidationSchema(defaultSchema) + + t.assert.strictEqual(typeof validateParent, 'function') + + reply.send({ hello: 'world' }) + }) + + fastify.register( + (instance, opts, next) => { + instance.get( + '/', + { + validatorCompiler: customChild + }, + (req, reply) => { + const validate1 = req.compileValidationSchema(defaultSchema) + validateChild = req.getValidationFunction(defaultSchema) + + t.assert.strictEqual(validate1, validateChild) + t.assert.notStrictEqual(validateParent, validateChild) + t.assert.strictEqual(calledParent, 1) + t.assert.strictEqual(calledChild, 1) + + reply.send({ hello: 'world' }) + } + ) + + next() + }, + { prefix: '/nested' } + ) + + await fastify.inject('/') + await fastify.inject('/nested') + }) + }) + + await tst.test('#validate', async ntst => { + ntst.plan(3) + + await ntst.test( + 'Should return true/false if input valid - Route without schema', + async t => { + const fastify = Fastify() + + t.plan(2) + + fastify.register((instance, opts, next) => { + instance.get('/', (req, reply) => { + const isNotValid = req.validateInput( + { world: 'string' }, + defaultSchema + ) + const isValid = req.validateInput({ hello: 'string' }, defaultSchema) + + t.assert.ok(!isNotValid) + t.assert.ok(isValid) + + reply.send({ hello: 'world' }) + }) + + next() + }) + + await fastify.inject('/') + } + ) + + await ntst.test( + 'Should use the custom validator compiler for the route', + async t => { + const fastify = Fastify() + let parentCalled = 0 + let childCalled = 0 + const customParent = () => { + parentCalled++ + + return () => true + } + + const customChild = ({ schema, httpPart, url, method }) => { + t.assert.strictEqual(schema, defaultSchema) + t.assert.strictEqual(url, '/') + t.assert.strictEqual(method, 'GET') + t.assert.strictEqual(httpPart, 'querystring') + + return input => { + childCalled++ + t.assert.deepStrictEqual(input, { hello: 'world' }) + return true + } + } + + t.plan(10) + + fastify.setValidatorCompiler(customParent) + + fastify.register((instance, opts, next) => { + instance.get( + '/', + { validatorCompiler: customChild }, + (req, reply) => { + const ok = req.validateInput( + { hello: 'world' }, + defaultSchema, + 'querystring' + ) + const ok2 = req.validateInput({ hello: 'world' }, defaultSchema) + + t.assert.ok(ok) + t.assert.ok(ok2) + t.assert.strictEqual(childCalled, 2) + t.assert.strictEqual(parentCalled, 0) + + reply.send({ hello: 'world' }) + } + ) + + next() + }) + + await fastify.inject('/') + } + ) + + await ntst.test( + 'Should return true/false if input valid - With Schema for Route defined and scoped validator compiler', + async t => { + const validator = new Ajv() + const fastify = Fastify() + const childCounter = { + query: 0, + body: 0, + params: 0, + headers: 0 + } + let parentCalled = 0 + + const parent = () => { + parentCalled++ + return () => true + } + const child = ({ schema, httpPart, url, method }) => { + httpPart = httpPart === 'querystring' ? 'query' : httpPart + const validate = validator.compile(schema) + + return input => { + childCounter[httpPart]++ + return validate(input) + } + } + + t.plan(13) + + fastify.setValidatorCompiler(parent) + fastify.register((instance, opts, next) => { + instance.setValidatorCompiler(child) + instance.post( + '/:id', + { + schema: requestSchema + }, + (req, reply) => { + const { params } = req + + switch (parseInt(params.id)) { + case 1: + t.assert.ok(req.validateInput({ hello: 'world' }, 'body')) + t.assert.ok(!req.validateInput({ hello: [], world: 'foo' }, 'body')) + break + case 2: + t.assert.ok(!req.validateInput({ foo: 'something' }, 'querystring')) + t.assert.ok(req.validateInput({ foo: 'bar' }, 'querystring')) + break + case 3: + t.assert.ok(!req.validateInput({ 'x-foo': [] }, 'headers')) + t.assert.ok(req.validateInput({ 'x-foo': 'something' }, 'headers')) + break + case 4: + t.assert.ok(req.validateInput({ id: 1 }, 'params')) + t.assert.ok(!req.validateInput({ id: params.id }, 'params')) + break + default: + t.assert.fail('Invalid id') + } + + reply.send({ hello: 'world' }) + } + ) + + next() + }) + + const promises = [] + + for (let i = 1; i < 5; i++) { + promises.push( + fastify.inject({ + path: `/${i}`, + method: 'post', + query: {}, + payload: { + hello: 'world' + } + }) + ) + } + + await Promise.all(promises) + + t.assert.strictEqual(childCounter.query, 6) // 4 calls made + 2 custom validations + t.assert.strictEqual(childCounter.headers, 6) // 4 calls made + 2 custom validations + t.assert.strictEqual(childCounter.body, 6) // 4 calls made + 2 custom validations + t.assert.strictEqual(childCounter.params, 6) // 4 calls made + 2 custom validations + t.assert.strictEqual(parentCalled, 0) + } + ) + }) + }) +}) diff --git a/test/internals/request.test.js b/test/internals/request.test.js index 44f8e487667..7ca08eb6d56 100644 --- a/test/internals/request.test.js +++ b/test/internals/request.test.js @@ -1,8 +1,14 @@ 'use strict' -const { test } = require('tap') +const { test } = require('node:test') const Request = require('../../lib/request') +const Context = require('../../lib/context') +const { + kReply, + kRequest, + kOptions +} = require('../../lib/symbols') process.removeAllListeners('warning') @@ -16,31 +22,112 @@ test('Regular request', t => { socket: { remoteAddress: 'ip' }, headers } + const context = new Context({ + schema: { + body: { + type: 'object', + required: ['hello'], + properties: { + hello: { type: 'string' } + } + } + }, + config: { + some: 'config', + url: req.url, + method: req.method + }, + server: { + [kReply]: {}, + [kRequest]: Request, + [kOptions]: { + requestIdLogLabel: 'reqId' + }, + server: {} + } + }) req.connection = req.socket - const request = new Request('id', 'params', req, 'query', 'log') - t.type(request, Request) - t.equal(request.id, 'id') - t.equal(request.params, 'params') - t.equal(request.raw, req) - t.equal(request.query, 'query') - t.equal(request.headers, headers) - t.equal(request.log, 'log') - t.equal(request.ip, 'ip') - t.equal(request.ips, undefined) - t.equal(request.hostname, 'hostname') - t.equal(request.body, undefined) - t.equal(request.method, 'GET') - t.equal(request.url, '/') - t.equal(request.socket, req.socket) - t.equal(request.protocol, 'http') + const request = new Request('id', 'params', req, 'query', 'log', context) + t.assert.ok(request instanceof Request) + t.assert.ok(request.validateInput instanceof Function) + t.assert.ok(request.getValidationFunction instanceof Function) + t.assert.ok(request.compileValidationSchema instanceof Function) + t.assert.strictEqual(request.id, 'id') + t.assert.strictEqual(request.params, 'params') + t.assert.strictEqual(request.raw, req) + t.assert.strictEqual(request.query, 'query') + t.assert.strictEqual(request.headers, headers) + t.assert.strictEqual(request.log, 'log') + t.assert.strictEqual(request.ip, 'ip') + t.assert.strictEqual(request.ips, undefined) + t.assert.strictEqual(request.host, 'hostname') + t.assert.strictEqual(request.body, undefined) + t.assert.strictEqual(request.method, 'GET') + t.assert.strictEqual(request.url, '/') + t.assert.strictEqual(request.originalUrl, '/') + t.assert.strictEqual(request.socket, req.socket) + t.assert.strictEqual(request.protocol, 'http') + // Aim to not bad property keys (including Symbols) + t.assert.ok(!('undefined' in request)) +}) - // This will be removed, it's deprecated - t.equal(request.connection, req.connection) - t.end() +test('Request with undefined config', t => { + const headers = { + host: 'hostname' + } + const req = { + method: 'GET', + url: '/', + socket: { remoteAddress: 'ip' }, + headers + } + const context = new Context({ + schema: { + body: { + type: 'object', + required: ['hello'], + properties: { + hello: { type: 'string' } + } + } + }, + server: { + [kReply]: {}, + [kRequest]: Request, + [kOptions]: { + requestIdLogLabel: 'reqId' + }, + server: {} + } + }) + req.connection = req.socket + const request = new Request('id', 'params', req, 'query', 'log', context) + t.assert.ok(request, Request) + t.assert.ok(request.validateInput, Function) + t.assert.ok(request.getValidationFunction, Function) + t.assert.ok(request.compileValidationSchema, Function) + t.assert.strictEqual(request.id, 'id') + t.assert.strictEqual(request.params, 'params') + t.assert.strictEqual(request.raw, req) + t.assert.strictEqual(request.query, 'query') + t.assert.strictEqual(request.headers, headers) + t.assert.strictEqual(request.log, 'log') + t.assert.strictEqual(request.ip, 'ip') + t.assert.strictEqual(request.ips, undefined) + t.assert.strictEqual(request.hostname, 'hostname') + t.assert.strictEqual(request.body, undefined) + t.assert.strictEqual(request.method, 'GET') + t.assert.strictEqual(request.url, '/') + t.assert.strictEqual(request.originalUrl, '/') + t.assert.strictEqual(request.socket, req.socket) + t.assert.strictEqual(request.protocol, 'http') + + // Aim to not bad property keys (including Symbols) + t.assert.ok(!('undefined' in request)) }) test('Regular request - hostname from authority', t => { - t.plan(2) + t.plan(3) const headers = { ':authority': 'authority' } @@ -50,14 +137,39 @@ test('Regular request - hostname from authority', t => { socket: { remoteAddress: 'ip' }, headers } + const context = new Context({ + schema: { + body: { + type: 'object', + required: ['hello'], + properties: { + hello: { type: 'string' } + } + } + }, + config: { + some: 'config', + url: req.url, + method: req.method + }, + server: { + [kReply]: {}, + [kRequest]: Request, + [kOptions]: { + requestIdLogLabel: 'reqId' + }, + server: {} + } + }) - const request = new Request('id', 'params', req, 'query', 'log') - t.type(request, Request) - t.equal(request.hostname, 'authority') + const request = new Request('id', 'params', req, 'query', 'log', context) + t.assert.ok(request instanceof Request) + t.assert.strictEqual(request.host, 'authority') + t.assert.strictEqual(request.port, null) }) test('Regular request - host header has precedence over authority', t => { - t.plan(2) + t.plan(3) const headers = { host: 'hostname', ':authority': 'authority' @@ -68,16 +180,41 @@ test('Regular request - host header has precedence over authority', t => { socket: { remoteAddress: 'ip' }, headers } - const request = new Request('id', 'params', req, 'query', 'log') - t.type(request, Request) - t.equal(request.hostname, 'hostname') + const context = new Context({ + schema: { + body: { + type: 'object', + required: ['hello'], + properties: { + hello: { type: 'string' } + } + } + }, + config: { + some: 'config', + url: req.url, + method: req.method + }, + server: { + [kReply]: {}, + [kRequest]: Request, + [kOptions]: { + requestIdLogLabel: 'reqId' + }, + server: {} + } + }) + const request = new Request('id', 'params', req, 'query', 'log', context) + t.assert.ok(request instanceof Request) + t.assert.strictEqual(request.host, 'hostname') + t.assert.strictEqual(request.port, null) }) test('Request with trust proxy', t => { - t.plan(15) + t.plan(18) const headers = { 'x-forwarded-for': '2.2.2.2, 1.1.1.1', - 'x-forwarded-host': 'example.com' + 'x-forwarded-host': 'fastify.test' } const req = { method: 'GET', @@ -85,31 +222,57 @@ test('Request with trust proxy', t => { socket: { remoteAddress: 'ip' }, headers } + const context = new Context({ + schema: { + body: { + type: 'object', + required: ['hello'], + properties: { + hello: { type: 'string' } + } + } + }, + config: { + some: 'config', + url: req.url, + method: req.method + }, + server: { + [kReply]: {}, + [kRequest]: Request, + [kOptions]: { + requestIdLogLabel: 'reqId' + } + } + }) const TpRequest = Request.buildRequest(Request, true) - const request = new TpRequest('id', 'params', req, 'query', 'log') - t.type(request, TpRequest) - t.equal(request.id, 'id') - t.equal(request.params, 'params') - t.same(request.raw, req) - t.equal(request.query, 'query') - t.equal(request.headers, headers) - t.equal(request.log, 'log') - t.equal(request.ip, '2.2.2.2') - t.same(request.ips, ['ip', '1.1.1.1', '2.2.2.2']) - t.equal(request.hostname, 'example.com') - t.equal(request.body, undefined) - t.equal(request.method, 'GET') - t.equal(request.url, '/') - t.equal(request.socket, req.socket) - t.equal(request.protocol, 'http') + const request = new TpRequest('id', 'params', req, 'query', 'log', context) + t.assert.ok(request instanceof TpRequest) + t.assert.strictEqual(request.id, 'id') + t.assert.strictEqual(request.params, 'params') + t.assert.deepStrictEqual(request.raw, req) + t.assert.strictEqual(request.query, 'query') + t.assert.strictEqual(request.headers, headers) + t.assert.strictEqual(request.log, 'log') + t.assert.strictEqual(request.ip, '2.2.2.2') + t.assert.deepStrictEqual(request.ips, ['ip', '1.1.1.1', '2.2.2.2']) + t.assert.strictEqual(request.host, 'fastify.test') + t.assert.strictEqual(request.body, undefined) + t.assert.strictEqual(request.method, 'GET') + t.assert.strictEqual(request.url, '/') + t.assert.strictEqual(request.socket, req.socket) + t.assert.strictEqual(request.protocol, 'http') + t.assert.ok(request.validateInput instanceof Function) + t.assert.ok(request.getValidationFunction instanceof Function) + t.assert.ok(request.compileValidationSchema instanceof Function) }) test('Request with trust proxy, encrypted', t => { t.plan(2) const headers = { 'x-forwarded-for': '2.2.2.2, 1.1.1.1', - 'x-forwarded-host': 'example.com' + 'x-forwarded-host': 'fastify.test' } const req = { method: 'GET', @@ -120,8 +283,8 @@ test('Request with trust proxy, encrypted', t => { const TpRequest = Request.buildRequest(Request, true) const request = new TpRequest('id', 'params', req, 'query', 'log') - t.type(request, TpRequest) - t.equal(request.protocol, 'https') + t.assert.ok(request instanceof TpRequest) + t.assert.strictEqual(request.protocol, 'https') }) test('Request with trust proxy - no x-forwarded-host header', t => { @@ -136,11 +299,35 @@ test('Request with trust proxy - no x-forwarded-host header', t => { socket: { remoteAddress: 'ip' }, headers } + const context = new Context({ + schema: { + body: { + type: 'object', + required: ['hello'], + properties: { + hello: { type: 'string' } + } + } + }, + config: { + some: 'config', + url: req.url, + method: req.method + }, + server: { + [kReply]: {}, + [kRequest]: Request, + [kOptions]: { + requestIdLogLabel: 'reqId' + }, + server: {} + } + }) const TpRequest = Request.buildRequest(Request, true) - const request = new TpRequest('id', 'params', req, 'query', 'log') - t.type(request, TpRequest) - t.equal(request.hostname, 'hostname') + const request = new TpRequest('id', 'params', req, 'query', 'log', context) + t.assert.ok(request instanceof TpRequest) + t.assert.strictEqual(request.host, 'hostname') }) test('Request with trust proxy - no x-forwarded-host header and fallback to authority', t => { @@ -155,18 +342,42 @@ test('Request with trust proxy - no x-forwarded-host header and fallback to auth socket: { remoteAddress: 'ip' }, headers } + const context = new Context({ + schema: { + body: { + type: 'object', + required: ['hello'], + properties: { + hello: { type: 'string' } + } + } + }, + config: { + some: 'config', + url: req.url, + method: req.method + }, + server: { + [kReply]: {}, + [kRequest]: Request, + [kOptions]: { + requestIdLogLabel: 'reqId' + }, + server: {} + } + }) const TpRequest = Request.buildRequest(Request, true) - const request = new TpRequest('id', 'params', req, 'query', 'log') - t.type(request, TpRequest) - t.equal(request.hostname, 'authority') + const request = new TpRequest('id', 'params', req, 'query', 'log', context) + t.assert.ok(request instanceof TpRequest) + t.assert.strictEqual(request.host, 'authority') }) test('Request with trust proxy - x-forwarded-host header has precedence over host', t => { t.plan(2) const headers = { 'x-forwarded-for': ' 2.2.2.2, 1.1.1.1', - 'x-forwarded-host': 'example.com', + 'x-forwarded-host': 'fastify.test', host: 'hostname' } const req = { @@ -178,14 +389,14 @@ test('Request with trust proxy - x-forwarded-host header has precedence over hos const TpRequest = Request.buildRequest(Request, true) const request = new TpRequest('id', 'params', req, 'query', 'log') - t.type(request, TpRequest) - t.equal(request.hostname, 'example.com') + t.assert.ok(request instanceof TpRequest) + t.assert.strictEqual(request.host, 'fastify.test') }) test('Request with trust proxy - handles multiple entries in x-forwarded-host/proto', t => { t.plan(3) const headers = { - 'x-forwarded-host': 'example2.com, example.com', + 'x-forwarded-host': 'example2.com, fastify.test', 'x-forwarded-proto': 'http, https' } const req = { @@ -197,16 +408,16 @@ test('Request with trust proxy - handles multiple entries in x-forwarded-host/pr const TpRequest = Request.buildRequest(Request, true) const request = new TpRequest('id', 'params', req, 'query', 'log') - t.type(request, TpRequest) - t.equal(request.hostname, 'example.com') - t.equal(request.protocol, 'https') + t.assert.ok(request instanceof TpRequest) + t.assert.strictEqual(request.host, 'fastify.test') + t.assert.strictEqual(request.protocol, 'https') }) test('Request with trust proxy - plain', t => { t.plan(1) const headers = { 'x-forwarded-for': '2.2.2.2, 1.1.1.1', - 'x-forwarded-host': 'example.com' + 'x-forwarded-host': 'fastify.test' } const req = { method: 'GET', @@ -217,11 +428,11 @@ test('Request with trust proxy - plain', t => { const TpRequest = Request.buildRequest(Request, true) const request = new TpRequest('id', 'params', req, 'query', 'log') - t.same(request.protocol, 'http') + t.assert.deepStrictEqual(request.protocol, 'http') }) test('Request with undefined socket', t => { - t.plan(15) + t.plan(18) const headers = { host: 'hostname' } @@ -231,29 +442,56 @@ test('Request with undefined socket', t => { socket: undefined, headers } - const request = new Request('id', 'params', req, 'query', 'log') - t.type(request, Request) - t.equal(request.id, 'id') - t.equal(request.params, 'params') - t.same(request.raw, req) - t.equal(request.query, 'query') - t.equal(request.headers, headers) - t.equal(request.log, 'log') - t.equal(request.ip, undefined) - t.equal(request.ips, undefined) - t.equal(request.hostname, 'hostname') - t.same(request.body, null) - t.equal(request.method, 'GET') - t.equal(request.url, '/') - t.equal(request.protocol, undefined) - t.same(request.socket, req.socket) + const context = new Context({ + schema: { + body: { + type: 'object', + required: ['hello'], + properties: { + hello: { type: 'string' } + } + } + }, + config: { + some: 'config', + url: req.url, + method: req.method + }, + server: { + [kReply]: {}, + [kRequest]: Request, + [kOptions]: { + requestIdLogLabel: 'reqId' + }, + server: {} + } + }) + const request = new Request('id', 'params', req, 'query', 'log', context) + t.assert.ok(request instanceof Request) + t.assert.strictEqual(request.id, 'id') + t.assert.strictEqual(request.params, 'params') + t.assert.deepStrictEqual(request.raw, req) + t.assert.strictEqual(request.query, 'query') + t.assert.strictEqual(request.headers, headers) + t.assert.strictEqual(request.log, 'log') + t.assert.strictEqual(request.ip, undefined) + t.assert.strictEqual(request.ips, undefined) + t.assert.strictEqual(request.host, 'hostname') + t.assert.deepStrictEqual(request.body, undefined) + t.assert.strictEqual(request.method, 'GET') + t.assert.strictEqual(request.url, '/') + t.assert.strictEqual(request.protocol, undefined) + t.assert.deepStrictEqual(request.socket, req.socket) + t.assert.ok(request.validateInput instanceof Function) + t.assert.ok(request.getValidationFunction instanceof Function) + t.assert.ok(request.compileValidationSchema instanceof Function) }) test('Request with trust proxy and undefined socket', t => { t.plan(1) const headers = { 'x-forwarded-for': '2.2.2.2, 1.1.1.1', - 'x-forwarded-host': 'example.com' + 'x-forwarded-host': 'fastify.test' } const req = { method: 'GET', @@ -264,5 +502,5 @@ test('Request with trust proxy and undefined socket', t => { const TpRequest = Request.buildRequest(Request, true) const request = new TpRequest('id', 'params', req, 'query', 'log') - t.same(request.protocol, undefined) + t.assert.deepStrictEqual(request.protocol, undefined) }) diff --git a/test/internals/schema-controller-perf.test.js b/test/internals/schema-controller-perf.test.js new file mode 100644 index 00000000000..0741b41f605 --- /dev/null +++ b/test/internals/schema-controller-perf.test.js @@ -0,0 +1,40 @@ +const { sep } = require('node:path') +const { test } = require('node:test') +const Fastify = require('../../fastify') + +test('SchemaController are NOT loaded when the controllers are custom', async t => { + const app = Fastify({ + schemaController: { + compilersFactory: { + buildValidator: () => () => { }, + buildSerializer: () => () => { } + } + } + }) + + await app.ready() + + const loaded = Object.keys(require.cache) + const ajvModule = loaded.find((path) => path.includes(`@fastify${sep}ajv-compiler`)) + const stringifyModule = loaded.find((path) => path.includes(`@fastify${sep}fast-json-stringify-compiler`)) + + t.assert.equal(ajvModule, undefined, 'Ajv compiler is loaded') + t.assert.equal(stringifyModule, undefined, 'Stringify compiler is loaded') +}) + +test('SchemaController are loaded when the controllers are not custom', async t => { + const app = Fastify() + await app.ready() + + const loaded = Object.keys(require.cache) + const ajvModule = loaded.find((path) => path.includes(`@fastify${sep}ajv-compiler`)) + const stringifyModule = loaded.find((path) => path.includes(`@fastify${sep}fast-json-stringify-compiler`)) + + t.after(() => { + delete require.cache[ajvModule] + delete require.cache[stringifyModule] + }) + + t.assert.ok(ajvModule, 'Ajv compiler is loaded') + t.assert.ok(stringifyModule, 'Stringify compiler is loaded') +}) diff --git a/test/internals/server.test.js b/test/internals/server.test.js index 941e8815eaf..b26a172dfcc 100644 --- a/test/internals/server.test.js +++ b/test/internals/server.test.js @@ -1,6 +1,6 @@ 'use strict' -const { test } = require('tap') +const { test } = require('node:test') const proxyquire = require('proxyquire') const Fastify = require('../../fastify') @@ -15,12 +15,12 @@ test('start listening', async t => { const { server, listen } = createServer({}, handler) await listen.call(Fastify(), { port: 0, host: 'localhost' }) server.close() - t.pass('server started') + t.assert.ok(true, 'server started') }) test('DNS errors does not stop the main server on localhost - promise interface', async t => { const { createServer } = proxyquire('../../lib/server', { - dns: { + 'node:dns': { lookup: (hostname, options, cb) => { cb(new Error('DNS error')) } @@ -29,13 +29,13 @@ test('DNS errors does not stop the main server on localhost - promise interface' const { server, listen } = createServer({}, handler) await listen.call(Fastify(), { port: 0, host: 'localhost' }) server.close() - t.pass('server started') + t.assert.ok(true, 'server started') }) -test('DNS errors does not stop the main server on localhost - callback interface', t => { +test('DNS errors does not stop the main server on localhost - callback interface', (t, done) => { t.plan(2) const { createServer } = proxyquire('../../lib/server', { - dns: { + 'node:dns': { lookup: (hostname, options, cb) => { cb(new Error('DNS error')) } @@ -43,16 +43,17 @@ test('DNS errors does not stop the main server on localhost - callback interface }) const { server, listen } = createServer({}, handler) listen.call(Fastify(), { port: 0, host: 'localhost' }, (err) => { - t.error(err) + t.assert.ifError(err) server.close() - t.pass('server started') + t.assert.ok(true, 'server started') + done() }) }) -test('DNS returns empty binding', t => { +test('DNS returns empty binding', (t, done) => { t.plan(2) const { createServer } = proxyquire('../../lib/server', { - dns: { + 'node:dns': { lookup: (hostname, options, cb) => { cb(null, []) } @@ -60,16 +61,17 @@ test('DNS returns empty binding', t => { }) const { server, listen } = createServer({}, handler) listen.call(Fastify(), { port: 0, host: 'localhost' }, (err) => { - t.error(err) + t.assert.ifError(err) server.close() - t.pass('server started') + t.assert.ok(true, 'server started') + done() }) }) -test('DNS returns more than two binding', t => { +test('DNS returns more than two binding', (t, done) => { t.plan(2) const { createServer } = proxyquire('../../lib/server', { - dns: { + 'node:dns': { lookup: (hostname, options, cb) => { cb(null, [ { address: '::1', family: 6 }, @@ -81,8 +83,9 @@ test('DNS returns more than two binding', t => { }) const { server, listen } = createServer({}, handler) listen.call(Fastify(), { port: 0, host: 'localhost' }, (err) => { - t.error(err) + t.assert.ifError(err) server.close() - t.pass('server started') + t.assert.ok(true, 'server started') + done() }) }) diff --git a/test/internals/validation.test.js b/test/internals/validation.test.js index 320f39bf5dd..7fe4738c56a 100644 --- a/test/internals/validation.test.js +++ b/test/internals/validation.test.js @@ -1,7 +1,6 @@ 'use strict' -const t = require('tap') -const test = t.test +const { test } = require('node:test') const Ajv = require('ajv') const ajv = new Ajv({ coerceTypes: true }) @@ -13,11 +12,11 @@ const { kSchemaVisited } = require('../../lib/symbols') test('Symbols', t => { t.plan(5) - t.equal(typeof symbols.responseSchema, 'symbol') - t.equal(typeof symbols.bodySchema, 'symbol') - t.equal(typeof symbols.querystringSchema, 'symbol') - t.equal(typeof symbols.paramsSchema, 'symbol') - t.equal(typeof symbols.headersSchema, 'symbol') + t.assert.strictEqual(typeof symbols.responseSchema, 'symbol') + t.assert.strictEqual(typeof symbols.bodySchema, 'symbol') + t.assert.strictEqual(typeof symbols.querystringSchema, 'symbol') + t.assert.strictEqual(typeof symbols.paramsSchema, 'symbol') + t.assert.strictEqual(typeof symbols.headersSchema, 'symbol') }) ;['compileSchemasForValidation', @@ -26,15 +25,15 @@ test('Symbols', t => { t.plan(2) const context = {} validation[func](context) - t.equal(typeof context[symbols.bodySchema], 'undefined') - t.equal(typeof context[symbols.responseSchema], 'undefined') + t.assert.strictEqual(typeof context[symbols.bodySchema], 'undefined') + t.assert.strictEqual(typeof context[symbols.responseSchema], 'undefined') }) test(`${func} schema - missing output schema`, t => { t.plan(1) const context = { schema: {} } validation[func](context, null) - t.equal(typeof context[symbols.responseSchema], 'undefined') + t.assert.strictEqual(typeof context[symbols.responseSchema], 'undefined') }) }) @@ -59,11 +58,11 @@ test('build schema - output schema', t => { } } validation.compileSchemasForSerialization(opts, ({ schema, method, url, httpPart }) => ajv.compile(schema)) - t.equal(typeof opts[symbols.responseSchema]['2xx'], 'function') - t.equal(typeof opts[symbols.responseSchema]['201'], 'function') + t.assert.strictEqual(typeof opts[symbols.responseSchema]['2xx'], 'function') + t.assert.strictEqual(typeof opts[symbols.responseSchema]['201'], 'function') }) -test('build schema - payload schema', t => { +test('build schema - body schema', t => { t.plan(1) const opts = { schema: { @@ -76,14 +75,38 @@ test('build schema - payload schema', t => { } } validation.compileSchemasForValidation(opts, ({ schema, method, url, httpPart }) => ajv.compile(schema)) - t.equal(typeof opts[symbols.bodySchema], 'function') + t.assert.strictEqual(typeof opts[symbols.bodySchema], 'function') +}) + +test('build schema - body with multiple content type schemas', t => { + t.plan(2) + const opts = { + schema: { + body: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + hello: { type: 'string' } + } + } + }, + 'text/plain': { + schema: { type: 'string' } + } + } + } + } + } + validation.compileSchemasForValidation(opts, ({ schema, method, url, httpPart }) => ajv.compile(schema)) + t.assert.ok(opts[symbols.bodySchema]['application/json'], 'function') + t.assert.ok(opts[symbols.bodySchema]['text/plain'], 'function') }) test('build schema - avoid repeated normalize schema', t => { t.plan(3) - const serverConfig = { - jsonShorthand: true - } + const serverConfig = {} const opts = { schema: { query: { @@ -95,16 +118,14 @@ test('build schema - avoid repeated normalize schema', t => { } } opts.schema = normalizeSchema(opts.schema, serverConfig) - t.not(kSchemaVisited, undefined) - t.equal(opts.schema[kSchemaVisited], true) - t.equal(opts.schema, normalizeSchema(opts.schema, serverConfig)) + t.assert.notStrictEqual(kSchemaVisited, undefined) + t.assert.strictEqual(opts.schema[kSchemaVisited], true) + t.assert.strictEqual(opts.schema, normalizeSchema(opts.schema, serverConfig)) }) test('build schema - query schema', t => { t.plan(2) - const serverConfig = { - jsonShorthand: true - } + const serverConfig = {} const opts = { schema: { query: { @@ -117,26 +138,27 @@ test('build schema - query schema', t => { } opts.schema = normalizeSchema(opts.schema, serverConfig) validation.compileSchemasForValidation(opts, ({ schema, method, url, httpPart }) => ajv.compile(schema)) - t.type(opts[symbols.querystringSchema].schema.type, 'string') - t.equal(typeof opts[symbols.querystringSchema], 'function') + t.assert.ok(typeof opts[symbols.querystringSchema].schema.type === 'string') + t.assert.strictEqual(typeof opts[symbols.querystringSchema], 'function') }) test('build schema - query schema abbreviated', t => { t.plan(2) - const serverConfig = { - jsonShorthand: true - } + const serverConfig = {} const opts = { schema: { query: { - hello: { type: 'string' } + type: 'object', + properties: { + hello: { type: 'string' } + } } } } opts.schema = normalizeSchema(opts.schema, serverConfig) validation.compileSchemasForValidation(opts, ({ schema, method, url, httpPart }) => ajv.compile(schema)) - t.type(opts[symbols.querystringSchema].schema.type, 'string') - t.equal(typeof opts[symbols.querystringSchema], 'function') + t.assert.ok(typeof opts[symbols.querystringSchema].schema.type === 'string') + t.assert.strictEqual(typeof opts[symbols.querystringSchema], 'function') }) test('build schema - querystring schema', t => { @@ -152,34 +174,33 @@ test('build schema - querystring schema', t => { } } validation.compileSchemasForValidation(opts, ({ schema, method, url, httpPart }) => ajv.compile(schema)) - t.type(opts[symbols.querystringSchema].schema.type, 'string') - t.equal(typeof opts[symbols.querystringSchema], 'function') + t.assert.ok(typeof opts[symbols.querystringSchema].schema.type === 'string') + t.assert.strictEqual(typeof opts[symbols.querystringSchema], 'function') }) test('build schema - querystring schema abbreviated', t => { t.plan(2) - const serverConfig = { - jsonShorthand: true - } + const serverConfig = {} const opts = { schema: { querystring: { - hello: { type: 'string' } + type: 'object', + properties: { + hello: { type: 'string' } + } } } } opts.schema = normalizeSchema(opts.schema, serverConfig) validation.compileSchemasForValidation(opts, ({ schema, method, url, httpPart }) => ajv.compile(schema)) - t.type(opts[symbols.querystringSchema].schema.type, 'string') - t.equal(typeof opts[symbols.querystringSchema], 'function') + t.assert.ok(typeof opts[symbols.querystringSchema].schema.type === 'string') + t.assert.strictEqual(typeof opts[symbols.querystringSchema], 'function') }) test('build schema - must throw if querystring and query schema exist', t => { t.plan(2) try { - const serverConfig = { - jsonShorthand: true - } + const serverConfig = {} const opts = { schema: { query: { @@ -198,8 +219,8 @@ test('build schema - must throw if querystring and query schema exist', t => { } opts.schema = normalizeSchema(opts.schema, serverConfig) } catch (err) { - t.equal(err.code, 'FST_ERR_SCH_DUPLICATE') - t.equal(err.message, 'Schema with \'querystring\' already present!') + t.assert.strictEqual(err.code, 'FST_ERR_SCH_DUPLICATE') + t.assert.strictEqual(err.message, 'Schema with \'querystring\' already present!') } }) @@ -216,7 +237,7 @@ test('build schema - params schema', t => { } } validation.compileSchemasForValidation(opts, ({ schema, method, url, httpPart }) => ajv.compile(schema)) - t.equal(typeof opts[symbols.paramsSchema], 'function') + t.assert.strictEqual(typeof opts[symbols.paramsSchema], 'function') }) test('build schema - headers schema', t => { @@ -232,7 +253,7 @@ test('build schema - headers schema', t => { } } validation.compileSchemasForValidation(opts, ({ schema, method, url, httpPart }) => ajv.compile(schema)) - t.equal(typeof opts[symbols.headersSchema], 'function') + t.assert.strictEqual(typeof opts[symbols.headersSchema], 'function') }) test('build schema - headers are lowercase', t => { @@ -248,26 +269,41 @@ test('build schema - headers are lowercase', t => { } } validation.compileSchemasForValidation(opts, ({ schema, method, url, httpPart }) => { - t.ok(schema.properties['content-type'], 'lowercase content-type exists') - return () => {} + t.assert.ok(schema.properties['content-type'], 'lowercase content-type exists') + return () => { } }) }) test('build schema - headers are not lowercased in case of custom object', t => { t.plan(1) - class Headers {} + class Headers { } const opts = { schema: { headers: new Headers() } } validation.compileSchemasForValidation(opts, ({ schema, method, url, httpPart }) => { - t.type(schema, Headers) - return () => {} + t.assert.ok(schema, Headers) + return () => { } }) }) +test('build schema - headers are not lowercased in case of custom validator provided', t => { + t.plan(1) + + class Headers { } + const opts = { + schema: { + headers: new Headers() + } + } + validation.compileSchemasForValidation(opts, ({ schema, method, url, httpPart }) => { + t.assert.ok(schema, Headers) + return () => { } + }, true) +}) + test('build schema - uppercased headers are not included', t => { t.plan(1) const opts = { @@ -281,7 +317,36 @@ test('build schema - uppercased headers are not included', t => { } } validation.compileSchemasForValidation(opts, ({ schema, method, url, httpPart }) => { - t.notOk('Content-Type' in schema.properties, 'uppercase does not exist') - return () => {} + t.assert.ok(!('Content-Type' in schema.properties), 'uppercase does not exist') + return () => { } + }) +}) + +test('build schema - mixed schema types are individually skipped or normalized', t => { + t.plan(2) + + class CustomSchemaClass { } + + const testCases = [{ + schema: { + body: new CustomSchemaClass() + }, + assertions: (schema) => { + t.assert.ok(schema.body, CustomSchemaClass) + } + }, { + schema: { + response: { + 200: new CustomSchemaClass() + } + }, + assertions: (schema) => { + t.assert.ok(schema.response[200], CustomSchemaClass) + } + }] + + testCases.forEach((testCase) => { + const result = normalizeSchema(testCase.schema, {}) + testCase.assertions(result) }) }) diff --git a/test/issue-4959.test.js b/test/issue-4959.test.js new file mode 100644 index 00000000000..87c98a3a520 --- /dev/null +++ b/test/issue-4959.test.js @@ -0,0 +1,118 @@ +'use strict' + +const { test } = require('node:test') +const http = require('node:http') +const Fastify = require('../fastify') +const { setTimeout } = require('node:timers') + +/* +* Ensure that a socket error during the request does not cause the +* onSend hook to be called multiple times. +* +* @see https://github.com/fastify/fastify/issues/4959 +*/ +function runBadClientCall (reqOptions, payload, waitBeforeDestroy) { + let innerResolve, innerReject + const promise = new Promise((resolve, reject) => { + innerResolve = resolve + innerReject = reject + }) + + const postData = JSON.stringify(payload) + + const req = http.request({ + ...reqOptions, + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(postData) + } + }, () => { + innerReject(new Error('Request should have failed')) + }) + + // Kill the socket after the request has been fully written. + // Destroying it on `connect` can race before any bytes are sent, making the + // server-side assertions (hooks/handler) non-deterministic. + // + // To keep the test deterministic, we optionally wait for a server-side signal + // (e.g. onSend entered) before aborting the client. + let socket + req.on('socket', (s) => { socket = s }) + req.on('finish', () => { + if (waitBeforeDestroy && typeof waitBeforeDestroy.then === 'function') { + Promise.race([ + waitBeforeDestroy, + new Promise(resolve => setTimeout(resolve, 200)) + ]).then(() => { + if (socket) socket.destroy() + }, innerResolve) + return + } + setTimeout(() => { socket.destroy() }, 0) + }) + req.on('error', innerResolve) + req.write(postData) + req.end() + + return promise +} + +test('should handle a socket error', async (t) => { + t.plan(4) + const fastify = Fastify() + + let resolveOnSendEntered + const onSendEntered = new Promise((resolve) => { + resolveOnSendEntered = resolve + }) + + function shouldNotHappen () { + t.assert.fail('This should not happen') + } + process.on('unhandledRejection', shouldNotHappen) + + t.after(() => { + fastify.close() + process.removeListener('unhandledRejection', shouldNotHappen) + }) + + fastify.addHook('onRequest', async (request, reply) => { + t.assert.ok('onRequest hook called') + }) + + fastify.addHook('onSend', async (request, reply, payload) => { + if (request.onSendCalled) { + t.assert.fail('onSend hook called more than once') + return + } + + t.assert.ok('onSend hook called') + request.onSendCalled = true + + if (resolveOnSendEntered) { + resolveOnSendEntered() + resolveOnSendEntered = null + } + + // Introduce a delay (gives time for client-side abort to happen while the + // request has already been processed, exercising the original issue). + await new Promise(resolve => setTimeout(resolve, 50)) + return payload + }) + + // The handler must be async to trigger the error + fastify.put('/', async (request, reply) => { + t.assert.ok('PUT handler called') + return reply.send({ hello: 'world' }) + }) + + await fastify.listen({ port: 0 }) + + const err = await runBadClientCall({ + hostname: 'localhost', + port: fastify.server.address().port, + path: '/', + method: 'PUT' + }, { test: 'me' }, onSendEntered) + t.assert.equal(err.code, 'ECONNRESET') +}) diff --git a/test/keepAliveTimeout.test.js b/test/keep-alive-timeout.test.js similarity index 61% rename from test/keepAliveTimeout.test.js rename to test/keep-alive-timeout.test.js index f322f04aee6..84b14bc4cb5 100644 --- a/test/keepAliveTimeout.test.js +++ b/test/keep-alive-timeout.test.js @@ -1,35 +1,34 @@ 'use strict' const Fastify = require('..') -const http = require('http') -const t = require('tap') -const test = t.test +const http = require('node:http') +const { test } = require('node:test') test('keepAliveTimeout', t => { t.plan(6) try { Fastify({ keepAliveTimeout: 1.3 }) - t.fail('option must be an integer') + t.assert.fail('option must be an integer') } catch (err) { - t.ok(err) + t.assert.ok(err) } try { Fastify({ keepAliveTimeout: [] }) - t.fail('option must be an integer') + t.assert.fail('option must be an integer') } catch (err) { - t.ok(err) + t.assert.ok(err) } const httpServer = Fastify({ keepAliveTimeout: 1 }).server - t.equal(httpServer.keepAliveTimeout, 1) + t.assert.strictEqual(httpServer.keepAliveTimeout, 1) const httpsServer = Fastify({ keepAliveTimeout: 2, https: {} }).server - t.equal(httpsServer.keepAliveTimeout, 2) + t.assert.strictEqual(httpsServer.keepAliveTimeout, 2) const http2Server = Fastify({ keepAliveTimeout: 3, http2: true }).server - t.not(http2Server.keepAliveTimeout, 3) + t.assert.notStrictEqual(http2Server.keepAliveTimeout, 3) const serverFactory = (handler, _) => { const server = http.createServer((req, res) => { @@ -39,5 +38,5 @@ test('keepAliveTimeout', t => { return server } const customServer = Fastify({ keepAliveTimeout: 4, serverFactory }).server - t.equal(customServer.keepAliveTimeout, 5) + t.assert.strictEqual(customServer.keepAliveTimeout, 5) }) diff --git a/test/listen.1.test.js b/test/listen.1.test.js new file mode 100644 index 00000000000..92d9cc0de75 --- /dev/null +++ b/test/listen.1.test.js @@ -0,0 +1,154 @@ +'use strict' + +const { networkInterfaces } = require('node:os') +const { test, before } = require('node:test') +const Fastify = require('..') +const helper = require('./helper') + +let localhost +let localhostForURL + +before(async function () { + [localhost, localhostForURL] = await helper.getLoopbackHost() +}) + +test('listen works without arguments', async t => { + const doNotWarn = () => { + t.assert.fail('should not be deprecated') + } + process.on('warning', doNotWarn) + + const fastify = Fastify() + t.after(() => { + fastify.close() + process.removeListener('warning', doNotWarn) + }) + await fastify.listen() + const address = fastify.server.address() + t.assert.strictEqual(address.address, localhost) + t.assert.ok(address.port > 0) +}) + +test('Async/await listen with arguments', async t => { + const doNotWarn = () => { + t.assert.fail('should not be deprecated') + } + process.on('warning', doNotWarn) + + const fastify = Fastify() + t.after(() => { + fastify.close() + process.removeListener('warning', doNotWarn) + }) + const addr = await fastify.listen({ port: 0, host: '0.0.0.0' }) + const address = fastify.server.address() + const { protocol, hostname, port, pathname } = new URL(addr) + t.assert.strictEqual(protocol, 'http:') + t.assert.ok(Object.values(networkInterfaces()) + .flat() + .filter(({ internal }) => internal) + .some(({ address }) => address === hostname)) + t.assert.strictEqual(pathname, '/') + t.assert.strictEqual(Number(port), address.port) + t.assert.deepEqual(address, { + address: '0.0.0.0', + family: 'IPv4', + port: address.port + }) +}) + +test('listen accepts a callback', (t, done) => { + t.plan(2) + const doNotWarn = () => { + t.assert.fail('should not be deprecated') + } + process.on('warning', doNotWarn) + + const fastify = Fastify() + t.after(() => { + fastify.close() + process.removeListener('warning', doNotWarn) + }) + fastify.listen({ port: 0 }, (err) => { + t.assert.ifError(err) + t.assert.strictEqual(fastify.server.address().address, localhost) + done() + }) +}) + +test('listen accepts options and a callback', (t, done) => { + t.plan(1) + const doNotWarn = () => { + t.assert.fail('should not be deprecated') + } + process.on('warning', doNotWarn) + + const fastify = Fastify() + t.after(() => { + fastify.close() + process.removeListener('warning', doNotWarn) + }) + fastify.listen({ + port: 0, + host: 'localhost', + backlog: 511, + exclusive: false, + readableAll: false, + writableAll: false, + ipv6Only: false + }, (err) => { + t.assert.ifError(err) + done() + }) +}) + +test('listen after Promise.resolve()', (t, done) => { + t.plan(2) + const fastify = Fastify() + t.after(() => fastify.close()) + Promise.resolve() + .then(() => { + fastify.listen({ port: 0 }, (err, address) => { + fastify.server.unref() + t.assert.strictEqual(address, `http://${localhostForURL}:${fastify.server.address().port}`) + t.assert.ifError(err) + done() + }) + }) +}) + +test('listen works with undefined host', async t => { + const doNotWarn = () => { + t.assert.fail('should not be deprecated') + } + process.on('warning', doNotWarn) + + const fastify = Fastify() + t.after(() => fastify.close()) + t.after(() => { + fastify.close() + process.removeListener('warning', doNotWarn) + }) + await fastify.listen({ host: undefined, port: 0 }) + const address = fastify.server.address() + t.assert.strictEqual(address.address, localhost) + t.assert.ok(address.port > 0) +}) + +test('listen works with null host', async t => { + const doNotWarn = () => { + t.fail('should not be deprecated') + } + process.on('warning', doNotWarn) + + const fastify = Fastify() + t.after(() => fastify.close()) + t.after(() => { + fastify.close() + process.removeListener('warning', doNotWarn) + }) + await fastify.listen({ host: null, port: 0 }) + const address = fastify.server.address() + t.assert.strictEqual(address.address, localhost) + t.assert.ok(address.port > 0) +}) diff --git a/test/listen.2.test.js b/test/listen.2.test.js new file mode 100644 index 00000000000..3c27a5a73ef --- /dev/null +++ b/test/listen.2.test.js @@ -0,0 +1,113 @@ +'use strict' + +const { test, before } = require('node:test') +const Fastify = require('..') +const helper = require('./helper') +const { networkInterfaces } = require('node:os') + +const isIPv6Missing = !Object.values(networkInterfaces()).flat().some(({ family }) => family === 'IPv6') + +let localhostForURL + +before(async function () { + [, localhostForURL] = await helper.getLoopbackHost() +}) + +test('register after listen using Promise.resolve()', async t => { + t.plan(1) + const fastify = Fastify() + + const handler = (req, res) => res.send({}) + await Promise.resolve() + .then(() => { + fastify.get('/', handler) + fastify.register((f2, options, done) => { + f2.get('/plugin', handler) + done() + }) + return fastify.ready() + }) + .catch((err) => { + t.assert.fail(err.message) + }) + .then(() => t.assert.ok('resolved')) +}) + +test('double listen errors', (t, done) => { + t.plan(3) + const fastify = Fastify() + t.after(() => fastify.close()) + fastify.listen({ port: 0 }, (err) => { + t.assert.ifError(err) + fastify.listen({ port: fastify.server.address().port }, (err, address) => { + t.assert.strictEqual(address, null) + t.assert.ok(err) + done() + }) + }) +}) + +test('double listen errors callback with (err, address)', (t, done) => { + t.plan(4) + const fastify = Fastify() + t.after(() => fastify.close()) + fastify.listen({ port: 0 }, (err1, address1) => { + t.assert.strictEqual(address1, `http://${localhostForURL}:${fastify.server.address().port}`) + t.assert.ifError(err1) + fastify.listen({ port: fastify.server.address().port }, (err2, address2) => { + t.assert.strictEqual(address2, null) + t.assert.ok(err2) + done() + }) + }) +}) + +test('nonlocalhost double listen errors callback with (err, address)', { skip: isIPv6Missing }, (t, done) => { + t.plan(4) + const fastify = Fastify() + t.after(() => fastify.close()) + fastify.listen({ host: '::1', port: 0 }, (err, address) => { + t.assert.strictEqual(address, `http://${'[::1]'}:${fastify.server.address().port}`) + t.assert.ifError(err) + fastify.listen({ host: '::1', port: fastify.server.address().port }, (err2, address2) => { + t.assert.strictEqual(address2, null) + t.assert.ok(err2) + done() + }) + }) +}) + +test('listen twice on the same port', (t, done) => { + t.plan(4) + const fastify = Fastify() + t.after(() => fastify.close()) + fastify.listen({ port: 0 }, (err1, address1) => { + t.assert.strictEqual(address1, `http://${localhostForURL}:${fastify.server.address().port}`) + t.assert.ifError(err1) + const s2 = Fastify() + t.after(() => fastify.close()) + s2.listen({ port: fastify.server.address().port }, (err2, address2) => { + t.assert.strictEqual(address2, null) + t.assert.ok(err2) + done() + }) + }) +}) + +test('listen twice on the same port callback with (err, address)', (t, done) => { + t.plan(4) + const fastify = Fastify() + t.after(() => fastify.close()) + fastify.listen({ port: 0 }, (err1, address1) => { + const _port = fastify.server.address().port + t.assert.strictEqual(address1, `http://${localhostForURL}:${_port}`) + t.assert.ifError(err1) + const s2 = Fastify() + t.after(() => fastify.close()) + s2.listen({ port: _port }, (err2, address2) => { + t.assert.strictEqual(address2, null) + t.assert.ok(err2) + done() + }) + }) +}) diff --git a/test/listen.3.test.js b/test/listen.3.test.js new file mode 100644 index 00000000000..3f536232d41 --- /dev/null +++ b/test/listen.3.test.js @@ -0,0 +1,83 @@ +'use strict' + +const os = require('node:os') +const path = require('node:path') +const fs = require('node:fs') +const { test, before } = require('node:test') +const Fastify = require('..') +const helper = require('./helper') + +let localhostForURL + +before(async function () { + [, localhostForURL] = await helper.getLoopbackHost() +}) + +// https://nodejs.org/api/net.html#net_ipc_support +if (os.platform() !== 'win32') { + test('listen on socket', async t => { + t.plan(2) + const fastify = Fastify() + t.after(() => fastify.close()) + + const sockFile = path.join(os.tmpdir(), `${(Math.random().toString(16) + '0000000').slice(2, 10)}-server.sock`) + try { + fs.unlinkSync(sockFile) + } catch (e) { } + + await fastify.listen({ path: sockFile }) + t.assert.deepStrictEqual(fastify.addresses(), [sockFile]) + t.assert.strictEqual(fastify.server.address(), sockFile) + }) +} else { + test('listen on socket', async t => { + t.plan(2) + const fastify = Fastify() + t.after(() => fastify.close()) + + const sockFile = `\\\\.\\pipe\\${(Math.random().toString(16) + '0000000').slice(2, 10)}-server-sock` + + await fastify.listen({ path: sockFile }) + t.assert.deepStrictEqual(fastify.addresses(), [sockFile]) + t.assert.strictEqual(fastify.server.address(), sockFile) + }) +} + +test('listen without callback with (address)', async t => { + t.plan(1) + const fastify = Fastify() + t.after(() => fastify.close()) + const address = await fastify.listen({ port: 0 }) + t.assert.strictEqual(address, `http://${localhostForURL}:${fastify.server.address().port}`) +}) + +test('double listen without callback rejects', (t, done) => { + t.plan(1) + const fastify = Fastify() + t.after(() => fastify.close()) + fastify.listen({ port: 0 }) + .then(() => { + fastify.listen({ port: 0 }) + .catch(err => { + t.assert.ok(err) + done() + }) + }) + .catch(err => t.assert.ifError(err)) +}) + +test('double listen without callback with (address)', (t, done) => { + t.plan(2) + const fastify = Fastify() + t.after(() => fastify.close()) + fastify.listen({ port: 0 }) + .then(address => { + t.assert.strictEqual(address, `http://${localhostForURL}:${fastify.server.address().port}`) + fastify.listen({ port: 0 }) + .catch(err => { + t.assert.ok(err) + done() + }) + }) + .catch(err => t.assert.ifError(err)) +}) diff --git a/test/listen.4.test.js b/test/listen.4.test.js new file mode 100644 index 00000000000..c73c1b2e19f --- /dev/null +++ b/test/listen.4.test.js @@ -0,0 +1,168 @@ +'use strict' + +const { test, before } = require('node:test') +const dns = require('node:dns').promises +const dnsCb = require('node:dns') +const Fastify = require('../fastify') +const helper = require('./helper') + +let localhostForURL + +function getUrl (fastify, lookup) { + const { port } = fastify.server.address() + if (lookup.family === 6) { + return `http://[${lookup.address}]:${port}/` + } else { + return `http://${lookup.address}:${port}/` + } +} + +before(async function () { + [, localhostForURL] = await helper.getLoopbackHost() +}) + +test('listen twice on the same port without callback rejects', (t, done) => { + t.plan(1) + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.listen({ port: 0 }) + .then(() => { + const server2 = Fastify() + t.after(() => server2.close()) + server2.listen({ port: fastify.server.address().port }) + .catch(err => { + t.assert.ok(err) + done() + }) + }) + .catch(err => { + t.assert.ifError(err) + }) +}) + +test('listen twice on the same port without callback rejects with (address)', (t, done) => { + t.plan(2) + const fastify = Fastify() + t.after(() => fastify.close()) + fastify.listen({ port: 0 }) + .then(address => { + const server2 = Fastify() + t.after(() => server2.close()) + t.assert.strictEqual(address, `http://${localhostForURL}:${fastify.server.address().port}`) + + server2.listen({ port: fastify.server.address().port }) + .catch(err => { + t.assert.ok(err) + done() + }) + }) + .catch(err => { + t.assert.ifError(err) + }) +}) + +test('listen on invalid port without callback rejects', t => { + const fastify = Fastify() + t.after(() => fastify.close()) + return fastify.listen({ port: -1 }) + .catch(err => { + t.assert.ok(err) + return true + }) +}) + +test('listen logs the port as info', async t => { + t.plan(1) + const fastify = Fastify() + t.after(() => fastify.close()) + + const msgs = [] + fastify.log.info = function (msg) { + msgs.push(msg) + } + + await fastify.listen({ port: 0 }) + t.assert.ok(/http:\/\//.test(msgs[0])) +}) + +test('listen on localhost binds IPv4 and IPv6 - promise interface', async t => { + const localAddresses = await dns.lookup('localhost', { all: true }) + t.plan(3 * localAddresses.length) + + const app = Fastify() + app.get('/', async () => 'hello localhost') + t.after(() => app.close()) + await app.listen({ port: 0, host: 'localhost' }) + + for (const lookup of localAddresses) { + const result = await fetch(getUrl(app, lookup), { + method: 'GET' + }) + + t.assert.ok(result.ok) + t.assert.deepEqual(result.status, 200) + t.assert.deepStrictEqual(await result.text(), 'hello localhost') + } +}) + +test('listen on localhost binds to all interfaces (both IPv4 and IPv6 if present) - callback interface', async (t) => { + const lookups = await new Promise((resolve, reject) => { + dnsCb.lookup('localhost', { all: true }, (err, lookups) => { + if (err) return reject(err) + resolve(lookups) + }) + }) + + t.plan(3 * lookups.length) + + const app = Fastify() + app.get('/', async () => 'hello localhost') + t.after(() => app.close()) + + await app.listen({ port: 0, host: 'localhost' }) + + // Loop over each lookup and perform the assertions + for (const lookup of lookups) { + const result = await fetch(getUrl(app, lookup), { + method: 'GET' + }) + + t.assert.ok(result.ok) + t.assert.deepEqual(result.status, 200) + t.assert.deepStrictEqual(await result.text(), 'hello localhost') + } +}) + +test('addresses getter', async t => { + let localAddresses = await dns.lookup('localhost', { all: true }) + + t.plan(4) + const app = Fastify() + app.get('/', async () => 'hello localhost') + t.after(() => app.close()) + + t.assert.deepStrictEqual(app.addresses(), [], 'before ready') + await app.ready() + + t.assert.deepStrictEqual(app.addresses(), [], 'after ready') + await app.listen({ port: 0, host: 'localhost' }) + + // fix citgm + // dns lookup may have duplicated addresses (rhel8-s390x rhel8-ppc64le debian10-x64) + + localAddresses = [...new Set([...localAddresses.map(a => JSON.stringify({ + address: a.address, + family: typeof a.family === 'number' ? 'IPv' + a.family : a.family + }))])].sort() + + const appAddresses = app.addresses().map(a => JSON.stringify({ + address: a.address, + family: typeof a.family === 'number' ? 'IPv' + a.family : a.family + })).sort() + + t.assert.deepStrictEqual(appAddresses, localAddresses, 'after listen') + + await app.close() + t.assert.deepStrictEqual(app.addresses(), [], 'after close') +}) diff --git a/test/listen.5.test.js b/test/listen.5.test.js new file mode 100644 index 00000000000..68aa23a1043 --- /dev/null +++ b/test/listen.5.test.js @@ -0,0 +1,122 @@ +'use strict' + +const { test } = require('node:test') +const net = require('node:net') +const Fastify = require('../fastify') +const { once } = require('node:events') +const { FSTWRN003 } = require('../lib/warnings.js') + +function createDeferredPromise () { + const promise = {} + promise.promise = new Promise((resolve) => { + promise.resolve = resolve + }) + return promise +} + +test('same port conflict and success should not fire callback multiple times - callback', async (t) => { + t.plan(7) + const server = net.createServer() + server.listen({ port: 0, host: '127.0.0.1' }) + await once(server, 'listening') + const option = { port: server.address().port, host: server.address().address } + let count = 0 + const fastify = Fastify() + const promise = createDeferredPromise() + function callback (err) { + switch (count) { + case 6: { + // success in here + t.assert.ifError(err) + fastify.close((err) => { + t.assert.ifError(err) + promise.resolve() + }) + break + } + case 5: { + server.close() + setTimeout(() => { + fastify.listen(option, callback) + }, 100) + break + } + default: { + // expect error + t.assert.strictEqual(err.code, 'EADDRINUSE') + setTimeout(() => { + fastify.listen(option, callback) + }, 100) + } + } + count++ + } + fastify.listen(option, callback) + await promise.promise +}) + +test('same port conflict and success should not fire callback multiple times - promise', async (t) => { + t.plan(5) + const server = net.createServer() + server.listen({ port: 0, host: '127.0.0.1' }) + await once(server, 'listening') + const option = { port: server.address().port, host: server.address().address } + const fastify = Fastify() + + try { + await fastify.listen(option) + } catch (err) { + t.assert.strictEqual(err.code, 'EADDRINUSE') + } + try { + await fastify.listen(option) + } catch (err) { + t.assert.strictEqual(err.code, 'EADDRINUSE') + } + try { + await fastify.listen(option) + } catch (err) { + t.assert.strictEqual(err.code, 'EADDRINUSE') + } + try { + await fastify.listen(option) + } catch (err) { + t.assert.strictEqual(err.code, 'EADDRINUSE') + } + try { + await fastify.listen(option) + } catch (err) { + t.assert.strictEqual(err.code, 'EADDRINUSE') + } + + server.close() + + await once(server, 'close') + + // when ever we can listen, and close properly + // which means there is no problem on the callback + await fastify.listen() + await fastify.close() +}) + +test('should emit a warning when using async callback', (t, done) => { + t.plan(2) + + process.on('warning', onWarning) + function onWarning (warning) { + t.assert.strictEqual(warning.name, 'FastifyWarning') + t.assert.strictEqual(warning.code, FSTWRN003.code) + } + + const fastify = Fastify() + + t.after(async () => { + await fastify.close() + process.removeListener('warning', onWarning) + FSTWRN003.emitted = false + }) + + fastify.listen({ port: 0 }, async function doNotUseAsyncCallback () { + done() + }) +}) diff --git a/test/listen.deprecated.test.js b/test/listen.deprecated.test.js deleted file mode 100644 index 8079067dc3b..00000000000 --- a/test/listen.deprecated.test.js +++ /dev/null @@ -1,202 +0,0 @@ -'use strict' - -// Tests for deprecated `.listen` signature. This file should be -// removed when the deprecation is complete. - -const { test, before } = require('tap') -const dns = require('dns').promises -const Fastify = require('..') - -let localhost -let localhostForURL - -process.removeAllListeners('warning') - -before(async function (t) { - const lookup = await dns.lookup('localhost') - localhost = lookup.address - if (lookup.family === 6) { - localhostForURL = `[${lookup.address}]` - } else { - localhostForURL = localhost - } -}) - -test('listen accepts a port and a callback', t => { - t.plan(2) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - fastify.listen(0, (err) => { - t.equal(fastify.server.address().address, localhost) - t.error(err) - }) -}) - -test('listen accepts a port and a callback with (err, address)', t => { - t.plan(2) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - fastify.listen(0, (err, address) => { - t.equal(address, `http://${localhostForURL}:${fastify.server.address().port}`) - t.error(err) - }) -}) - -test('listen accepts a port, address, and callback', t => { - t.plan(1) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - fastify.listen(0, localhost, (err) => { - t.error(err) - }) -}) - -test('listen accepts options, backlog and a callback', t => { - t.plan(1) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - fastify.listen({ - port: 0, - host: 'localhost' - }, 511, (err) => { - t.error(err) - }) -}) - -test('listen accepts options (no port), backlog and a callback', t => { - t.plan(1) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - fastify.listen({ - host: 'localhost' - }, 511, (err) => { - t.error(err) - }) -}) - -test('listen accepts options (no host), backlog and a callback', t => { - t.plan(1) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - fastify.listen({ - port: 0 - }, 511, (err) => { - t.error(err) - }) -}) - -test('listen accepts options (no port, no host), backlog and a callback', t => { - t.plan(1) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - fastify.listen({ - ipv6Only: false - }, 511, (err) => { - t.error(err) - }) -}) - -test('listen accepts a port, address and a callback with (err, address)', t => { - t.plan(2) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - fastify.listen(0, localhost, (err, address) => { - t.equal(address, `http://${localhostForURL}:${fastify.server.address().port}`) - t.error(err) - }) -}) - -test('listen accepts a port, address, backlog and callback', t => { - t.plan(1) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - fastify.listen(0, localhost, 511, (err) => { - t.error(err) - }) -}) - -test('listen accepts a port, address, backlog and callback with (err, address)', t => { - t.plan(2) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - fastify.listen(0, localhost, 511, (err, address) => { - t.equal(address, `http://${localhostForURL}:${fastify.server.address().port}`) - t.error(err) - }) -}) - -test('listen without callback (port zero)', t => { - t.plan(1) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - fastify.listen(0) - .then(() => { - t.equal(fastify.server.address().address, localhost) - }) -}) - -test('listen without callback (port not given)', t => { - t.plan(1) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - fastify.listen() - .then(() => { - t.equal(fastify.server.address().address, localhost) - }) -}) - -test('listen null without callback with (address)', t => { - t.plan(1) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - fastify.listen(null) - .then(address => { - t.equal(address, `http://${localhostForURL}:${fastify.server.address().port}`) - }) -}) - -test('listen without port without callback with (address)', t => { - t.plan(1) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - fastify.listen() - .then(address => { - t.equal(address, `http://${localhostForURL}:${fastify.server.address().port}`) - }) -}) - -test('listen with undefined without callback with (address)', t => { - t.plan(1) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - fastify.listen(undefined) - .then(address => { - t.equal(address, `http://${localhostForURL}:${fastify.server.address().port}`) - }) -}) - -test('listen when firstArg is string(pipe) and without backlog', async t => { - t.plan(1) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - const address = await fastify.listen('\\\\.\\pipe\\testPipe') - t.equal(address, '\\\\.\\pipe\\testPipe') -}) - -test('listen when firstArg is string(pipe) and with backlog', async t => { - t.plan(1) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - const address = await fastify.listen('\\\\.\\pipe\\testPipe2', 511) - t.equal(address, '\\\\.\\pipe\\testPipe2') -}) - -test('listen when firstArg is { path: string(pipe) } and with backlog and callback', t => { - t.plan(2) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - fastify.listen({ path: '\\\\.\\pipe\\testPipe3' }, 511, (err, address) => { - t.error(err) - t.equal(address, '\\\\.\\pipe\\testPipe3') - }) -}) diff --git a/test/listen.test.js b/test/listen.test.js deleted file mode 100644 index 46847713d68..00000000000 --- a/test/listen.test.js +++ /dev/null @@ -1,413 +0,0 @@ -'use strict' - -const os = require('os') -const path = require('path') -const fs = require('fs') -const { test, before } = require('tap') -const dns = require('dns').promises -const dnsCb = require('dns') -const sget = require('simple-get').concat -const Fastify = require('..') - -let localhost -let localhostForURL - -before(async function () { - const lookup = await dns.lookup('localhost') - localhost = lookup.address - if (lookup.family === 6) { - localhostForURL = `[${lookup.address}]` - } else { - localhostForURL = localhost - } -}) - -test('listen works without arguments', async t => { - process.on('warning', () => { - t.fail('should not be deprecated') - }) - - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - await fastify.listen() - const address = fastify.server.address() - t.equal(address.address, localhost) - t.ok(address.port > 0) -}) - -test('Async/await listen with arguments', async t => { - process.on('warning', () => { - t.fail('should not be deprecated') - }) - - t.plan(1) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - const addr = await fastify.listen({ port: 0, host: '0.0.0.0' }) - const address = fastify.server.address() - t.equal(addr, `http://${address.address}:${address.port}`) -}) - -test('Promise listen with arguments', t => { - process.on('warning', () => { - t.fail('should not be deprecated') - }) - - t.plan(1) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - fastify.listen({ port: 0, host: '0.0.0.0' }).then(addr => { - const address = fastify.server.address() - t.equal(addr, `http://${address.address}:${address.port}`) - }) -}) - -test('listen accepts a callback', t => { - process.on('warning', () => { - t.fail('should not be deprecated') - }) - - t.plan(2) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - fastify.listen({ port: 0 }, (err) => { - t.equal(fastify.server.address().address, localhost) - t.error(err) - }) -}) - -test('listen accepts options and a callback', t => { - process.on('warning', () => { - t.fail('should not be deprecated') - }) - - t.plan(1) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - fastify.listen({ - port: 0, - host: 'localhost', - backlog: 511, - exclusive: false, - readableAll: false, - writableAll: false, - ipv6Only: false - }, (err) => { - t.error(err) - }) -}) - -test('listen after Promise.resolve()', t => { - t.plan(2) - const f = Fastify() - t.teardown(f.close.bind(f)) - Promise.resolve() - .then(() => { - f.listen({ port: 0 }, (err, address) => { - f.server.unref() - t.equal(address, `http://${localhostForURL}:${f.server.address().port}`) - t.error(err) - }) - }) -}) - -test('register after listen using Promise.resolve()', t => { - t.plan(1) - const f = Fastify() - - const handler = (req, res) => res.send({}) - Promise.resolve() - .then(() => { - f.get('/', handler) - f.register((f2, options, done) => { - f2.get('/plugin', handler) - done() - }) - return f.ready() - }) - .catch(t.error) - .then(() => t.pass('resolved')) -}) - -test('double listen errors', t => { - t.plan(3) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - fastify.listen({ port: 0 }, (err) => { - t.error(err) - fastify.listen({ port: fastify.server.address().port }, (err, address) => { - t.equal(address, null) - t.ok(err) - }) - }) -}) - -test('double listen errors callback with (err, address)', t => { - t.plan(4) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - fastify.listen({ port: 0 }, (err1, address1) => { - t.equal(address1, `http://${localhostForURL}:${fastify.server.address().port}`) - t.error(err1) - fastify.listen({ port: fastify.server.address().port }, (err2, address2) => { - t.equal(address2, null) - t.ok(err2) - }) - }) -}) - -test('listen twice on the same port', t => { - t.plan(4) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - fastify.listen({ port: 0 }, (err1, address1) => { - t.equal(address1, `http://${localhostForURL}:${fastify.server.address().port}`) - t.error(err1) - const s2 = Fastify() - t.teardown(s2.close.bind(s2)) - s2.listen({ port: fastify.server.address().port }, (err2, address2) => { - t.equal(address2, null) - t.ok(err2) - }) - }) -}) - -test('listen twice on the same port callback with (err, address)', t => { - t.plan(4) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - fastify.listen({ port: 0 }, (err1, address1) => { - const _port = fastify.server.address().port - t.equal(address1, `http://${localhostForURL}:${_port}`) - t.error(err1) - const s2 = Fastify() - t.teardown(s2.close.bind(s2)) - s2.listen({ port: _port }, (err2, address2) => { - t.equal(address2, null) - t.ok(err2) - }) - }) -}) - -// https://nodejs.org/api/net.html#net_ipc_support -if (os.platform() !== 'win32') { - test('listen on socket', t => { - t.plan(3) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - - const sockFile = path.join(os.tmpdir(), `${(Math.random().toString(16) + '0000000').slice(2, 10)}-server.sock`) - try { - fs.unlinkSync(sockFile) - } catch (e) { } - - fastify.listen({ path: sockFile }, (err, address) => { - t.error(err) - t.strictSame(fastify.addresses(), [sockFile]) - t.equal(address, sockFile) - }) - }) -} else { - test('listen on socket', t => { - t.plan(3) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - - const sockFile = `\\\\.\\pipe\\${(Math.random().toString(16) + '0000000').slice(2, 10)}-server-sock` - - fastify.listen({ path: sockFile }, (err, address) => { - t.error(err) - t.strictSame(fastify.addresses(), [sockFile]) - t.equal(address, sockFile) - }) - }) -} - -test('listen without callback with (address)', t => { - t.plan(1) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - fastify.listen({ port: 0 }) - .then(address => { - t.equal(address, `http://${localhostForURL}:${fastify.server.address().port}`) - }) -}) - -test('double listen without callback rejects', t => { - t.plan(1) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - fastify.listen({ port: 0 }) - .then(() => { - fastify.listen({ port: 0 }) - .catch(err => { - t.ok(err) - }) - }) - .catch(err => t.error(err)) -}) - -test('double listen without callback with (address)', t => { - t.plan(2) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - fastify.listen({ port: 0 }) - .then(address => { - t.equal(address, `http://${localhostForURL}:${fastify.server.address().port}`) - fastify.listen({ port: 0 }) - .catch(err => { - t.ok(err) - }) - }) - .catch(err => t.error(err)) -}) - -test('listen twice on the same port without callback rejects', t => { - t.plan(1) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - - fastify.listen({ port: 0 }) - .then(() => { - const s2 = Fastify() - t.teardown(s2.close.bind(s2)) - s2.listen({ port: fastify.server.address().port }) - .catch(err => { - t.ok(err) - }) - }) - .catch(err => t.error(err)) -}) - -test('listen twice on the same port without callback rejects with (address)', t => { - t.plan(2) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - fastify.listen({ port: 0 }) - .then(address => { - const s2 = Fastify() - t.teardown(s2.close.bind(s2)) - t.equal(address, `http://${localhostForURL}:${fastify.server.address().port}`) - s2.listen({ port: fastify.server.address().port }) - .catch(err => { - t.ok(err) - }) - }) - .catch(err => t.error(err)) -}) - -test('listen on invalid port without callback rejects', t => { - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - return fastify.listen({ port: -1 }) - .catch(err => { - t.ok(err) - return true - }) -}) - -test('listen logs the port as info', t => { - t.plan(1) - const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) - - const msgs = [] - fastify.log.info = function (msg) { - msgs.push(msg) - } - - fastify.listen({ port: 0 }) - .then(() => { - t.ok(/http:\/\//.test(msgs[0])) - }) -}) - -test('listen on localhost binds IPv4 and IPv6 - promise interface', async t => { - const lookups = await dns.lookup('localhost', { all: true }) - t.plan(2 * lookups.length) - - const app = Fastify() - app.get('/', async () => 'hello localhost') - t.teardown(app.close.bind(app)) - await app.listen({ port: 0, host: 'localhost' }) - - for (const lookup of lookups) { - await new Promise((resolve, reject) => { - sget({ - method: 'GET', - url: getUrl(app, lookup) - }, (err, response, body) => { - if (err) { return reject(err) } - t.equal(response.statusCode, 200) - t.same(body.toString(), 'hello localhost') - resolve() - }) - }) - } -}) - -test('listen on localhost binds to all interfaces (both IPv4 and IPv6 if present) - callback interface', t => { - dnsCb.lookup('localhost', { all: true }, (err, lookups) => { - t.plan(2 + (3 * lookups.length)) - t.error(err) - - const app = Fastify() - app.get('/', async () => 'hello localhost') - app.listen({ port: 0, host: 'localhost' }, (err) => { - t.error(err) - t.teardown(app.close.bind(app)) - - for (const lookup of lookups) { - sget({ - method: 'GET', - url: getUrl(app, lookup) - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body.toString(), 'hello localhost') - }) - } - }) - }) -}) - -test('addresses getter', async t => { - t.plan(4) - const app = Fastify() - app.get('/', async () => 'hello localhost') - - t.same(app.addresses(), [], 'before ready') - await app.ready() - - t.same(app.addresses(), [], 'after ready') - await app.listen({ port: 0, host: 'localhost' }) - const { port } = app.server.address() - const localAddresses = await dns.lookup('localhost', { all: true }) - for (const address of localAddresses) { - address.port = port - if (typeof address.family === 'number') { - address.family = 'IPv' + address.family - } - } - const appAddresses = app.addresses() - for (const address of appAddresses) { - if (typeof address.family === 'number') { - address.family = 'IPv' + address.family - } - } - localAddresses.sort((a, b) => a.address.localeCompare(b.address)) - appAddresses.sort((a, b) => a.address.localeCompare(b.address)) - t.same(appAddresses, localAddresses, 'after listen') - - await app.close() - t.same(app.addresses(), [], 'after close') -}) - -function getUrl (fastify, lookup) { - const { port } = fastify.server.address() - if (lookup.family === 6) { - return `http://[${lookup.address}]:${port}/` - } else { - return `http://${lookup.address}:${port}/` - } -} diff --git a/test/logger.test.js b/test/logger.test.js deleted file mode 100644 index c084c2f2e5f..00000000000 --- a/test/logger.test.js +++ /dev/null @@ -1,1581 +0,0 @@ -'use strict' - -const { test, teardown, before } = require('tap') -const helper = require('./helper') -const http = require('http') -const stream = require('stream') -const split = require('split2') -const Fastify = require('..') -const pino = require('pino') -const path = require('path') -const os = require('os') -const fs = require('fs') -const sget = require('simple-get').concat -const dns = require('dns') - -const files = [] -let count = 0 -let localhost -let localhostForURL - -function file () { - const file = path.join(os.tmpdir(), `sonic-boom-${process.pid}-${process.hrtime().toString()}-${count++}`) - files.push(file) - return file -} - -before(async function () { - [localhost, localhostForURL] = await helper.getLoopbackHost() -}) - -teardown(() => { - files.forEach((file) => { - try { - fs.unlinkSync(file) - } catch (e) { - console.log(e) - } - }) -}) - -test('defaults to info level', t => { - let fastify = null - const stream = split(JSON.parse) - try { - fastify = Fastify({ - logger: { - stream - } - }) - } catch (e) { - t.fail() - } - - fastify.get('/', function (req, reply) { - t.ok(req.log) - reply.send({ hello: 'world' }) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - dns.lookup('localhost', { all: true }, function (err, addresses) { - t.error(err) - let toSkip = addresses.length - - function skip (data) { - if (--toSkip === 0) { - stream.removeListener('data', skip) - check() - } - } - - stream.on('data', skip) - - http.get(`http://${localhostForURL}:` + fastify.server.address().port) - }) - }) - - function check () { - stream.once('data', line => { - const id = line.reqId - t.ok(line.reqId, 'reqId is defined') - t.ok(line.req, 'req is defined') - t.equal(line.msg, 'incoming request', 'message is set') - t.equal(line.req.method, 'GET', 'method is get') - - stream.once('data', line => { - t.equal(line.reqId, id) - t.ok(line.reqId, 'reqId is defined') - t.ok(line.res, 'res is defined') - t.equal(line.msg, 'request completed', 'message is set') - t.equal(line.res.statusCode, 200, 'statusCode is 200') - t.ok(line.responseTime, 'responseTime is defined') - t.end() - }) - }) - } -}) - -test('test log stream', t => { - t.plan(12) - let fastify = null - const stream = split(JSON.parse) - try { - fastify = Fastify({ - logger: { - stream, - level: 'info' - } - }) - } catch (e) { - t.fail() - } - - fastify.get('/', function (req, reply) { - t.ok(req.log) - reply.send({ hello: 'world' }) - }) - - fastify.listen({ port: 0, host: localhost }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - http.get(`http://${localhostForURL}:` + fastify.server.address().port) - stream.once('data', listenAtLogLine => { - t.ok(listenAtLogLine, 'listen at log message is ok') - - stream.once('data', line => { - const id = line.reqId - t.ok(line.reqId, 'reqId is defined') - t.ok(line.req, 'req is defined') - t.equal(line.msg, 'incoming request', 'message is set') - t.equal(line.req.method, 'GET', 'method is get') - - stream.once('data', line => { - t.equal(line.reqId, id) - t.ok(line.reqId, 'reqId is defined') - t.ok(line.res, 'res is defined') - t.equal(line.msg, 'request completed', 'message is set') - t.equal(line.res.statusCode, 200, 'statusCode is 200') - }) - }) - }) - }) -}) - -test('test error log stream', t => { - t.plan(11) - let fastify = null - const stream = split(JSON.parse) - try { - fastify = Fastify({ - logger: { - stream, - level: 'info' - } - }) - } catch (e) { - t.fail() - } - - fastify.get('/error', function (req, reply) { - t.ok(req.log) - reply.send(new Error('kaboom')) - }) - - fastify.listen({ port: 0, host: localhost }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - http.get(`http://${localhostForURL}:` + fastify.server.address().port + '/error') - stream.once('data', listenAtLogLine => { - t.ok(listenAtLogLine, 'listen at log message is ok') - - stream.once('data', line => { - t.ok(line.reqId, 'reqId is defined') - t.ok(line.req, 'req is defined') - t.equal(line.msg, 'incoming request', 'message is set') - t.equal(line.req.method, 'GET', 'method is get') - - stream.once('data', line => { - t.ok(line.reqId, 'reqId is defined') - t.ok(line.res, 'res is defined') - t.equal(line.msg, 'kaboom', 'message is set') - t.equal(line.res.statusCode, 500, 'statusCode is 500') - }) - }) - }) - }) -}) - -test('can use external logger instance', t => { - const lines = [/^Server listening at /, /^incoming request$/, /^log success$/, /^request completed$/] - t.plan(lines.length + 2) - - const splitStream = split(JSON.parse) - splitStream.on('data', (line) => { - const regex = lines.shift() - t.ok(regex.test(line.msg), '"' + line.msg + '" dont match "' + regex + '"') - }) - - const logger = require('pino')(splitStream) - - const localFastify = Fastify({ logger }) - - localFastify.get('/foo', function (req, reply) { - t.ok(req.log) - req.log.info('log success') - reply.send({ hello: 'world' }) - }) - - localFastify.listen({ port: 0, host: localhost }, err => { - t.error(err) - http.get(`http://${localhostForURL}:` + localFastify.server.address().port + '/foo', (res) => { - res.resume() - res.on('end', () => { - localFastify.server.close() - }) - }) - }) -}) - -test('can use external logger instance with custom serializer', t => { - const lines = [['level', 30], ['req', { url: '/foo' }], ['level', 30], ['res', { statusCode: 200 }]] - t.plan(lines.length + 2) - - const splitStream = split(JSON.parse) - splitStream.on('data', (line) => { - const check = lines.shift() - const key = check[0] - const value = check[1] - - t.same(line[key], value) - }) - - const logger = require('pino')({ - level: 'info', - serializers: { - req: function (req) { - return { - url: req.url - } - } - } - }, splitStream) - - const localFastify = Fastify({ - logger - }) - - localFastify.get('/foo', function (req, reply) { - t.ok(req.log) - req.log.info('log success') - reply.send({ hello: 'world' }) - }) - - localFastify.listen({ port: 0, host: localhost }, err => { - t.error(err) - http.get(`http://${localhostForURL}:` + localFastify.server.address().port + '/foo', (res) => { - res.resume() - res.on('end', () => { - localFastify.server.close() - }) - }) - }) -}) - -test('expose the logger', t => { - t.plan(2) - let fastify = null - const stream = split(JSON.parse) - try { - fastify = Fastify({ - logger: { - stream, - level: 'info' - } - }) - } catch (e) { - t.fail() - } - - t.ok(fastify.log) - t.same(typeof fastify.log, 'object') -}) - -test('The request id header key can be customized', t => { - t.plan(9) - const REQUEST_ID = '42' - - const stream = split(JSON.parse) - const fastify = Fastify({ - logger: { stream, level: 'info' }, - requestIdHeader: 'my-custom-request-id' - }) - t.teardown(() => fastify.close()) - - fastify.get('/', (req, reply) => { - t.equal(req.id, REQUEST_ID) - req.log.info('some log message') - reply.send({ id: req.id }) - }) - - fastify.inject({ - method: 'GET', - url: '/', - headers: { - 'my-custom-request-id': REQUEST_ID - } - }, (err, res) => { - t.error(err) - const payload = JSON.parse(res.payload) - t.equal(payload.id, REQUEST_ID) - - stream.once('data', line => { - t.equal(line.reqId, REQUEST_ID) - t.equal(line.msg, 'incoming request', 'message is set') - - stream.once('data', line => { - t.equal(line.reqId, REQUEST_ID) - t.equal(line.msg, 'some log message', 'message is set') - - stream.once('data', line => { - t.equal(line.reqId, REQUEST_ID) - t.equal(line.msg, 'request completed', 'message is set') - }) - }) - }) - }) -}) - -test('The request id header key can be customized along with a custom id generator', t => { - t.plan(12) - const REQUEST_ID = '42' - - const stream = split(JSON.parse) - const fastify = Fastify({ - logger: { stream, level: 'info' }, - requestIdHeader: 'my-custom-request-id', - genReqId (req) { - return 'foo' - } - }) - t.teardown(() => fastify.close()) - - fastify.get('/one', (req, reply) => { - t.equal(req.id, REQUEST_ID) - req.log.info('some log message') - reply.send({ id: req.id }) - }) - - fastify.get('/two', (req, reply) => { - t.equal(req.id, 'foo') - req.log.info('some log message 2') - reply.send({ id: req.id }) - }) - - const matches = [ - { reqId: REQUEST_ID, msg: /incoming request/ }, - { reqId: REQUEST_ID, msg: /some log message/ }, - { reqId: REQUEST_ID, msg: /request completed/ }, - { reqId: 'foo', msg: /incoming request/ }, - { reqId: 'foo', msg: /some log message 2/ }, - { reqId: 'foo', msg: /request completed/ } - ] - - let i = 0 - stream.on('data', line => { - t.match(line, matches[i]) - i += 1 - }) - - fastify.inject({ - method: 'GET', - url: '/one', - headers: { - 'my-custom-request-id': REQUEST_ID - } - }, (err, res) => { - t.error(err) - const payload = JSON.parse(res.payload) - t.equal(payload.id, REQUEST_ID) - }) - - fastify.inject({ - method: 'GET', - url: '/two' - }, (err, res) => { - t.error(err) - const payload = JSON.parse(res.payload) - t.equal(payload.id, 'foo') - }) -}) - -test('The request id log label can be changed', t => { - t.plan(6) - const REQUEST_ID = '42' - - const stream = split(JSON.parse) - const fastify = Fastify({ - logger: { stream, level: 'info' }, - requestIdHeader: 'my-custom-request-id', - requestIdLogLabel: 'traceId' - }) - t.teardown(() => fastify.close()) - - fastify.get('/one', (req, reply) => { - t.equal(req.id, REQUEST_ID) - req.log.info('some log message') - reply.send({ id: req.id }) - }) - - const matches = [ - { traceId: REQUEST_ID, msg: /incoming request/ }, - { traceId: REQUEST_ID, msg: /some log message/ }, - { traceId: REQUEST_ID, msg: /request completed/ } - ] - - let i = 0 - stream.on('data', line => { - t.match(line, matches[i]) - i += 1 - }) - - fastify.inject({ - method: 'GET', - url: '/one', - headers: { - 'my-custom-request-id': REQUEST_ID - } - }, (err, res) => { - t.error(err) - const payload = JSON.parse(res.payload) - t.equal(payload.id, REQUEST_ID) - }) -}) - -test('The logger should accept custom serializer', t => { - t.plan(9) - - const stream = split(JSON.parse) - const fastify = Fastify({ - logger: { - stream, - level: 'info', - serializers: { - req: function (req) { - return { - url: req.url - } - } - } - } - }) - - fastify.get('/custom', function (req, reply) { - t.ok(req.log) - reply.send(new Error('kaboom')) - }) - - fastify.listen({ port: 0, host: localhost }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - http.get(`http://${localhostForURL}:` + fastify.server.address().port + '/custom') - stream.once('data', listenAtLogLine => { - t.ok(listenAtLogLine, 'listen at log message is ok') - - stream.once('data', line => { - t.ok(line.req, 'req is defined') - t.equal(line.msg, 'incoming request', 'message is set') - t.same(line.req, { url: '/custom' }, 'custom req serializer is use') - - stream.once('data', line => { - t.ok(line.res, 'res is defined') - t.equal(line.msg, 'kaboom', 'message is set') - t.same(line.res, { statusCode: 500 }, 'default res serializer is use') - }) - }) - }) - }) -}) - -test('reply.send logs an error if called twice in a row', t => { - const lines = ['incoming request', 'request completed', 'Reply already sent', 'Reply already sent'] - t.plan(lines.length + 2) - - const splitStream = split(JSON.parse) - splitStream.on('data', (line) => { - t.same(line.msg, lines.shift()) - }) - - const logger = pino(splitStream) - - const fastify = Fastify({ - logger - }) - - fastify.get('/', (req, reply) => { - reply.send({ hello: 'world' }) - reply.send({ hello: 'world2' }) - reply.send({ hello: 'world3' }) - }) - - fastify.inject({ - method: 'GET', - url: '/' - }, (err, res) => { - t.error(err) - const payload = JSON.parse(res.payload) - t.same(payload, { hello: 'world' }) - }) -}) - -test('logger can be silented', t => { - t.plan(17) - const fastify = Fastify({ - logger: false - }) - t.ok(fastify.log) - t.same(typeof fastify.log, 'object') - t.same(typeof fastify.log.fatal, 'function') - t.same(typeof fastify.log.error, 'function') - t.same(typeof fastify.log.warn, 'function') - t.same(typeof fastify.log.info, 'function') - t.same(typeof fastify.log.debug, 'function') - t.same(typeof fastify.log.trace, 'function') - t.same(typeof fastify.log.child, 'function') - - const childLog = fastify.log.child() - - t.same(typeof childLog, 'object') - t.same(typeof childLog.fatal, 'function') - t.same(typeof childLog.error, 'function') - t.same(typeof childLog.warn, 'function') - t.same(typeof childLog.info, 'function') - t.same(typeof childLog.debug, 'function') - t.same(typeof childLog.trace, 'function') - t.same(typeof childLog.child, 'function') -}) - -test('Should set a custom logLevel for a plugin', t => { - const lines = ['incoming request', 'Hello', 'request completed'] - t.plan(7) - - const splitStream = split(JSON.parse) - splitStream.on('data', (line) => { - t.same(line.msg, lines.shift()) - }) - - const logger = pino({ level: 'error' }, splitStream) - - const fastify = Fastify({ - logger - }) - - fastify.get('/', (req, reply) => { - req.log.info('Hello') // we should not see this log - reply.send({ hello: 'world' }) - }) - - fastify.register(function (instance, opts, done) { - instance.get('/plugin', (req, reply) => { - req.log.info('Hello') // we should see this log - reply.send({ hello: 'world' }) - }) - done() - }, { logLevel: 'info' }) - - fastify.inject({ - method: 'GET', - url: '/' - }, (err, res) => { - t.error(err) - const payload = JSON.parse(res.payload) - t.same(payload, { hello: 'world' }) - }) - - fastify.inject({ - method: 'GET', - url: '/plugin' - }, (err, res) => { - t.error(err) - const payload = JSON.parse(res.payload) - t.same(payload, { hello: 'world' }) - }) -}) - -test('Should set a custom logSerializers for a plugin', t => { - t.plan(3) - - const splitStream = split(JSON.parse) - splitStream.on('data', (line) => { - if (line.test) { - t.same(line.test, 'XHello') - } - }) - - const logger = pino({ level: 'error' }, splitStream) - - const fastify = Fastify({ - logger - }) - - fastify.register(function (instance, opts, done) { - instance.get('/plugin', (req, reply) => { - req.log.info({ test: 'Hello' }) // we should see this log - reply.send({ hello: 'world' }) - }) - done() - }, { logLevel: 'info', logSerializers: { test: value => 'X' + value } }) - - fastify.inject({ - method: 'GET', - url: '/plugin' - }, (err, res) => { - t.error(err) - const payload = JSON.parse(res.payload) - t.same(payload, { hello: 'world' }) - }) -}) - -test('Should set a custom logLevel for every plugin', t => { - const lines = ['incoming request', 'request completed', 'info', 'debug'] - t.plan(18) - - const splitStream = split(JSON.parse) - splitStream.on('data', (line) => { - t.ok(line.level === 30 || line.level === 20) - t.ok(lines.indexOf(line.msg) > -1) - }) - - const logger = pino({ level: 'error' }, splitStream) - - const fastify = Fastify({ - logger - }) - - fastify.get('/', (req, reply) => { - req.log.warn('Hello') // we should not see this log - reply.send({ hello: 'world' }) - }) - - fastify.register(function (instance, opts, done) { - instance.get('/info', (req, reply) => { - req.log.info('info') // we should see this log - req.log.debug('hidden log') - reply.send({ hello: 'world' }) - }) - done() - }, { logLevel: 'info' }) - - fastify.register(function (instance, opts, done) { - instance.get('/debug', (req, reply) => { - req.log.debug('debug') // we should see this log - req.log.trace('hidden log') - reply.send({ hello: 'world' }) - }) - done() - }, { logLevel: 'debug' }) - - fastify.inject({ - method: 'GET', - url: '/' - }, (err, res) => { - t.error(err) - const payload = JSON.parse(res.payload) - t.same(payload, { hello: 'world' }) - }) - - fastify.inject({ - method: 'GET', - url: '/info' - }, (err, res) => { - t.error(err) - const payload = JSON.parse(res.payload) - t.same(payload, { hello: 'world' }) - }) - - fastify.inject({ - method: 'GET', - url: '/debug' - }, (err, res) => { - t.error(err) - const payload = JSON.parse(res.payload) - t.same(payload, { hello: 'world' }) - }) -}) - -test('Should set a custom logSerializers for every plugin', async t => { - const lines = ['Hello', 'XHello', 'ZHello'] - t.plan(6) - - const splitStream = split(JSON.parse) - splitStream.on('data', (line) => { - if (line.test) { - t.same(line.test, lines.shift()) - } - }) - - const logger = pino({ level: 'info' }, splitStream) - const fastify = Fastify({ - logger - }) - - fastify.get('/', (req, reply) => { - req.log.warn({ test: 'Hello' }) - reply.send({ hello: 'world' }) - }) - - fastify.register(function (instance, opts, done) { - instance.get('/test1', (req, reply) => { - req.log.info({ test: 'Hello' }) - reply.send({ hello: 'world' }) - }) - done() - }, { logSerializers: { test: value => 'X' + value } }) - - fastify.register(function (instance, opts, done) { - instance.get('/test2', (req, reply) => { - req.log.info({ test: 'Hello' }) - reply.send({ hello: 'world' }) - }) - done() - }, { logSerializers: { test: value => 'Z' + value } }) - - let res = await fastify.inject({ - method: 'GET', - url: '/' - }) - t.same(res.json(), { hello: 'world' }) - - res = await fastify.inject({ - method: 'GET', - url: '/test1' - }) - t.same(res.json(), { hello: 'world' }) - - res = await fastify.inject({ - method: 'GET', - url: '/test2' - }) - t.same(res.json(), { hello: 'world' }) -}) - -test('Should override serializers from route', t => { - t.plan(3) - - const splitStream = split(JSON.parse) - splitStream.on('data', (line) => { - if (line.test) { - t.same(line.test, 'ZHello') - } - }) - - const logger = pino({ level: 'info' }, splitStream) - const fastify = Fastify({ - logger - }) - - fastify.register(function (instance, opts, done) { - instance.get('/', { - logSerializers: { - test: value => 'Z' + value // should override - } - }, (req, reply) => { - req.log.info({ test: 'Hello' }) - reply.send({ hello: 'world' }) - }) - done() - }, { logSerializers: { test: value => 'X' + value } }) - - fastify.inject({ - method: 'GET', - url: '/' - }, (err, res) => { - t.error(err) - const payload = JSON.parse(res.payload) - t.same(payload, { hello: 'world' }) - }) -}) - -test('Should override serializers from plugin', t => { - t.plan(3) - - const splitStream = split(JSON.parse) - splitStream.on('data', (line) => { - if (line.test) { - t.same(line.test, 'ZHello') - } - }) - - const logger = pino({ level: 'info' }, splitStream) - const fastify = Fastify({ - logger - }) - - fastify.register(function (instance, opts, done) { - instance.register(context1, { - logSerializers: { - test: value => 'Z' + value // should override - } - }) - done() - }, { logSerializers: { test: value => 'X' + value } }) - - function context1 (instance, opts, done) { - instance.get('/', (req, reply) => { - req.log.info({ test: 'Hello' }) - reply.send({ hello: 'world' }) - }) - done() - } - - fastify.inject({ - method: 'GET', - url: '/' - }, (err, res) => { - t.error(err) - const payload = JSON.parse(res.payload) - t.same(payload, { hello: 'world' }) - }) -}) - -test('Should use serializers from plugin and route', t => { - t.plan(4) - - const splitStream = split(JSON.parse) - splitStream.on('data', (line) => { - if (line.test) { - t.same(line.test, 'XHello') - } - if (line.test2) { - t.same(line.test2, 'ZHello') - } - }) - - const logger = pino({ level: 'info' }, splitStream) - const fastify = Fastify({ - logger - }) - - fastify.register(context1, { - logSerializers: { test: value => 'X' + value } - }) - - function context1 (instance, opts, done) { - instance.get('/', { - logSerializers: { - test2: value => 'Z' + value - } - }, (req, reply) => { - req.log.info({ test: 'Hello', test2: 'Hello' }) // { test: 'XHello', test2: 'ZHello' } - reply.send({ hello: 'world' }) - }) - done() - } - - fastify.inject({ - method: 'GET', - url: '/' - }, (err, res) => { - t.error(err) - const payload = JSON.parse(res.payload) - t.same(payload, { hello: 'world' }) - }) -}) - -test('Should use serializers from instance fastify and route', t => { - t.plan(4) - - const splitStream = split(JSON.parse) - splitStream.on('data', (line) => { - if (line.test) { - t.same(line.test, 'XHello') - } - if (line.test2) { - t.same(line.test2, 'ZHello') - } - }) - - const logger = pino({ - level: 'info', - serializers: { - test: value => 'X' + value, - test2: value => 'This should be override - ' + value - } - }, splitStream) - const fastify = Fastify({ - logger - }) - - fastify.get('/', { - logSerializers: { - test2: value => 'Z' + value - } - }, (req, reply) => { - req.log.info({ test: 'Hello', test2: 'Hello' }) // { test: 'XHello', test2: 'ZHello' } - reply.send({ hello: 'world' }) - }) - - fastify.inject({ - method: 'GET', - url: '/' - }, (err, res) => { - t.error(err) - const payload = JSON.parse(res.payload) - t.same(payload, { hello: 'world' }) - }) -}) - -test('Should use serializers inherit from contexts', t => { - t.plan(5) - - const splitStream = split(JSON.parse) - splitStream.on('data', (line) => { - if (line.test && line.test2 && line.test3) { - t.same(line.test, 'XHello') - t.same(line.test2, 'YHello') - t.same(line.test3, 'ZHello') - } - }) - - const logger = pino({ - level: 'info', - serializers: { - test: value => 'X' + value - } - }, splitStream) - - const fastify = Fastify({ logger }) - fastify.register(context1, { logSerializers: { test2: value => 'Y' + value } }) - - function context1 (instance, opts, done) { - instance.get('/', { - logSerializers: { - test3: value => 'Z' + value - } - }, (req, reply) => { - req.log.info({ test: 'Hello', test2: 'Hello', test3: 'Hello' }) // { test: 'XHello', test2: 'YHello', test3: 'ZHello' } - reply.send({ hello: 'world' }) - }) - done() - } - - fastify.inject({ - method: 'GET', - url: '/' - }, (err, res) => { - t.error(err) - const payload = JSON.parse(res.payload) - t.same(payload, { hello: 'world' }) - }) -}) - -test('Should increase the log level for a specific plugin', t => { - t.plan(4) - - const splitStream = split(JSON.parse) - splitStream.on('data', (line) => { - t.same(line.msg, 'Hello') - t.ok(line.level === 50) - }) - - const logger = pino({ level: 'info' }, splitStream) - - const fastify = Fastify({ - logger - }) - - fastify.register(function (instance, opts, done) { - instance.get('/', (req, reply) => { - req.log.error('Hello') // we should see this log - reply.send({ hello: 'world' }) - }) - done() - }, { logLevel: 'error' }) - - fastify.inject({ - method: 'GET', - url: '/' - }, (err, res) => { - t.error(err) - const payload = JSON.parse(res.payload) - t.same(payload, { hello: 'world' }) - }) -}) - -test('Should set the log level for the customized 404 handler', t => { - t.plan(4) - - const splitStream = split(JSON.parse) - splitStream.on('data', (line) => { - t.same(line.msg, 'Hello') - t.ok(line.level === 50) - }) - - const logger = pino({ level: 'warn' }, splitStream) - - const fastify = Fastify({ - logger - }) - - fastify.register(function (instance, opts, done) { - instance.setNotFoundHandler(function (req, reply) { - req.log.error('Hello') - reply.code(404).send() - }) - done() - }, { logLevel: 'error' }) - - fastify.inject({ - method: 'GET', - url: '/' - }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 404) - }) -}) - -test('Should set the log level for the customized 500 handler', t => { - t.plan(4) - - const splitStream = split(JSON.parse) - splitStream.on('data', (line) => { - t.same(line.msg, 'Hello') - t.ok(line.level === 60) - }) - - const logger = pino({ level: 'warn' }, splitStream) - - const fastify = Fastify({ - logger - }) - - fastify.register(function (instance, opts, done) { - instance.get('/', (req, reply) => { - req.log.error('kaboom') - reply.send(new Error('kaboom')) - }) - - instance.setErrorHandler(function (e, request, reply) { - reply.log.fatal('Hello') - reply.code(500).send() - }) - done() - }, { logLevel: 'fatal' }) - - fastify.inject({ - method: 'GET', - url: '/' - }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 500) - }) -}) - -test('Should set a custom log level for a specific route', t => { - const lines = ['incoming request', 'Hello', 'request completed'] - t.plan(7) - - const splitStream = split(JSON.parse) - splitStream.on('data', (line) => { - t.same(line.msg, lines.shift()) - }) - - const logger = pino({ level: 'error' }, splitStream) - - const fastify = Fastify({ - logger - }) - - fastify.get('/log', { logLevel: 'info' }, (req, reply) => { - req.log.info('Hello') - reply.send({ hello: 'world' }) - }) - - fastify.get('/no-log', (req, reply) => { - req.log.info('Hello') - reply.send({ hello: 'world' }) - }) - - fastify.inject({ - method: 'GET', - url: '/log' - }, (err, res) => { - t.error(err) - const payload = JSON.parse(res.payload) - t.same(payload, { hello: 'world' }) - }) - - fastify.inject({ - method: 'GET', - url: '/no-log' - }, (err, res) => { - t.error(err) - const payload = JSON.parse(res.payload) - t.same(payload, { hello: 'world' }) - }) -}) - -test('The default 404 handler logs the incoming request', t => { - t.plan(5) - - const expectedMessages = [ - 'incoming request', - 'Route GET:/not-found not found', - 'request completed' - ] - - const splitStream = split(JSON.parse) - splitStream.on('data', (line) => { - t.same(line.msg, expectedMessages.shift()) - }) - - const logger = pino({ level: 'trace' }, splitStream) - - const fastify = Fastify({ - logger - }) - - fastify.inject({ - method: 'GET', - url: '/not-found' - }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 404) - }) -}) - -test('should serialize request and response', t => { - t.plan(3) - const lines = [] - const dest = new stream.Writable({ - write: function (chunk, enc, cb) { - lines.push(JSON.parse(chunk)) - cb() - } - }) - const fastify = Fastify({ logger: { level: 'info', stream: dest } }) - - fastify.get('/500', (req, reply) => { - reply.code(500).send(Error('500 error')) - }) - - fastify.inject({ - url: '/500', - method: 'GET' - }, (e, res) => { - const l = lines.find((line) => line.res && line.res.statusCode === 500) - t.ok(l.req) - t.same(l.req.method, 'GET') - t.same(l.req.url, '/500') - }) -}) - -{ - const interfaces = os.networkInterfaces() - const ipv6 = Object.keys(interfaces) - .filter(name => name.substr(0, 2) === 'lo') - .map(name => interfaces[name]) - .reduce((list, set) => list.concat(set), []) - .filter(info => info.family === 'IPv6') - .map(info => info.address) - .shift() - - if (ipv6 !== undefined) { - test('Wrap IPv6 address in listening log message', t => { - t.plan(2) - const stream = split(JSON.parse) - const fastify = Fastify({ - logger: { - stream, - level: 'info' - } - }) - fastify.listen({ port: 0, host: ipv6 }, err => { - t.error(err) - stream.once('data', line => { - const expected = 'Server listening at http://[' + ipv6 + ']:' + - fastify.server.address().port - t.same(line.msg, expected) - fastify.close() - }) - }) - }) - } -} - -test('Do not wrap IPv4 address', t => { - t.plan(2) - const stream = split(JSON.parse) - const fastify = Fastify({ - logger: { - stream, - level: 'info' - } - }) - fastify.listen({ port: 0, host: '127.0.0.1' }, err => { - t.error(err) - stream.once('data', line => { - const expected = 'Server listening at http://127.0.0.1:' + - fastify.server.address().port - t.same(line.msg, expected) - fastify.close() - }) - }) -}) - -test('file option', t => { - t.plan(13) - let fastify = null - const dest = file() - - fastify = Fastify({ - logger: { - file: dest - } - }) - - fastify.get('/', function (req, reply) { - t.ok(req.log) - reply.send({ hello: 'world' }) - }) - - fastify.listen({ port: 0, host: localhost }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - http.get(`http://${localhostForURL}:` + fastify.server.address().port, () => { - const stream = fs.createReadStream(dest).pipe(split(JSON.parse)) - - stream.once('data', listenAtLogLine => { - t.ok(listenAtLogLine, 'listen at log message is ok') - - stream.once('data', line => { - const id = line.reqId - t.ok(line.reqId, 'reqId is defined') - t.ok(line.req, 'req is defined') - t.equal(line.msg, 'incoming request', 'message is set') - t.equal(line.req.method, 'GET', 'method is get') - - stream.once('data', line => { - t.equal(line.reqId, id) - t.ok(line.reqId, 'reqId is defined') - t.ok(line.res, 'res is defined') - t.equal(line.msg, 'request completed', 'message is set') - t.equal(line.res.statusCode, 200, 'statusCode is 200') - t.ok(line.responseTime, 'responseTime is defined') - stream.resume() - }) - }) - }) - }) - }) -}) - -test('should log the error if no error handler is defined', t => { - t.plan(8) - const stream = split(JSON.parse) - const fastify = Fastify({ - logger: { - stream, - level: 'info' - } - }) - fastify.get('/error', function (req, reply) { - t.ok(req.log) - reply.send(new Error('a generic error')) - }) - fastify.listen({ port: 0, host: localhost }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - http.get(`http://${localhostForURL}:` + fastify.server.address().port + '/error') - stream.once('data', listenAtLogLine => { - t.ok(listenAtLogLine, 'listen at log message is ok') - stream.once('data', line => { - t.equal(line.msg, 'incoming request', 'message is set') - stream.once('data', line => { - t.equal(line.level, 50, 'level is correct') - t.equal(line.msg, 'a generic error', 'message is set') - stream.once('data', line => { - t.equal(line.msg, 'request completed', 'message is set') - t.same(line.res, { statusCode: 500 }, 'status code is set') - }) - }) - }) - }) - }) -}) - -test('should log as info if error status code >= 400 and < 500 if no error handler is defined', t => { - t.plan(8) - const stream = split(JSON.parse) - const fastify = Fastify({ - logger: { - stream, - level: 'info' - } - }) - fastify.get('/400', function (req, reply) { - t.ok(req.log) - reply.send(Object.assign(new Error('a 400 error'), { statusCode: 400 })) - }) - fastify.get('/503', function (req, reply) { - t.ok(req.log) - reply.send(Object.assign(new Error('a 503 error'), { statusCode: 503 })) - }) - fastify.listen({ port: 0, host: localhost }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - http.get(`http://${localhostForURL}:` + fastify.server.address().port + '/400') - stream.once('data', listenAtLogLine => { - t.ok(listenAtLogLine, 'listen at log message is ok') - stream.once('data', line => { - t.equal(line.msg, 'incoming request', 'message is set') - stream.once('data', line => { - t.equal(line.level, 30, 'level is correct') - t.equal(line.msg, 'a 400 error', 'message is set') - stream.once('data', line => { - t.equal(line.msg, 'request completed', 'message is set') - t.same(line.res, { statusCode: 400 }, 'status code is set') - }) - }) - }) - }) - }) -}) - -test('should log as error if error status code >= 500 if no error handler is defined', t => { - t.plan(8) - const stream = split(JSON.parse) - const fastify = Fastify({ - logger: { - stream, - level: 'info' - } - }) - fastify.get('/503', function (req, reply) { - t.ok(req.log) - reply.send(Object.assign(new Error('a 503 error'), { statusCode: 503 })) - }) - fastify.listen({ port: 0, host: localhost }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - http.get(`http://${localhostForURL}:` + fastify.server.address().port + '/503') - stream.once('data', listenAtLogLine => { - t.ok(listenAtLogLine, 'listen at log message is ok') - stream.once('data', line => { - t.equal(line.msg, 'incoming request', 'message is set') - stream.once('data', line => { - t.equal(line.level, 50, 'level is correct') - t.equal(line.msg, 'a 503 error', 'message is set') - stream.once('data', line => { - t.equal(line.msg, 'request completed', 'message is set') - t.same(line.res, { statusCode: 503 }, 'status code is set') - }) - }) - }) - }) - }) -}) - -test('should not log the error if error handler is defined and it does not error', t => { - t.plan(8) - const stream = split(JSON.parse) - const fastify = Fastify({ - logger: { - stream, - level: 'info' - } - }) - fastify.get('/error', function (req, reply) { - t.ok(req.log) - reply.send(new Error('something happened')) - }) - fastify.setErrorHandler((err, req, reply) => { - t.ok(err) - reply.send('something bad happened') - }) - fastify.listen({ port: 0, host: localhost }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - http.get(`http://${localhostForURL}:` + fastify.server.address().port + '/error') - stream.once('data', listenAtLogLine => { - t.ok(listenAtLogLine, 'listen at log message is ok') - stream.once('data', line => { - t.equal(line.msg, 'incoming request', 'message is set') - stream.once('data', line => { - t.equal(line.level, 30, 'level is correct') - t.equal(line.msg, 'request completed', 'message is set') - t.same(line.res, { statusCode: 200 }, 'status code is set') - }) - }) - }) - }) -}) - -test('should not rely on raw request to log errors', t => { - t.plan(7) - const stream = split(JSON.parse) - const fastify = Fastify({ - logger: { - stream, - level: 'info' - } - }) - fastify.get('/error', function (req, reply) { - t.ok(req.log) - reply.status(415).send(new Error('something happened')) - }) - fastify.listen({ port: 0, host: localhost }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - http.get(`http://${localhostForURL}:` + fastify.server.address().port + '/error') - stream.once('data', listenAtLogLine => { - t.ok(listenAtLogLine, 'listen at log message is ok') - stream.once('data', line => { - t.equal(line.msg, 'incoming request', 'message is set') - stream.once('data', line => { - t.equal(line.level, 30, 'level is correct') - t.equal(line.msg, 'something happened', 'message is set') - t.same(line.res, { statusCode: 415 }, 'status code is set') - }) - }) - }) - }) -}) - -test('should redact the authorization header if so specified', t => { - t.plan(7) - const stream = split(JSON.parse) - const fastify = Fastify({ - logger: { - stream, - redact: ['req.headers.authorization'], - level: 'info', - serializers: { - req (req) { - return { - method: req.method, - url: req.url, - headers: req.headers, - hostname: req.hostname, - remoteAddress: req.ip, - remotePort: req.socket.remotePort - } - } - } - } - }) - fastify.get('/', function (req, reply) { - t.same(req.headers.authorization, 'Bearer abcde') - reply.send({ hello: 'world' }) - }) - stream.once('data', listenAtLogLine => { - t.ok(listenAtLogLine, 'listen at log message is ok') - stream.once('data', line => { - t.equal(line.req.headers.authorization, '[Redacted]', 'authorization is redacted') - }) - }) - fastify.listen({ port: 0, host: localhost }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - sget({ - method: 'GET', - url: `http://${localhostForURL}:` + fastify.server.address().port, - headers: { - authorization: 'Bearer abcde' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(body.toString(), JSON.stringify({ hello: 'world' })) - }) - }) -}) - -test('should not log incoming request and outgoing response when disabled', t => { - t.plan(3) - const lines = [] - const dest = new stream.Writable({ - write: function (chunk, enc, cb) { - lines.push(JSON.parse(chunk)) - cb() - } - }) - const fastify = Fastify({ disableRequestLogging: true, logger: { level: 'info', stream: dest } }) - - fastify.get('/500', (req, reply) => { - reply.code(500).send(Error('500 error')) - }) - - fastify.inject({ - url: '/500', - method: 'GET' - }, (e, res) => { - t.same(lines.length, 1) - t.ok(lines[0].msg) - t.same(lines[0].msg, '500 error') - }) -}) - -test('should not log incoming request and outgoing response for 404 onBadUrl when disabled', t => { - t.plan(1) - const lines = [] - const dest = new stream.Writable({ - write: function (chunk, enc, cb) { - lines.push(JSON.parse(chunk)) - cb() - } - }) - const fastify = Fastify({ disableRequestLogging: true, logger: { level: 'info', stream: dest } }) - - fastify.inject({ - url: '/%c0', - method: 'GET' - }, (e, res) => { - // it will log 1 line only because of basic404 - t.same(lines.length, 1) - }) -}) - -test('should pass when using unWritable props in the logger option', t => { - t.plan(1) - Fastify({ - logger: Object.defineProperty({}, 'level', { value: 'info' }) - }) - t.pass() -}) - -test('should be able to use a custom logger', t => { - t.plan(1) - - const logger = { - fatal: () => {}, - error: () => {}, - warn: () => {}, - info: () => {}, - debug: () => {}, - trace: () => {}, - child: () => {} - } - - Fastify({ logger }) - - t.pass() -}) - -test('should create a default logger if provided one is invalid', t => { - t.plan(1) - - const logger = new Date() - - Fastify({ logger }) - - t.pass() -}) - -test('should not throw error when serializing custom req', t => { - t.plan(1) - - const lines = [] - const dest = new stream.Writable({ - write: function (chunk, enc, cb) { - lines.push(JSON.parse(chunk)) - cb() - } - }) - const fastify = Fastify({ logger: { level: 'info', stream: dest } }) - fastify.log.info({ req: {} }) - - t.same(lines[0].req, {}) -}) diff --git a/test/logger/instantiation.test.js b/test/logger/instantiation.test.js new file mode 100644 index 00000000000..3fd84421964 --- /dev/null +++ b/test/logger/instantiation.test.js @@ -0,0 +1,341 @@ +'use strict' + +const stream = require('node:stream') +const os = require('node:os') +const fs = require('node:fs') + +const t = require('node:test') +const split = require('split2') + +const { streamSym } = require('pino/lib/symbols') + +const Fastify = require('../../fastify') +const helper = require('../helper') +const { FST_ERR_LOG_INVALID_LOGGER } = require('../../lib/errors') +const { once, on } = stream +const { createTempFile, request } = require('./logger-test-utils') +const { partialDeepStrictEqual } = require('../toolkit') + +t.test('logger instantiation', { timeout: 60000 }, async (t) => { + let localhost + let localhostForURL + + t.plan(11) + t.before(async function () { + [localhost, localhostForURL] = await helper.getLoopbackHost() + }) + + await t.test('can use external logger instance', async (t) => { + const lines = [/^Server listening at /, /^incoming request$/, /^log success$/, /^request completed$/] + t.plan(lines.length + 1) + + const stream = split(JSON.parse) + + const loggerInstance = require('pino')(stream) + + const fastify = Fastify({ loggerInstance }) + t.after(() => fastify.close()) + + fastify.get('/foo', function (req, reply) { + t.assert.ok(req.log) + req.log.info('log success') + reply.send({ hello: 'world' }) + }) + + await fastify.listen({ port: 0, host: localhost }) + + await request(`http://${localhostForURL}:` + fastify.server.address().port + '/foo') + + for await (const [line] of on(stream, 'data')) { + const regex = lines.shift() + t.assert.ok(regex.test(line.msg), '"' + line.msg + '" does not match "' + regex + '"') + if (lines.length === 0) break + } + }) + + await t.test('should create a default logger if provided one is invalid', (t) => { + t.plan(8) + + const logger = new Date() + + const fastify = Fastify({ logger }) + t.after(() => fastify.close()) + + t.assert.strictEqual(typeof fastify.log, 'object') + t.assert.strictEqual(typeof fastify.log.fatal, 'function') + t.assert.strictEqual(typeof fastify.log.error, 'function') + t.assert.strictEqual(typeof fastify.log.warn, 'function') + t.assert.strictEqual(typeof fastify.log.info, 'function') + t.assert.strictEqual(typeof fastify.log.debug, 'function') + t.assert.strictEqual(typeof fastify.log.trace, 'function') + t.assert.strictEqual(typeof fastify.log.child, 'function') + }) + + await t.test('expose the logger', async (t) => { + t.plan(2) + const stream = split(JSON.parse) + const fastify = Fastify({ + logger: { + stream, + level: 'info' + } + }) + t.after(() => fastify.close()) + + await fastify.ready() + + t.assert.ok(fastify.log) + t.assert.strictEqual(typeof fastify.log, 'object') + }) + + const interfaces = os.networkInterfaces() + const ipv6 = Object.keys(interfaces) + .filter(name => name.substr(0, 2) === 'lo') + .map(name => interfaces[name]) + .reduce((list, set) => list.concat(set), []) + .filter(info => info.family === 'IPv6') + .map(info => info.address) + .shift() + + await t.test('Wrap IPv6 address in listening log message', { skip: !ipv6 }, async (t) => { + t.plan(1) + + const stream = split(JSON.parse) + const fastify = Fastify({ + logger: { + stream, + level: 'info' + } + }) + t.after(() => fastify.close()) + + await fastify.ready() + await fastify.listen({ port: 0, host: ipv6 }) + + { + const [line] = await once(stream, 'data') + t.assert.strictEqual(line.msg, `Server listening at http://[${ipv6}]:${fastify.server.address().port}`) + } + }) + + await t.test('Do not wrap IPv4 address', async (t) => { + t.plan(1) + const stream = split(JSON.parse) + const fastify = Fastify({ + logger: { + stream, + level: 'info' + } + }) + t.after(() => fastify.close()) + + await fastify.ready() + await fastify.listen({ port: 0, host: '127.0.0.1' }) + + { + const [line] = await once(stream, 'data') + t.assert.strictEqual(line.msg, `Server listening at http://127.0.0.1:${fastify.server.address().port}`) + } + }) + + await t.test('file option', async (t) => { + const { file, cleanup } = createTempFile(t) + // 0600 permissions (read/write for owner only) + if (process.env.CITGM) { fs.writeFileSync(file, '', { mode: 0o600 }) } + + const fastify = Fastify({ + logger: { file } + }) + + t.after(async () => { + await helper.sleep(250) + // may fail on win + try { + // cleanup the file after sonic-boom closed + // otherwise we may face racing condition + fastify.log[streamSym].once('close', cleanup) + // we must flush the stream ourself + // otherwise buffer may whole sonic-boom + fastify.log[streamSym].flushSync() + // end after flushing to actually close file + fastify.log[streamSym].end() + } catch (err) { + console.warn(err) + } + }) + t.after(() => fastify.close()) + + fastify.get('/', function (req, reply) { + t.assert.ok(req.log) + reply.send({ hello: 'world' }) + }) + + await fastify.ready() + const server = await fastify.listen({ port: 0, host: localhost }) + const lines = [ + { msg: `Server listening at ${server}` }, + { req: { method: 'GET', url: '/' }, msg: 'incoming request' }, + { res: { statusCode: 200 }, msg: 'request completed' } + ] + await request(`http://${localhostForURL}:` + fastify.server.address().port) + + await helper.sleep(250) + + const log = fs.readFileSync(file, 'utf8').split('\n') + // strip last line + log.pop() + + let id + for (let line of log) { + line = JSON.parse(line) + if (id === undefined && line.reqId) id = line.reqId + if (id !== undefined && line.reqId) t.assert.strictEqual(line.reqId, id) + t.assert.ok(partialDeepStrictEqual(line, lines.shift())) + } + }) + + await t.test('should be able to use a custom logger', (t) => { + t.plan(7) + + const loggerInstance = { + fatal: (msg) => { t.assert.strictEqual(msg, 'fatal') }, + error: (msg) => { t.assert.strictEqual(msg, 'error') }, + warn: (msg) => { t.assert.strictEqual(msg, 'warn') }, + info: (msg) => { t.assert.strictEqual(msg, 'info') }, + debug: (msg) => { t.assert.strictEqual(msg, 'debug') }, + trace: (msg) => { t.assert.strictEqual(msg, 'trace') }, + child: () => loggerInstance + } + + const fastify = Fastify({ loggerInstance }) + t.after(() => fastify.close()) + + fastify.log.fatal('fatal') + fastify.log.error('error') + fastify.log.warn('warn') + fastify.log.info('info') + fastify.log.debug('debug') + fastify.log.trace('trace') + const child = fastify.log.child() + t.assert.strictEqual(child, loggerInstance) + }) + + await t.test('should throw in case a partially matching logger is provided', async (t) => { + t.plan(1) + + try { + const fastify = Fastify({ logger: console }) + await fastify.ready() + } catch (err) { + t.assert.strictEqual( + err instanceof FST_ERR_LOG_INVALID_LOGGER, + true, + "Invalid logger object provided. The logger instance should have these functions(s): 'fatal,child'." + ) + } + }) + + await t.test('can use external logger instance with custom serializer', async (t) => { + const lines = [['level', 30], ['req', { url: '/foo' }], ['level', 30], ['res', { statusCode: 200 }]] + t.plan(lines.length + 1) + + const stream = split(JSON.parse) + const loggerInstance = require('pino')({ + level: 'info', + serializers: { + req: function (req) { + return { + url: req.url + } + } + } + }, stream) + + const fastify = Fastify({ + loggerInstance + }) + t.after(() => fastify.close()) + + fastify.get('/foo', function (req, reply) { + t.assert.ok(req.log) + req.log.info('log success') + reply.send({ hello: 'world' }) + }) + + await fastify.ready() + await fastify.listen({ port: 0, host: localhost }) + + await request(`http://${localhostForURL}:` + fastify.server.address().port + '/foo') + + for await (const [line] of on(stream, 'data')) { + const check = lines.shift() + const key = check[0] + const value = check[1] + t.assert.deepStrictEqual(line[key], value) + if (lines.length === 0) break + } + }) + + await t.test('The logger should accept custom serializer', async (t) => { + const stream = split(JSON.parse) + const fastify = Fastify({ + logger: { + stream, + level: 'info', + serializers: { + req: function (req) { + return { + url: req.url + } + } + } + } + }) + t.after(() => fastify.close()) + + fastify.get('/custom', function (req, reply) { + t.assert.ok(req.log) + reply.send(new Error('kaboom')) + }) + + await fastify.ready() + const server = await fastify.listen({ port: 0, host: localhost }) + const lines = [ + { msg: `Server listening at ${server}` }, + { req: { url: '/custom' }, msg: 'incoming request' }, + { res: { statusCode: 500 }, msg: 'kaboom' }, + { res: { statusCode: 500 }, msg: 'request completed' } + ] + t.plan(lines.length + 1) + + await request(`http://${localhostForURL}:` + fastify.server.address().port + '/custom') + + for await (const [line] of on(stream, 'data')) { + t.assert.ok(partialDeepStrictEqual(line, lines.shift())) + if (lines.length === 0) break + } + }) + + await t.test('should throw in case the external logger provided does not have a child method', async (t) => { + t.plan(1) + const loggerInstance = { + info: console.info, + error: console.error, + debug: console.debug, + fatal: console.error, + warn: console.warn, + trace: console.trace + } + + try { + const fastify = Fastify({ logger: loggerInstance }) + await fastify.ready() + } catch (err) { + t.assert.strictEqual( + err instanceof FST_ERR_LOG_INVALID_LOGGER, + true, + "Invalid logger object provided. The logger instance should have these functions(s): 'child'." + ) + } + }) +}) diff --git a/test/logger/logger-test-utils.js b/test/logger/logger-test-utils.js new file mode 100644 index 00000000000..cce6669163b --- /dev/null +++ b/test/logger/logger-test-utils.js @@ -0,0 +1,47 @@ +'use strict' + +const http = require('node:http') +const os = require('node:os') +const fs = require('node:fs') + +const path = require('node:path') + +function createDeferredPromise () { + const promise = {} + promise.promise = new Promise(function (resolve) { + promise.resolve = resolve + }) + return promise +} + +let count = 0 +function createTempFile () { + const file = path.join(os.tmpdir(), `sonic-boom-${process.pid}-${count++}`) + function cleanup () { + try { + fs.unlinkSync(file) + } catch { } + } + return { file, cleanup } +} + +function request (url, cleanup = () => { }) { + const promise = createDeferredPromise() + http.get(url, (res) => { + const chunks = [] + // we consume the response + res.on('data', function (chunk) { + chunks.push(chunk) + }) + res.once('end', function () { + cleanup(res, Buffer.concat(chunks).toString()) + promise.resolve() + }) + }) + return promise.promise +} + +module.exports = { + request, + createTempFile +} diff --git a/test/logger/logging.test.js b/test/logger/logging.test.js new file mode 100644 index 00000000000..0ed5187252d --- /dev/null +++ b/test/logger/logging.test.js @@ -0,0 +1,460 @@ +'use strict' + +const stream = require('node:stream') + +const t = require('node:test') +const split = require('split2') +const pino = require('pino') + +const Fastify = require('../../fastify') +const helper = require('../helper') +const { once, on } = stream +const { request } = require('./logger-test-utils') +const { partialDeepStrictEqual } = require('../toolkit') + +t.test('logging', { timeout: 60000 }, async (t) => { + let localhost + let localhostForURL + + t.plan(14) + + t.before(async function () { + [localhost, localhostForURL] = await helper.getLoopbackHost() + }) + + await t.test('The default 404 handler logs the incoming request', async (t) => { + const lines = ['incoming request', 'Route GET:/not-found not found', 'request completed'] + t.plan(lines.length + 1) + + const stream = split(JSON.parse) + + const loggerInstance = pino({ level: 'trace' }, stream) + + const fastify = Fastify({ + loggerInstance + }) + t.after(() => fastify.close()) + + await fastify.ready() + + { + const response = await fastify.inject({ method: 'GET', url: '/not-found' }) + t.assert.strictEqual(response.statusCode, 404) + } + + for await (const [line] of on(stream, 'data')) { + t.assert.strictEqual(line.msg, lines.shift()) + if (lines.length === 0) break + } + }) + + await t.test('should not rely on raw request to log errors', async (t) => { + const stream = split(JSON.parse) + const fastify = Fastify({ + logger: { + stream, + level: 'info' + } + }) + t.after(() => fastify.close()) + fastify.get('/error', function (req, reply) { + t.assert.ok(req.log) + reply.status(415).send(new Error('something happened')) + }) + + await fastify.ready() + const server = await fastify.listen({ port: 0, host: localhost }) + const lines = [ + { msg: `Server listening at ${server}` }, + { level: 30, msg: 'incoming request' }, + { res: { statusCode: 415 }, msg: 'something happened' }, + { res: { statusCode: 415 }, msg: 'request completed' } + ] + t.plan(lines.length + 1) + + await request(`http://${localhostForURL}:` + fastify.server.address().port + '/error') + + for await (const [line] of on(stream, 'data')) { + t.assert.ok(partialDeepStrictEqual(line, lines.shift())) + if (lines.length === 0) break + } + }) + + await t.test('should log the error if no error handler is defined', async (t) => { + const stream = split(JSON.parse) + const fastify = Fastify({ + logger: { + stream, + level: 'info' + } + }) + t.after(() => fastify.close()) + + fastify.get('/error', function (req, reply) { + t.assert.ok(req.log) + reply.send(new Error('a generic error')) + }) + + await fastify.ready() + const server = await fastify.listen({ port: 0, host: localhost }) + const lines = [ + { msg: `Server listening at ${server}` }, + { msg: 'incoming request' }, + { level: 50, msg: 'a generic error' }, + { res: { statusCode: 500 }, msg: 'request completed' } + ] + t.plan(lines.length + 1) + + await request(`http://${localhostForURL}:` + fastify.server.address().port + '/error') + + for await (const [line] of on(stream, 'data')) { + t.assert.ok(partialDeepStrictEqual(line, lines.shift())) + if (lines.length === 0) break + } + }) + + await t.test('should log as info if error status code >= 400 and < 500 if no error handler is defined', async (t) => { + const stream = split(JSON.parse) + const fastify = Fastify({ + logger: { + stream, + level: 'info' + } + }) + t.after(() => fastify.close()) + + fastify.get('/400', function (req, reply) { + t.assert.ok(req.log) + reply.send(Object.assign(new Error('a 400 error'), { statusCode: 400 })) + }) + fastify.get('/503', function (req, reply) { + t.assert.ok(req.log) + reply.send(Object.assign(new Error('a 503 error'), { statusCode: 503 })) + }) + + await fastify.ready() + const server = await fastify.listen({ port: 0, host: localhost }) + const lines = [ + { msg: `Server listening at ${server}` }, + { msg: 'incoming request' }, + { level: 30, msg: 'a 400 error' }, + { res: { statusCode: 400 }, msg: 'request completed' } + ] + t.plan(lines.length + 1) + + await request(`http://${localhostForURL}:` + fastify.server.address().port + '/400') + + for await (const [line] of on(stream, 'data')) { + t.assert.ok(partialDeepStrictEqual(line, lines.shift())) + if (lines.length === 0) break + } + }) + + await t.test('should log as error if error status code >= 500 if no error handler is defined', async (t) => { + const stream = split(JSON.parse) + const fastify = Fastify({ + logger: { + stream, + level: 'info' + } + }) + t.after(() => fastify.close()) + fastify.get('/503', function (req, reply) { + t.assert.ok(req.log) + reply.send(Object.assign(new Error('a 503 error'), { statusCode: 503 })) + }) + + await fastify.ready() + const server = await fastify.listen({ port: 0, host: localhost }) + const lines = [ + { msg: `Server listening at ${server}` }, + { msg: 'incoming request' }, + { level: 50, msg: 'a 503 error' }, + { res: { statusCode: 503 }, msg: 'request completed' } + ] + t.plan(lines.length + 1) + + await request(`http://${localhostForURL}:` + fastify.server.address().port + '/503') + + for await (const [line] of on(stream, 'data')) { + t.assert.ok(partialDeepStrictEqual(line, lines.shift())) + if (lines.length === 0) break + } + }) + + await t.test('should not log the error if error handler is defined and it does not error', async (t) => { + const stream = split(JSON.parse) + const fastify = Fastify({ + logger: { + stream, + level: 'info' + } + }) + t.after(() => fastify.close()) + fastify.get('/error', function (req, reply) { + t.assert.ok(req.log) + reply.send(new Error('something happened')) + }) + fastify.setErrorHandler((err, req, reply) => { + t.assert.ok(err) + reply.send('something bad happened') + }) + + await fastify.ready() + const server = await fastify.listen({ port: 0, host: localhost }) + const lines = [ + { msg: `Server listening at ${server}` }, + { level: 30, msg: 'incoming request' }, + { res: { statusCode: 200 }, msg: 'request completed' } + ] + t.plan(lines.length + 2) + + await request(`http://${localhostForURL}:` + fastify.server.address().port + '/error') + + for await (const [line] of on(stream, 'data')) { + t.assert.ok(partialDeepStrictEqual(line, lines.shift())) + if (lines.length === 0) break + } + }) + + await t.test('reply.send logs an error if called twice in a row', async (t) => { + const lines = [ + 'incoming request', + 'request completed', + 'Reply was already sent, did you forget to "return reply" in "/" (GET)?', + 'Reply was already sent, did you forget to "return reply" in "/" (GET)?' + ] + t.plan(lines.length + 1) + + const stream = split(JSON.parse) + const loggerInstance = pino(stream) + + const fastify = Fastify({ + loggerInstance + }) + t.after(() => fastify.close()) + + fastify.get('/', (req, reply) => { + reply.send({ hello: 'world' }) + reply.send({ hello: 'world2' }) + reply.send({ hello: 'world3' }) + }) + + const response = await fastify.inject({ method: 'GET', url: '/' }) + const body = await response.json() + t.assert.ok(partialDeepStrictEqual(body, { hello: 'world' })) + + for await (const [line] of on(stream, 'data')) { + t.assert.strictEqual(line.msg, lines.shift()) + if (lines.length === 0) break + } + }) + + await t.test('should not log incoming request and outgoing response when disabled', async (t) => { + t.plan(1) + const stream = split(JSON.parse) + const fastify = Fastify({ disableRequestLogging: true, logger: { level: 'info', stream } }) + t.after(() => fastify.close()) + + fastify.get('/500', (req, reply) => { + reply.code(500).send(Error('500 error')) + }) + + await fastify.ready() + + await fastify.inject({ method: 'GET', url: '/500' }) + + // no more readable data + t.assert.strictEqual(stream.readableLength, 0) + }) + + await t.test('should not log incoming request, outgoing response and route not found for 404 onBadUrl when disabled', async (t) => { + t.plan(1) + const stream = split(JSON.parse) + const fastify = Fastify({ disableRequestLogging: true, logger: { level: 'info', stream } }) + t.after(() => fastify.close()) + + await fastify.ready() + + await fastify.inject({ method: 'GET', url: '/%c0' }) + + // no more readable data + t.assert.strictEqual(stream.readableLength, 0) + }) + + await t.test('should log incoming request and outgoing response based on disableRequestLogging function', async (t) => { + const lines = [ + 'incoming request', + 'request completed' + ] + t.plan(lines.length) + + const stream = split(JSON.parse) + const loggerInstance = pino(stream) + + const fastify = Fastify({ + disableRequestLogging: (request) => { + return request.url !== '/not-logged' + }, + loggerInstance + }) + t.after(() => fastify.close()) + + fastify.get('/logged', (req, reply) => { + return reply.code(200).send({}) + }) + + fastify.get('/not-logged', (req, reply) => { + return reply.code(200).send({}) + }) + + await fastify.ready() + + await fastify.inject({ method: 'GET', url: '/not-logged' }) + await fastify.inject({ method: 'GET', url: '/logged' }) + + for await (const [line] of on(stream, 'data')) { + t.assert.strictEqual(line.msg, lines.shift()) + if (lines.length === 0) break + } + }) + + await t.test('defaults to info level', async (t) => { + const lines = [ + { req: { method: 'GET' }, msg: 'incoming request' }, + { res: { statusCode: 200 }, msg: 'request completed' } + ] + t.plan(lines.length * 2 + 1) + const stream = split(JSON.parse) + const fastify = Fastify({ + logger: { + stream + } + }) + t.after(() => fastify.close()) + + fastify.get('/', function (req, reply) { + t.assert.ok(req.log) + reply.send({ hello: 'world' }) + }) + + await fastify.ready() + await fastify.listen({ port: 0 }) + + await request(`http://${localhostForURL}:` + fastify.server.address().port) + + let id + for await (const [line] of on(stream, 'data')) { + // we skip the non-request log + if (typeof line.reqId !== 'string') continue + if (id === undefined && line.reqId) id = line.reqId + if (id !== undefined && line.reqId) t.assert.strictEqual(line.reqId, id) + t.assert.ok(partialDeepStrictEqual(line, lines.shift())) + if (lines.length === 0) break + } + }) + + await t.test('test log stream', async (t) => { + const stream = split(JSON.parse) + const fastify = Fastify({ + logger: { + stream, + level: 'info' + } + }) + t.after(() => fastify.close()) + + fastify.get('/', function (req, reply) { + t.assert.ok(req.log) + reply.send({ hello: 'world' }) + }) + + await fastify.ready() + const server = await fastify.listen({ port: 0, host: localhost }) + const lines = [ + { msg: `Server listening at ${server}` }, + { req: { method: 'GET' }, msg: 'incoming request' }, + { res: { statusCode: 200 }, msg: 'request completed' } + ] + t.plan(lines.length + 3) + + await request(`http://${localhostForURL}:` + fastify.server.address().port) + + let id + for await (const [line] of on(stream, 'data')) { + if (id === undefined && line.reqId) id = line.reqId + if (id !== undefined && line.reqId) t.assert.strictEqual(line.reqId, id) + t.assert.ok(partialDeepStrictEqual(line, lines.shift())) + if (lines.length === 0) break + } + }) + + await t.test('test error log stream', async (t) => { + const stream = split(JSON.parse) + const fastify = Fastify({ + logger: { + stream, + level: 'info' + } + }) + t.after(() => fastify.close()) + + fastify.get('/error', function (req, reply) { + t.assert.ok(req.log) + reply.send(new Error('kaboom')) + }) + + await fastify.ready() + const server = await fastify.listen({ port: 0, host: localhost }) + const lines = [ + { msg: `Server listening at ${server}` }, + { req: { method: 'GET' }, msg: 'incoming request' }, + { res: { statusCode: 500 }, msg: 'kaboom' }, + { res: { statusCode: 500 }, msg: 'request completed' } + ] + t.plan(lines.length + 4) + + await request(`http://${localhostForURL}:` + fastify.server.address().port + '/error') + + let id + for await (const [line] of on(stream, 'data')) { + if (id === undefined && line.reqId) id = line.reqId + if (id !== undefined && line.reqId) t.assert.strictEqual(line.reqId, id) + t.assert.ok(partialDeepStrictEqual(line, lines.shift())) + if (lines.length === 0) break + } + }) + + await t.test('should not log the error if request logging is disabled', async (t) => { + t.plan(4) + + const stream = split(JSON.parse) + const fastify = Fastify({ + logger: { + stream, + level: 'info' + }, + disableRequestLogging: true + }) + t.after(() => fastify.close()) + + fastify.get('/error', function (req, reply) { + t.assert.ok(req.log) + reply.send(new Error('a generic error')) + }) + + await fastify.ready() + await fastify.listen({ port: 0, host: localhost }) + + await request(`http://${localhostForURL}:` + fastify.server.address().port + '/error') + + { + const [line] = await once(stream, 'data') + t.assert.ok(typeof line.msg === 'string') + t.assert.ok(line.msg.startsWith('Server listening at'), 'message is set') + } + + // no more readable data + t.assert.strictEqual(stream.readableLength, 0) + }) +}) diff --git a/test/logger/options.test.js b/test/logger/options.test.js new file mode 100644 index 00000000000..46b1338ee52 --- /dev/null +++ b/test/logger/options.test.js @@ -0,0 +1,579 @@ +'use strict' + +const stream = require('node:stream') + +const t = require('node:test') +const split = require('split2') +const pino = require('pino') + +const Fastify = require('../../fastify') +const { on } = stream + +t.test('logger options', { timeout: 60000 }, async (t) => { + t.plan(16) + + await t.test('logger can be silenced', (t) => { + t.plan(17) + const fastify = Fastify({ + logger: false + }) + t.after(() => fastify.close()) + t.assert.ok(fastify.log) + t.assert.deepEqual(typeof fastify.log, 'object') + t.assert.deepEqual(typeof fastify.log.fatal, 'function') + t.assert.deepEqual(typeof fastify.log.error, 'function') + t.assert.deepEqual(typeof fastify.log.warn, 'function') + t.assert.deepEqual(typeof fastify.log.info, 'function') + t.assert.deepEqual(typeof fastify.log.debug, 'function') + t.assert.deepEqual(typeof fastify.log.trace, 'function') + t.assert.deepEqual(typeof fastify.log.child, 'function') + + const childLog = fastify.log.child() + + t.assert.deepEqual(typeof childLog, 'object') + t.assert.deepEqual(typeof childLog.fatal, 'function') + t.assert.deepEqual(typeof childLog.error, 'function') + t.assert.deepEqual(typeof childLog.warn, 'function') + t.assert.deepEqual(typeof childLog.info, 'function') + t.assert.deepEqual(typeof childLog.debug, 'function') + t.assert.deepEqual(typeof childLog.trace, 'function') + t.assert.deepEqual(typeof childLog.child, 'function') + }) + + await t.test('Should set a custom logLevel for a plugin', async (t) => { + const lines = ['incoming request', 'Hello', 'request completed'] + t.plan(lines.length + 2) + + const stream = split(JSON.parse) + + const loggerInstance = pino({ level: 'error' }, stream) + + const fastify = Fastify({ + loggerInstance + }) + t.after(() => fastify.close()) + + fastify.get('/', (req, reply) => { + req.log.info('Not Exist') // we should not see this log + reply.send({ hello: 'world' }) + }) + + fastify.register(function (instance, opts, done) { + instance.get('/plugin', (req, reply) => { + req.log.info('Hello') // we should see this log + reply.send({ hello: 'world' }) + }) + done() + }, { logLevel: 'info' }) + + await fastify.ready() + + { + const response = await fastify.inject({ method: 'GET', url: '/' }) + const body = await response.json() + t.assert.deepEqual(body.hello, 'world') + } + + { + const response = await fastify.inject({ method: 'GET', url: '/plugin' }) + const body = await response.json() + t.assert.deepEqual(body.hello, 'world') + } + + for await (const [line] of on(stream, 'data')) { + t.assert.deepEqual(line.msg, lines.shift()) + if (lines.length === 0) break + } + }) + + await t.test('Should set a custom logSerializers for a plugin', async (t) => { + const lines = ['incoming request', 'XHello', 'request completed'] + t.plan(lines.length + 1) + + const stream = split(JSON.parse) + + const loggerInstance = pino({ level: 'error' }, stream) + + const fastify = Fastify({ + loggerInstance + }) + t.after(() => fastify.close()) + + fastify.register(function (instance, opts, done) { + instance.get('/plugin', (req, reply) => { + req.log.info({ test: 'Hello' }) // we should see this log + reply.send({ hello: 'world' }) + }) + done() + }, { logLevel: 'info', logSerializers: { test: value => 'X' + value } }) + + await fastify.ready() + + { + const response = await fastify.inject({ method: 'GET', url: '/plugin' }) + const body = await response.json() + t.assert.deepEqual(body.hello, 'world') + } + + for await (const [line] of on(stream, 'data')) { + // either test or msg + t.assert.deepEqual(line.test || line.msg, lines.shift()) + if (lines.length === 0) break + } + }) + + await t.test('Should set a custom logLevel for every plugin', async (t) => { + const lines = ['incoming request', 'info', 'request completed', 'incoming request', 'debug', 'request completed'] + t.plan(lines.length * 2 + 3) + + const stream = split(JSON.parse) + + const loggerInstance = pino({ level: 'error' }, stream) + + const fastify = Fastify({ + loggerInstance + }) + t.after(() => fastify.close()) + + fastify.get('/', (req, reply) => { + req.log.warn('Hello') // we should not see this log + reply.send({ hello: 'world' }) + }) + + fastify.register(function (instance, opts, done) { + instance.get('/info', (req, reply) => { + req.log.info('info') // we should see this log + req.log.debug('hidden log') + reply.send({ hello: 'world' }) + }) + done() + }, { logLevel: 'info' }) + + fastify.register(function (instance, opts, done) { + instance.get('/debug', (req, reply) => { + req.log.debug('debug') // we should see this log + req.log.trace('hidden log') + reply.send({ hello: 'world' }) + }) + done() + }, { logLevel: 'debug' }) + + await fastify.ready() + + { + const response = await fastify.inject({ method: 'GET', url: '/' }) + const body = await response.json() + t.assert.deepEqual(body, { hello: 'world' }) + } + + { + const response = await fastify.inject({ method: 'GET', url: '/info' }) + const body = await response.json() + t.assert.deepEqual(body, { hello: 'world' }) + } + + { + const response = await fastify.inject({ method: 'GET', url: '/debug' }) + const body = await response.json() + t.assert.deepEqual(body, { hello: 'world' }) + } + + for await (const [line] of on(stream, 'data')) { + t.assert.ok(line.level === 30 || line.level === 20) + t.assert.deepEqual(line.msg, lines.shift()) + if (lines.length === 0) break + } + }) + + await t.test('Should set a custom logSerializers for every plugin', async (t) => { + const lines = ['incoming request', 'Hello', 'request completed', 'incoming request', 'XHello', 'request completed', 'incoming request', 'ZHello', 'request completed'] + t.plan(lines.length + 3) + + const stream = split(JSON.parse) + + const loggerInstance = pino({ level: 'info' }, stream) + const fastify = Fastify({ + loggerInstance + }) + t.after(() => fastify.close()) + + fastify.get('/', (req, reply) => { + req.log.warn({ test: 'Hello' }) + reply.send({ hello: 'world' }) + }) + + fastify.register(function (instance, opts, done) { + instance.get('/test1', (req, reply) => { + req.log.info({ test: 'Hello' }) + reply.send({ hello: 'world' }) + }) + done() + }, { logSerializers: { test: value => 'X' + value } }) + + fastify.register(function (instance, opts, done) { + instance.get('/test2', (req, reply) => { + req.log.info({ test: 'Hello' }) + reply.send({ hello: 'world' }) + }) + done() + }, { logSerializers: { test: value => 'Z' + value } }) + + await fastify.ready() + + { + const response = await fastify.inject({ method: 'GET', url: '/' }) + const body = await response.json() + t.assert.deepEqual(body, { hello: 'world' }) + } + + { + const response = await fastify.inject({ method: 'GET', url: '/test1' }) + const body = await response.json() + t.assert.deepEqual(body, { hello: 'world' }) + } + + { + const response = await fastify.inject({ method: 'GET', url: '/test2' }) + const body = await response.json() + t.assert.deepEqual(body, { hello: 'world' }) + } + + for await (const [line] of on(stream, 'data')) { + t.assert.deepEqual(line.test || line.msg, lines.shift()) + if (lines.length === 0) break + } + }) + + await t.test('Should override serializers from route', async (t) => { + const lines = ['incoming request', 'ZHello', 'request completed'] + t.plan(lines.length + 1) + + const stream = split(JSON.parse) + + const loggerInstance = pino({ level: 'info' }, stream) + const fastify = Fastify({ + loggerInstance + }) + t.after(() => fastify.close()) + + fastify.register(function (instance, opts, done) { + instance.get('/', { + logSerializers: { + test: value => 'Z' + value // should override + } + }, (req, reply) => { + req.log.info({ test: 'Hello' }) + reply.send({ hello: 'world' }) + }) + done() + }, { logSerializers: { test: value => 'X' + value } }) + + await fastify.ready() + + { + const response = await fastify.inject({ method: 'GET', url: '/' }) + const body = await response.json() + t.assert.deepEqual(body, { hello: 'world' }) + } + + for await (const [line] of on(stream, 'data')) { + t.assert.deepEqual(line.test || line.msg, lines.shift()) + if (lines.length === 0) break + } + }) + + await t.test('Should override serializers from plugin', async (t) => { + const lines = ['incoming request', 'ZHello', 'request completed'] + t.plan(lines.length + 1) + + const stream = split(JSON.parse) + + const loggerInstance = pino({ level: 'info' }, stream) + const fastify = Fastify({ + loggerInstance + }) + t.after(() => fastify.close()) + + fastify.register(function (instance, opts, done) { + instance.register(context1, { + logSerializers: { + test: value => 'Z' + value // should override + } + }) + done() + }, { logSerializers: { test: value => 'X' + value } }) + + function context1 (instance, opts, done) { + instance.get('/', (req, reply) => { + req.log.info({ test: 'Hello' }) + reply.send({ hello: 'world' }) + }) + done() + } + + await fastify.ready() + + { + const response = await fastify.inject({ method: 'GET', url: '/' }) + const body = await response.json() + t.assert.deepEqual(body, { hello: 'world' }) + } + + for await (const [line] of on(stream, 'data')) { + t.assert.deepEqual(line.test || line.msg, lines.shift()) + if (lines.length === 0) break + } + }) + + await t.test('Should increase the log level for a specific plugin', async (t) => { + const lines = ['Hello'] + t.plan(lines.length * 2 + 1) + + const stream = split(JSON.parse) + + const loggerInstance = pino({ level: 'info' }, stream) + + const fastify = Fastify({ + loggerInstance + }) + t.after(() => fastify.close()) + + fastify.register(function (instance, opts, done) { + instance.get('/', (req, reply) => { + req.log.error('Hello') // we should see this log + reply.send({ hello: 'world' }) + }) + done() + }, { logLevel: 'error' }) + + await fastify.ready() + + { + const response = await fastify.inject({ method: 'GET', url: '/' }) + const body = await response.json() + t.assert.deepEqual(body, { hello: 'world' }) + } + + for await (const [line] of on(stream, 'data')) { + t.assert.deepEqual(line.level, 50) + t.assert.deepEqual(line.msg, lines.shift()) + if (lines.length === 0) break + } + }) + + await t.test('Should set the log level for the customized 404 handler', async (t) => { + const lines = ['Hello'] + t.plan(lines.length * 2 + 1) + + const stream = split(JSON.parse) + + const loggerInstance = pino({ level: 'warn' }, stream) + + const fastify = Fastify({ + loggerInstance + }) + t.after(() => fastify.close()) + + fastify.register(function (instance, opts, done) { + instance.setNotFoundHandler(function (req, reply) { + req.log.error('Hello') + reply.code(404).send() + }) + done() + }, { logLevel: 'error' }) + + await fastify.ready() + + { + const response = await fastify.inject({ method: 'GET', url: '/' }) + t.assert.deepEqual(response.statusCode, 404) + } + + for await (const [line] of on(stream, 'data')) { + t.assert.deepEqual(line.level, 50) + t.assert.deepEqual(line.msg, lines.shift()) + if (lines.length === 0) break + } + }) + + await t.test('Should set the log level for the customized 500 handler', async (t) => { + const lines = ['Hello'] + t.plan(lines.length * 2 + 1) + + const stream = split(JSON.parse) + + const loggerInstance = pino({ level: 'warn' }, stream) + + const fastify = Fastify({ + loggerInstance + }) + t.after(() => fastify.close()) + + fastify.register(function (instance, opts, done) { + instance.get('/', (req, reply) => { + req.log.error('kaboom') + reply.send(new Error('kaboom')) + }) + + instance.setErrorHandler(function (e, request, reply) { + reply.log.fatal('Hello') + reply.code(500).send() + }) + done() + }, { logLevel: 'fatal' }) + + await fastify.ready() + + { + const response = await fastify.inject({ method: 'GET', url: '/' }) + t.assert.deepEqual(response.statusCode, 500) + } + + for await (const [line] of on(stream, 'data')) { + t.assert.deepEqual(line.level, 60) + t.assert.deepEqual(line.msg, lines.shift()) + if (lines.length === 0) break + } + }) + + await t.test('Should set a custom log level for a specific route', async (t) => { + const lines = ['incoming request', 'Hello', 'request completed'] + t.plan(lines.length + 2) + + const stream = split(JSON.parse) + + const loggerInstance = pino({ level: 'error' }, stream) + + const fastify = Fastify({ + loggerInstance + }) + t.after(() => fastify.close()) + + fastify.get('/log', { logLevel: 'info' }, (req, reply) => { + req.log.info('Hello') + reply.send({ hello: 'world' }) + }) + + fastify.get('/no-log', (req, reply) => { + req.log.info('Hello') + reply.send({ hello: 'world' }) + }) + + await fastify.ready() + + { + const response = await fastify.inject({ method: 'GET', url: '/log' }) + const body = await response.json() + t.assert.deepEqual(body, { hello: 'world' }) + } + + { + const response = await fastify.inject({ method: 'GET', url: '/no-log' }) + const body = await response.json() + t.assert.deepEqual(body, { hello: 'world' }) + } + + for await (const [line] of on(stream, 'data')) { + t.assert.deepEqual(line.msg, lines.shift()) + if (lines.length === 0) break + } + }) + + await t.test('should pass when using unWritable props in the logger option', (t) => { + t.plan(8) + const fastify = Fastify({ + logger: Object.defineProperty({}, 'level', { value: 'info' }) + }) + t.after(() => fastify.close()) + + t.assert.deepEqual(typeof fastify.log, 'object') + t.assert.deepEqual(typeof fastify.log.fatal, 'function') + t.assert.deepEqual(typeof fastify.log.error, 'function') + t.assert.deepEqual(typeof fastify.log.warn, 'function') + t.assert.deepEqual(typeof fastify.log.info, 'function') + t.assert.deepEqual(typeof fastify.log.debug, 'function') + t.assert.deepEqual(typeof fastify.log.trace, 'function') + t.assert.deepEqual(typeof fastify.log.child, 'function') + }) + + await t.test('Should throw an error if logger instance is passed to `logger`', async (t) => { + t.plan(2) + const stream = split(JSON.parse) + + const logger = require('pino')(stream) + + try { + Fastify({ logger }) + } catch (err) { + t.assert.ok(err) + t.assert.deepEqual(err.code, 'FST_ERR_LOG_INVALID_LOGGER_CONFIG') + } + }) + + await t.test('Should throw an error if options are passed to `loggerInstance`', async (t) => { + t.plan(2) + try { + Fastify({ loggerInstance: { level: 'log' } }) + } catch (err) { + t.assert.ok(err) + t.assert.strictEqual(err.code, 'FST_ERR_LOG_INVALID_LOGGER_INSTANCE') + } + }) + + await t.test('If both `loggerInstance` and `logger` are provided, an error should be thrown', async (t) => { + t.plan(2) + const loggerInstanceStream = split(JSON.parse) + const loggerInstance = pino({ level: 'error' }, loggerInstanceStream) + const loggerStream = split(JSON.parse) + try { + Fastify({ + logger: { + stream: loggerStream, + level: 'info' + }, + loggerInstance + }) + } catch (err) { + t.assert.ok(err) + t.assert.deepEqual(err.code, 'FST_ERR_LOG_LOGGER_AND_LOGGER_INSTANCE_PROVIDED') + } + }) + + await t.test('`logger` should take pino configuration and create a pino logger', async (t) => { + const lines = ['hello', 'world'] + t.plan(2 * lines.length + 2) + const loggerStream = split(JSON.parse) + const fastify = Fastify({ + logger: { + stream: loggerStream, + level: 'error' + } + }) + t.after(() => fastify.close()) + fastify.get('/hello', (req, reply) => { + req.log.error('hello') + reply.code(404).send() + }) + + fastify.get('/world', (req, reply) => { + req.log.error('world') + reply.code(201).send() + }) + + await fastify.ready() + { + const response = await fastify.inject({ method: 'GET', url: '/hello' }) + t.assert.deepEqual(response.statusCode, 404) + } + { + const response = await fastify.inject({ method: 'GET', url: '/world' }) + t.assert.deepEqual(response.statusCode, 201) + } + + for await (const [line] of on(loggerStream, 'data')) { + t.assert.deepEqual(line.level, 50) + t.assert.deepEqual(line.msg, lines.shift()) + if (lines.length === 0) break + } + }) +}) diff --git a/test/logger/request.test.js b/test/logger/request.test.js new file mode 100644 index 00000000000..212b8d5296f --- /dev/null +++ b/test/logger/request.test.js @@ -0,0 +1,292 @@ +'use strict' + +const stream = require('node:stream') + +const t = require('node:test') +const split = require('split2') + +const Fastify = require('../../fastify') +const helper = require('../helper') +const { on } = stream +const { request } = require('./logger-test-utils') +const { partialDeepStrictEqual } = require('../toolkit') + +t.test('request', { timeout: 60000 }, async (t) => { + let localhost + + t.plan(7) + t.before(async function () { + [localhost] = await helper.getLoopbackHost() + }) + + await t.test('The request id header key can be customized', async (t) => { + const lines = ['incoming request', 'some log message', 'request completed'] + t.plan(lines.length * 2 + 2) + const REQUEST_ID = '42' + + const stream = split(JSON.parse) + const fastify = Fastify({ + logger: { stream, level: 'info' }, + requestIdHeader: 'my-custom-request-id' + }) + t.after(() => fastify.close()) + + fastify.get('/', (req, reply) => { + t.assert.strictEqual(req.id, REQUEST_ID) + req.log.info('some log message') + reply.send({ id: req.id }) + }) + + const response = await fastify.inject({ method: 'GET', url: '/', headers: { 'my-custom-request-id': REQUEST_ID } }) + const body = await response.json() + t.assert.strictEqual(body.id, REQUEST_ID) + + for await (const [line] of on(stream, 'data')) { + t.assert.strictEqual(line.reqId, REQUEST_ID) + t.assert.strictEqual(line.msg, lines.shift(), 'message is set') + if (lines.length === 0) break + } + }) + + await t.test('The request id header key can be ignored', async (t) => { + const lines = ['incoming request', 'some log message', 'request completed'] + t.plan(lines.length * 2 + 2) + const REQUEST_ID = 'ignore-me' + + const stream = split(JSON.parse) + const fastify = Fastify({ + logger: { stream, level: 'info' }, + requestIdHeader: false + }) + t.after(() => fastify.close()) + + fastify.get('/', (req, reply) => { + t.assert.strictEqual(req.id, 'req-1') + req.log.info('some log message') + reply.send({ id: req.id }) + }) + const response = await fastify.inject({ method: 'GET', url: '/', headers: { 'request-id': REQUEST_ID } }) + const body = await response.json() + t.assert.strictEqual(body.id, 'req-1') + + for await (const [line] of on(stream, 'data')) { + t.assert.strictEqual(line.reqId, 'req-1') + t.assert.strictEqual(line.msg, lines.shift(), 'message is set') + if (lines.length === 0) break + } + }) + + await t.test('The request id header key can be customized along with a custom id generator', async (t) => { + const REQUEST_ID = '42' + const matches = [ + { reqId: REQUEST_ID, msg: 'incoming request' }, + { reqId: REQUEST_ID, msg: 'some log message' }, + { reqId: REQUEST_ID, msg: 'request completed' }, + { reqId: 'foo', msg: 'incoming request' }, + { reqId: 'foo', msg: 'some log message 2' }, + { reqId: 'foo', msg: 'request completed' } + ] + t.plan(matches.length + 4) + + const stream = split(JSON.parse) + const fastify = Fastify({ + logger: { stream, level: 'info' }, + requestIdHeader: 'my-custom-request-id', + genReqId (req) { + return 'foo' + } + }) + t.after(() => fastify.close()) + + fastify.get('/one', (req, reply) => { + t.assert.strictEqual(req.id, REQUEST_ID) + req.log.info('some log message') + reply.send({ id: req.id }) + }) + + fastify.get('/two', (req, reply) => { + t.assert.strictEqual(req.id, 'foo') + req.log.info('some log message 2') + reply.send({ id: req.id }) + }) + + { + const response = await fastify.inject({ method: 'GET', url: '/one', headers: { 'my-custom-request-id': REQUEST_ID } }) + const body = await response.json() + t.assert.strictEqual(body.id, REQUEST_ID) + } + + { + const response = await fastify.inject({ method: 'GET', url: '/two' }) + const body = await response.json() + t.assert.strictEqual(body.id, 'foo') + } + + for await (const [line] of on(stream, 'data')) { + t.assert.ok(partialDeepStrictEqual(line, matches.shift())) + if (matches.length === 0) break + } + }) + + await t.test('The request id header key can be ignored along with a custom id generator', async (t) => { + const REQUEST_ID = 'ignore-me' + const matches = [ + { reqId: 'foo', msg: 'incoming request' }, + { reqId: 'foo', msg: 'some log message' }, + { reqId: 'foo', msg: 'request completed' }, + { reqId: 'foo', msg: 'incoming request' }, + { reqId: 'foo', msg: 'some log message 2' }, + { reqId: 'foo', msg: 'request completed' } + ] + t.plan(matches.length + 4) + + const stream = split(JSON.parse) + const fastify = Fastify({ + logger: { stream, level: 'info' }, + requestIdHeader: false, + genReqId (req) { + return 'foo' + } + }) + t.after(() => fastify.close()) + + fastify.get('/one', (req, reply) => { + t.assert.strictEqual(req.id, 'foo') + req.log.info('some log message') + reply.send({ id: req.id }) + }) + + fastify.get('/two', (req, reply) => { + t.assert.strictEqual(req.id, 'foo') + req.log.info('some log message 2') + reply.send({ id: req.id }) + }) + + { + const response = await fastify.inject({ method: 'GET', url: '/one', headers: { 'request-id': REQUEST_ID } }) + const body = await response.json() + t.assert.strictEqual(body.id, 'foo') + } + + { + const response = await fastify.inject({ method: 'GET', url: '/two' }) + const body = await response.json() + t.assert.strictEqual(body.id, 'foo') + } + + for await (const [line] of on(stream, 'data')) { + t.assert.ok(partialDeepStrictEqual(line, matches.shift())) + if (matches.length === 0) break + } + }) + + await t.test('The request id log label can be changed', async (t) => { + const REQUEST_ID = '42' + const matches = [ + { traceId: REQUEST_ID, msg: 'incoming request' }, + { traceId: REQUEST_ID, msg: 'some log message' }, + { traceId: REQUEST_ID, msg: 'request completed' } + ] + t.plan(matches.length + 2) + + const stream = split(JSON.parse) + const fastify = Fastify({ + logger: { stream, level: 'info' }, + requestIdHeader: 'my-custom-request-id', + requestIdLogLabel: 'traceId' + }) + t.after(() => fastify.close()) + + fastify.get('/one', (req, reply) => { + t.assert.strictEqual(req.id, REQUEST_ID) + req.log.info('some log message') + reply.send({ id: req.id }) + }) + + { + const response = await fastify.inject({ method: 'GET', url: '/one', headers: { 'my-custom-request-id': REQUEST_ID } }) + const body = await response.json() + t.assert.strictEqual(body.id, REQUEST_ID) + } + + for await (const [line] of on(stream, 'data')) { + t.assert.ok(partialDeepStrictEqual(line, matches.shift())) + if (matches.length === 0) break + } + }) + + await t.test('should redact the authorization header if so specified', async (t) => { + const stream = split(JSON.parse) + const fastify = Fastify({ + logger: { + stream, + redact: ['req.headers.authorization'], + level: 'info', + serializers: { + req (req) { + return { + method: req.method, + url: req.url, + headers: req.headers, + hostname: req.hostname, + remoteAddress: req.ip, + remotePort: req.socket.remotePort + } + } + } + } + }) + t.after(() => fastify.close()) + + fastify.get('/', function (req, reply) { + t.assert.deepStrictEqual(req.headers.authorization, 'Bearer abcde') + reply.send({ hello: 'world' }) + }) + + await fastify.ready() + const server = await fastify.listen({ port: 0, host: localhost }) + + const lines = [ + { msg: `Server listening at ${server}` }, + { req: { headers: { authorization: '[Redacted]' } }, msg: 'incoming request' }, + { res: { statusCode: 200 }, msg: 'request completed' } + ] + t.plan(lines.length + 3) + + await request({ + method: 'GET', + path: '/', + host: localhost, + port: fastify.server.address().port, + headers: { + authorization: 'Bearer abcde' + } + }, function (response, body) { + t.assert.strictEqual(response.statusCode, 200) + t.assert.deepStrictEqual(body, JSON.stringify({ hello: 'world' })) + }) + + for await (const [line] of on(stream, 'data')) { + t.assert.ok(partialDeepStrictEqual(line, lines.shift())) + if (lines.length === 0) break + } + }) + + await t.test('should not throw error when serializing custom req', (t) => { + t.plan(1) + + const lines = [] + const dest = new stream.Writable({ + write: function (chunk, enc, cb) { + lines.push(JSON.parse(chunk)) + cb() + } + }) + const fastify = Fastify({ logger: { level: 'info', stream: dest } }) + t.after(() => fastify.close()) + + fastify.log.info({ req: {} }) + + t.assert.deepStrictEqual(lines[0].req, {}) + }) +}) diff --git a/test/logger/response.test.js b/test/logger/response.test.js new file mode 100644 index 00000000000..227f5883fa3 --- /dev/null +++ b/test/logger/response.test.js @@ -0,0 +1,183 @@ +'use strict' + +const stream = require('node:stream') + +const t = require('node:test') +const split = require('split2') +const pino = require('pino') + +const Fastify = require('../../fastify') +const { partialDeepStrictEqual } = require('../toolkit') +const { on } = stream + +t.test('response serialization', { timeout: 60000 }, async (t) => { + t.plan(4) + + await t.test('Should use serializers from plugin and route', async (t) => { + const lines = [ + { msg: 'incoming request' }, + { test: 'XHello', test2: 'ZHello' }, + { msg: 'request completed' } + ] + t.plan(lines.length + 1) + + const stream = split(JSON.parse) + + const loggerInstance = pino({ level: 'info' }, stream) + const fastify = Fastify({ + loggerInstance + }) + t.after(() => fastify.close()) + + fastify.register(context1, { + logSerializers: { test: value => 'X' + value } + }) + + function context1 (instance, opts, done) { + instance.get('/', { + logSerializers: { + test2: value => 'Z' + value + } + }, (req, reply) => { + req.log.info({ test: 'Hello', test2: 'Hello' }) // { test: 'XHello', test2: 'ZHello' } + reply.send({ hello: 'world' }) + }) + done() + } + + await fastify.ready() + + { + const response = await fastify.inject({ method: 'GET', url: '/' }) + const body = await response.json() + t.assert.deepStrictEqual(body, { hello: 'world' }) + } + + for await (const [line] of on(stream, 'data')) { + t.assert.ok(partialDeepStrictEqual(line, lines.shift())) + if (lines.length === 0) break + } + }) + + await t.test('Should use serializers from instance fastify and route', async (t) => { + const lines = [ + { msg: 'incoming request' }, + { test: 'XHello', test2: 'ZHello' }, + { msg: 'request completed' } + ] + t.plan(lines.length + 1) + + const stream = split(JSON.parse) + + const loggerInstance = pino({ + level: 'info', + serializers: { + test: value => 'X' + value, + test2: value => 'This should be override - ' + value + } + }, stream) + const fastify = Fastify({ + loggerInstance + }) + t.after(() => fastify.close()) + + fastify.get('/', { + logSerializers: { + test2: value => 'Z' + value + } + }, (req, reply) => { + req.log.info({ test: 'Hello', test2: 'Hello' }) // { test: 'XHello', test2: 'ZHello' } + reply.send({ hello: 'world' }) + }) + + await fastify.ready() + + { + const response = await fastify.inject({ method: 'GET', url: '/' }) + const body = await response.json() + t.assert.deepStrictEqual(body, { hello: 'world' }) + } + + for await (const [line] of on(stream, 'data')) { + t.assert.ok(partialDeepStrictEqual(line, lines.shift())) + if (lines.length === 0) break + } + }) + + await t.test('Should use serializers inherit from contexts', async (t) => { + const lines = [ + { msg: 'incoming request' }, + { test: 'XHello', test2: 'YHello', test3: 'ZHello' }, + { msg: 'request completed' } + ] + t.plan(lines.length + 1) + + const stream = split(JSON.parse) + + const loggerInstance = pino({ + level: 'info', + serializers: { + test: value => 'X' + value + } + }, stream) + + const fastify = Fastify({ loggerInstance }) + t.after(() => fastify.close()) + + fastify.register(context1, { logSerializers: { test2: value => 'Y' + value } }) + + function context1 (instance, opts, done) { + instance.get('/', { + logSerializers: { + test3: value => 'Z' + value + } + }, (req, reply) => { + req.log.info({ test: 'Hello', test2: 'Hello', test3: 'Hello' }) // { test: 'XHello', test2: 'YHello', test3: 'ZHello' } + reply.send({ hello: 'world' }) + }) + done() + } + + await fastify.ready() + + { + const response = await fastify.inject({ method: 'GET', url: '/' }) + const body = await response.json() + t.assert.deepStrictEqual(body, { hello: 'world' }) + } + + for await (const [line] of on(stream, 'data')) { + t.assert.ok(partialDeepStrictEqual(line, lines.shift())) + if (lines.length === 0) break + } + }) + + await t.test('should serialize request and response', async (t) => { + const lines = [ + { req: { method: 'GET', url: '/500' }, msg: 'incoming request' }, + { req: { method: 'GET', url: '/500' }, msg: '500 error' }, + { msg: 'request completed' } + ] + t.plan(lines.length + 1) + + const stream = split(JSON.parse) + const fastify = Fastify({ logger: { level: 'info', stream } }) + t.after(() => fastify.close()) + + fastify.get('/500', (req, reply) => { + reply.code(500).send(Error('500 error')) + }) + + await fastify.ready() + + { + const response = await fastify.inject({ method: 'GET', url: '/500' }) + t.assert.strictEqual(response.statusCode, 500) + } + + for await (const [line] of on(stream, 'data')) { + t.assert.ok(partialDeepStrictEqual(line, lines.shift())) + if (lines.length === 0) break + } + }) +}) diff --git a/test/logger/tap-parallel-not-ok b/test/logger/tap-parallel-not-ok new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/max-requests-per-socket.test.js b/test/max-requests-per-socket.test.js new file mode 100644 index 00000000000..d27adbbf18b --- /dev/null +++ b/test/max-requests-per-socket.test.js @@ -0,0 +1,113 @@ +'use strict' + +const net = require('node:net') +const { test } = require('node:test') +const Fastify = require('..') + +test('maxRequestsPerSocket', (t, done) => { + t.plan(8) + + const fastify = Fastify({ maxRequestsPerSocket: 2 }) + fastify.get('/', (req, reply) => { + reply.send({ hello: 'world' }) + }) + + fastify.listen({ port: 0 }, function (err) { + t.assert.ifError(err) + + const port = fastify.server.address().port + const client = net.createConnection({ port }, () => { + client.write('GET / HTTP/1.1\r\nHost: fastify.test\r\n\r\n') + + client.once('data', data => { + t.assert.match(data.toString(), /Connection:\s*keep-alive/i) + t.assert.match(data.toString(), /Keep-Alive:\s*timeout=\d+/i) + t.assert.match(data.toString(), /200 OK/i) + + client.write('GET / HTTP/1.1\r\nHost: fastify.test\r\n\r\n') + + client.once('data', data => { + t.assert.match(data.toString(), /Connection:\s*close/i) + t.assert.match(data.toString(), /200 OK/i) + + client.write('GET / HTTP/1.1\r\nHost: fastify.test\r\n\r\n') + + client.once('data', data => { + t.assert.match(data.toString(), /Connection:\s*close/i) + t.assert.match(data.toString(), /503 Service Unavailable/i) + client.end() + fastify.close() + done() + }) + }) + }) + }) + }) +}) + +test('maxRequestsPerSocket zero should behave same as null', (t, done) => { + t.plan(10) + + const fastify = Fastify({ maxRequestsPerSocket: 0 }) + fastify.get('/', (req, reply) => { + reply.send({ hello: 'world' }) + }) + + fastify.listen({ port: 0 }, function (err) { + t.assert.ifError(err) + + const port = fastify.server.address().port + const client = net.createConnection({ port }, () => { + client.write('GET / HTTP/1.1\r\nHost: fastify.test\r\n\r\n') + + client.once('data', data => { + t.assert.match(data.toString(), /Connection:\s*keep-alive/i) + t.assert.match(data.toString(), /Keep-Alive:\s*timeout=\d+/i) + t.assert.match(data.toString(), /200 OK/i) + + client.write('GET / HTTP/1.1\r\nHost: fastify.test\r\n\r\n') + + client.once('data', data => { + t.assert.match(data.toString(), /Connection:\s*keep-alive/i) + t.assert.match(data.toString(), /Keep-Alive:\s*timeout=\d+/i) + t.assert.match(data.toString(), /200 OK/i) + + client.write('GET / HTTP/1.1\r\nHost: fastify.test\r\n\r\n') + + client.once('data', data => { + t.assert.match(data.toString(), /Connection:\s*keep-alive/i) + t.assert.match(data.toString(), /Keep-Alive:\s*timeout=\d+/i) + t.assert.match(data.toString(), /200 OK/i) + client.end() + fastify.close() + done() + }) + }) + }) + }) + }) +}) + +test('maxRequestsPerSocket should be set', async (t) => { + t.plan(1) + + const initialConfig = Fastify({ maxRequestsPerSocket: 5 }).initialConfig + t.assert.deepStrictEqual(initialConfig.maxRequestsPerSocket, 5) +}) + +test('maxRequestsPerSocket should 0', async (t) => { + t.plan(1) + + const initialConfig = Fastify().initialConfig + t.assert.deepStrictEqual(initialConfig.maxRequestsPerSocket, 0) +}) + +test('requestTimeout passed to server', t => { + t.plan(2) + + const httpServer = Fastify({ maxRequestsPerSocket: 5 }).server + t.assert.strictEqual(httpServer.maxRequestsPerSocket, 5) + + const httpsServer = Fastify({ maxRequestsPerSocket: 5, https: true }).server + t.assert.strictEqual(httpsServer.maxRequestsPerSocket, 5) +}) diff --git a/test/maxRequestsPerSocket.test.js b/test/maxRequestsPerSocket.test.js deleted file mode 100644 index 1b9887a8d86..00000000000 --- a/test/maxRequestsPerSocket.test.js +++ /dev/null @@ -1,116 +0,0 @@ -'use strict' - -const net = require('net') -const { test } = require('tap') -const semver = require('semver') -const Fastify = require('../fastify') - -const skip = semver.lt(process.versions.node, '16.10.0') - -test('maxRequestsPerSocket on node version >= 16.10.0', { skip }, t => { - t.plan(8) - - const fastify = Fastify({ maxRequestsPerSocket: 2 }) - fastify.get('/', (req, reply) => { - reply.send({ hello: 'world' }) - }) - - t.teardown(fastify.close.bind(fastify)) - - fastify.listen({ port: 0 }, function (err) { - t.error(err) - - const port = fastify.server.address().port - const client = net.createConnection({ port }, () => { - client.write('GET / HTTP/1.1\r\n\r\n') - - client.once('data', data => { - t.match(data.toString(), /Connection:\s*keep-alive/i) - t.match(data.toString(), /Keep-Alive:\s*timeout=\d+/i) - t.match(data.toString(), /200 OK/i) - - client.write('GET / HTTP/1.1\r\n\r\n') - - client.once('data', data => { - t.match(data.toString(), /Connection:\s*close/i) - t.match(data.toString(), /200 OK/i) - - client.write('GET / HTTP/1.1\r\n\r\n') - - client.once('data', data => { - t.match(data.toString(), /Connection:\s*close/i) - t.match(data.toString(), /503 Service Unavailable/i) - client.end() - }) - }) - }) - }) - }) -}) - -test('maxRequestsPerSocket zero should behave same as null', { skip }, t => { - t.plan(10) - - const fastify = Fastify({ maxRequestsPerSocket: 0 }) - fastify.get('/', (req, reply) => { - reply.send({ hello: 'world' }) - }) - - t.teardown(fastify.close.bind(fastify)) - - fastify.listen({ port: 0 }, function (err) { - t.error(err) - - const port = fastify.server.address().port - const client = net.createConnection({ port }, () => { - client.write('GET / HTTP/1.1\r\n\r\n') - - client.once('data', data => { - t.match(data.toString(), /Connection:\s*keep-alive/i) - t.match(data.toString(), /Keep-Alive:\s*timeout=\d+/i) - t.match(data.toString(), /200 OK/i) - - client.write('GET / HTTP/1.1\r\n\r\n') - - client.once('data', data => { - t.match(data.toString(), /Connection:\s*keep-alive/i) - t.match(data.toString(), /Keep-Alive:\s*timeout=\d+/i) - t.match(data.toString(), /200 OK/i) - - client.write('GET / HTTP/1.1\r\n\r\n') - - client.once('data', data => { - t.match(data.toString(), /Connection:\s*keep-alive/i) - t.match(data.toString(), /Keep-Alive:\s*timeout=\d+/i) - t.match(data.toString(), /200 OK/i) - client.end() - }) - }) - }) - }) - }) -}) - -test('maxRequestsPerSocket should be set', async (t) => { - t.plan(1) - - const initialConfig = Fastify({ maxRequestsPerSocket: 5 }).initialConfig - t.same(initialConfig.maxRequestsPerSocket, 5) -}) - -test('maxRequestsPerSocket should 0', async (t) => { - t.plan(1) - - const initialConfig = Fastify().initialConfig - t.same(initialConfig.maxRequestsPerSocket, 0) -}) - -test('requestTimeout passed to server', t => { - t.plan(2) - - const httpServer = Fastify({ maxRequestsPerSocket: 5 }).server - t.equal(httpServer.maxRequestsPerSocket, 5) - - const httpsServer = Fastify({ maxRequestsPerSocket: 5, https: true }).server - t.equal(httpsServer.maxRequestsPerSocket, 5) -}) diff --git a/test/middleware.test.js b/test/middleware.test.js index 990c505d22c..05da9566735 100644 --- a/test/middleware.test.js +++ b/test/middleware.test.js @@ -1,6 +1,6 @@ 'use strict' -const { test } = require('tap') +const { test } = require('node:test') const Fastify = require('..') const { FST_ERR_DEC_ALREADY_PRESENT @@ -10,7 +10,7 @@ test('Should be able to override the default use API', t => { t.plan(1) const fastify = Fastify() fastify.decorate('use', () => true) - t.equal(fastify.use(), true) + t.assert.strictEqual(fastify.use(), true) }) test('Cannot decorate use twice', t => { @@ -20,17 +20,16 @@ test('Cannot decorate use twice', t => { try { fastify.decorate('use', () => true) } catch (err) { - t.ok(err instanceof FST_ERR_DEC_ALREADY_PRESENT) + t.assert.ok(err instanceof FST_ERR_DEC_ALREADY_PRESENT) } }) test('Encapsulation works', t => { - t.plan(1) const fastify = Fastify() fastify.register((instance, opts, done) => { instance.decorate('use', () => true) - t.equal(instance.use(), true) + t.assert.strictEqual(instance.use(), true) done() }) diff --git a/test/noop-set.test.js b/test/noop-set.test.js index 44f0b5fad99..8b56a14eb27 100644 --- a/test/noop-set.test.js +++ b/test/noop-set.test.js @@ -1,19 +1,19 @@ 'use strict' -const tap = require('tap') +const { test } = require('node:test') const noopSet = require('../lib/noop-set') -tap.test('does a lot of nothing', async t => { +test('does a lot of nothing', async t => { const aSet = noopSet() - t.type(aSet, 'object') + t.assert.ok(aSet, 'object') const item = {} aSet.add(item) aSet.add({ another: 'item' }) aSet.delete(item) - t.equal(aSet.has(item), true) + t.assert.strictEqual(aSet.has(item), true) for (const i of aSet) { - t.fail('should not have any items', i) + t.assert.fail('should not have any items: ' + i) } }) diff --git a/test/nullable-validation.test.js b/test/nullable-validation.test.js index 2c9da1094ff..456e7a72ac9 100644 --- a/test/nullable-validation.test.js +++ b/test/nullable-validation.test.js @@ -1,18 +1,16 @@ 'use strict' -const t = require('tap') -const test = t.test -const sget = require('simple-get').concat +const { test } = require('node:test') const Fastify = require('..') -test('nullable string', t => { +test('nullable string', (t, done) => { t.plan(3) const fastify = Fastify() fastify.route({ method: 'POST', url: '/', handler: (req, reply) => { - t.same(req.body.hello, null) + t.assert.strictEqual(req.body.hello, null) reply.code(200).send(req.body) }, schema: { @@ -47,13 +45,14 @@ test('nullable string', t => { hello: null } }, (err, res) => { - t.error(err) - t.same(res.payload.hello, null) + t.assert.ifError(err) + t.assert.strictEqual(res.json().hello, null) + done() }) }) -test('object or null body', t => { - t.plan(5) +test('object or null body', async (t) => { + t.plan(4) const fastify = Fastify() @@ -61,7 +60,7 @@ test('object or null body', t => { method: 'POST', url: '/', handler: (req, reply) => { - t.equal(req.body, undefined) + t.assert.strictEqual(req.body, undefined) reply.code(200).send({ isUndefinedBody: req.body === undefined }) }, schema: { @@ -88,23 +87,20 @@ test('object or null body', t => { } }) - fastify.listen({ port: 0 }, (err) => { - t.error(err) - t.teardown(() => { fastify.close() }) - - sget({ - method: 'POST', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(JSON.parse(body), { isUndefinedBody: true }) - }) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) + + const result = await fetch(fastifyServer, { + method: 'POST' }) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.deepStrictEqual(await result.json(), { isUndefinedBody: true }) }) -test('nullable body', t => { - t.plan(5) +test('nullable body', async (t) => { + t.plan(4) const fastify = Fastify() @@ -112,7 +108,7 @@ test('nullable body', t => { method: 'POST', url: '/', handler: (req, reply) => { - t.equal(req.body, undefined) + t.assert.strictEqual(req.body, undefined) reply.code(200).send({ isUndefinedBody: req.body === undefined }) }, schema: { @@ -140,23 +136,20 @@ test('nullable body', t => { } }) - fastify.listen({ port: 0 }, (err) => { - t.error(err) - t.teardown(() => { fastify.close() }) - - sget({ - method: 'POST', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(JSON.parse(body), { isUndefinedBody: true }) - }) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const result = await fetch(fastifyServer, { + method: 'POST' }) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.deepStrictEqual(await result.json(), { isUndefinedBody: true }) }) -test('Nullable body with 204', t => { - t.plan(5) +test('Nullable body with 204', async (t) => { + t.plan(4) const fastify = Fastify() @@ -164,7 +157,7 @@ test('Nullable body with 204', t => { method: 'POST', url: '/', handler: (req, reply) => { - t.equal(req.body, undefined) + t.assert.strictEqual(req.body, undefined) reply.code(204).send() }, schema: { @@ -181,17 +174,14 @@ test('Nullable body with 204', t => { } }) - fastify.listen({ port: 0 }, (err) => { - t.error(err) - t.teardown(() => { fastify.close() }) - - sget({ - method: 'POST', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 204) - t.equal(body.length, 0) - }) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const result = await fetch(fastifyServer, { + method: 'POST' }) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 204) + t.assert.strictEqual((await result.text()).length, 0) }) diff --git a/test/options.error-handler.test.js b/test/options.error-handler.test.js index efe6ed34c79..5dfb3987316 100644 --- a/test/options.error-handler.test.js +++ b/test/options.error-handler.test.js @@ -1,5 +1,5 @@ 'use strict' -const t = require('tap') +const t = require('node:test') require('./helper').payloadMethod('options', t, true) require('./input-validation').payloadMethod('options', t) diff --git a/test/options.test.js b/test/options.test.js index 4799468d1d3..0f891513acd 100644 --- a/test/options.test.js +++ b/test/options.test.js @@ -1,5 +1,5 @@ 'use strict' -const t = require('tap') +const t = require('node:test') require('./helper').payloadMethod('options', t) require('./input-validation').payloadMethod('options', t) diff --git a/test/output-validation.test.js b/test/output-validation.test.js index 69d99ebe494..91ad938de73 100644 --- a/test/output-validation.test.js +++ b/test/output-validation.test.js @@ -1,8 +1,6 @@ 'use strict' -const t = require('tap') -const test = t.test -const sget = require('simple-get').concat +const { test } = require('node:test') const fastify = require('..')() const opts = { @@ -34,9 +32,9 @@ test('shorthand - output string', t => { fastify.get('/string', opts, function (req, reply) { reply.code(200).send({ hello: 'world' }) }) - t.pass() + t.assert.ok(true) } catch (e) { - t.fail() + t.assert.fail() } }) @@ -46,9 +44,9 @@ test('shorthand - output number', t => { fastify.get('/number', opts, function (req, reply) { reply.code(201).send({ hello: 55 }) }) - t.pass() + t.assert.ok(true) } catch (e) { - t.fail() + t.assert.fail() } }) @@ -59,9 +57,9 @@ test('wrong object for schema - output', t => { // will send { } reply.code(201).send({ hello: 'world' }) }) - t.pass() + t.assert.ok(true) } catch (e) { - t.fail() + t.assert.fail() } }) @@ -72,9 +70,9 @@ test('empty response', t => { fastify.get('/empty', opts, function (req, reply) { reply.code(204).send() }) - t.pass() + t.assert.ok(true) } catch (e) { - t.fail() + t.assert.fail() } }) @@ -84,80 +82,59 @@ test('unlisted response code', t => { fastify.get('/400', opts, function (req, reply) { reply.code(400).send({ hello: 'DOOM' }) }) - t.pass() + t.assert.ok(true) } catch (e) { - t.fail() + t.assert.fail() } }) -fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) +test('start server and run tests', async (t) => { + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) - test('shorthand - string get ok', t => { - t.plan(4) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/string' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) + await test('shorthand - string get ok', async (t) => { + const result = await fetch(fastifyServer + '/string') + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { hello: 'world' }) }) - test('shorthand - number get ok', t => { - t.plan(4) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/number' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 201) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 55 }) - }) + await test('shorthand - number get ok', async (t) => { + const result = await fetch(fastifyServer + '/number') + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 201) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { hello: 55 }) }) - test('shorthand - wrong-object-for-schema', t => { - t.plan(4) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/wrong-object-for-schema' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 500) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { - statusCode: 500, - error: 'Internal Server Error', - message: 'The value "world" cannot be converted to a number.' - }) + await test('shorthand - wrong-object-for-schema', async (t) => { + const result = await fetch(fastifyServer + '/wrong-object-for-schema') + t.assert.ok(!result.ok) + t.assert.strictEqual(result.status, 500) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { + statusCode: 500, + error: 'Internal Server Error', + message: 'The value "world" cannot be converted to a number.' }) }) - test('shorthand - empty', t => { - t.plan(2) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/empty' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 204) - }) + await test('shorthand - empty', async (t) => { + const result = await fetch(fastifyServer + '/empty') + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 204) }) - test('shorthand - 400', t => { - t.plan(4) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/400' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 400) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'DOOM' }) - }) + await test('shorthand - 400', async (t) => { + const result = await fetch(fastifyServer + '/400') + t.assert.ok(!result.ok) + t.assert.strictEqual(result.status, 400) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { hello: 'DOOM' }) }) }) diff --git a/test/patch.error-handler.test.js b/test/patch.error-handler.test.js index 7295bf79d64..fed052cf206 100644 --- a/test/patch.error-handler.test.js +++ b/test/patch.error-handler.test.js @@ -1,5 +1,5 @@ 'use strict' -const t = require('tap') +const t = require('node:test') require('./helper').payloadMethod('patch', t, true) require('./input-validation').payloadMethod('patch', t) diff --git a/test/patch.test.js b/test/patch.test.js index cec8a816cf8..aa61beec39b 100644 --- a/test/patch.test.js +++ b/test/patch.test.js @@ -1,5 +1,5 @@ 'use strict' -const t = require('tap') +const t = require('node:test') require('./helper').payloadMethod('patch', t) require('./input-validation').payloadMethod('patch', t) diff --git a/test/plugin.1.test.js b/test/plugin.1.test.js new file mode 100644 index 00000000000..34027aa3d03 --- /dev/null +++ b/test/plugin.1.test.js @@ -0,0 +1,230 @@ +'use strict' + +const t = require('node:test') +const test = t.test +const Fastify = require('../fastify') +const fp = require('fastify-plugin') + +test('require a plugin', (t, testDone) => { + t.plan(1) + const fastify = Fastify() + fastify.register(require('./plugin.helper')) + fastify.ready(() => { + t.assert.ok(fastify.test) + testDone() + }) +}) + +test('plugin metadata - ignore prefix', (t, testDone) => { + t.plan(2) + const fastify = Fastify() + + plugin[Symbol.for('skip-override')] = true + fastify.register(plugin, { prefix: 'foo' }) + + fastify.inject({ + method: 'GET', + url: '/' + }, function (err, res) { + t.assert.ifError(err) + t.assert.strictEqual(res.payload, 'hello') + testDone() + }) + + function plugin (instance, opts, done) { + instance.get('/', function (request, reply) { + reply.send('hello') + }) + done() + } +}) + +test('plugin metadata - naming plugins', async t => { + t.plan(2) + const fastify = Fastify() + + fastify.register(require('./plugin.name.display')) + fastify.register(function (fastify, opts, done) { + // one line + t.assert.strictEqual(fastify.pluginName, 'function (fastify, opts, done) { -- // one line') + done() + }) + fastify.register(function fooBar (fastify, opts, done) { + t.assert.strictEqual(fastify.pluginName, 'fooBar') + done() + }) + + await fastify.ready() +}) + +test('fastify.register with fastify-plugin should not encapsulate his code', async t => { + t.plan(9) + const fastify = Fastify() + + fastify.register((instance, opts, done) => { + instance.register(fp((i, o, n) => { + i.decorate('test', () => {}) + t.assert.ok(i.test) + n() + })) + + t.assert.ok(!instance.test) + + // the decoration is added at the end + instance.after(() => { + t.assert.ok(instance.test) + }) + + instance.get('/', (req, reply) => { + t.assert.ok(instance.test) + reply.send({ hello: 'world' }) + }) + + done() + }) + + fastify.ready(() => { + t.assert.ok(!fastify.test) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) + + const result = await fetch(fastifyServer) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { hello: 'world' }) +}) + +test('fastify.register with fastify-plugin should provide access to external fastify instance if opts argument is a function', async t => { + t.plan(21) + const fastify = Fastify() + + fastify.register((instance, opts, done) => { + instance.register(fp((i, o, n) => { + i.decorate('global', () => {}) + t.assert.ok(i.global) + n() + })) + + instance.register((i, o, n) => n(), p => { + t.assert.ok(!(p === instance || p === fastify)) + t.assert.ok(Object.prototype.isPrototypeOf.call(instance, p)) + t.assert.ok(Object.prototype.isPrototypeOf.call(fastify, p)) + t.assert.ok(p.global) + }) + + instance.register((i, o, n) => { + i.decorate('local', () => {}) + n() + }) + + instance.register((i, o, n) => n(), p => t.assert.ok(!p.local)) + + instance.register((i, o, n) => { + t.assert.ok(i.local) + n() + }, p => p.decorate('local', () => {})) + + instance.register((i, o, n) => n(), p => t.assert.ok(!p.local)) + + instance.register(fp((i, o, n) => { + t.assert.ok(i.global_2) + n() + }), p => p.decorate('global_2', () => 'hello')) + + instance.register((i, o, n) => { + i.decorate('global_2', () => 'world') + n() + }, p => p.get('/', (req, reply) => { + t.assert.ok(p.global_2) + reply.send({ hello: p.global_2() }) + })) + + t.assert.ok(!instance.global) + t.assert.ok(!instance.global_2) + t.assert.ok(!instance.local) + + // the decoration is added at the end + instance.after(() => { + t.assert.ok(instance.global) + t.assert.strictEqual(instance.global_2(), 'hello') + t.assert.ok(!instance.local) + }) + + done() + }) + + fastify.ready(() => { + t.assert.ok(!fastify.global) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) + + const result = await fetch(fastifyServer) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { hello: 'world' }) +}) + +test('fastify.register with fastify-plugin registers fastify level plugins', async t => { + t.plan(14) + const fastify = Fastify() + + function fastifyPlugin (instance, opts, done) { + instance.decorate('test', 'first') + t.assert.ok(instance.test) + done() + } + + function innerPlugin (instance, opts, done) { + instance.decorate('test2', 'second') + done() + } + + fastify.register(fp(fastifyPlugin)) + + fastify.register((instance, opts, done) => { + t.assert.ok(instance.test) + instance.register(fp(innerPlugin)) + + instance.get('/test2', (req, reply) => { + t.assert.ok(instance.test2) + reply.send({ test2: instance.test2 }) + }) + + done() + }) + + fastify.ready(() => { + t.assert.ok(fastify.test) + t.assert.ok(!fastify.test2) + }) + + fastify.get('/', (req, reply) => { + t.assert.ok(fastify.test) + reply.send({ test: fastify.test }) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) + + const result1 = await fetch(fastifyServer) + t.assert.ok(result1.ok) + t.assert.strictEqual(result1.status, 200) + const body1 = await result1.text() + t.assert.strictEqual(result1.headers.get('content-length'), '' + body1.length) + t.assert.deepStrictEqual(JSON.parse(body1), { test: 'first' }) + + const result2 = await fetch(fastifyServer + '/test2') + t.assert.ok(result2.ok) + t.assert.strictEqual(result2.status, 200) + const body2 = await result2.text() + t.assert.strictEqual(result2.headers.get('content-length'), '' + body2.length) + t.assert.deepStrictEqual(JSON.parse(body2), { test2: 'second' }) +}) diff --git a/test/plugin.2.test.js b/test/plugin.2.test.js new file mode 100644 index 00000000000..a073995d843 --- /dev/null +++ b/test/plugin.2.test.js @@ -0,0 +1,314 @@ +'use strict' + +const { test } = require('node:test') +const Fastify = require('../fastify') +const fp = require('fastify-plugin') + +test('check dependencies - should not throw', async t => { + t.plan(11) + const fastify = Fastify() + + fastify.register((instance, opts, done) => { + instance.register(fp((i, o, n) => { + i.decorate('test', () => {}) + t.assert.ok(i.test) + n() + })) + + instance.register(fp((i, o, n) => { + try { + i.decorate('otherTest', () => {}, ['test']) + t.assert.ok(i.test) + t.assert.ok(i.otherTest) + n() + } catch (e) { + t.assert.fail() + } + })) + + instance.get('/', (req, reply) => { + t.assert.ok(instance.test) + t.assert.ok(instance.otherTest) + reply.send({ hello: 'world' }) + }) + + done() + }) + + fastify.ready(() => { + t.assert.ok(!fastify.test) + t.assert.ok(!fastify.otherTest) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) + + const result = await fetch(fastifyServer) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { hello: 'world' }) +}) + +test('check dependencies - should throw', async t => { + t.plan(11) + const fastify = Fastify() + + fastify.register((instance, opts, done) => { + instance.register(fp((i, o, n) => { + try { + i.decorate('otherTest', () => {}, ['test']) + t.assert.fail() + } catch (e) { + t.assert.strictEqual(e.code, 'FST_ERR_DEC_MISSING_DEPENDENCY') + t.assert.strictEqual(e.message, 'The decorator is missing dependency \'test\'.') + } + n() + })) + + instance.register(fp((i, o, n) => { + i.decorate('test', () => {}) + t.assert.ok(i.test) + t.assert.ok(!i.otherTest) + n() + })) + + instance.get('/', (req, reply) => { + t.assert.ok(instance.test) + t.assert.ok(!instance.otherTest) + reply.send({ hello: 'world' }) + }) + + done() + }) + + fastify.ready(() => { + t.assert.ok(!fastify.test) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) + + const result = await fetch(fastifyServer) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { hello: 'world' }) +}) + +test('set the plugin name based on the plugin displayName symbol', (t, testDone) => { + t.plan(6) + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.register(fp((fastify, opts, done) => { + t.assert.strictEqual(fastify.pluginName, 'fastify -> plugin-A') + fastify.register(fp((fastify, opts, done) => { + t.assert.strictEqual(fastify.pluginName, 'fastify -> plugin-A -> plugin-AB') + done() + }, { name: 'plugin-AB' })) + fastify.register(fp((fastify, opts, done) => { + t.assert.strictEqual(fastify.pluginName, 'fastify -> plugin-A -> plugin-AB -> plugin-AC') + done() + }, { name: 'plugin-AC' })) + done() + }, { name: 'plugin-A' })) + + fastify.register(fp((fastify, opts, done) => { + t.assert.strictEqual(fastify.pluginName, 'fastify -> plugin-A -> plugin-AB -> plugin-AC -> plugin-B') + done() + }, { name: 'plugin-B' })) + + t.assert.strictEqual(fastify.pluginName, 'fastify') + + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + testDone() + }) +}) + +test('plugin name will change when using no encapsulation', (t, testDone) => { + t.plan(6) + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.register(fp((fastify, opts, done) => { + // store it in a different variable will hold the correct name + const pluginName = fastify.pluginName + fastify.register(fp((fastify, opts, done) => { + t.assert.strictEqual(fastify.pluginName, 'fastify -> plugin-A -> plugin-AB') + done() + }, { name: 'plugin-AB' })) + fastify.register(fp((fastify, opts, done) => { + t.assert.strictEqual(fastify.pluginName, 'fastify -> plugin-A -> plugin-AB -> plugin-AC') + done() + }, { name: 'plugin-AC' })) + setImmediate(() => { + // normally we would expect the name plugin-A + // but we operate on the same instance in each plugin + t.assert.strictEqual(fastify.pluginName, 'fastify -> plugin-A -> plugin-AB -> plugin-AC') + t.assert.strictEqual(pluginName, 'fastify -> plugin-A') + }) + done() + }, { name: 'plugin-A' })) + + t.assert.strictEqual(fastify.pluginName, 'fastify') + + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + testDone() + }) +}) + +test('plugin name is undefined when accessing in no plugin context', (t, testDone) => { + t.plan(2) + const fastify = Fastify() + t.after(() => fastify.close()) + + t.assert.strictEqual(fastify.pluginName, 'fastify') + + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + testDone() + }) +}) + +test('set the plugin name based on the plugin function name', (t, testDone) => { + t.plan(5) + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.register(function myPluginA (fastify, opts, done) { + t.assert.strictEqual(fastify.pluginName, 'myPluginA') + fastify.register(function myPluginAB (fastify, opts, done) { + t.assert.strictEqual(fastify.pluginName, 'myPluginAB') + done() + }) + setImmediate(() => { + // exact name due to encapsulation + t.assert.strictEqual(fastify.pluginName, 'myPluginA') + }) + done() + }) + + fastify.register(function myPluginB (fastify, opts, done) { + t.assert.strictEqual(fastify.pluginName, 'myPluginB') + done() + }) + + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + testDone() + }) +}) + +test('approximate a plugin name when no meta data is available', (t, testDone) => { + t.plan(7) + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.register((fastify, opts, done) => { + // A + t.assert.strictEqual(fastify.pluginName.startsWith('(fastify, opts, done)'), true) + t.assert.strictEqual(fastify.pluginName.includes('// A'), true) + fastify.register((fastify, opts, done) => { + // B + t.assert.strictEqual(fastify.pluginName.startsWith('(fastify, opts, done)'), true) + t.assert.strictEqual(fastify.pluginName.includes('// B'), true) + done() + }) + setImmediate(() => { + t.assert.strictEqual(fastify.pluginName.startsWith('(fastify, opts, done)'), true) + t.assert.strictEqual(fastify.pluginName.includes('// A'), true) + }) + done() + }) + + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + testDone() + }) +}) + +test('approximate a plugin name also when fastify-plugin has no meta data', (t, testDone) => { + t.plan(4) + const fastify = Fastify() + t.after(() => fastify.close()) + + // plugin name is got from current file name + const pluginName = /plugin\.2\.test/ + const pluginNameWithFunction = /plugin\.2\.test-auto-\d+ -> B/ + + fastify.register(fp((fastify, opts, done) => { + t.assert.match(fastify.pluginName, pluginName) + fastify.register(fp(function B (fastify, opts, done) { + // function has name + t.assert.match(fastify.pluginName, pluginNameWithFunction) + done() + })) + setImmediate(() => { + t.assert.match(fastify.pluginName, pluginNameWithFunction) + }) + done() + })) + + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + testDone() + }) +}) + +test('plugin encapsulation', async t => { + t.plan(9) + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.register((instance, opts, done) => { + instance.register(fp((i, o, n) => { + i.decorate('test', 'first') + n() + })) + + instance.get('/first', (req, reply) => { + reply.send({ plugin: instance.test }) + }) + + done() + }) + + fastify.register((instance, opts, done) => { + instance.register(fp((i, o, n) => { + i.decorate('test', 'second') + n() + })) + + instance.get('/second', (req, reply) => { + reply.send({ plugin: instance.test }) + }) + + done() + }) + + fastify.ready(() => { + t.assert.ok(!fastify.test) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) + + const result1 = await fetch(fastifyServer + '/first') + t.assert.ok(result1.ok) + t.assert.strictEqual(result1.status, 200) + const body1 = await result1.text() + t.assert.strictEqual(result1.headers.get('content-length'), '' + body1.length) + t.assert.deepStrictEqual(JSON.parse(body1), { plugin: 'first' }) + + const result2 = await fetch(fastifyServer + '/second') + t.assert.ok(result2.ok) + t.assert.strictEqual(result2.status, 200) + const body2 = await result2.text() + t.assert.strictEqual(result2.headers.get('content-length'), '' + body2.length) + t.assert.deepStrictEqual(JSON.parse(body2), { plugin: 'second' }) +}) diff --git a/test/plugin.3.test.js b/test/plugin.3.test.js new file mode 100644 index 00000000000..423bb74d6a4 --- /dev/null +++ b/test/plugin.3.test.js @@ -0,0 +1,287 @@ +'use strict' + +const { test } = require('node:test') +const Fastify = require('../fastify') +const fp = require('fastify-plugin') + +test('if a plugin raises an error and there is not a callback to handle it, the server must not start', (t, testDone) => { + t.plan(2) + const fastify = Fastify() + + fastify.register((instance, opts, done) => { + done(new Error('err')) + }) + + fastify.listen({ port: 0 }, err => { + t.assert.ok(err instanceof Error) + t.assert.strictEqual(err.message, 'err') + testDone() + }) +}) + +test('add hooks after route declaration', async t => { + t.plan(2) + const fastify = Fastify() + t.after(() => fastify.close()) + + function plugin (instance, opts, done) { + instance.decorateRequest('check', null) + instance.addHook('onRequest', (req, reply, done) => { + req.check = {} + done() + }) + setImmediate(done) + } + fastify.register(fp(plugin)) + + fastify.register((instance, options, done) => { + instance.addHook('preHandler', function b (req, res, done) { + req.check.hook2 = true + done() + }) + + instance.get('/', (req, reply) => { + reply.send(req.check) + }) + + instance.addHook('preHandler', function c (req, res, done) { + req.check.hook3 = true + done() + }) + + done() + }) + + fastify.addHook('preHandler', function a (req, res, done) { + req.check.hook1 = true + done() + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + + const result = await fetch(fastifyServer) + t.assert.ok(result.ok) + t.assert.deepStrictEqual(await result.json(), { hook1: true, hook2: true, hook3: true }) +}) + +test('nested plugins', async t => { + t.plan(4) + + const fastify = Fastify() + + t.after(() => fastify.close()) + + fastify.register(function (fastify, opts, done) { + fastify.register((fastify, opts, done) => { + fastify.get('/', function (req, reply) { + reply.send('I am child 1') + }) + done() + }, { prefix: '/child1' }) + + fastify.register((fastify, opts, done) => { + fastify.get('/', function (req, reply) { + reply.send('I am child 2') + }) + done() + }, { prefix: '/child2' }) + + done() + }, { prefix: '/parent' }) + + const fastifyServer = await fastify.listen({ port: 0 }) + + const result1 = await fetch(fastifyServer + '/parent/child1') + t.assert.ok(result1.ok) + t.assert.deepStrictEqual(await result1.text(), 'I am child 1') + + const result2 = await fetch(fastifyServer + '/parent/child2') + t.assert.ok(result2.ok) + t.assert.deepStrictEqual(await result2.text(), 'I am child 2') +}) + +test('nested plugins awaited', async t => { + t.plan(4) + + const fastify = Fastify() + + t.after(() => fastify.close()) + + fastify.register(async function wrap (fastify, opts) { + await fastify.register(async function child1 (fastify, opts) { + fastify.get('/', function (req, reply) { + reply.send('I am child 1') + }) + }, { prefix: '/child1' }) + + await fastify.register(async function child2 (fastify, opts) { + fastify.get('/', function (req, reply) { + reply.send('I am child 2') + }) + }, { prefix: '/child2' }) + }, { prefix: '/parent' }) + + const fastifyServer = await fastify.listen({ port: 0 }) + + const result1 = await fetch(fastifyServer + '/parent/child1') + t.assert.ok(result1.ok) + t.assert.deepStrictEqual(await result1.text(), 'I am child 1') + + const result2 = await fetch(fastifyServer + '/parent/child2') + t.assert.ok(result2.ok) + t.assert.deepStrictEqual(await result2.text(), 'I am child 2') +}) + +test('plugin metadata - decorators', (t, testDone) => { + t.plan(1) + const fastify = Fastify() + + fastify.decorate('plugin1', true) + fastify.decorateReply('plugin1', true) + fastify.decorateRequest('plugin1', true) + + plugin[Symbol.for('skip-override')] = true + plugin[Symbol.for('plugin-meta')] = { + decorators: { + fastify: ['plugin1'], + reply: ['plugin1'], + request: ['plugin1'] + } + } + + fastify.register(plugin) + + fastify.ready(() => { + t.assert.ok(fastify.plugin) + testDone() + }) + + function plugin (instance, opts, done) { + instance.decorate('plugin', true) + done() + } +}) + +test('plugin metadata - decorators - should throw', (t, testDone) => { + t.plan(1) + const fastify = Fastify() + + fastify.decorate('plugin1', true) + fastify.decorateReply('plugin1', true) + + plugin[Symbol.for('skip-override')] = true + plugin[Symbol.for('plugin-meta')] = { + decorators: { + fastify: ['plugin1'], + reply: ['plugin1'], + request: ['plugin1'] + } + } + + fastify.register(plugin) + fastify.ready((err) => { + t.assert.strictEqual(err.message, "The decorator 'plugin1' is not present in Request") + testDone() + }) + + function plugin (instance, opts, done) { + instance.decorate('plugin', true) + done() + } +}) + +test('plugin metadata - decorators - should throw with plugin name', (t, testDone) => { + t.plan(1) + const fastify = Fastify() + + fastify.decorate('plugin1', true) + fastify.decorateReply('plugin1', true) + + plugin[Symbol.for('skip-override')] = true + plugin[Symbol.for('plugin-meta')] = { + name: 'the-plugin', + decorators: { + fastify: ['plugin1'], + reply: ['plugin1'], + request: ['plugin1'] + } + } + + fastify.register(plugin) + fastify.ready((err) => { + t.assert.strictEqual(err.message, "The decorator 'plugin1' required by 'the-plugin' is not present in Request") + testDone() + }) + + function plugin (instance, opts, done) { + instance.decorate('plugin', true) + done() + } +}) + +test('plugin metadata - dependencies', (t, testDone) => { + t.plan(1) + const fastify = Fastify() + + dependency[Symbol.for('skip-override')] = true + dependency[Symbol.for('plugin-meta')] = { + name: 'plugin' + } + + plugin[Symbol.for('skip-override')] = true + plugin[Symbol.for('plugin-meta')] = { + dependencies: ['plugin'] + } + + fastify.register(dependency) + fastify.register(plugin) + + fastify.ready(() => { + t.assert.ok('everything right') + testDone() + }) + + function dependency (instance, opts, done) { + done() + } + + function plugin (instance, opts, done) { + done() + } +}) + +test('plugin metadata - dependencies (nested)', (t, testDone) => { + t.plan(1) + const fastify = Fastify() + + dependency[Symbol.for('skip-override')] = true + dependency[Symbol.for('plugin-meta')] = { + name: 'plugin' + } + + nested[Symbol.for('skip-override')] = true + nested[Symbol.for('plugin-meta')] = { + dependencies: ['plugin'] + } + + fastify.register(dependency) + fastify.register(plugin) + + fastify.ready(() => { + t.assert.ok('everything right') + testDone() + }) + + function dependency (instance, opts, done) { + done() + } + + function plugin (instance, opts, done) { + instance.register(nested) + done() + } + + function nested (instance, opts, done) { + done() + } +}) diff --git a/test/plugin.4.test.js b/test/plugin.4.test.js new file mode 100644 index 00000000000..497d06cf1db --- /dev/null +++ b/test/plugin.4.test.js @@ -0,0 +1,504 @@ +'use strict' + +const { test, describe } = require('node:test') +const Fastify = require('../fastify') +const fp = require('fastify-plugin') +const fakeTimer = require('@sinonjs/fake-timers') +const { FST_ERR_PLUGIN_INVALID_ASYNC_HANDLER } = require('../lib/errors') + +test('pluginTimeout', (t, testDone) => { + t.plan(5) + const fastify = Fastify({ + pluginTimeout: 10 + }) + fastify.register(function (app, opts, done) { + // to no call done on purpose + }) + fastify.ready((err) => { + t.assert.ok(err) + t.assert.strictEqual(err.message, + "fastify-plugin: Plugin did not start in time: 'function (app, opts, done) { -- // to no call done on purpose'. You may have forgotten to call 'done' function or to resolve a Promise") + t.assert.strictEqual(err.code, 'FST_ERR_PLUGIN_TIMEOUT') + t.assert.ok(err.cause) + t.assert.strictEqual(err.cause.code, 'AVV_ERR_PLUGIN_EXEC_TIMEOUT') + testDone() + }) +}) + +test('pluginTimeout - named function', (t, testDone) => { + t.plan(5) + const fastify = Fastify({ + pluginTimeout: 10 + }) + fastify.register(function nameFunction (app, opts, done) { + // to no call done on purpose + }) + fastify.ready((err) => { + t.assert.ok(err) + t.assert.strictEqual(err.message, + "fastify-plugin: Plugin did not start in time: 'nameFunction'. You may have forgotten to call 'done' function or to resolve a Promise") + t.assert.strictEqual(err.code, 'FST_ERR_PLUGIN_TIMEOUT') + t.assert.ok(err.cause) + t.assert.strictEqual(err.cause.code, 'AVV_ERR_PLUGIN_EXEC_TIMEOUT') + testDone() + }) +}) + +test('pluginTimeout default', (t, testDone) => { + t.plan(5) + const clock = fakeTimer.install({ shouldClearNativeTimers: true }) + + const fastify = Fastify() + fastify.register(function (app, opts, done) { + // default time elapsed without calling done + clock.tick(10000) + }) + + fastify.ready((err) => { + t.assert.ok(err) + t.assert.strictEqual(err.message, + "fastify-plugin: Plugin did not start in time: 'function (app, opts, done) { -- // default time elapsed without calling done'. You may have forgotten to call 'done' function or to resolve a Promise") + t.assert.strictEqual(err.code, 'FST_ERR_PLUGIN_TIMEOUT') + t.assert.ok(err.cause) + t.assert.strictEqual(err.cause.code, 'AVV_ERR_PLUGIN_EXEC_TIMEOUT') + testDone() + }) + + t.after(clock.uninstall) +}) + +test('plugin metadata - version', (t, testDone) => { + t.plan(1) + const fastify = Fastify() + + plugin[Symbol.for('skip-override')] = true + plugin[Symbol.for('plugin-meta')] = { + name: 'plugin', + fastify: '2.0.0' + } + + fastify.register(plugin) + + fastify.ready(() => { + t.assert.ok('everything right') + testDone() + }) + + function plugin (instance, opts, done) { + done() + } +}) + +test('plugin metadata - version range', (t, testDone) => { + t.plan(1) + const fastify = Fastify() + + plugin[Symbol.for('skip-override')] = true + plugin[Symbol.for('plugin-meta')] = { + name: 'plugin', + fastify: '>=2.0.0' + } + + fastify.register(plugin) + + fastify.ready(() => { + t.assert.ok('everything right') + testDone() + }) + + function plugin (instance, opts, done) { + done() + } +}) + +test('plugin metadata - version not matching requirement', (t, testDone) => { + t.plan(2) + const fastify = Fastify() + + plugin[Symbol.for('skip-override')] = true + plugin[Symbol.for('plugin-meta')] = { + name: 'plugin', + fastify: '99.0.0' + } + + fastify.register(plugin) + + fastify.ready((err) => { + t.assert.ok(err) + t.assert.strictEqual(err.code, 'FST_ERR_PLUGIN_VERSION_MISMATCH') + testDone() + }) + + function plugin (instance, opts, done) { + done() + } +}) + +test('plugin metadata - version not matching requirement 2', (t, testDone) => { + t.plan(2) + const fastify = Fastify() + Object.defineProperty(fastify, 'version', { + value: '99.0.0' + }) + + plugin[Symbol.for('skip-override')] = true + plugin[Symbol.for('plugin-meta')] = { + name: 'plugin', + fastify: '<=3.0.0' + } + + fastify.register(plugin) + + fastify.ready((err) => { + t.assert.ok(err) + t.assert.strictEqual(err.code, 'FST_ERR_PLUGIN_VERSION_MISMATCH') + testDone() + }) + + function plugin (instance, opts, done) { + done() + } +}) + +test('plugin metadata - version not matching requirement 3', (t, testDone) => { + t.plan(2) + const fastify = Fastify() + + plugin[Symbol.for('skip-override')] = true + plugin[Symbol.for('plugin-meta')] = { + name: 'plugin', + fastify: '>=99.0.0' + } + + fastify.register(plugin) + + fastify.ready((err) => { + t.assert.ok(err) + t.assert.strictEqual(err.code, 'FST_ERR_PLUGIN_VERSION_MISMATCH') + testDone() + }) + + function plugin (instance, opts, done) { + done() + } +}) + +test('plugin metadata - release candidate', (t, testDone) => { + t.plan(2) + const fastify = Fastify() + Object.defineProperty(fastify, 'version', { + value: '99.0.0-rc.1' + }) + + plugin[Symbol.for('plugin-meta')] = { + name: 'plugin', + fastify: '99.x' + } + + fastify.register(plugin) + + fastify.ready((err) => { + t.assert.ifError(err) + t.assert.ok('everything right') + testDone() + }) + + function plugin (instance, opts, done) { + done() + } +}) + +describe('fastify-rc loads prior version plugins', async () => { + test('baseline (rc)', (t, testDone) => { + t.plan(1) + + const fastify = Fastify() + Object.defineProperty(fastify, 'version', { + value: '99.0.0-rc.1' + }) + + plugin[Symbol.for('plugin-meta')] = { + name: 'plugin', + fastify: '^98.1.0' + } + plugin2[Symbol.for('plugin-meta')] = { + name: 'plugin2', + fastify: '98.x' + } + + fastify.register(plugin) + + fastify.ready((err) => { + t.assert.ifError(err) + testDone() + }) + + function plugin (instance, opts, done) { + done() + } + + function plugin2 (instance, opts, done) { + done() + } + }) + + test('pre', (t, testDone) => { + t.plan(1) + + const fastify = Fastify() + Object.defineProperty(fastify, 'version', { value: '99.0.0-pre.1' }) + + plugin[Symbol.for('plugin-meta')] = { name: 'plugin', fastify: '^98.x' } + + fastify.register(plugin) + + fastify.ready((err) => { + t.assert.ifError(err) + testDone() + }) + + function plugin (instance, opts, done) { done() } + }) + + test('alpha', (t, testDone) => { + t.plan(1) + + const fastify = Fastify() + Object.defineProperty(fastify, 'version', { value: '99.0.0-pre.1' }) + + plugin[Symbol.for('plugin-meta')] = { name: 'plugin', fastify: '^98.x' } + + fastify.register(plugin) + + fastify.ready((err) => { + t.assert.ifError(err) + testDone() + }) + + function plugin (instance, opts, done) { done() } + }) +}) + +test('hasPlugin method exists as a function', (t) => { + const fastify = Fastify() + t.assert.strictEqual(typeof fastify.hasPlugin, 'function') +}) + +test('hasPlugin returns true if the specified plugin has been registered', async t => { + t.plan(4) + + const fastify = Fastify() + + function pluginA (fastify, opts, done) { + t.assert.ok(fastify.hasPlugin('plugin-A')) + done() + } + pluginA[Symbol.for('fastify.display-name')] = 'plugin-A' + fastify.register(pluginA) + + fastify.register(function pluginB (fastify, opts, done) { + t.assert.ok(fastify.hasPlugin('pluginB')) + done() + }) + + fastify.register(function (fastify, opts, done) { + // one line + t.assert.ok(fastify.hasPlugin('function (fastify, opts, done) { -- // one line')) + done() + }) + + await fastify.ready() + + t.assert.ok(fastify.hasPlugin('fastify')) +}) + +test('hasPlugin returns false if the specified plugin has not been registered', (t) => { + const fastify = Fastify() + t.assert.ok(!fastify.hasPlugin('pluginFoo')) +}) + +test('hasPlugin returns false when using encapsulation', async t => { + t.plan(25) + + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.register(function pluginA (fastify, opts, done) { + t.assert.ok(fastify.hasPlugin('pluginA')) + t.assert.ok(!fastify.hasPlugin('pluginAA')) + t.assert.ok(!fastify.hasPlugin('pluginAAA')) + t.assert.ok(!fastify.hasPlugin('pluginAB')) + t.assert.ok(!fastify.hasPlugin('pluginB')) + + fastify.register(function pluginAA (fastify, opts, done) { + t.assert.ok(!fastify.hasPlugin('pluginA')) + t.assert.ok(fastify.hasPlugin('pluginAA')) + t.assert.ok(!fastify.hasPlugin('pluginAAA')) + t.assert.ok(!fastify.hasPlugin('pluginAB')) + t.assert.ok(!fastify.hasPlugin('pluginB')) + + fastify.register(function pluginAAA (fastify, opts, done) { + t.assert.ok(!fastify.hasPlugin('pluginA')) + t.assert.ok(!fastify.hasPlugin('pluginAA')) + t.assert.ok(fastify.hasPlugin('pluginAAA')) + t.assert.ok(!fastify.hasPlugin('pluginAB')) + t.assert.ok(!fastify.hasPlugin('pluginB')) + + done() + }) + + done() + }) + + fastify.register(function pluginAB (fastify, opts, done) { + t.assert.ok(!fastify.hasPlugin('pluginA')) + t.assert.ok(!fastify.hasPlugin('pluginAA')) + t.assert.ok(!fastify.hasPlugin('pluginAAA')) + t.assert.ok(fastify.hasPlugin('pluginAB')) + t.assert.ok(!fastify.hasPlugin('pluginB')) + + done() + }) + + done() + }) + + fastify.register(function pluginB (fastify, opts, done) { + t.assert.ok(!fastify.hasPlugin('pluginA')) + t.assert.ok(!fastify.hasPlugin('pluginAA')) + t.assert.ok(!fastify.hasPlugin('pluginAAA')) + t.assert.ok(!fastify.hasPlugin('pluginAB')) + t.assert.ok(fastify.hasPlugin('pluginB')) + + done() + }) + + await fastify.ready() +}) + +test('hasPlugin returns true when using no encapsulation', async t => { + t.plan(26) + + const fastify = Fastify() + + fastify.register(fp((fastify, opts, done) => { + t.assert.strictEqual(fastify.pluginName, 'fastify -> plugin-AA') + t.assert.ok(fastify.hasPlugin('plugin-AA')) + t.assert.ok(!fastify.hasPlugin('plugin-A')) + t.assert.ok(!fastify.hasPlugin('plugin-AAA')) + t.assert.ok(!fastify.hasPlugin('plugin-AB')) + t.assert.ok(!fastify.hasPlugin('plugin-B')) + + fastify.register(fp((fastify, opts, done) => { + t.assert.ok(fastify.hasPlugin('plugin-AA')) + t.assert.ok(fastify.hasPlugin('plugin-A')) + t.assert.ok(!fastify.hasPlugin('plugin-AAA')) + t.assert.ok(!fastify.hasPlugin('plugin-AB')) + t.assert.ok(!fastify.hasPlugin('plugin-B')) + + fastify.register(fp((fastify, opts, done) => { + t.assert.ok(fastify.hasPlugin('plugin-AA')) + t.assert.ok(fastify.hasPlugin('plugin-A')) + t.assert.ok(fastify.hasPlugin('plugin-AAA')) + t.assert.ok(!fastify.hasPlugin('plugin-AB')) + t.assert.ok(!fastify.hasPlugin('plugin-B')) + + done() + }, { name: 'plugin-AAA' })) + + done() + }, { name: 'plugin-A' })) + + fastify.register(fp((fastify, opts, done) => { + t.assert.ok(fastify.hasPlugin('plugin-AA')) + t.assert.ok(fastify.hasPlugin('plugin-A')) + t.assert.ok(fastify.hasPlugin('plugin-AAA')) + t.assert.ok(fastify.hasPlugin('plugin-AB')) + t.assert.ok(!fastify.hasPlugin('plugin-B')) + + done() + }, { name: 'plugin-AB' })) + + done() + }, { name: 'plugin-AA' })) + + fastify.register(fp((fastify, opts, done) => { + t.assert.ok(fastify.hasPlugin('plugin-AA')) + t.assert.ok(fastify.hasPlugin('plugin-A')) + t.assert.ok(fastify.hasPlugin('plugin-AAA')) + t.assert.ok(fastify.hasPlugin('plugin-AB')) + t.assert.ok(fastify.hasPlugin('plugin-B')) + + done() + }, { name: 'plugin-B' })) + + await fastify.ready() +}) + +test('hasPlugin returns true when using encapsulation', async t => { + t.plan(2) + + const fastify = Fastify() + + const pluginCallback = function (server, options, done) { + done() + } + const pluginName = 'awesome-plugin' + const plugin = fp(pluginCallback, { name: pluginName }) + + fastify.register(plugin) + + fastify.register(async (server) => { + t.assert.ok(server.hasPlugin(pluginName)) + }) + + fastify.register(async function foo (server) { + server.register(async function bar (server) { + t.assert.ok(server.hasPlugin(pluginName)) + }) + }) + + await fastify.ready() +}) + +test('registering anonymous plugin with mixed style should throw', async t => { + t.plan(2) + + const fastify = Fastify() + + const anonymousPlugin = async (app, opts, done) => { + done() + } + + fastify.register(anonymousPlugin) + + try { + await fastify.ready() + t.fail('should throw') + } catch (error) { + t.assert.ok(error instanceof FST_ERR_PLUGIN_INVALID_ASYNC_HANDLER) + t.assert.strictEqual(error.message, 'The anonymousPlugin plugin being registered mixes async and callback styles. Async plugin should not mix async and callback style.') + } +}) + +test('registering named plugin with mixed style should throw', async t => { + t.plan(2) + + const fastify = Fastify() + + const pluginName = 'error-plugin' + const errorPlugin = async (app, opts, done) => { + done() + } + const namedPlugin = fp(errorPlugin, { name: pluginName }) + + fastify.register(namedPlugin) + + try { + await fastify.ready() + t.fail('should throw') + } catch (error) { + t.assert.ok(error instanceof FST_ERR_PLUGIN_INVALID_ASYNC_HANDLER) + t.assert.strictEqual(error.message, 'The error-plugin plugin being registered mixes async and callback styles. Async plugin should not mix async and callback style.') + } +}) diff --git a/test/plugin.name.display.js b/test/plugin.name.display.js index 05927b604bd..0d42e052501 100644 --- a/test/plugin.name.display.js +++ b/test/plugin.name.display.js @@ -1,6 +1,6 @@ 'use strict' -const assert = require('assert') +const assert = require('node:assert') module.exports = function (fastify, opts, done) { assert.strictEqual(fastify.pluginName, 'test-plugin') diff --git a/test/plugin.test.js b/test/plugin.test.js deleted file mode 100644 index a64a5d6c79b..00000000000 --- a/test/plugin.test.js +++ /dev/null @@ -1,1249 +0,0 @@ -'use strict' - -/* eslint no-prototype-builtins: 0 */ - -const t = require('tap') -const test = t.test -const Fastify = require('..') -const sget = require('simple-get').concat -const fp = require('fastify-plugin') -const fakeTimer = require('@sinonjs/fake-timers') - -test('require a plugin', t => { - t.plan(1) - const fastify = Fastify() - fastify.register(require('./plugin.helper')) - fastify.ready(() => { - t.ok(fastify.test) - }) -}) - -test('plugin metadata - ignore prefix', t => { - t.plan(2) - const fastify = Fastify() - - plugin[Symbol.for('skip-override')] = true - fastify.register(plugin, { prefix: 'foo' }) - - fastify.inject({ - method: 'GET', - url: '/' - }, function (err, res) { - t.error(err) - t.equal(res.payload, 'hello') - }) - - function plugin (instance, opts, done) { - instance.get('/', function (request, reply) { - reply.send('hello') - }) - done() - } -}) - -test('plugin metadata - naming plugins', async t => { - t.plan(2) - const fastify = Fastify() - - fastify.register(require('./plugin.name.display')) - fastify.register(function (fastify, opts, done) { - // one line - t.equal(fastify.pluginName, 'function (fastify, opts, done) { -- // one line') - done() - }) - fastify.register(function fooBar (fastify, opts, done) { - t.equal(fastify.pluginName, 'fooBar') - done() - }) - - await fastify.ready() -}) - -test('fastify.register with fastify-plugin should not encapsulate his code', t => { - t.plan(10) - const fastify = Fastify() - - fastify.register((instance, opts, done) => { - instance.register(fp((i, o, n) => { - i.decorate('test', () => {}) - t.ok(i.test) - n() - })) - - t.notOk(instance.test) - - // the decoration is added at the end - instance.after(() => { - t.ok(instance.test) - }) - - instance.get('/', (req, reply) => { - t.ok(instance.test) - reply.send({ hello: 'world' }) - }) - - done() - }) - - fastify.ready(() => { - t.notOk(fastify.test) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) - }) -}) - -test('fastify.register with fastify-plugin should provide access to external fastify instance if opts argument is a function', t => { - t.plan(22) - const fastify = Fastify() - - fastify.register((instance, opts, done) => { - instance.register(fp((i, o, n) => { - i.decorate('global', () => {}) - t.ok(i.global) - n() - })) - - instance.register((i, o, n) => n(), p => { - t.notOk(p === instance || p === fastify) - t.ok(instance.isPrototypeOf(p)) - t.ok(fastify.isPrototypeOf(p)) - t.ok(p.global) - }) - - instance.register((i, o, n) => { - i.decorate('local', () => {}) - n() - }) - - instance.register((i, o, n) => n(), p => t.notOk(p.local)) - - instance.register((i, o, n) => { - t.ok(i.local) - n() - }, p => p.decorate('local', () => {})) - - instance.register((i, o, n) => n(), p => t.notOk(p.local)) - - instance.register(fp((i, o, n) => { - t.ok(i.global_2) - n() - }), p => p.decorate('global_2', () => 'hello')) - - instance.register((i, o, n) => { - i.decorate('global_2', () => 'world') - n() - }, p => p.get('/', (req, reply) => { - t.ok(p.global_2) - reply.send({ hello: p.global_2() }) - })) - - t.notOk(instance.global) - t.notOk(instance.global_2) - t.notOk(instance.local) - - // the decoration is added at the end - instance.after(() => { - t.ok(instance.global) - t.equal(instance.global_2(), 'hello') - t.notOk(instance.local) - }) - - done() - }) - - fastify.ready(() => { - t.notOk(fastify.global) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) - }) -}) - -test('fastify.register with fastify-plugin registers fastify level plugins', t => { - t.plan(15) - const fastify = Fastify() - - function fastifyPlugin (instance, opts, done) { - instance.decorate('test', 'first') - t.ok(instance.test) - done() - } - - function innerPlugin (instance, opts, done) { - instance.decorate('test2', 'second') - done() - } - - fastify.register(fp(fastifyPlugin)) - - fastify.register((instance, opts, done) => { - t.ok(instance.test) - instance.register(fp(innerPlugin)) - - instance.get('/test2', (req, reply) => { - t.ok(instance.test2) - reply.send({ test2: instance.test2 }) - }) - - done() - }) - - fastify.ready(() => { - t.ok(fastify.test) - t.notOk(fastify.test2) - }) - - fastify.get('/', (req, reply) => { - t.ok(fastify.test) - reply.send({ test: fastify.test }) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { test: 'first' }) - }) - - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/test2' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { test2: 'second' }) - }) - }) -}) - -test('check dependencies - should not throw', t => { - t.plan(12) - const fastify = Fastify() - - fastify.register((instance, opts, done) => { - instance.register(fp((i, o, n) => { - i.decorate('test', () => {}) - t.ok(i.test) - n() - })) - - instance.register(fp((i, o, n) => { - try { - i.decorate('otherTest', () => {}, ['test']) - t.ok(i.test) - t.ok(i.otherTest) - n() - } catch (e) { - t.fail() - } - })) - - instance.get('/', (req, reply) => { - t.ok(instance.test) - t.ok(instance.otherTest) - reply.send({ hello: 'world' }) - }) - - done() - }) - - fastify.ready(() => { - t.notOk(fastify.test) - t.notOk(fastify.otherTest) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) - }) -}) - -test('check dependencies - should throw', t => { - t.plan(12) - const fastify = Fastify() - - fastify.register((instance, opts, done) => { - instance.register(fp((i, o, n) => { - try { - i.decorate('otherTest', () => {}, ['test']) - t.fail() - } catch (e) { - t.equal(e.code, 'FST_ERR_DEC_MISSING_DEPENDENCY') - t.equal(e.message, 'The decorator is missing dependency \'test\'.') - } - n() - })) - - instance.register(fp((i, o, n) => { - i.decorate('test', () => {}) - t.ok(i.test) - t.notOk(i.otherTest) - n() - })) - - instance.get('/', (req, reply) => { - t.ok(instance.test) - t.notOk(instance.otherTest) - reply.send({ hello: 'world' }) - }) - - done() - }) - - fastify.ready(() => { - t.notOk(fastify.test) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) - }) -}) - -test('set the plugin name based on the plugin displayName symbol', t => { - t.plan(6) - const fastify = Fastify() - - fastify.register(fp((fastify, opts, done) => { - t.equal(fastify.pluginName, 'fastify -> plugin-A') - fastify.register(fp((fastify, opts, done) => { - t.equal(fastify.pluginName, 'fastify -> plugin-A -> plugin-AB') - done() - }, { name: 'plugin-AB' })) - fastify.register(fp((fastify, opts, done) => { - t.equal(fastify.pluginName, 'fastify -> plugin-A -> plugin-AB -> plugin-AC') - done() - }, { name: 'plugin-AC' })) - done() - }, { name: 'plugin-A' })) - - fastify.register(fp((fastify, opts, done) => { - t.equal(fastify.pluginName, 'fastify -> plugin-A -> plugin-AB -> plugin-AC -> plugin-B') - done() - }, { name: 'plugin-B' })) - - t.equal(fastify.pluginName, 'fastify') - - fastify.listen({ port: 0 }, err => { - t.error(err) - fastify.close() - }) -}) - -test('plugin name will change when using no encapsulation', t => { - t.plan(6) - const fastify = Fastify() - - fastify.register(fp((fastify, opts, done) => { - // store it in a different variable will hold the correct name - const pluginName = fastify.pluginName - fastify.register(fp((fastify, opts, done) => { - t.equal(fastify.pluginName, 'fastify -> plugin-A -> plugin-AB') - done() - }, { name: 'plugin-AB' })) - fastify.register(fp((fastify, opts, done) => { - t.equal(fastify.pluginName, 'fastify -> plugin-A -> plugin-AB -> plugin-AC') - done() - }, { name: 'plugin-AC' })) - setImmediate(() => { - // normally we would expect the name plugin-A - // but we operate on the same instance in each plugin - t.equal(fastify.pluginName, 'fastify -> plugin-A -> plugin-AB -> plugin-AC') - t.equal(pluginName, 'fastify -> plugin-A') - }) - done() - }, { name: 'plugin-A' })) - - t.equal(fastify.pluginName, 'fastify') - - fastify.listen({ port: 0 }, err => { - t.error(err) - fastify.close() - }) -}) - -test('plugin name is undefined when accessing in no plugin context', t => { - t.plan(2) - const fastify = Fastify() - - t.equal(fastify.pluginName, 'fastify') - - fastify.listen({ port: 0 }, err => { - t.error(err) - fastify.close() - }) -}) - -test('set the plugin name based on the plugin function name', t => { - t.plan(5) - const fastify = Fastify() - - fastify.register(function myPluginA (fastify, opts, done) { - t.equal(fastify.pluginName, 'myPluginA') - fastify.register(function myPluginAB (fastify, opts, done) { - t.equal(fastify.pluginName, 'myPluginAB') - done() - }) - setImmediate(() => { - // exact name due to encapsulation - t.equal(fastify.pluginName, 'myPluginA') - }) - done() - }) - - fastify.register(function myPluginB (fastify, opts, done) { - t.equal(fastify.pluginName, 'myPluginB') - done() - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - fastify.close() - }) -}) - -test('approximate a plugin name when no meta data is available', t => { - t.plan(7) - const fastify = Fastify() - - fastify.register((fastify, opts, done) => { - // A - t.equal(fastify.pluginName.startsWith('(fastify, opts, done)'), true) - t.equal(fastify.pluginName.includes('// A'), true) - fastify.register((fastify, opts, done) => { - // B - t.equal(fastify.pluginName.startsWith('(fastify, opts, done)'), true) - t.equal(fastify.pluginName.includes('// B'), true) - done() - }) - setImmediate(() => { - t.equal(fastify.pluginName.startsWith('(fastify, opts, done)'), true) - t.equal(fastify.pluginName.includes('// A'), true) - }) - done() - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - fastify.close() - }) -}) - -test('approximate a plugin name also when fastify-plugin has no meta data', t => { - t.plan(4) - const fastify = Fastify() - - fastify.register(fp((fastify, opts, done) => { - t.match(fastify.pluginName, /plugin\.test/) - fastify.register(fp(function B (fastify, opts, done) { - // function has name - t.match(fastify.pluginName, /plugin\.test-auto-\d+ -> B/) - done() - })) - setImmediate(() => { - t.match(fastify.pluginName, /plugin\.test-auto-\d+ -> B/) - }) - done() - })) - - fastify.listen({ port: 0 }, err => { - t.error(err) - fastify.close() - }) -}) - -test('plugin encapsulation', t => { - t.plan(10) - const fastify = Fastify() - - fastify.register((instance, opts, done) => { - instance.register(fp((i, o, n) => { - i.decorate('test', 'first') - n() - })) - - instance.get('/first', (req, reply) => { - reply.send({ plugin: instance.test }) - }) - - done() - }) - - fastify.register((instance, opts, done) => { - instance.register(fp((i, o, n) => { - i.decorate('test', 'second') - n() - })) - - instance.get('/second', (req, reply) => { - reply.send({ plugin: instance.test }) - }) - - done() - }) - - fastify.ready(() => { - t.notOk(fastify.test) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/first' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { plugin: 'first' }) - }) - - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/second' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { plugin: 'second' }) - }) - }) -}) - -test('if a plugin raises an error and there is not a callback to handle it, the server must not start', t => { - t.plan(2) - const fastify = Fastify() - - fastify.register((instance, opts, done) => { - done(new Error('err')) - }) - - fastify.listen({ port: 0 }, err => { - t.ok(err instanceof Error) - t.equal(err.message, 'err') - }) -}) - -test('add hooks after route declaration', t => { - t.plan(3) - const fastify = Fastify() - - function plugin (instance, opts, done) { - instance.decorateRequest('check', null) - instance.addHook('onRequest', (req, reply, done) => { - req.check = {} - done() - }) - setImmediate(done) - } - fastify.register(fp(plugin)) - - fastify.register((instance, options, done) => { - instance.addHook('preHandler', function b (req, res, done) { - req.check.hook2 = true - done() - }) - - instance.get('/', (req, reply) => { - reply.send(req.check) - }) - - instance.addHook('preHandler', function c (req, res, done) { - req.check.hook3 = true - done() - }) - - done() - }) - - fastify.addHook('preHandler', function a (req, res, done) { - req.check.hook1 = true - done() - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.same(JSON.parse(body), { hook1: true, hook2: true, hook3: true }) - fastify.close() - }) - }) -}) - -test('nested plugins', t => { - t.plan(5) - - const fastify = Fastify() - - t.teardown(fastify.close.bind(fastify)) - - fastify.register(function (fastify, opts, done) { - fastify.register((fastify, opts, done) => { - fastify.get('/', function (req, reply) { - reply.send('I am child 1') - }) - done() - }, { prefix: '/child1' }) - - fastify.register((fastify, opts, done) => { - fastify.get('/', function (req, reply) { - reply.send('I am child 2') - }) - done() - }, { prefix: '/child2' }) - - done() - }, { prefix: '/parent' }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/parent/child1' - }, (err, response, body) => { - t.error(err) - t.same(body.toString(), 'I am child 1') - }) - - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/parent/child2' - }, (err, response, body) => { - t.error(err) - t.same(body.toString(), 'I am child 2') - }) - }) -}) - -test('nested plugins awaited', t => { - t.plan(5) - - const fastify = Fastify() - - t.teardown(fastify.close.bind(fastify)) - - fastify.register(async function wrap (fastify, opts) { - await fastify.register(async function child1 (fastify, opts) { - fastify.get('/', function (req, reply) { - reply.send('I am child 1') - }) - }, { prefix: '/child1' }) - - await fastify.register(async function child2 (fastify, opts) { - fastify.get('/', function (req, reply) { - reply.send('I am child 2') - }) - }, { prefix: '/child2' }) - }, { prefix: '/parent' }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/parent/child1' - }, (err, response, body) => { - t.error(err) - t.same(body.toString(), 'I am child 1') - }) - - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/parent/child2' - }, (err, response, body) => { - t.error(err) - t.same(body.toString(), 'I am child 2') - }) - }) -}) - -test('plugin metadata - decorators', t => { - t.plan(1) - const fastify = Fastify() - - fastify.decorate('plugin1', true) - fastify.decorateReply('plugin1', true) - fastify.decorateRequest('plugin1', true) - - plugin[Symbol.for('skip-override')] = true - plugin[Symbol.for('plugin-meta')] = { - decorators: { - fastify: ['plugin1'], - reply: ['plugin1'], - request: ['plugin1'] - } - } - - fastify.register(plugin) - - fastify.ready(() => { - t.ok(fastify.plugin) - }) - - function plugin (instance, opts, done) { - instance.decorate('plugin', true) - done() - } -}) - -test('plugin metadata - decorators - should throw', t => { - t.plan(1) - const fastify = Fastify() - - fastify.decorate('plugin1', true) - fastify.decorateReply('plugin1', true) - - plugin[Symbol.for('skip-override')] = true - plugin[Symbol.for('plugin-meta')] = { - decorators: { - fastify: ['plugin1'], - reply: ['plugin1'], - request: ['plugin1'] - } - } - - fastify.register(plugin) - fastify.ready((err) => { - t.equal(err.message, "The decorator 'plugin1' is not present in Request") - }) - - function plugin (instance, opts, done) { - instance.decorate('plugin', true) - done() - } -}) - -test('plugin metadata - decorators - should throw with plugin name', t => { - t.plan(1) - const fastify = Fastify() - - fastify.decorate('plugin1', true) - fastify.decorateReply('plugin1', true) - - plugin[Symbol.for('skip-override')] = true - plugin[Symbol.for('plugin-meta')] = { - name: 'the-plugin', - decorators: { - fastify: ['plugin1'], - reply: ['plugin1'], - request: ['plugin1'] - } - } - - fastify.register(plugin) - fastify.ready((err) => { - t.equal(err.message, "The decorator 'plugin1' required by 'the-plugin' is not present in Request") - }) - - function plugin (instance, opts, done) { - instance.decorate('plugin', true) - done() - } -}) - -test('plugin metadata - dependencies', t => { - t.plan(1) - const fastify = Fastify() - - dependency[Symbol.for('skip-override')] = true - dependency[Symbol.for('plugin-meta')] = { - name: 'plugin' - } - - plugin[Symbol.for('skip-override')] = true - plugin[Symbol.for('plugin-meta')] = { - dependencies: ['plugin'] - } - - fastify.register(dependency) - fastify.register(plugin) - - fastify.ready(() => { - t.pass('everything right') - }) - - function dependency (instance, opts, done) { - done() - } - - function plugin (instance, opts, done) { - done() - } -}) - -test('plugin metadata - dependencies (nested)', t => { - t.plan(1) - const fastify = Fastify() - - dependency[Symbol.for('skip-override')] = true - dependency[Symbol.for('plugin-meta')] = { - name: 'plugin' - } - - nested[Symbol.for('skip-override')] = true - nested[Symbol.for('plugin-meta')] = { - dependencies: ['plugin'] - } - - fastify.register(dependency) - fastify.register(plugin) - - fastify.ready(() => { - t.pass('everything right') - }) - - function dependency (instance, opts, done) { - done() - } - - function plugin (instance, opts, done) { - instance.register(nested) - done() - } - - function nested (instance, opts, done) { - done() - } -}) - -test('pluginTimeout', t => { - t.plan(5) - const fastify = Fastify({ - pluginTimeout: 10 - }) - fastify.register(function (app, opts, done) { - // to no call done on purpose - }) - fastify.ready((err) => { - t.ok(err) - t.equal(err.message, - "fastify-plugin: Plugin did not start in time: 'function (app, opts, done) { -- // to no call done on purpose'. You may have forgotten to call 'done' function or to resolve a Promise") - t.equal(err.code, 'FST_ERR_PLUGIN_TIMEOUT') - t.ok(err.cause) - t.equal(err.cause.code, 'AVV_ERR_READY_TIMEOUT') - }) -}) - -test('pluginTimeout - named function', { only: true }, t => { - t.plan(5) - const fastify = Fastify({ - pluginTimeout: 10 - }) - fastify.register(function nameFunction (app, opts, done) { - // to no call done on purpose - }) - fastify.ready((err) => { - t.ok(err) - t.equal(err.message, - "fastify-plugin: Plugin did not start in time: 'nameFunction'. You may have forgotten to call 'done' function or to resolve a Promise") - t.equal(err.code, 'FST_ERR_PLUGIN_TIMEOUT') - t.ok(err.cause) - t.equal(err.cause.code, 'AVV_ERR_READY_TIMEOUT') - }) -}) - -test('pluginTimeout default', t => { - t.plan(5) - const clock = fakeTimer.install({ shouldClearNativeTimers: true }) - - const fastify = Fastify() - fastify.register(function (app, opts, done) { - // default time elapsed without calling done - clock.tick(10000) - }) - - fastify.ready((err) => { - t.ok(err) - t.equal(err.message, - "fastify-plugin: Plugin did not start in time: 'function (app, opts, done) { -- // default time elapsed without calling done'. You may have forgotten to call 'done' function or to resolve a Promise") - t.equal(err.code, 'FST_ERR_PLUGIN_TIMEOUT') - t.ok(err.cause) - t.equal(err.cause.code, 'AVV_ERR_READY_TIMEOUT') - }) - - t.teardown(clock.uninstall) -}) - -test('plugin metadata - version', t => { - t.plan(1) - const fastify = Fastify() - - plugin[Symbol.for('skip-override')] = true - plugin[Symbol.for('plugin-meta')] = { - name: 'plugin', - fastify: '2.0.0' - } - - fastify.register(plugin) - - fastify.ready(() => { - t.pass('everything right') - }) - - function plugin (instance, opts, done) { - done() - } -}) - -test('plugin metadata - version range', t => { - t.plan(1) - const fastify = Fastify() - - plugin[Symbol.for('skip-override')] = true - plugin[Symbol.for('plugin-meta')] = { - name: 'plugin', - fastify: '>=2.0.0' - } - - fastify.register(plugin) - - fastify.ready(() => { - t.pass('everything right') - }) - - function plugin (instance, opts, done) { - done() - } -}) - -test('plugin metadata - version not matching requirement', t => { - t.plan(2) - const fastify = Fastify() - - plugin[Symbol.for('skip-override')] = true - plugin[Symbol.for('plugin-meta')] = { - name: 'plugin', - fastify: '99.0.0' - } - - fastify.register(plugin) - - fastify.ready((err) => { - t.ok(err) - t.equal(err.code, 'FST_ERR_PLUGIN_VERSION_MISMATCH') - }) - - function plugin (instance, opts, done) { - done() - } -}) - -test('plugin metadata - version not matching requirement 2', t => { - t.plan(2) - const fastify = Fastify() - - plugin[Symbol.for('skip-override')] = true - plugin[Symbol.for('plugin-meta')] = { - name: 'plugin', - fastify: '<=3.0.0' - } - - fastify.register(plugin) - - fastify.ready((err) => { - t.ok(err) - t.equal(err.code, 'FST_ERR_PLUGIN_VERSION_MISMATCH') - }) - - function plugin (instance, opts, done) { - done() - } -}) - -test('plugin metadata - version not matching requirement 3', t => { - t.plan(2) - const fastify = Fastify() - - plugin[Symbol.for('skip-override')] = true - plugin[Symbol.for('plugin-meta')] = { - name: 'plugin', - fastify: '>=99.0.0' - } - - fastify.register(plugin) - - fastify.ready((err) => { - t.ok(err) - t.equal(err.code, 'FST_ERR_PLUGIN_VERSION_MISMATCH') - }) - - function plugin (instance, opts, done) { - done() - } -}) - -test('plugin metadata - release candidate', t => { - t.plan(2) - const fastify = Fastify() - Object.defineProperty(fastify, 'version', { - value: '99.0.0-rc.1' - }) - - plugin[Symbol.for('plugin-meta')] = { - name: 'plugin', - fastify: '99.x' - } - - fastify.register(plugin) - - fastify.ready((err) => { - t.error(err) - t.pass('everything right') - }) - - function plugin (instance, opts, done) { - done() - } -}) - -test('fastify-rc loads prior version plugins', t => { - t.plan(2) - const fastify = Fastify() - Object.defineProperty(fastify, 'version', { - value: '99.0.0-rc.1' - }) - - plugin[Symbol.for('plugin-meta')] = { - name: 'plugin', - fastify: '^98.1.0' - } - plugin2[Symbol.for('plugin-meta')] = { - name: 'plugin2', - fastify: '98.x' - } - - fastify.register(plugin) - - fastify.ready((err) => { - t.error(err) - t.pass('everything right') - }) - - function plugin (instance, opts, done) { - done() - } - - function plugin2 (instance, opts, done) { - done() - } -}) - -test('hasPlugin method exists as a function', t => { - t.plan(1) - - const fastify = Fastify() - t.equal(typeof fastify.hasPlugin, 'function') -}) - -test('hasPlugin returns true if the specified plugin has been registered', async t => { - t.plan(4) - - const fastify = Fastify() - - function pluginA (fastify, opts, done) { - t.ok(fastify.hasPlugin('plugin-A')) - done() - } - pluginA[Symbol.for('fastify.display-name')] = 'plugin-A' - fastify.register(pluginA) - - fastify.register(function pluginB (fastify, opts, done) { - t.ok(fastify.hasPlugin('pluginB')) - done() - }) - - fastify.register(function (fastify, opts, done) { - // one line - t.ok(fastify.hasPlugin('function (fastify, opts, done) { -- // one line')) - done() - }) - - await fastify.ready() - - t.ok(fastify.hasPlugin('fastify')) -}) - -test('hasPlugin returns false if the specified plugin has not been registered', t => { - t.plan(1) - - const fastify = Fastify() - t.notOk(fastify.hasPlugin('pluginFoo')) -}) - -test('hasPlugin returns false when using encapsulation', async t => { - t.plan(25) - - const fastify = Fastify() - - fastify.register(function pluginA (fastify, opts, done) { - t.ok(fastify.hasPlugin('pluginA')) - t.notOk(fastify.hasPlugin('pluginAA')) - t.notOk(fastify.hasPlugin('pluginAAA')) - t.notOk(fastify.hasPlugin('pluginAB')) - t.notOk(fastify.hasPlugin('pluginB')) - - fastify.register(function pluginAA (fastify, opts, done) { - t.notOk(fastify.hasPlugin('pluginA')) - t.ok(fastify.hasPlugin('pluginAA')) - t.notOk(fastify.hasPlugin('pluginAAA')) - t.notOk(fastify.hasPlugin('pluginAB')) - t.notOk(fastify.hasPlugin('pluginB')) - - fastify.register(function pluginAAA (fastify, opts, done) { - t.notOk(fastify.hasPlugin('pluginA')) - t.notOk(fastify.hasPlugin('pluginAA')) - t.ok(fastify.hasPlugin('pluginAAA')) - t.notOk(fastify.hasPlugin('pluginAB')) - t.notOk(fastify.hasPlugin('pluginB')) - - done() - }) - - done() - }) - - fastify.register(function pluginAB (fastify, opts, done) { - t.notOk(fastify.hasPlugin('pluginA')) - t.notOk(fastify.hasPlugin('pluginAA')) - t.notOk(fastify.hasPlugin('pluginAAA')) - t.ok(fastify.hasPlugin('pluginAB')) - t.notOk(fastify.hasPlugin('pluginB')) - - done() - }) - - done() - }) - - fastify.register(function pluginB (fastify, opts, done) { - t.notOk(fastify.hasPlugin('pluginA')) - t.notOk(fastify.hasPlugin('pluginAA')) - t.notOk(fastify.hasPlugin('pluginAAA')) - t.notOk(fastify.hasPlugin('pluginAB')) - t.ok(fastify.hasPlugin('pluginB')) - - done() - }) - - await fastify.ready() -}) - -test('hasPlugin returns true when using no encapsulation', async t => { - t.plan(26) - - const fastify = Fastify() - - fastify.register(fp((fastify, opts, done) => { - t.equal(fastify.pluginName, 'fastify -> plugin-AA') - t.ok(fastify.hasPlugin('plugin-AA')) - t.notOk(fastify.hasPlugin('plugin-A')) - t.notOk(fastify.hasPlugin('plugin-AAA')) - t.notOk(fastify.hasPlugin('plugin-AB')) - t.notOk(fastify.hasPlugin('plugin-B')) - - fastify.register(fp((fastify, opts, done) => { - t.ok(fastify.hasPlugin('plugin-AA')) - t.ok(fastify.hasPlugin('plugin-A')) - t.notOk(fastify.hasPlugin('plugin-AAA')) - t.notOk(fastify.hasPlugin('plugin-AB')) - t.notOk(fastify.hasPlugin('plugin-B')) - - fastify.register(fp((fastify, opts, done) => { - t.ok(fastify.hasPlugin('plugin-AA')) - t.ok(fastify.hasPlugin('plugin-A')) - t.ok(fastify.hasPlugin('plugin-AAA')) - t.notOk(fastify.hasPlugin('plugin-AB')) - t.notOk(fastify.hasPlugin('plugin-B')) - - done() - }, { name: 'plugin-AAA' })) - - done() - }, { name: 'plugin-A' })) - - fastify.register(fp((fastify, opts, done) => { - t.ok(fastify.hasPlugin('plugin-AA')) - t.ok(fastify.hasPlugin('plugin-A')) - t.ok(fastify.hasPlugin('plugin-AAA')) - t.ok(fastify.hasPlugin('plugin-AB')) - t.notOk(fastify.hasPlugin('plugin-B')) - - done() - }, { name: 'plugin-AB' })) - - done() - }, { name: 'plugin-AA' })) - - fastify.register(fp((fastify, opts, done) => { - t.ok(fastify.hasPlugin('plugin-AA')) - t.ok(fastify.hasPlugin('plugin-A')) - t.ok(fastify.hasPlugin('plugin-AAA')) - t.ok(fastify.hasPlugin('plugin-AB')) - t.ok(fastify.hasPlugin('plugin-B')) - - done() - }, { name: 'plugin-B' })) - - await fastify.ready() -}) diff --git a/test/post-empty-body.test.js b/test/post-empty-body.test.js new file mode 100644 index 00000000000..8389e39da84 --- /dev/null +++ b/test/post-empty-body.test.js @@ -0,0 +1,38 @@ +'use strict' + +const { test } = require('node:test') +const Fastify = require('..') +const { request, setGlobalDispatcher, Agent } = require('undici') + +setGlobalDispatcher(new Agent({ + keepAliveTimeout: 10, + keepAliveMaxTimeout: 10 +})) + +test('post empty body', { timeout: 3_000 }, async t => { + const fastify = Fastify({ forceCloseConnections: true }) + const abortController = new AbortController() + const { signal } = abortController + t.after(() => { + fastify.close() + abortController.abort() + }) + + fastify.post('/bug', async () => { + // This function must be async and return nothing + }) + + await fastify.listen({ port: 0 }) + + const res = await request(`http://localhost:${fastify.server.address().port}/bug`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ foo: 'bar' }), + signal + }) + + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(await res.body.text(), '') +}) diff --git a/test/pretty-print.test.js b/test/pretty-print.test.js index 47ec61f8e11..e02a493d3ab 100644 --- a/test/pretty-print.test.js +++ b/test/pretty-print.test.js @@ -1,10 +1,9 @@ 'use strict' -const t = require('tap') -const test = t.test +const { test } = require('node:test') const Fastify = require('..') -test('pretty print - static routes', t => { +test('pretty print - static routes', (t, done) => { t.plan(2) const fastify = Fastify({ exposeHeadRoutes: false }) @@ -15,18 +14,56 @@ test('pretty print - static routes', t => { fastify.ready(() => { const tree = fastify.printRoutes() - const expected = `└── / + const expected = `\ +└── / ├── test (GET) │ └── /hello (GET) └── hello/world (GET) ` - t.equal(typeof tree, 'string') - t.equal(tree, expected) + t.assert.strictEqual(typeof tree, 'string') + t.assert.strictEqual(tree, expected) + done() }) }) -test('pretty print - parametric routes', t => { +test('pretty print - internal tree - static routes', (t, done) => { + t.plan(4) + + const fastify = Fastify({ exposeHeadRoutes: false }) + fastify.get('/test', () => {}) + fastify.get('/test/hello', () => {}) + fastify.get('/hello/world', () => {}) + + fastify.put('/test', () => {}) + fastify.put('/test/foo', () => {}) + + fastify.ready(() => { + const getTree = fastify.printRoutes({ method: 'GET' }) + const expectedGetTree = `\ +└── / + ├── test (GET) + │ └── /hello (GET) + └── hello/world (GET) +` + + t.assert.strictEqual(typeof getTree, 'string') + t.assert.strictEqual(getTree, expectedGetTree) + + const putTree = fastify.printRoutes({ method: 'PUT' }) + const expectedPutTree = `\ +└── / + └── test (PUT) + └── /foo (PUT) +` + + t.assert.strictEqual(typeof putTree, 'string') + t.assert.strictEqual(putTree, expectedPutTree) + done() + }) +}) + +test('pretty print - parametric routes', (t, done) => { t.plan(2) const fastify = Fastify({ exposeHeadRoutes: false }) @@ -37,18 +74,61 @@ test('pretty print - parametric routes', t => { fastify.ready(() => { const tree = fastify.printRoutes() - const expected = `└── / + const expected = `\ +└── / + ├── test (GET) + │ └── / + │ └── :hello (GET) + └── hello/ + └── :world (GET) +` + + t.assert.strictEqual(typeof tree, 'string') + t.assert.strictEqual(tree, expected) + done() + }) +}) + +test('pretty print - internal tree - parametric routes', (t, done) => { + t.plan(4) + + const fastify = Fastify({ exposeHeadRoutes: false }) + fastify.get('/test', () => {}) + fastify.get('/test/:hello', () => {}) + fastify.get('/hello/:world', () => {}) + + fastify.put('/test', () => {}) + fastify.put('/test/:hello', () => {}) + + fastify.ready(() => { + const getTree = fastify.printRoutes({ method: 'GET' }) + const expectedGetTree = `\ +└── / ├── test (GET) - │ └── /:hello (GET) - └── hello/:world (GET) + │ └── / + │ └── :hello (GET) + └── hello/ + └── :world (GET) ` - t.equal(typeof tree, 'string') - t.equal(tree, expected) + t.assert.strictEqual(typeof getTree, 'string') + t.assert.strictEqual(getTree, expectedGetTree) + + const putTree = fastify.printRoutes({ method: 'PUT' }) + const expectedPutTree = `\ +└── / + └── test (PUT) + └── / + └── :hello (PUT) +` + + t.assert.strictEqual(typeof putTree, 'string') + t.assert.strictEqual(putTree, expectedPutTree) + done() }) }) -test('pretty print - mixed parametric routes', t => { +test('pretty print - mixed parametric routes', (t, done) => { t.plan(2) const fastify = Fastify({ exposeHeadRoutes: false }) @@ -60,19 +140,21 @@ test('pretty print - mixed parametric routes', t => { fastify.ready(() => { const tree = fastify.printRoutes() - const expected = `└── /test (GET) - └── / - └── :hello (GET) - :hello (POST) - └── /world (GET) + const expected = `\ +└── / + └── test (GET) + └── / + └── :hello (GET, POST) + └── /world (GET) ` - t.equal(typeof tree, 'string') - t.equal(tree, expected) + t.assert.strictEqual(typeof tree, 'string') + t.assert.strictEqual(tree, expected) + done() }) }) -test('pretty print - wildcard routes', t => { +test('pretty print - wildcard routes', (t, done) => { t.plan(2) const fastify = Fastify({ exposeHeadRoutes: false }) @@ -83,29 +165,73 @@ test('pretty print - wildcard routes', t => { fastify.ready(() => { const tree = fastify.printRoutes() - const expected = `└── / + const expected = `\ +└── / + ├── test (GET) + │ └── / + │ └── * (GET) + └── hello/ + └── * (GET) +` + + t.assert.strictEqual(typeof tree, 'string') + t.assert.strictEqual(tree, expected) + done() + }) +}) + +test('pretty print - internal tree - wildcard routes', (t, done) => { + t.plan(4) + + const fastify = Fastify({ exposeHeadRoutes: false }) + fastify.get('/test', () => {}) + fastify.get('/test/*', () => {}) + fastify.get('/hello/*', () => {}) + + fastify.put('/*', () => {}) + fastify.put('/test/*', () => {}) + + fastify.ready(() => { + const getTree = fastify.printRoutes({ method: 'GET' }) + const expectedGetTree = `\ +└── / ├── test (GET) - │ └── /* (GET) - └── hello/* (GET) + │ └── / + │ └── * (GET) + └── hello/ + └── * (GET) +` + + t.assert.strictEqual(typeof getTree, 'string') + t.assert.strictEqual(getTree, expectedGetTree) + + const putTree = fastify.printRoutes({ method: 'PUT' }) + const expectedPutTree = `\ +└── / + ├── test/ + │ └── * (PUT) + └── * (PUT) ` - t.equal(typeof tree, 'string') - t.equal(tree, expected) + t.assert.strictEqual(typeof putTree, 'string') + t.assert.strictEqual(putTree, expectedPutTree) + done() }) }) -test('pretty print - empty plugins', t => { +test('pretty print - empty plugins', (t, done) => { t.plan(2) const fastify = Fastify() fastify.ready(() => { const tree = fastify.printPlugins() - t.equal(typeof tree, 'string') - t.match(tree, 'bound root') + t.assert.strictEqual(typeof tree, 'string') + t.assert.match(tree, /root \d+ ms\n└── bound _after \d+ ms/m) + done() }) }) -test('pretty print - nested plugins', t => { +test('pretty print - nested plugins', (t, done) => { t.plan(4) const fastify = Fastify() @@ -115,14 +241,15 @@ test('pretty print - nested plugins', t => { }) fastify.ready(() => { const tree = fastify.printPlugins() - t.equal(typeof tree, 'string') - t.match(tree, 'foo') - t.match(tree, 'bar') - t.match(tree, 'baz') + t.assert.strictEqual(typeof tree, 'string') + t.assert.match(tree, /foo/) + t.assert.match(tree, /bar/) + t.assert.match(tree, /baz/) + done() }) }) -test('pretty print - commonPrefix', t => { +test('pretty print - commonPrefix', (t, done) => { t.plan(4) const fastify = Fastify() @@ -134,26 +261,25 @@ test('pretty print - commonPrefix', t => { const radixTree = fastify.printRoutes() const flatTree = fastify.printRoutes({ commonPrefix: false }) - const radixExpected = `└── / - ├── hel - │ ├── lo (GET) - │ │ lo (HEAD) - │ └── icopter (GET) - │ icopter (HEAD) - └── hello (PUT) + const radixExpected = `\ +└── / + └── hel + ├── lo (GET, HEAD, PUT) + └── icopter (GET, HEAD) ` - const flatExpected = `└── / (-) - ├── helicopter (GET, HEAD) - └── hello (GET, HEAD, PUT) + const flatExpected = `\ +├── /hello (GET, HEAD, PUT) +└── /helicopter (GET, HEAD) ` - t.equal(typeof radixTree, 'string') - t.equal(typeof flatTree, 'string') - t.equal(radixTree, radixExpected) - t.equal(flatTree, flatExpected) + t.assert.strictEqual(typeof radixTree, 'string') + t.assert.strictEqual(typeof flatTree, 'string') + t.assert.strictEqual(radixTree, radixExpected) + t.assert.strictEqual(flatTree, flatExpected) + done() }) }) -test('pretty print - includeMeta, includeHooks', t => { +test('pretty print - includeMeta, includeHooks', (t, done) => { t.plan(6) const fastify = Fastify() @@ -170,55 +296,71 @@ test('pretty print - includeMeta, includeHooks', t => { const flatTree = fastify.printRoutes({ commonPrefix: false, includeHooks: true, includeMeta: ['errorHandler'] }) const hooksOnly = fastify.printRoutes({ commonPrefix: false, includeHooks: true }) - const radixExpected = `└── / - ├── hel - │ ├── lo (GET) - │ │ • (onTimeout) ["onTimeout()"] - │ │ • (onRequest) ["anonymous()"] - │ │ • (errorHandler) "defaultErrorHandler()" - │ │ lo (HEAD) - │ │ • (onTimeout) ["onTimeout()"] - │ │ • (onRequest) ["anonymous()"] - │ │ • (onSend) ["headRouteOnSendHandler()"] - │ │ • (errorHandler) "defaultErrorHandler()" - │ └── icopter (GET) - │ • (onTimeout) ["onTimeout()"] - │ • (onRequest) ["anonymous()"] - │ • (errorHandler) "defaultErrorHandler()" - │ icopter (HEAD) - │ • (onTimeout) ["onTimeout()"] - │ • (onRequest) ["anonymous()"] - │ • (onSend) ["headRouteOnSendHandler()"] - │ • (errorHandler) "defaultErrorHandler()" - └── hello (PUT) - • (onTimeout) ["onTimeout()"] - • (onRequest) ["anonymous()"] - • (errorHandler) "defaultErrorHandler()" + const radixExpected = `\ +└── / + └── hel + ├── lo (GET, PUT) + │ • (onTimeout) ["onTimeout()"] + │ • (onRequest) ["anonymous()"] + │ • (errorHandler) "defaultErrorHandler()" + │ lo (HEAD) + │ • (onTimeout) ["onTimeout()"] + │ • (onRequest) ["anonymous()"] + │ • (onSend) ["headRouteOnSendHandler()"] + │ • (errorHandler) "defaultErrorHandler()" + └── icopter (GET) + • (onTimeout) ["onTimeout()"] + • (onRequest) ["anonymous()"] + • (errorHandler) "defaultErrorHandler()" + icopter (HEAD) + • (onTimeout) ["onTimeout()"] + • (onRequest) ["anonymous()"] + • (onSend) ["headRouteOnSendHandler()"] + • (errorHandler) "defaultErrorHandler()" ` - const flatExpected = `└── / (-) - ├── helicopter (GET, HEAD) - │ • (onTimeout) ["onTimeout()"] - │ • (onRequest) ["anonymous()"] - │ • (errorHandler) "defaultErrorHandler()" - └── hello (GET, HEAD, PUT) - • (onTimeout) ["onTimeout()"] - • (onRequest) ["anonymous()"] - • (errorHandler) "defaultErrorHandler()" + const flatExpected = `\ +├── /hello (GET, PUT) +│ • (onTimeout) ["onTimeout()"] +│ • (onRequest) ["anonymous()"] +│ • (errorHandler) "defaultErrorHandler()" +│ /hello (HEAD) +│ • (onTimeout) ["onTimeout()"] +│ • (onRequest) ["anonymous()"] +│ • (onSend) ["headRouteOnSendHandler()"] +│ • (errorHandler) "defaultErrorHandler()" +└── /helicopter (GET) + • (onTimeout) ["onTimeout()"] + • (onRequest) ["anonymous()"] + • (errorHandler) "defaultErrorHandler()" + /helicopter (HEAD) + • (onTimeout) ["onTimeout()"] + • (onRequest) ["anonymous()"] + • (onSend) ["headRouteOnSendHandler()"] + • (errorHandler) "defaultErrorHandler()" ` - const hooksOnlyExpected = `└── / (-) - ├── helicopter (GET, HEAD) - │ • (onTimeout) ["onTimeout()"] - │ • (onRequest) ["anonymous()"] - └── hello (GET, HEAD, PUT) - • (onTimeout) ["onTimeout()"] - • (onRequest) ["anonymous()"] + const hooksOnlyExpected = `\ +├── /hello (GET, PUT) +│ • (onTimeout) ["onTimeout()"] +│ • (onRequest) ["anonymous()"] +│ /hello (HEAD) +│ • (onTimeout) ["onTimeout()"] +│ • (onRequest) ["anonymous()"] +│ • (onSend) ["headRouteOnSendHandler()"] +└── /helicopter (GET) + • (onTimeout) ["onTimeout()"] + • (onRequest) ["anonymous()"] + /helicopter (HEAD) + • (onTimeout) ["onTimeout()"] + • (onRequest) ["anonymous()"] + • (onSend) ["headRouteOnSendHandler()"] ` - t.equal(typeof radixTree, 'string') - t.equal(typeof flatTree, 'string') - t.equal(typeof hooksOnlyExpected, 'string') - t.equal(radixTree, radixExpected) - t.equal(flatTree, flatExpected) - t.equal(hooksOnly, hooksOnlyExpected) + t.assert.strictEqual(typeof radixTree, 'string') + t.assert.strictEqual(typeof flatTree, 'string') + t.assert.strictEqual(typeof hooksOnlyExpected, 'string') + t.assert.strictEqual(radixTree, radixExpected) + t.assert.strictEqual(flatTree, flatExpected) + t.assert.strictEqual(hooksOnly, hooksOnlyExpected) + done() }) }) diff --git a/test/promises.test.js b/test/promises.test.js index 6363506f3d4..68b828844cc 100644 --- a/test/promises.test.js +++ b/test/promises.test.js @@ -1,10 +1,11 @@ 'use strict' -const t = require('tap') -const test = t.test -const sget = require('simple-get').concat +const { test } = require('node:test') +const assert = require('node:assert') const fastify = require('..')() +test.after(() => fastify.close()) + const opts = { schema: { response: { @@ -60,81 +61,65 @@ fastify.get('/return-reply', opts, function (req, reply) { return reply.send({ hello: 'world' }) }) -fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) +fastify.listen({ port: 0 }, (err, fastifyServer) => { + assert.ifError(err) - test('shorthand - sget return promise es6 get', t => { + test('shorthand - fetch return promise es6 get', async t => { t.plan(4) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/return' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) + + const result = await fetch(`${fastifyServer}/return`) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { hello: 'world' }) }) - test('shorthand - sget promise es6 get return error', t => { + test('shorthand - fetch promise es6 get return error', async t => { t.plan(2) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/return-error' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 500) - }) + + const result = await fetch(`${fastifyServer}/return-error`) + t.assert.ok(!result.ok) + t.assert.strictEqual(result.status, 500) }) - test('sget promise double send', t => { + test('fetch promise double send', async t => { t.plan(3) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/double' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(JSON.parse(body), { hello: '42' }) - }) + const result = await fetch(`${fastifyServer}/double`) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + const body = await result.text() + t.assert.deepStrictEqual(JSON.parse(body), { hello: '42' }) }) - test('thenable', t => { + test('thenable', async t => { t.plan(4) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/thenable' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) + + const result = await fetch(`${fastifyServer}/thenable`) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { hello: 'world' }) }) - test('thenable (error)', t => { + test('thenable (error)', async t => { t.plan(2) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/thenable-error' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 500) - }) + + const result = await fetch(`${fastifyServer}/thenable-error`) + t.assert.ok(!result.ok) + t.assert.strictEqual(result.status, 500) }) - test('return-reply', t => { + test('return-reply', async t => { t.plan(4) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/return-reply' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) + + const result = await fetch(`${fastifyServer}/return-reply`) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + const body = await result.text() + t.assert.strictEqual(result.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { hello: 'world' }) }) }) diff --git a/test/proto-poisoning.test.js b/test/proto-poisoning.test.js index c0200f88ddd..9b557c00aeb 100644 --- a/test/proto-poisoning.test.js +++ b/test/proto-poisoning.test.js @@ -1,159 +1,145 @@ 'use strict' const Fastify = require('..') -const sget = require('simple-get').concat -const t = require('tap') -const test = t.test +const { test } = require('node:test') -test('proto-poisoning error', t => { - t.plan(3) +test('proto-poisoning error', async (t) => { + t.plan(2) const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) fastify.post('/', (request, reply) => { - t.fail('handler should not be called') + t.assert.fail('handler should not be called') }) - fastify.listen({ port: 0 }, function (err) { - t.error(err) - - sget({ - method: 'POST', - url: 'http://localhost:' + fastify.server.address().port, - headers: { 'Content-Type': 'application/json' }, - body: '{ "__proto__": { "a": 42 } }' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 400) - }) + t.after(() => fastify.close()) + + const fastifyServer = await fastify.listen({ port: 0 }) + + const result = await fetch(fastifyServer, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{ "__proto__": { "a": 42 } }' }) + + t.assert.ok(!result.ok) + t.assert.strictEqual(result.status, 400) }) -test('proto-poisoning remove', t => { - t.plan(4) +test('proto-poisoning remove', async (t) => { + t.plan(3) const fastify = Fastify({ onProtoPoisoning: 'remove' }) - t.teardown(fastify.close.bind(fastify)) + + t.after(() => fastify.close()) fastify.post('/', (request, reply) => { - t.equal(undefined, Object.assign({}, request.body).a) + t.assert.strictEqual(undefined, Object.assign({}, request.body).a) reply.send({ ok: true }) }) - fastify.listen({ port: 0 }, function (err) { - t.error(err) - - sget({ - method: 'POST', - url: 'http://localhost:' + fastify.server.address().port, - headers: { 'Content-Type': 'application/json' }, - body: '{ "__proto__": { "a": 42 }, "b": 42 }' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - }) + const fastifyServer = await fastify.listen({ port: 0 }) + + const result = await fetch(fastifyServer, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{ "__proto__": { "a": 42 }, "b": 42 }' }) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) }) -test('proto-poisoning ignore', t => { - t.plan(4) +test('proto-poisoning ignore', async (t) => { + t.plan(3) const fastify = Fastify({ onProtoPoisoning: 'ignore' }) - t.teardown(fastify.close.bind(fastify)) fastify.post('/', (request, reply) => { - t.equal(42, Object.assign({}, request.body).a) + t.assert.strictEqual(42, Object.assign({}, request.body).a) reply.send({ ok: true }) }) - fastify.listen({ port: 0 }, function (err) { - t.error(err) - - sget({ - method: 'POST', - url: 'http://localhost:' + fastify.server.address().port, - headers: { 'Content-Type': 'application/json' }, - body: '{ "__proto__": { "a": 42 }, "b": 42 }' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - }) + t.after(() => fastify.close()) + + const fastifyServer = await fastify.listen({ port: 0 }) + + const result = await fetch(fastifyServer, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{ "__proto__": { "a": 42 }, "b": 42 }' }) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) }) -test('constructor-poisoning error (default in v3)', t => { - t.plan(3) +test('constructor-poisoning error (default in v3)', async (t) => { + t.plan(2) const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) fastify.post('/', (request, reply) => { reply.send('ok') }) - fastify.listen({ port: 0 }, function (err) { - t.error(err) - - sget({ - method: 'POST', - url: 'http://localhost:' + fastify.server.address().port, - headers: { 'Content-Type': 'application/json' }, - body: '{ "constructor": { "prototype": { "foo": "bar" } } }' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 400) - }) + t.after(() => fastify.close()) + + const fastifyServer = await fastify.listen({ port: 0 }) + + const result = await fetch(fastifyServer, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{ "constructor": { "prototype": { "foo": "bar" } } }' }) + + t.assert.ok(!result.ok) + t.assert.strictEqual(result.status, 400) }) -test('constructor-poisoning error', t => { - t.plan(3) +test('constructor-poisoning error', async (t) => { + t.plan(2) const fastify = Fastify({ onConstructorPoisoning: 'error' }) - t.teardown(fastify.close.bind(fastify)) + + t.after(() => fastify.close()) fastify.post('/', (request, reply) => { - t.fail('handler should not be called') + t.assert.fail('handler should not be called') }) - fastify.listen({ port: 0 }, function (err) { - t.error(err) - - sget({ - method: 'POST', - url: 'http://localhost:' + fastify.server.address().port, - headers: { 'Content-Type': 'application/json' }, - body: '{ "constructor": { "prototype": { "foo": "bar" } } }' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 400) - }) + const fastifyServer = await fastify.listen({ port: 0 }) + + const result = await fetch(fastifyServer, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{ "constructor": { "prototype": { "foo": "bar" } } }' }) + + t.assert.ok(!result.ok) + t.assert.strictEqual(result.status, 400) }) -test('constructor-poisoning remove', t => { - t.plan(4) +test('constructor-poisoning remove', async (t) => { + t.plan(3) const fastify = Fastify({ onConstructorPoisoning: 'remove' }) - t.teardown(fastify.close.bind(fastify)) + + t.after(() => fastify.close()) fastify.post('/', (request, reply) => { - t.equal(undefined, Object.assign({}, request.body).foo) + t.assert.strictEqual(undefined, Object.assign({}, request.body).foo) reply.send({ ok: true }) }) - fastify.listen({ port: 0 }, function (err) { - t.error(err) - - sget({ - method: 'POST', - url: 'http://localhost:' + fastify.server.address().port, - headers: { 'Content-Type': 'application/json' }, - body: '{ "constructor": { "prototype": { "foo": "bar" } } }' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - }) + const fastifyServer = await fastify.listen({ port: 0 }) + + const result = await fetch(fastifyServer, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{ "constructor": { "prototype": { "foo": "bar" } } }' }) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) }) diff --git a/test/put.error-handler.test.js b/test/put.error-handler.test.js index 010430c64a0..8217a88bf74 100644 --- a/test/put.error-handler.test.js +++ b/test/put.error-handler.test.js @@ -1,5 +1,5 @@ 'use strict' -const t = require('tap') +const t = require('node:test') require('./helper').payloadMethod('put', t, true) require('./input-validation').payloadMethod('put', t) diff --git a/test/put.test.js b/test/put.test.js index c55197a3e39..808d275987f 100644 --- a/test/put.test.js +++ b/test/put.test.js @@ -1,5 +1,5 @@ 'use strict' -const t = require('tap') +const t = require('node:test') require('./helper').payloadMethod('put', t) require('./input-validation').payloadMethod('put', t) diff --git a/test/register.test.js b/test/register.test.js index 2c9dddcbe83..e00d01b0277 100644 --- a/test/register.test.js +++ b/test/register.test.js @@ -1,23 +1,19 @@ 'use strict' -/* eslint no-prototype-builtins: 0 */ - -const t = require('tap') -const test = t.test -const sget = require('simple-get').concat +const { test } = require('node:test') const Fastify = require('..') -test('register', t => { - t.plan(17) +test('register', async (t) => { + t.plan(16) const fastify = Fastify() fastify.register(function (instance, opts, done) { - t.not(instance, fastify) - t.ok(fastify.isPrototypeOf(instance)) + t.assert.notStrictEqual(instance, fastify) + t.assert.ok(Object.prototype.isPrototypeOf.call(fastify, instance)) - t.equal(typeof opts, 'object') - t.equal(typeof done, 'function') + t.assert.strictEqual(typeof opts, 'object') + t.assert.strictEqual(typeof done, 'function') instance.get('/first', function (req, reply) { reply.send({ hello: 'world' }) @@ -26,11 +22,11 @@ test('register', t => { }) fastify.register(function (instance, opts, done) { - t.not(instance, fastify) - t.ok(fastify.isPrototypeOf(instance)) + t.assert.notStrictEqual(instance, fastify) + t.assert.ok(Object.prototype.isPrototypeOf.call(fastify, instance)) - t.equal(typeof opts, 'object') - t.equal(typeof done, 'function') + t.assert.strictEqual(typeof opts, 'object') + t.assert.strictEqual(typeof done, 'function') instance.get('/second', function (req, reply) { reply.send({ hello: 'world' }) @@ -38,28 +34,23 @@ test('register', t => { done() }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - makeRequest('first') - makeRequest('second') - }) - - function makeRequest (path) { - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/' + path - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(response.headers['content-length'], '' + body.length) - t.same(JSON.parse(body), { hello: 'world' }) - }) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + await makeRequest('first') + await makeRequest('second') + + async function makeRequest (path) { + const response = await fetch(fastifyServer + '/' + path) + t.assert.ok(response.ok) + t.assert.strictEqual(response.status, 200) + const body = await response.text() + t.assert.strictEqual(response.headers.get('content-length'), '' + body.length) + t.assert.deepStrictEqual(JSON.parse(body), { hello: 'world' }) } }) -test('internal route declaration should pass the error generated by the register to the done handler / 1', t => { +test('internal route declaration should pass the error generated by the register to the done handler / 1', (t, done) => { t.plan(1) const fastify = Fastify() @@ -72,12 +63,13 @@ test('internal route declaration should pass the error generated by the register }) fastify.listen({ port: 0 }, err => { - fastify.close() - t.equal(err.message, 'kaboom') + t.after(() => fastify.close()) + t.assert.strictEqual(err.message, 'kaboom') + done() }) }) -test('internal route declaration should pass the error generated by the register to the done handler / 2', t => { +test('internal route declaration should pass the error generated by the register to the done handler / 2', (t, done) => { t.plan(2) const fastify = Fastify() @@ -90,12 +82,13 @@ test('internal route declaration should pass the error generated by the register }) fastify.after(err => { - t.equal(err.message, 'kaboom') + t.assert.strictEqual(err.message, 'kaboom') }) fastify.listen({ port: 0 }, err => { - fastify.close() - t.error(err) + t.after(() => fastify.close()) + t.assert.ifError(err) + done() }) }) @@ -105,29 +98,29 @@ test('awaitable register and after', async t => { let second = false let third = false - await fastify.register(async (instance, opts, done) => { + await fastify.register(async (instance, opts) => { first = true }) - t.equal(first, true) + t.assert.strictEqual(first, true) - fastify.register(async (instance, opts, done) => { + fastify.register(async (instance, opts) => { second = true }) await fastify.after() - t.equal(second, true) + t.assert.strictEqual(second, true) - fastify.register(async (instance, opts, done) => { + fastify.register(async (instance, opts) => { third = true }) await fastify.ready() - t.equal(third, true) + t.assert.strictEqual(third, true) }) function thenableRejects (t, promise, error) { - return t.rejects(async () => { await promise }, error) + return t.assert.rejects(async () => { await promise }, error) } test('awaitable register error handling', async t => { @@ -140,13 +133,13 @@ test('awaitable register error handling', async t => { }), e) fastify.register(async (instance, opts) => { - t.fail('should not be executed') + t.assert.fail('should not be executed') }) - await t.rejects(fastify.after(), e) + await t.assert.rejects(fastify.after(), e) - fastify.register(async (instance, opts, done) => { - t.fail('should not be executed') + fastify.register(async (instance, opts) => { + t.assert.fail('should not be executed') }) await thenableRejects(t, fastify.ready(), e) @@ -162,16 +155,16 @@ test('awaitable after error handling', async t => { }) fastify.register(async (instance, opts) => { - t.fail('should not be executed') + t.assert.fail('should not be executed') }) - await t.rejects(fastify.after(), e) + await t.assert.rejects(fastify.after(), e) - fastify.register(async (instance, opts, done) => { - t.fail('should not be executed') + fastify.register(async (instance, opts) => { + t.assert.fail('should not be executed') }) - await t.rejects(fastify.ready()) + await t.assert.rejects(fastify.ready()) }) test('chainable register', async t => { @@ -180,11 +173,11 @@ test('chainable register', async t => { const fastify = Fastify() fastify.register(async () => { - t.pass('first loaded') + t.assert.ok('first loaded') }).register(async () => { - t.pass('second loaded') + t.assert.ok('second loaded') }).register(async () => { - t.pass('third loaded') + t.assert.ok('third loaded') }) await fastify.ready() diff --git a/test/reply-code.test.js b/test/reply-code.test.js new file mode 100644 index 00000000000..700f6f41f0c --- /dev/null +++ b/test/reply-code.test.js @@ -0,0 +1,148 @@ +'use strict' + +const { test } = require('node:test') +const { Readable } = require('node:stream') +const Fastify = require('..') + +test('code should handle null/undefined/float', (t, done) => { + t.plan(8) + + const fastify = Fastify() + + fastify.get('/null', function (request, reply) { + reply.status(null).send() + }) + + fastify.get('/undefined', function (request, reply) { + reply.status(undefined).send() + }) + + fastify.get('/404.5', function (request, reply) { + reply.status(404.5).send() + }) + + fastify.inject({ + method: 'GET', + url: '/null' + }, (error, res) => { + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 500) + t.assert.deepStrictEqual(res.json(), { + statusCode: 500, + code: 'FST_ERR_BAD_STATUS_CODE', + error: 'Internal Server Error', + message: 'Called reply with an invalid status code: null' + }) + }) + + fastify.inject({ + method: 'GET', + url: '/undefined' + }, (error, res) => { + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 500) + t.assert.deepStrictEqual(res.json(), { + statusCode: 500, + code: 'FST_ERR_BAD_STATUS_CODE', + error: 'Internal Server Error', + message: 'Called reply with an invalid status code: undefined' + }) + }) + + fastify.inject({ + method: 'GET', + url: '/404.5' + }, (error, res) => { + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 404) + done() + }) +}) + +test('code should handle 204', (t, done) => { + t.plan(13) + + const fastify = Fastify() + + fastify.get('/204', function (request, reply) { + reply.status(204) + return null + }) + + fastify.get('/undefined/204', function (request, reply) { + reply.status(204).send({ message: 'hello' }) + }) + + fastify.get('/stream/204', function (request, reply) { + const stream = new Readable({ + read () { + this.push(null) + } + }) + stream.on('end', () => { + t.assert.ok('stream ended') + }) + reply.status(204).send(stream) + }) + + fastify.inject({ + method: 'GET', + url: '/204' + }, (error, res) => { + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 204) + t.assert.strictEqual(res.payload, '') + t.assert.strictEqual(res.headers['content-length'], undefined) + }) + + fastify.inject({ + method: 'GET', + url: '/undefined/204' + }, (error, res) => { + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 204) + t.assert.strictEqual(res.payload, '') + t.assert.strictEqual(res.headers['content-length'], undefined) + }) + + fastify.inject({ + method: 'GET', + url: '/stream/204' + }, (error, res) => { + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 204) + t.assert.strictEqual(res.payload, '') + t.assert.strictEqual(res.headers['content-length'], undefined) + done() + }) +}) + +test('code should handle onSend hook on 204', (t, done) => { + t.plan(5) + + const fastify = Fastify() + fastify.addHook('onSend', async function (request, reply, payload) { + return { + ...payload, + world: 'hello' + } + }) + + fastify.get('/204', function (request, reply) { + reply.status(204).send({ + hello: 'world' + }) + }) + + fastify.inject({ + method: 'GET', + url: '/204' + }, (error, res) => { + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 204) + t.assert.strictEqual(res.payload, '') + t.assert.strictEqual(res.headers['content-length'], undefined) + t.assert.strictEqual(res.headers['content-type'], undefined) + done() + }) +}) diff --git a/test/reply-early-hints.test.js b/test/reply-early-hints.test.js new file mode 100644 index 00000000000..a6042e5a36b --- /dev/null +++ b/test/reply-early-hints.test.js @@ -0,0 +1,100 @@ +'use strict' + +const Fastify = require('..') +const { test } = require('node:test') +const http = require('node:http') +const http2 = require('node:http2') + +const testResBody = 'Hello, world!' + +test('sends early hints', (t, done) => { + t.plan(6) + + const fastify = Fastify({ + logger: false + }) + + fastify.get('/', async (request, reply) => { + reply.writeEarlyHints({ + link: '; rel=preload; as=style' + }, () => { + t.assert.ok('callback called') + }) + + return testResBody + }) + + fastify.listen({ port: 0 }, (err, address) => { + t.assert.ifError(err) + + const req = http.get(address) + + req.on('information', (res) => { + t.assert.strictEqual(res.statusCode, 103) + t.assert.strictEqual(res.headers.link, '; rel=preload; as=style') + }) + + req.on('response', (res) => { + t.assert.strictEqual(res.statusCode, 200) + + let data = '' + res.on('data', (chunk) => { + data += chunk + }) + + res.on('end', () => { + t.assert.strictEqual(data, testResBody) + fastify.close() + done() + }) + }) + }) +}) + +test('sends early hints (http2)', (t, done) => { + t.plan(6) + + const fastify = Fastify({ + http2: true, + logger: false + }) + + fastify.get('/', async (request, reply) => { + reply.writeEarlyHints({ + link: '; rel=preload; as=style' + }) + + return testResBody + }) + + fastify.listen({ port: 0 }, (err, address) => { + t.assert.ifError(err) + + const client = http2.connect(address) + const req = client.request() + + req.on('headers', (headers) => { + t.assert.notStrictEqual(headers, undefined) + t.assert.strictEqual(headers[':status'], 103) + t.assert.strictEqual(headers.link, '; rel=preload; as=style') + }) + + req.on('response', (headers) => { + t.assert.strictEqual(headers[':status'], 200) + }) + + let data = '' + req.on('data', (chunk) => { + data += chunk + }) + + req.on('end', () => { + t.assert.strictEqual(data, testResBody) + client.close() + fastify.close() + done() + }) + + req.end() + }) +}) diff --git a/test/reply-error.test.js b/test/reply-error.test.js index c9ab206eea4..8f2a35f36de 100644 --- a/test/reply-error.test.js +++ b/test/reply-error.test.js @@ -1,13 +1,12 @@ 'use strict' -const t = require('tap') -const test = t.test -const net = require('net') +const { test, describe } = require('node:test') +const net = require('node:net') const Fastify = require('..') -const statusCodes = require('http').STATUS_CODES +const statusCodes = require('node:http').STATUS_CODES const split = require('split2') -const fs = require('fs') -const path = require('path') +const fs = require('node:fs') +const path = require('node:path') const codes = Object.keys(statusCodes) codes.forEach(code => { @@ -15,9 +14,10 @@ codes.forEach(code => { }) function helper (code) { - test('Reply error handling - code: ' + code, t => { + test('Reply error handling - code: ' + code, (t, testDone) => { t.plan(4) const fastify = Fastify() + t.after(() => fastify.close()) const err = new Error('winter is coming') fastify.get('/', (req, reply) => { @@ -30,10 +30,10 @@ function helper (code) { method: 'GET', url: '/' }, (error, res) => { - t.error(error) - t.equal(res.statusCode, Number(code)) - t.equal(res.headers['content-type'], 'application/json; charset=utf-8') - t.same( + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, Number(code)) + t.assert.strictEqual(res.headers['content-type'], 'application/json; charset=utf-8') + t.assert.deepStrictEqual( { error: statusCodes[code], message: err.message, @@ -41,13 +41,15 @@ function helper (code) { }, JSON.parse(res.payload) ) + testDone() }) }) } -test('preHandler hook error handling with external code', t => { +test('preHandler hook error handling with external code', (t, testDone) => { t.plan(3) const fastify = Fastify() + t.after(() => fastify.close()) const err = new Error('winter is coming') fastify.addHook('preHandler', (req, reply, done) => { @@ -61,9 +63,9 @@ test('preHandler hook error handling with external code', t => { method: 'GET', url: '/' }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 400) - t.same( + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 400) + t.assert.deepStrictEqual( { error: statusCodes['400'], message: err.message, @@ -71,12 +73,14 @@ test('preHandler hook error handling with external code', t => { }, JSON.parse(res.payload) ) + testDone() }) }) -test('onRequest hook error handling with external done', t => { +test('onRequest hook error handling with external done', (t, testDone) => { t.plan(3) const fastify = Fastify() + t.after(() => fastify.close()) const err = new Error('winter is coming') fastify.addHook('onRequest', (req, reply, done) => { @@ -90,9 +94,9 @@ test('onRequest hook error handling with external done', t => { method: 'GET', url: '/' }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 400) - t.same( + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 400) + t.assert.deepStrictEqual( { error: statusCodes['400'], message: err.message, @@ -100,17 +104,19 @@ test('onRequest hook error handling with external done', t => { }, JSON.parse(res.payload) ) + testDone() }) }) -test('Should reply 400 on client error', t => { +test('Should reply 400 on client error', (t, testDone) => { t.plan(2) const fastify = Fastify() - fastify.listen({ port: 0 }, err => { - t.error(err) + t.after(() => fastify.close()) + fastify.listen({ port: 0, host: '127.0.0.1' }, err => { + t.assert.ifError(err) - const client = net.connect(fastify.server.address().port) + const client = net.connect(fastify.server.address().port, '127.0.0.1') client.end('oooops!') let chunks = '' @@ -124,13 +130,13 @@ test('Should reply 400 on client error', t => { message: 'Client Error', statusCode: 400 }) - t.equal(`HTTP/1.1 400 Bad Request\r\nContent-Length: ${body.length}\r\nContent-Type: application/json\r\n\r\n${body}`, chunks) - fastify.close() + t.assert.strictEqual(`HTTP/1.1 400 Bad Request\r\nContent-Length: ${body.length}\r\nContent-Type: application/json\r\n\r\n${body}`, chunks) + testDone() }) }) }) -test('Should set the response from client error handler', t => { +test('Should set the response from client error handler', (t, testDone) => { t.plan(5) const responseBody = JSON.stringify({ @@ -141,7 +147,7 @@ test('Should set the response from client error handler', t => { const response = `HTTP/1.1 400 Bad Request\r\nContent-Length: ${responseBody.length}\r\nContent-Type: application/json; charset=utf-8\r\n\r\n${responseBody}` function clientErrorHandler (err, socket) { - t.type(err, Error) + t.assert.ok(err instanceof Error) this.log.warn({ err }, 'Handled client error') socket.end(response) @@ -156,10 +162,11 @@ test('Should set the response from client error handler', t => { } }) - fastify.listen({ port: 0 }, err => { - t.error(err) + fastify.listen({ port: 0, host: '127.0.0.1' }, err => { + t.assert.ifError(err) + t.after(() => fastify.close()) - const client = net.connect(fastify.server.address().port) + const client = net.connect(fastify.server.address().port, '127.0.0.1') client.end('oooops!') let chunks = '' @@ -168,20 +175,22 @@ test('Should set the response from client error handler', t => { }) client.once('end', () => { - t.equal(response, chunks) - fastify.close() + t.assert.strictEqual(response, chunks) + + testDone() }) }) logStream.once('data', line => { - t.equal('Handled client error', line.msg) - t.equal(40, line.level, 'Log level is not warn') + t.assert.strictEqual('Handled client error', line.msg) + t.assert.strictEqual(40, line.level, 'Log level is not warn') }) }) -test('Error instance sets HTTP status code', t => { +test('Error instance sets HTTP status code', (t, testDone) => { t.plan(3) const fastify = Fastify() + t.after(() => fastify.close()) const err = new Error('winter is coming') err.statusCode = 418 @@ -193,9 +202,9 @@ test('Error instance sets HTTP status code', t => { method: 'GET', url: '/' }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 418) - t.same( + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 418) + t.assert.deepStrictEqual( { error: statusCodes['418'], message: err.message, @@ -203,12 +212,14 @@ test('Error instance sets HTTP status code', t => { }, JSON.parse(res.payload) ) + testDone() }) }) -test('Error status code below 400 defaults to 500', t => { +test('Error status code below 400 defaults to 500', (t, testDone) => { t.plan(3) const fastify = Fastify() + t.after(() => fastify.close()) const err = new Error('winter is coming') err.statusCode = 399 @@ -220,9 +231,9 @@ test('Error status code below 400 defaults to 500', t => { method: 'GET', url: '/' }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 500) - t.same( + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 500) + t.assert.deepStrictEqual( { error: statusCodes['500'], message: err.message, @@ -230,12 +241,14 @@ test('Error status code below 400 defaults to 500', t => { }, JSON.parse(res.payload) ) + testDone() }) }) -test('Error.status property support', t => { +test('Error.status property support', (t, testDone) => { t.plan(3) const fastify = Fastify() + t.after(() => fastify.close()) const err = new Error('winter is coming') err.status = 418 @@ -247,9 +260,9 @@ test('Error.status property support', t => { method: 'GET', url: '/' }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 418) - t.same( + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 418) + t.assert.deepStrictEqual( { error: statusCodes['418'], message: err.message, @@ -257,10 +270,11 @@ test('Error.status property support', t => { }, JSON.parse(res.payload) ) + testDone() }) }) -test('Support rejection with values that are not Error instances', t => { +describe('Support rejection with values that are not Error instances', () => { const objs = [ 0, '', @@ -274,11 +288,11 @@ test('Support rejection with values that are not Error instances', t => { new Date(), new Uint8Array() ] - t.plan(objs.length) for (const nonErr of objs) { - t.test('Type: ' + typeof nonErr, t => { + test('Type: ' + typeof nonErr, (t, testDone) => { t.plan(4) const fastify = Fastify() + t.after(() => fastify.close()) fastify.get('/', () => { return Promise.reject(nonErr) @@ -286,9 +300,9 @@ test('Support rejection with values that are not Error instances', t => { fastify.setErrorHandler((err, request, reply) => { if (typeof err === 'object') { - t.same(err, nonErr) + t.assert.deepStrictEqual(err, nonErr) } else { - t.equal(err, nonErr) + t.assert.strictEqual(err, nonErr) } reply.code(500).send('error') }) @@ -297,18 +311,20 @@ test('Support rejection with values that are not Error instances', t => { method: 'GET', url: '/' }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 500) - t.equal(res.payload, 'error') + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 500) + t.assert.strictEqual(res.payload, 'error') + testDone() }) }) } }) -test('invalid schema - ajv', t => { +test('invalid schema - ajv', (t, testDone) => { t.plan(4) const fastify = Fastify() + t.after(() => fastify.close()) fastify.get('/', { schema: { querystring: { @@ -319,11 +335,11 @@ test('invalid schema - ajv', t => { } } }, (req, reply) => { - t.fail('we should not be here') + t.assert.fail('we should not be here') }) fastify.setErrorHandler((err, request, reply) => { - t.ok(Array.isArray(err.validation)) + t.assert.ok(Array.isArray(err.validation)) reply.code(400).send('error') }) @@ -331,15 +347,17 @@ test('invalid schema - ajv', t => { url: '/?id=abc', method: 'GET' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) - t.equal(res.payload, 'error') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) + t.assert.strictEqual(res.payload, 'error') + testDone() }) }) -test('should set the status code and the headers from the error object (from route handler) (no custom error handler)', t => { +test('should set the status code and the headers from the error object (from route handler) (no custom error handler)', (t, testDone) => { t.plan(4) const fastify = Fastify() + t.after(() => fastify.close()) fastify.get('/', (req, reply) => { const error = new Error('kaboom') @@ -352,20 +370,23 @@ test('should set the status code and the headers from the error object (from rou url: '/', method: 'GET' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) - t.equal(res.headers.hello, 'world') - t.same(JSON.parse(res.payload), { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) + t.assert.strictEqual(res.headers.hello, 'world') + t.assert.deepStrictEqual(JSON.parse(res.payload), { error: 'Bad Request', message: 'kaboom', statusCode: 400 }) + + testDone() }) }) -test('should set the status code and the headers from the error object (from custom error handler)', t => { +test('should set the status code and the headers from the error object (from custom error handler)', (t, testDone) => { t.plan(6) const fastify = Fastify() + t.after(() => fastify.close()) fastify.get('/', (req, reply) => { const error = new Error('ouch') @@ -374,8 +395,8 @@ test('should set the status code and the headers from the error object (from cus }) fastify.setErrorHandler((err, request, reply) => { - t.equal(err.message, 'ouch') - t.equal(reply.raw.statusCode, 200) + t.assert.strictEqual(err.message, 'ouch') + t.assert.strictEqual(reply.raw.statusCode, 200) const error = new Error('kaboom') error.headers = { hello: 'world' } error.statusCode = 400 @@ -386,30 +407,33 @@ test('should set the status code and the headers from the error object (from cus url: '/', method: 'GET' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) - t.equal(res.headers.hello, 'world') - t.same(JSON.parse(res.payload), { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) + t.assert.strictEqual(res.headers.hello, 'world') + t.assert.deepStrictEqual(JSON.parse(res.payload), { error: 'Bad Request', message: 'kaboom', statusCode: 400 }) + testDone() }) }) // Issue 595 https://github.com/fastify/fastify/issues/595 -test('\'*\' should throw an error due to serializer can not handle the payload type', t => { +test('\'*\' should throw an error due to serializer can not handle the payload type', (t, testDone) => { t.plan(3) const fastify = Fastify() + t.after(() => fastify.close()) fastify.get('/', (req, reply) => { reply.type('text/html') try { reply.send({}) } catch (err) { - t.type(err, TypeError) - t.equal(err.code, 'FST_ERR_REP_INVALID_PAYLOAD_TYPE') - t.equal(err.message, "Attempted to send payload of invalid type 'object'. Expected a string or Buffer.") + t.assert.ok(err instanceof TypeError) + t.assert.strictEqual(err.code, 'FST_ERR_REP_INVALID_PAYLOAD_TYPE') + t.assert.strictEqual(err.message, "Attempted to send payload of invalid type 'object'. Expected a string or Buffer.") + testDone() } }) @@ -417,13 +441,14 @@ test('\'*\' should throw an error due to serializer can not handle the payload t url: '/', method: 'GET' }, (e, res) => { - t.fail('should not be called') + t.assert.fail('should not be called') }) }) -test('should throw an error if the custom serializer does not serialize the payload to a valid type', t => { +test('should throw an error if the custom serializer does not serialize the payload to a valid type', (t, testDone) => { t.plan(3) const fastify = Fastify() + t.after(() => fastify.close()) fastify.get('/', (req, reply) => { try { @@ -432,9 +457,10 @@ test('should throw an error if the custom serializer does not serialize the payl .serializer(payload => payload) .send({}) } catch (err) { - t.type(err, TypeError) - t.equal(err.code, 'FST_ERR_REP_INVALID_PAYLOAD_TYPE') - t.equal(err.message, "Attempted to send payload of invalid type 'object'. Expected a string or Buffer.") + t.assert.ok(err instanceof TypeError) + t.assert.strictEqual(err.code, 'FST_ERR_REP_INVALID_PAYLOAD_TYPE') + t.assert.strictEqual(err.message, "Attempted to send payload of invalid type 'object'. Expected a string or Buffer.") + testDone() } }) @@ -442,14 +468,15 @@ test('should throw an error if the custom serializer does not serialize the payl url: '/', method: 'GET' }, (e, res) => { - t.fail('should not be called') + t.assert.fail('should not be called') }) }) -test('should not set headers or status code for custom error handler', t => { +test('should not set headers or status code for custom error handler', (t, testDone) => { t.plan(7) const fastify = Fastify() + t.after(() => fastify.close()) fastify.get('/', function (req, reply) { const err = new Error('kaboom') err.headers = { @@ -459,8 +486,8 @@ test('should not set headers or status code for custom error handler', t => { }) fastify.setErrorHandler(async (err, req, res) => { - t.equal(res.statusCode, 200) - t.equal('fake-random-header' in res.headers, false) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual('fake-random-header' in res.headers, false) return res.code(500).send(err.message) }) @@ -468,18 +495,20 @@ test('should not set headers or status code for custom error handler', t => { method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 500) - t.equal('fake-random-header' in res.headers, false) - t.equal(res.headers['content-length'], ('kaboom'.length).toString()) - t.same(res.payload, 'kaboom') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 500) + t.assert.strictEqual('fake-random-header' in res.headers, false) + t.assert.strictEqual(res.headers['content-length'], ('kaboom'.length).toString()) + t.assert.deepStrictEqual(res.payload, 'kaboom') + testDone() }) }) -test('error thrown by custom error handler routes to default error handler', t => { +test('error thrown by custom error handler routes to default error handler', (t, testDone) => { t.plan(6) const fastify = Fastify() + t.after(() => fastify.close()) const error = new Error('kaboom') error.headers = { @@ -493,9 +522,9 @@ test('error thrown by custom error handler routes to default error handler', t = const newError = new Error('kabong') fastify.setErrorHandler(async (err, req, res) => { - t.equal(res.statusCode, 200) - t.equal('fake-random-header' in res.headers, false) - t.same(err.headers, error.headers) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual('fake-random-header' in res.headers, false) + t.assert.deepStrictEqual(err.headers, error.headers) return res.send(newError) }) @@ -504,13 +533,44 @@ test('error thrown by custom error handler routes to default error handler', t = method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 500) - t.same(JSON.parse(res.payload), { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 500) + t.assert.deepStrictEqual(JSON.parse(res.payload), { error: statusCodes['500'], message: newError.message, statusCode: 500 }) + testDone() + }) +}) + +// Refs: https://github.com/fastify/fastify/pull/4484#issuecomment-1367301750 +test('allow re-thrown error to default error handler when route handler is async and error handler is sync', (t, testDone) => { + t.plan(4) + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.setErrorHandler(function (error) { + t.assert.strictEqual(error.message, 'kaboom') + throw Error('kabong') + }) + + fastify.get('/', async function () { + throw Error('kaboom') + }) + + fastify.inject({ + url: '/', + method: 'GET' + }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 500) + t.assert.deepStrictEqual(JSON.parse(res.payload), { + error: statusCodes['500'], + message: 'kabong', + statusCode: 500 + }) + testDone() }) }) @@ -529,41 +589,44 @@ const invalidErrorCodes = [ 700 ] invalidErrorCodes.forEach((invalidCode) => { - test(`should throw error if error code is ${invalidCode}`, t => { + test(`should throw error if error code is ${invalidCode}`, (t, testDone) => { t.plan(2) const fastify = Fastify() + t.after(() => fastify.close()) fastify.get('/', (request, reply) => { try { return reply.code(invalidCode).send('You should not read this') } catch (err) { - t.equal(err.code, 'FST_ERR_BAD_STATUS_CODE') - t.equal(err.message, 'Called reply with an invalid status code: ' + invalidCode) + t.assert.strictEqual(err.code, 'FST_ERR_BAD_STATUS_CODE') + t.assert.strictEqual(err.message, 'Called reply with an invalid status code: ' + invalidCode) + testDone() } }) + fastify.inject({ url: '/', method: 'GET' }, (e, res) => { - t.fail('should not be called') + t.assert.fail('should not be called') }) }) }) -test('error handler is triggered when a string is thrown from sync handler', t => { +test('error handler is triggered when a string is thrown from sync handler', (t, testDone) => { t.plan(3) const fastify = Fastify() + t.after(() => fastify.close()) const throwable = 'test' const payload = 'error' fastify.get('/', function (req, reply) { - // eslint-disable-next-line no-throw-literal throw throwable }) fastify.setErrorHandler((err, req, res) => { - t.equal(err, throwable) + t.assert.strictEqual(err, throwable) res.send(payload) }) @@ -572,14 +635,16 @@ test('error handler is triggered when a string is thrown from sync handler', t = method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.equal(res.payload, payload) + t.assert.ifError(err) + t.assert.strictEqual(res.payload, payload) + testDone() }) }) test('status code should be set to 500 and return an error json payload if route handler throws any non Error object expression', async t => { t.plan(2) const fastify = Fastify() + t.after(() => fastify.close()) fastify.get('/', () => { /* eslint-disable-next-line */ @@ -588,13 +653,14 @@ test('status code should be set to 500 and return an error json payload if route // ---- const reply = await fastify.inject({ method: 'GET', url: '/' }) - t.equal(reply.statusCode, 500) - t.equal(JSON.parse(reply.body).foo, 'bar') + t.assert.strictEqual(reply.statusCode, 500) + t.assert.strictEqual(JSON.parse(reply.body).foo, 'bar') }) test('should preserve the status code set by the user if an expression is thrown in a sync route', async t => { t.plan(2) const fastify = Fastify() + t.after(() => fastify.close()) fastify.get('/', (_, rep) => { rep.status(501) @@ -605,36 +671,37 @@ test('should preserve the status code set by the user if an expression is thrown // ---- const reply = await fastify.inject({ method: 'GET', url: '/' }) - t.equal(reply.statusCode, 501) - t.equal(JSON.parse(reply.body).foo, 'bar') + t.assert.strictEqual(reply.statusCode, 501) + t.assert.strictEqual(JSON.parse(reply.body).foo, 'bar') }) test('should trigger error handlers if a sync route throws any non-error object', async t => { t.plan(2) const fastify = Fastify() + t.after(() => fastify.close()) const throwable = 'test' const payload = 'error' fastify.get('/', function async (req, reply) { - // eslint-disable-next-line no-throw-literal throw throwable }) fastify.setErrorHandler((err, req, res) => { - t.equal(err, throwable) + t.assert.strictEqual(err, throwable) res.code(500).send(payload) }) const reply = await fastify.inject({ method: 'GET', url: '/' }) - t.equal(reply.statusCode, 500) + t.assert.strictEqual(reply.statusCode, 500) }) test('should trigger error handlers if a sync route throws undefined', async t => { t.plan(1) const fastify = Fastify() + t.after(() => fastify.close()) fastify.get('/', function async (req, reply) { // eslint-disable-next-line no-throw-literal @@ -642,12 +709,13 @@ test('should trigger error handlers if a sync route throws undefined', async t = }) const reply = await fastify.inject({ method: 'GET', url: '/' }) - t.equal(reply.statusCode, 500) + t.assert.strictEqual(reply.statusCode, 500) }) -test('setting content-type on reply object should not hang the server case 1', t => { +test('setting content-type on reply object should not hang the server case 1', (t, testDone) => { t.plan(2) const fastify = Fastify() + t.after(() => fastify.close()) fastify.get('/', (req, reply) => { reply @@ -660,14 +728,16 @@ test('setting content-type on reply object should not hang the server case 1', t url: '/', method: 'GET' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + testDone() }) }) test('setting content-type on reply object should not hang the server case 2', async t => { t.plan(1) const fastify = Fastify() + t.after(() => fastify.close()) fastify.get('/', (req, reply) => { reply @@ -677,12 +747,12 @@ test('setting content-type on reply object should not hang the server case 2', a }) try { - await fastify.listen({ port: 0 }) + await fastify.ready() const res = await fastify.inject({ url: '/', method: 'GET' }) - t.same({ + t.assert.deepStrictEqual({ error: 'Internal Server Error', message: 'Attempted to send payload of invalid type \'object\'. Expected a string or Buffer.', statusCode: 500, @@ -690,15 +760,14 @@ test('setting content-type on reply object should not hang the server case 2', a }, res.json()) } catch (error) { - t.error(error) - } finally { - await fastify.close() + t.assert.ifError(error) } }) -test('setting content-type on reply object should not hang the server case 3', t => { +test('setting content-type on reply object should not hang the server case 3', (t, testDone) => { t.plan(2) const fastify = Fastify() + t.after(() => fastify.close()) fastify.get('/', (req, reply) => { reply @@ -711,17 +780,19 @@ test('setting content-type on reply object should not hang the server case 3', t url: '/', method: 'GET' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + testDone() }) }) -test('pipe stream inside error handler should not cause error', t => { +test('pipe stream inside error handler should not cause error', (t, testDone) => { t.plan(3) const location = path.join(__dirname, '..', 'package.json') const json = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json')).toString('utf8')) const fastify = Fastify() + t.after(() => fastify.close()) fastify.setErrorHandler((_error, _request, reply) => { const stream = fs.createReadStream(location) @@ -736,8 +807,9 @@ test('pipe stream inside error handler should not cause error', t => { url: '/', method: 'GET' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) - t.same(JSON.parse(res.payload), json) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) + t.assert.deepStrictEqual(JSON.parse(res.payload), json) + testDone() }) }) diff --git a/test/reply-trailers.test.js b/test/reply-trailers.test.js index e9ae60cdf7b..0d72eef42eb 100644 --- a/test/reply-trailers.test.js +++ b/test/reply-trailers.test.js @@ -1,19 +1,19 @@ 'use strict' -const t = require('tap') -const test = t.test +const { test, describe } = require('node:test') const Fastify = require('..') -const { Readable } = require('stream') -const { createHash } = require('crypto') +const { Readable } = require('node:stream') +const { createHash } = require('node:crypto') +const { sleep } = require('./helper') -test('send trailers when payload is empty string', t => { +test('send trailers when payload is empty string', (t, testDone) => { t.plan(5) const fastify = Fastify() fastify.get('/', function (request, reply) { - reply.trailer('ETag', function (reply, payload) { - return 'custom-etag' + reply.trailer('ETag', function (reply, payload, done) { + done(null, 'custom-etag') }) reply.send('') }) @@ -22,22 +22,23 @@ test('send trailers when payload is empty string', t => { method: 'GET', url: '/' }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 200) - t.equal(res.headers.trailer, 'etag') - t.equal(res.trailers.etag, 'custom-etag') - t.notHas(res.headers, 'content-length') + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers.trailer, 'etag') + t.assert.strictEqual(res.trailers.etag, 'custom-etag') + t.assert.ok(!res.headers['content-length']) + testDone() }) }) -test('send trailers when payload is empty buffer', t => { +test('send trailers when payload is empty buffer', (t, testDone) => { t.plan(5) const fastify = Fastify() fastify.get('/', function (request, reply) { - reply.trailer('ETag', function (reply, payload) { - return 'custom-etag' + reply.trailer('ETag', function (reply, payload, done) { + done(null, 'custom-etag') }) reply.send(Buffer.alloc(0)) }) @@ -46,22 +47,23 @@ test('send trailers when payload is empty buffer', t => { method: 'GET', url: '/' }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 200) - t.equal(res.headers.trailer, 'etag') - t.equal(res.trailers.etag, 'custom-etag') - t.notHas(res.headers, 'content-length') + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers.trailer, 'etag') + t.assert.strictEqual(res.trailers.etag, 'custom-etag') + t.assert.ok(!res.headers['content-length']) + testDone() }) }) -test('send trailers when payload is undefined', t => { +test('send trailers when payload is undefined', (t, testDone) => { t.plan(5) const fastify = Fastify() fastify.get('/', function (request, reply) { - reply.trailer('ETag', function (reply, payload) { - return 'custom-etag' + reply.trailer('ETag', function (reply, payload, done) { + done(null, 'custom-etag') }) reply.send(undefined) }) @@ -70,15 +72,16 @@ test('send trailers when payload is undefined', t => { method: 'GET', url: '/' }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 200) - t.equal(res.headers.trailer, 'etag') - t.equal(res.trailers.etag, 'custom-etag') - t.notHas(res.headers, 'content-length') + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers.trailer, 'etag') + t.assert.strictEqual(res.trailers.etag, 'custom-etag') + t.assert.ok(!res.headers['content-length']) + testDone() }) }) -test('send trailers when payload is json', t => { +test('send trailers when payload is json', (t, testDone) => { t.plan(7) const fastify = Fastify() @@ -88,11 +91,11 @@ test('send trailers when payload is json', t => { const md5 = hash.digest('hex') fastify.get('/', function (request, reply) { - reply.trailer('Content-MD5', function (reply, payload) { - t.equal(data, payload) + reply.trailer('Content-MD5', function (reply, payload, done) { + t.assert.strictEqual(data, payload) const hash = createHash('md5') hash.update(payload) - return hash.digest('hex') + done(null, hash.digest('hex')) }) reply.send(data) }) @@ -101,24 +104,25 @@ test('send trailers when payload is json', t => { method: 'GET', url: '/' }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 200) - t.equal(res.headers['transfer-encoding'], 'chunked') - t.equal(res.headers.trailer, 'content-md5') - t.equal(res.trailers['content-md5'], md5) - t.notHas(res.headers, 'content-length') + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['transfer-encoding'], 'chunked') + t.assert.strictEqual(res.headers.trailer, 'content-md5') + t.assert.strictEqual(res.trailers['content-md5'], md5) + t.assert.ok(!res.headers['content-length']) + testDone() }) }) -test('send trailers when payload is stream', t => { +test('send trailers when payload is stream', (t, testDone) => { t.plan(7) const fastify = Fastify() fastify.get('/', function (request, reply) { - reply.trailer('ETag', function (reply, payload) { - t.same(payload, null) - return 'custom-etag' + reply.trailer('ETag', function (reply, payload, done) { + t.assert.deepStrictEqual(payload, null) + done(null, 'custom-etag') }) const stream = Readable.from([JSON.stringify({ hello: 'world' })]) reply.send(stream) @@ -128,28 +132,155 @@ test('send trailers when payload is stream', t => { method: 'GET', url: '/' }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 200) - t.equal(res.headers['transfer-encoding'], 'chunked') - t.equal(res.headers.trailer, 'etag') - t.equal(res.trailers.etag, 'custom-etag') - t.notHas(res.headers, 'content-length') + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['transfer-encoding'], 'chunked') + t.assert.strictEqual(res.headers.trailer, 'etag') + t.assert.strictEqual(res.trailers.etag, 'custom-etag') + t.assert.ok(!res.headers['content-length']) + testDone() + }) +}) + +test('send trailers when using async-await', (t, testDone) => { + t.plan(5) + + const fastify = Fastify() + + fastify.get('/', function (request, reply) { + reply.trailer('ETag', async function (reply, payload) { + return 'custom-etag' + }) + reply.send('') + }) + + fastify.inject({ + method: 'GET', + url: '/' + }, (error, res) => { + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers.trailer, 'etag') + t.assert.strictEqual(res.trailers.etag, 'custom-etag') + t.assert.ok(!res.headers['content-length']) + testDone() + }) +}) + +test('error in trailers should be ignored', (t, testDone) => { + t.plan(5) + + const fastify = Fastify() + + fastify.get('/', function (request, reply) { + reply.trailer('ETag', function (reply, payload, done) { + done('error') + }) + reply.send('') + }) + + fastify.inject({ + method: 'GET', + url: '/' + }, (error, res) => { + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers.trailer, 'etag') + t.assert.ok(!res.trailers['etag']) + t.assert.ok(!res.headers['content-length']) + testDone() }) }) -test('removeTrailer', t => { +describe('trailer handler counter', () => { + const data = JSON.stringify({ hello: 'world' }) + const hash = createHash('md5') + hash.update(data) + const md5 = hash.digest('hex') + + test('callback with timeout', (t, testDone) => { + t.plan(9) + const fastify = Fastify() + + fastify.get('/', function (request, reply) { + reply.trailer('Return-Early', function (reply, payload, done) { + t.assert.strictEqual(data, payload) + done(null, 'return') + }) + reply.trailer('Content-MD5', function (reply, payload, done) { + t.assert.strictEqual(data, payload) + const hash = createHash('md5') + hash.update(payload) + setTimeout(() => { + done(null, hash.digest('hex')) + }, 500) + }) + reply.send(data) + }) + + fastify.inject({ + method: 'GET', + url: '/' + }, (error, res) => { + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['transfer-encoding'], 'chunked') + t.assert.strictEqual(res.headers.trailer, 'return-early content-md5') + t.assert.strictEqual(res.trailers['return-early'], 'return') + t.assert.strictEqual(res.trailers['content-md5'], md5) + t.assert.ok(!res.headers['content-length']) + testDone() + }) + }) + + test('async-await', (t, testDone) => { + t.plan(9) + const fastify = Fastify() + + fastify.get('/', function (request, reply) { + reply.trailer('Return-Early', async function (reply, payload) { + t.assert.strictEqual(data, payload) + return 'return' + }) + reply.trailer('Content-MD5', async function (reply, payload) { + t.assert.strictEqual(data, payload) + const hash = createHash('md5') + hash.update(payload) + await sleep(500) + return hash.digest('hex') + }) + reply.send(data) + }) + + fastify.inject({ + method: 'GET', + url: '/' + }, (error, res) => { + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['transfer-encoding'], 'chunked') + t.assert.strictEqual(res.headers.trailer, 'return-early content-md5') + t.assert.strictEqual(res.trailers['return-early'], 'return') + t.assert.strictEqual(res.trailers['content-md5'], md5) + t.assert.ok(!res.headers['content-length']) + testDone() + }) + }) +}) + +test('removeTrailer', (t, testDone) => { t.plan(6) const fastify = Fastify() fastify.get('/', function (request, reply) { reply.removeTrailer('ETag') // remove nothing - reply.trailer('ETag', function (reply, payload) { - return 'custom-etag' + reply.trailer('ETag', function (reply, payload, done) { + done(null, 'custom-etag') }) - reply.trailer('Should-Not-Call', function (reply, payload) { - t.fail('it should not called as this trailer is removed') - return 'should-not-call' + reply.trailer('Should-Not-Call', function (reply, payload, done) { + t.assert.fail('it should not called as this trailer is removed') + done(null, 'should-not-call') }) reply.removeTrailer('Should-Not-Call') reply.send(undefined) @@ -159,33 +290,67 @@ test('removeTrailer', t => { method: 'GET', url: '/' }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 200) - t.equal(res.headers.trailer, 'etag') - t.equal(res.trailers.etag, 'custom-etag') - t.notOk(res.trailers['should-not-call']) - t.notHas(res.headers, 'content-length') + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers.trailer, 'etag') + t.assert.strictEqual(res.trailers.etag, 'custom-etag') + t.assert.ok(!res.trailers['should-not-call']) + t.assert.ok(!res.headers['content-length']) + testDone() + }) +}) + +test('remove all trailers', (t, testDone) => { + t.plan(6) + + const fastify = Fastify() + + fastify.get('/', function (request, reply) { + reply.trailer('ETag', function (reply, payload, done) { + t.assert.fail('it should not called as this trailer is removed') + done(null, 'custom-etag') + }) + reply.removeTrailer('ETag') + reply.trailer('Should-Not-Call', function (reply, payload, done) { + t.assert.fail('it should not called as this trailer is removed') + done(null, 'should-not-call') + }) + reply.removeTrailer('Should-Not-Call') + reply.send('') + }) + + fastify.inject({ + method: 'GET', + url: '/' + }, (error, res) => { + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 200) + t.assert.ok(!res.headers.trailer) + t.assert.ok(!res.trailers.etag) + t.assert.ok(!res.trailers['should-not-call']) + t.assert.ok(!res.headers['content-length']) + testDone() }) }) -test('hasTrailer', t => { +test('hasTrailer', (t, testDone) => { t.plan(10) const fastify = Fastify() fastify.get('/', function (request, reply) { - t.equal(reply.hasTrailer('ETag'), false) - reply.trailer('ETag', function (reply, payload) { - return 'custom-etag' + t.assert.strictEqual(reply.hasTrailer('ETag'), false) + reply.trailer('ETag', function (reply, payload, done) { + done(null, 'custom-etag') }) - t.equal(reply.hasTrailer('ETag'), true) - reply.trailer('Should-Not-Call', function (reply, payload) { - t.fail('it should not called as this trailer is removed') - return 'should-not-call' + t.assert.strictEqual(reply.hasTrailer('ETag'), true) + reply.trailer('Should-Not-Call', function (reply, payload, done) { + t.assert.fail('it should not called as this trailer is removed') + done(null, 'should-not-call') }) - t.equal(reply.hasTrailer('Should-Not-Call'), true) + t.assert.strictEqual(reply.hasTrailer('Should-Not-Call'), true) reply.removeTrailer('Should-Not-Call') - t.equal(reply.hasTrailer('Should-Not-Call'), false) + t.assert.strictEqual(reply.hasTrailer('Should-Not-Call'), false) reply.send(undefined) }) @@ -193,16 +358,17 @@ test('hasTrailer', t => { method: 'GET', url: '/' }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 200) - t.equal(res.headers.trailer, 'etag') - t.equal(res.trailers.etag, 'custom-etag') - t.notOk(res.trailers['should-not-call']) - t.notHas(res.headers, 'content-length') + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers.trailer, 'etag') + t.assert.strictEqual(res.trailers.etag, 'custom-etag') + t.assert.ok(!res.trailers['should-not-call']) + t.assert.ok(!res.headers['content-length']) + testDone() }) }) -test('throw error when trailer header name is not allowed', t => { +test('throw error when trailer header name is not allowed', (t, testDone) => { const INVALID_TRAILERS = [ 'transfer-encoding', 'content-length', @@ -224,9 +390,9 @@ test('throw error when trailer header name is not allowed', t => { fastify.get('/', function (request, reply) { for (const key of INVALID_TRAILERS) { try { - reply.trailer(key, () => {}) + reply.trailer(key, () => { }) } catch (err) { - t.equal(err.message, `Called reply.trailer with an invalid header name: ${key}`) + t.assert.strictEqual(err.message, `Called reply.trailer with an invalid header name: ${key}`) } } reply.send('') @@ -236,12 +402,13 @@ test('throw error when trailer header name is not allowed', t => { method: 'GET', url: '/' }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 200) + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 200) + testDone() }) }) -test('throw error when trailer header value is not function', t => { +test('throw error when trailer header value is not function', (t, testDone) => { const INVALID_TRAILERS_VALUE = [ undefined, null, @@ -261,7 +428,7 @@ test('throw error when trailer header value is not function', t => { try { reply.trailer('invalid', value) } catch (err) { - t.equal(err.message, `Called reply.trailer('invalid', fn) with an invalid type: ${typeof value}. Expected a function.`) + t.assert.strictEqual(err.message, `Called reply.trailer('invalid', fn) with an invalid type: ${typeof value}. Expected a function.`) } } reply.send('') @@ -271,7 +438,8 @@ test('throw error when trailer header value is not function', t => { method: 'GET', url: '/' }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 200) + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 200) + testDone() }) }) diff --git a/test/reply-web-stream-locked.test.js b/test/reply-web-stream-locked.test.js new file mode 100644 index 00000000000..21262a05906 --- /dev/null +++ b/test/reply-web-stream-locked.test.js @@ -0,0 +1,37 @@ +'use strict' + +const { ReadableStream } = require('node:stream/web') +const { test, after } = require('node:test') +const Fastify = require('..') + +test('reply.send(web ReadableStream) throws if locked', async t => { + t.plan(3) + + const app = Fastify() + after(() => app.close()) + + app.get('/', (req, reply) => { + const rs = new ReadableStream({ + start (controller) { controller.enqueue(new TextEncoder().encode('hi')); controller.close() } + }) + // lock the stream + const reader = rs.getReader() + t.assert.strictEqual(rs.locked, true, 'stream is locked') + + // sending a locked stream should trigger the Fastify error + reply.send(rs) + reader.releaseLock() + }) + + const res = await app.inject({ method: 'GET', url: '/' }) + t.assert.strictEqual(res.statusCode, 500) + t.assert.deepStrictEqual( + JSON.parse(res.body), + { + statusCode: 500, + code: 'FST_ERR_REP_READABLE_STREAM_LOCKED', + error: 'Internal Server Error', + message: 'ReadableStream was locked. You should call releaseLock() method on reader before sending.' + } + ) +}) diff --git a/test/request-error.test.js b/test/request-error.test.js index 15924c7b34e..d56c2e02dee 100644 --- a/test/request-error.test.js +++ b/test/request-error.test.js @@ -1,12 +1,14 @@ 'use strict' -const { connect } = require('net') -const t = require('tap') -const test = t.test +const { connect } = require('node:net') +const { test } = require('node:test') const Fastify = require('..') const { kRequest } = require('../lib/symbols.js') +const split = require('split2') +const { Readable } = require('node:stream') +const { getServerUrl } = require('./helper') -test('default 400 on request error', t => { +test('default 400 on request error', (t, done) => { t.plan(4) const fastify = Fastify() @@ -25,25 +27,26 @@ test('default 400 on request error', t => { text: '12345678901234567890123456789012345678901234567890' } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) - t.equal(res.headers['content-type'], 'application/json; charset=utf-8') - t.same(JSON.parse(res.payload), { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) + t.assert.strictEqual(res.headers['content-type'], 'application/json; charset=utf-8') + t.assert.deepStrictEqual(JSON.parse(res.payload), { error: 'Bad Request', message: 'Simulated', statusCode: 400 }) + done() }) }) -test('default 400 on request error with custom error handler', t => { +test('default 400 on request error with custom error handler', (t, done) => { t.plan(6) const fastify = Fastify() fastify.setErrorHandler(function (err, request, reply) { - t.type(request, 'object') - t.type(request, fastify[kRequest].parent) + t.assert.strictEqual(typeof request, 'object') + t.assert.strictEqual(request instanceof fastify[kRequest].parent, true) reply .code(err.statusCode) .type('application/json; charset=utf-8') @@ -64,18 +67,19 @@ test('default 400 on request error with custom error handler', t => { text: '12345678901234567890123456789012345678901234567890' } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) - t.equal(res.headers['content-type'], 'application/json; charset=utf-8') - t.same(JSON.parse(res.payload), { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) + t.assert.strictEqual(res.headers['content-type'], 'application/json; charset=utf-8') + t.assert.deepStrictEqual(JSON.parse(res.payload), { error: 'Bad Request', message: 'Simulated', statusCode: 400 }) + done() }) }) -test('default clientError handler ignores ECONNRESET', t => { +test('default clientError handler ignores ECONNRESET', (t, done) => { t.plan(3) let logs = '' @@ -106,8 +110,8 @@ test('default clientError handler ignores ECONNRESET', t => { }) fastify.listen({ port: 0 }, function (err) { - t.error(err) - t.teardown(() => { fastify.close() }) + t.assert.ifError(err) + t.after(() => fastify.close()) const client = connect(fastify.server.address().port) @@ -116,12 +120,14 @@ test('default clientError handler ignores ECONNRESET', t => { }) client.on('end', () => { - t.match(response, /^HTTP\/1.1 200 OK/) - t.notMatch(logs, /ECONNRESET/) + t.assert.match(response, /^HTTP\/1.1 200 OK/) + t.assert.notEqual(logs, /ECONNRESET/) + done() }) client.resume() client.write('GET / HTTP/1.1\r\n') + client.write('Host: fastify.test\r\n') client.write('Connection: close\r\n') client.write('\r\n\r\n') }) @@ -136,21 +142,21 @@ test('default clientError handler ignores sockets in destroyed state', t => { }) fastify.server.on('clientError', () => { // this handler is called after default handler, so we can make sure end was not called - t.pass() + t.assert.ok('end should not be called') }) fastify.server.emit('clientError', new Error(), { destroyed: true, end () { - t.fail('end should not be called') + t.assert.fail('end should not be called') }, destroy () { - t.fail('destroy should not be called') + t.assert.fail('destroy should not be called') } }) }) test('default clientError handler destroys sockets in writable state', t => { - t.plan(1) + t.plan(2) const fastify = Fastify({ bodyLimit: 1, @@ -162,10 +168,13 @@ test('default clientError handler destroys sockets in writable state', t => { writable: true, encrypted: true, end () { - t.fail('end should not be called') + t.assert.fail('end should not be called') }, destroy () { - t.pass('destroy should be called') + t.assert.ok('destroy should be called') + }, + write (response) { + t.assert.match(response, /^HTTP\/1.1 400 Bad Request/) } }) }) @@ -182,21 +191,24 @@ test('default clientError handler destroys http sockets in non-writable state', destroyed: false, writable: false, end () { - t.fail('end should not be called') + t.assert.fail('end should not be called') }, destroy () { - t.pass('destroy should be called') + t.assert.ok('destroy should be called') + }, + write (response) { + t.assert.fail('write should not be called') } }) }) -test('error handler binding', t => { +test('error handler binding', (t, done) => { t.plan(5) const fastify = Fastify() fastify.setErrorHandler(function (err, request, reply) { - t.equal(this, fastify) + t.assert.strictEqual(this, fastify) reply .code(err.statusCode) .type('application/json; charset=utf-8') @@ -217,30 +229,31 @@ test('error handler binding', t => { text: '12345678901234567890123456789012345678901234567890' } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) - t.equal(res.headers['content-type'], 'application/json; charset=utf-8') - t.same(JSON.parse(res.payload), { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) + t.assert.strictEqual(res.headers['content-type'], 'application/json; charset=utf-8') + t.assert.deepStrictEqual(JSON.parse(res.payload), { error: 'Bad Request', message: 'Simulated', statusCode: 400 }) + done() }) }) -test('encapsulated error handler binding', t => { +test('encapsulated error handler binding', (t, done) => { t.plan(7) const fastify = Fastify() fastify.register(function (app, opts, done) { app.decorate('hello', 'world') - t.equal(app.hello, 'world') + t.assert.strictEqual(app.hello, 'world') app.post('/', function (req, reply) { reply.send({ hello: 'world' }) }) app.setErrorHandler(function (err, request, reply) { - t.equal(this.hello, 'world') + t.assert.strictEqual(this.hello, 'world') reply .code(err.statusCode) .type('application/json; charset=utf-8') @@ -259,14 +272,353 @@ test('encapsulated error handler binding', t => { text: '12345678901234567890123456789012345678901234567890' } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) - t.equal(res.headers['content-type'], 'application/json; charset=utf-8') - t.same(res.json(), { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) + t.assert.strictEqual(res.headers['content-type'], 'application/json; charset=utf-8') + t.assert.deepStrictEqual(res.json(), { error: 'Bad Request', message: 'Simulated', statusCode: 400 }) - t.equal(fastify.hello, undefined) + t.assert.strictEqual(fastify.hello, undefined) + done() + }) +}) + +test('default clientError replies with bad request on reused keep-alive connection', (t, done) => { + t.plan(2) + + let response = '' + + const fastify = Fastify({ + bodyLimit: 1, + keepAliveTimeout: 100 + }) + + fastify.get('/', (request, reply) => { + reply.send('OK\n') + }) + + fastify.listen({ port: 0 }, function (err) { + t.assert.ifError(err) + fastify.server.unref() + + const client = connect(fastify.server.address().port) + + client.on('data', chunk => { + response += chunk.toString('utf-8') + }) + + client.on('end', () => { + t.assert.match(response, /^HTTP\/1.1 200 OK.*HTTP\/1.1 400 Bad Request/s) + done() + }) + + client.resume() + client.write('GET / HTTP/1.1\r\n') + client.write('Host: fastify.test\r\n') + client.write('\r\n\r\n') + client.write('GET /?a b HTTP/1.1\r\n') + client.write('Host: fastify.test\r\n') + client.write('Connection: close\r\n') + client.write('\r\n\r\n') + }) +}) + +test('non-numeric content-length is rejected before Fastify body parsing', (t, done) => { + t.plan(3) + + let response = '' + + const fastify = Fastify({ + bodyLimit: 1, + keepAliveTimeout: 100 + }) + + fastify.post('/', () => { + t.assert.fail('handler should not be called') + }) + + fastify.listen({ port: 0 }, function (err) { + t.assert.ifError(err) + t.after(() => fastify.close()) + + const client = connect(fastify.server.address().port) + + client.on('data', chunk => { + response += chunk.toString('utf-8') + }) + + client.on('end', () => { + t.assert.match(response, /^HTTP\/1.1 400 Bad Request/) + t.assert.match(response, /"message":"Client Error"/) + done() + }) + + client.resume() + client.write('POST / HTTP/1.1\r\n') + client.write('Host: example.com\r\n') + client.write('Content-Type: text/plain\r\n') + client.write('Content-Length: abc\r\n') + client.write('Connection: close\r\n') + client.write('\r\n') + client.write('x'.repeat(32)) + }) +}) + +test('request.routeOptions.method is an uppercase string /1', async t => { + t.plan(3) + const fastify = Fastify() + const handler = function (req, res) { + t.assert.strictEqual('POST', req.routeOptions.method) + res.send({}) + } + + fastify.post('/', { + bodyLimit: 1000, + handler + }) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const result = await fetch(fastifyServer, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify([]) + }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) +}) + +test('request.routeOptions.method is an uppercase string /2', async t => { + t.plan(3) + const fastify = Fastify() + const handler = function (req, res) { + t.assert.strictEqual('POST', req.routeOptions.method) + res.send({}) + } + + fastify.route({ + url: '/', + method: 'POST', + bodyLimit: 1000, + handler + }) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const result = await fetch(fastifyServer, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify([]) + }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) +}) + +test('request.routeOptions.method is an uppercase string /3', async t => { + t.plan(3) + const fastify = Fastify() + const handler = function (req, res) { + t.assert.strictEqual('POST', req.routeOptions.method) + res.send({}) + } + + fastify.route({ + url: '/', + method: 'pOSt', + bodyLimit: 1000, + handler + }) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const result = await fetch(fastifyServer, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify([]) + }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) +}) + +test('request.routeOptions.method is an array with uppercase string', async t => { + t.plan(3) + const fastify = Fastify() + const handler = function (req, res) { + t.assert.deepStrictEqual(['POST'], req.routeOptions.method) + res.send({}) + } + + fastify.route({ + url: '/', + method: ['pOSt'], + bodyLimit: 1000, + handler + }) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const result = await fetch(fastifyServer, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify([]) + }) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) +}) + +test('test request.routeOptions.version', async t => { + t.plan(6) + const fastify = Fastify() + + fastify.route({ + method: 'POST', + url: '/version', + constraints: { version: '1.2.0' }, + handler: function (request, reply) { + t.assert.strictEqual('1.2.0', request.routeOptions.version) + reply.send({}) + } + }) + + fastify.route({ + method: 'POST', + url: '/version-undefined', + handler: function (request, reply) { + t.assert.strictEqual(undefined, request.routeOptions.version) + reply.send({}) + } + }) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const result1 = await fetch(fastifyServer + '/version', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept-Version': '1.2.0' }, + body: JSON.stringify([]) + }) + t.assert.ok(result1.ok) + t.assert.strictEqual(result1.status, 200) + + const result2 = await fetch(fastifyServer + '/version-undefined', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify([]) + }) + t.assert.ok(result2.ok) + t.assert.strictEqual(result2.status, 200) +}) + +test('customErrorHandler should throw for json err and stream response', async (t) => { + t.plan(5) + + const logStream = split(JSON.parse) + const fastify = Fastify({ + logger: { + stream: logStream, + level: 'error' + } + }) + t.after(() => fastify.close()) + + fastify.get('/', async (req, reply) => { + const stream = new Readable({ + read () { + this.push('hello') + } + }) + process.nextTick(() => stream.destroy(new Error('stream error'))) + + reply.type('application/text') + await reply.send(stream) + }) + + fastify.setErrorHandler((err, req, reply) => { + t.assert.strictEqual(err.message, 'stream error') + reply.code(400) + reply.send({ error: err.message }) + }) + + logStream.once('data', line => { + t.assert.strictEqual(line.msg, 'Attempted to send payload of invalid type \'object\'. Expected a string or Buffer.') + t.assert.strictEqual(line.level, 50) + }) + + await fastify.listen({ port: 0 }) + + const response = await fetch(getServerUrl(fastify) + '/') + + t.assert.strictEqual(response.status, 500) + t.assert.deepStrictEqual(await response.json(), { statusCode: 500, code: 'FST_ERR_REP_INVALID_PAYLOAD_TYPE', error: 'Internal Server Error', message: "Attempted to send payload of invalid type 'object'. Expected a string or Buffer." }) +}) + +test('customErrorHandler should not throw for json err and stream response with content-type defined', async (t) => { + t.plan(4) + + const logStream = split(JSON.parse) + const fastify = Fastify({ + logger: { + stream: logStream, + level: 'error' + } + }) + + t.after(() => fastify.close()) + + fastify.get('/', async (req, reply) => { + const stream = new Readable({ + read () { + this.push('hello') + } + }) + process.nextTick(() => stream.destroy(new Error('stream error'))) + + reply.type('application/text') + await reply.send(stream) + }) + + fastify.setErrorHandler((err, req, reply) => { + t.assert.strictEqual(err.message, 'stream error') + reply + .code(400) + .type('application/json') + .send({ error: err.message }) + }) + + await fastify.listen({ port: 0 }) + + const response = await fetch(getServerUrl(fastify) + '/') + + t.assert.strictEqual(response.status, 400) + t.assert.strictEqual(response.headers.get('content-type'), 'application/json; charset=utf-8') + t.assert.deepStrictEqual(await response.json(), { error: 'stream error' }) +}) + +test('customErrorHandler should not call handler for in-stream error', async (t) => { + t.plan(1) + + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.get('/', async (req, reply) => { + const stream = new Readable({ + read () { + this.push('hello') + stream.destroy(new Error('stream error')) + } + }) + + reply.type('application/text') + await reply.send(stream) + }) + + fastify.setErrorHandler(() => { + t.assert.fail('must not be called') + }) + await fastify.listen({ port: 0 }) + + await t.assert.rejects(fetch(getServerUrl(fastify) + '/'), { + message: 'fetch failed' }) }) diff --git a/test/request-header-host.test.js b/test/request-header-host.test.js new file mode 100644 index 00000000000..26c77584ac8 --- /dev/null +++ b/test/request-header-host.test.js @@ -0,0 +1,339 @@ +'use strict' + +const { test } = require('node:test') +const { connect } = require('node:net') +const Fastify = require('..') + +// RFC9112 +// https://www.rfc-editor.org/rfc/rfc9112 +test('Return 400 when Host header is missing', (t, done) => { + t.plan(2) + let data = Buffer.alloc(0) + const fastify = Fastify() + + t.after(() => fastify.close()) + + fastify.get('/', async function () { + t.assert.fail('should not reach handler') + return { ok: true } + }) + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + + const socket = connect(fastify.server.address().port) + socket.write('GET / HTTP/1.1\r\n\r\n') + socket.on('data', c => (data = Buffer.concat([data, c]))) + socket.on('end', () => { + t.assert.match( + data.toString('utf-8'), + /^HTTP\/1.1 400 Bad Request/ + ) + done() + }) + }) +}) + +test('Return 400 when Host header is missing with trust proxy', (t, done) => { + t.plan(2) + let data = Buffer.alloc(0) + const fastify = Fastify({ + trustProxy: true + }) + + t.after(() => fastify.close()) + + fastify.get('/', async function () { + t.assert.fail('should not reach handler') + return { ok: true } + }) + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + + const socket = connect(fastify.server.address().port) + socket.write('GET / HTTP/1.1\r\n\r\n') + socket.on('data', c => (data = Buffer.concat([data, c]))) + socket.on('end', () => { + t.assert.match( + data.toString('utf-8'), + /^HTTP\/1.1 400 Bad Request/ + ) + done() + }) + }) +}) + +test('Return 200 when Host header is empty', (t, done) => { + t.plan(5) + let data = Buffer.alloc(0) + const fastify = Fastify({ + keepAliveTimeout: 10 + }) + + t.after(() => fastify.close()) + + fastify.get('/', async function (request) { + t.assert.strictEqual(request.host, '') + t.assert.strictEqual(request.hostname, '') + t.assert.strictEqual(request.port, null) + return { ok: true } + }) + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + + const socket = connect(fastify.server.address().port) + socket.write('GET / HTTP/1.1\r\nHost:\r\n\r\n') + socket.on('data', c => (data = Buffer.concat([data, c]))) + socket.on('end', () => { + t.assert.match( + data.toString('utf-8'), + /^HTTP\/1.1 200 OK/ + ) + done() + }) + }) +}) + +test('Return 200 when Host header is empty with trust proxy', (t, done) => { + t.plan(5) + let data = Buffer.alloc(0) + const fastify = Fastify({ + trustProxy: true, + keepAliveTimeout: 10 + }) + + t.after(() => fastify.close()) + + fastify.get('/', async function (request) { + t.assert.strictEqual(request.host, '') + t.assert.strictEqual(request.hostname, '') + t.assert.strictEqual(request.port, null) + return { ok: true } + }) + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + + const socket = connect(fastify.server.address().port) + socket.write('GET / HTTP/1.1\r\nHost:\r\n\r\n') + socket.on('data', c => (data = Buffer.concat([data, c]))) + socket.on('end', () => { + t.assert.match( + data.toString('utf-8'), + /^HTTP\/1.1 200 OK/ + ) + done() + }) + }) +}) + +// Node.js allows exploiting RFC9112 +// https://nodejs.org/docs/latest-v22.x/api/http.html#httpcreateserveroptions-requestlistener +test('Return 200 when Host header is missing and http.requireHostHeader = false', (t, done) => { + t.plan(5) + let data = Buffer.alloc(0) + const fastify = Fastify({ + http: { + requireHostHeader: false + }, + keepAliveTimeout: 10 + }) + + t.after(() => fastify.close()) + + fastify.get('/', async function (request) { + t.assert.strictEqual(request.host, '') + t.assert.strictEqual(request.hostname, '') + t.assert.strictEqual(request.port, null) + return { ok: true } + }) + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + + const socket = connect(fastify.server.address().port) + socket.write('GET / HTTP/1.1\r\n\r\n') + socket.on('data', c => (data = Buffer.concat([data, c]))) + socket.on('end', () => { + t.assert.match( + data.toString('utf-8'), + /^HTTP\/1.1 200 OK/ + ) + done() + }) + }) +}) + +test('Return 200 when Host header is missing and http.requireHostHeader = false with trust proxy', (t, done) => { + t.plan(5) + let data = Buffer.alloc(0) + const fastify = Fastify({ + http: { + requireHostHeader: false + }, + trustProxy: true, + keepAliveTimeout: 10 + }) + + t.after(() => fastify.close()) + + fastify.get('/', async function (request) { + t.assert.strictEqual(request.host, '') + t.assert.strictEqual(request.hostname, '') + t.assert.strictEqual(request.port, null) + return { ok: true } + }) + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + + const socket = connect(fastify.server.address().port) + socket.write('GET / HTTP/1.1\r\n\r\n') + socket.on('data', c => (data = Buffer.concat([data, c]))) + socket.on('end', () => { + t.assert.match( + data.toString('utf-8'), + /^HTTP\/1.1 200 OK/ + ) + done() + }) + }) +}) + +test('Return 200 when Host header is missing using HTTP/1.0', (t, done) => { + t.plan(5) + let data = Buffer.alloc(0) + const fastify = Fastify({ + keepAliveTimeout: 10 + }) + + t.after(() => fastify.close()) + + fastify.get('/', async function (request) { + t.assert.strictEqual(request.host, '') + t.assert.strictEqual(request.hostname, '') + t.assert.strictEqual(request.port, null) + return { ok: true } + }) + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + + const socket = connect(fastify.server.address().port) + socket.write('GET / HTTP/1.0\r\n\r\n') + socket.on('data', c => (data = Buffer.concat([data, c]))) + socket.on('end', () => { + t.assert.match( + data.toString('utf-8'), + /^HTTP\/1.1 200 OK/ + ) + done() + }) + }) +}) + +test('Return 200 when Host header is missing with trust proxy using HTTP/1.0', (t, done) => { + t.plan(5) + let data = Buffer.alloc(0) + const fastify = Fastify({ + trustProxy: true, + keepAliveTimeout: 10 + }) + + t.after(() => fastify.close()) + + fastify.get('/', async function (request) { + t.assert.strictEqual(request.host, '') + t.assert.strictEqual(request.hostname, '') + t.assert.strictEqual(request.port, null) + return { ok: true } + }) + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + + const socket = connect(fastify.server.address().port) + socket.write('GET / HTTP/1.0\r\n\r\n') + socket.on('data', c => (data = Buffer.concat([data, c]))) + socket.on('end', () => { + t.assert.match( + data.toString('utf-8'), + /^HTTP\/1.1 200 OK/ + ) + done() + }) + }) +}) + +test('Return 200 when Host header is removed by schema', (t, done) => { + t.plan(5) + let data = Buffer.alloc(0) + const fastify = Fastify({ + keepAliveTimeout: 10 + }) + + t.after(() => fastify.close()) + + fastify.get('/', { + schema: { + headers: { + type: 'object', + properties: {}, + additionalProperties: false + } + } + }, async function (request) { + t.assert.strictEqual(request.host, '') + t.assert.strictEqual(request.hostname, '') + t.assert.strictEqual(request.port, null) + return { ok: true } + }) + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + + const socket = connect(fastify.server.address().port) + socket.write('GET / HTTP/1.1\r\nHost: localhost\r\n\r\n') + socket.on('data', c => (data = Buffer.concat([data, c]))) + socket.on('end', () => { + t.assert.match( + data.toString('utf-8'), + /^HTTP\/1.1 200 OK/ + ) + done() + }) + }) +}) + +test('Return 200 when Host header is removed by schema with trust proxy', (t, done) => { + t.plan(5) + let data = Buffer.alloc(0) + const fastify = Fastify({ + trustProxy: true, + keepAliveTimeout: 10 + }) + + t.after(() => fastify.close()) + + fastify.get('/', { + schema: { + headers: { + type: 'object', + properties: {}, + additionalProperties: false + } + } + }, async function (request) { + t.assert.strictEqual(request.host, '') + t.assert.strictEqual(request.hostname, '') + t.assert.strictEqual(request.port, null) + return { ok: true } + }) + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + + const socket = connect(fastify.server.address().port) + socket.write('GET / HTTP/1.1\r\nHost: localhost\r\n\r\n') + socket.on('data', c => (data = Buffer.concat([data, c]))) + socket.on('end', () => { + t.assert.match( + data.toString('utf-8'), + /^HTTP\/1.1 200 OK/ + ) + done() + }) + }) +}) diff --git a/test/request-id.test.js b/test/request-id.test.js new file mode 100644 index 00000000000..e0b935d7659 --- /dev/null +++ b/test/request-id.test.js @@ -0,0 +1,118 @@ +'use strict' + +const { test } = require('node:test') +const Fastify = require('..') + +test('The request id header key can be customized', async (t) => { + t.plan(2) + const REQUEST_ID = '42' + + const fastify = Fastify({ + requestIdHeader: 'my-custom-request-id' + }) + + fastify.get('/', (req, reply) => { + t.assert.strictEqual(req.id, REQUEST_ID) + reply.send({ id: req.id }) + }) + + const response = await fastify.inject({ method: 'GET', url: '/', headers: { 'my-custom-request-id': REQUEST_ID } }) + const body = await response.json() + t.assert.strictEqual(body.id, REQUEST_ID) +}) + +test('The request id header key can be customized', async (t) => { + t.plan(2) + const REQUEST_ID = '42' + + const fastify = Fastify({ + requestIdHeader: 'my-custom-request-id' + }) + + fastify.get('/', (req, reply) => { + t.assert.strictEqual(req.id, REQUEST_ID) + reply.send({ id: req.id }) + }) + + const response = await fastify.inject({ method: 'GET', url: '/', headers: { 'MY-CUSTOM-REQUEST-ID': REQUEST_ID } }) + const body = await response.json() + t.assert.strictEqual(body.id, REQUEST_ID) +}) + +test('The request id header key can be customized', async (t) => { + t.plan(3) + const REQUEST_ID = '42' + + const fastify = Fastify({ + requestIdHeader: 'my-custom-request-id' + }) + + fastify.get('/', (req, reply) => { + t.assert.strictEqual(req.id, REQUEST_ID) + reply.send({ id: req.id }) + }) + + t.after(() => fastify.close()) + + const fastifyServer = await fastify.listen({ port: 0 }) + + const result = await fetch(fastifyServer, { + headers: { + 'my-custom-request-id': REQUEST_ID + } + }) + t.assert.ok(result.ok) + t.assert.deepStrictEqual(await result.json(), { id: REQUEST_ID }) +}) + +test('The request id header key can be customized', async (t) => { + t.plan(3) + const REQUEST_ID = '42' + + const fastify = Fastify({ + requestIdHeader: 'my-custom-request-id' + }) + + fastify.get('/', (req, reply) => { + t.assert.strictEqual(req.id, REQUEST_ID) + reply.send({ id: req.id }) + }) + + t.after(() => fastify.close()) + + const fastifyServer = await fastify.listen({ port: 0 }) + + const result = await fetch(fastifyServer, { + headers: { + 'MY-CUSTOM-REQUEST-ID': REQUEST_ID + } + }) + t.assert.ok(result.ok) + t.assert.deepStrictEqual(await result.json(), { id: REQUEST_ID }) +}) + +test('The request id header key can be customized', async (t) => { + t.plan(3) + const REQUEST_ID = '42' + + const fastify = Fastify({ + requestIdHeader: 'MY-CUSTOM-REQUEST-ID' + }) + + fastify.get('/', (req, reply) => { + t.assert.strictEqual(req.id, REQUEST_ID) + reply.send({ id: req.id }) + }) + + t.after(() => fastify.close()) + + const fastifyServer = await fastify.listen({ port: 0 }) + + const result = await fetch(fastifyServer, { + headers: { + 'MY-CUSTOM-REQUEST-ID': REQUEST_ID + } + }) + t.assert.ok(result.ok) + t.assert.deepStrictEqual(await result.json(), { id: REQUEST_ID }) +}) diff --git a/test/requestTimeout.test.js b/test/request-timeout.test.js similarity index 62% rename from test/requestTimeout.test.js rename to test/request-timeout.test.js index 6f62a63bac5..995dc384043 100644 --- a/test/requestTimeout.test.js +++ b/test/request-timeout.test.js @@ -1,31 +1,31 @@ 'use strict' -const http = require('http') -const { test } = require('tap') -const Fastify = require('../fastify') +const http = require('node:http') +const { test } = require('node:test') +const Fastify = require('..') test('requestTimeout passed to server', t => { t.plan(5) try { Fastify({ requestTimeout: 500.1 }) - t.fail('option must be an integer') + t.assert.fail('option must be an integer') } catch (err) { - t.ok(err) + t.assert.ok(err) } try { Fastify({ requestTimeout: [] }) - t.fail('option must be an integer') + t.assert.fail('option must be an integer') } catch (err) { - t.ok(err) + t.assert.ok(err) } const httpServer = Fastify({ requestTimeout: 1000 }).server - t.equal(httpServer.requestTimeout, 1000) + t.assert.strictEqual(httpServer.requestTimeout, 1000) const httpsServer = Fastify({ requestTimeout: 1000, https: true }).server - t.equal(httpsServer.requestTimeout, 1000) + t.assert.strictEqual(httpsServer.requestTimeout, 1000) const serverFactory = (handler, _) => { const server = http.createServer((req, res) => { @@ -35,19 +35,19 @@ test('requestTimeout passed to server', t => { return server } const customServer = Fastify({ requestTimeout: 4000, serverFactory }).server - t.equal(customServer.requestTimeout, 5000) + t.assert.strictEqual(customServer.requestTimeout, 5000) }) test('requestTimeout should be set', async (t) => { t.plan(1) const initialConfig = Fastify({ requestTimeout: 5000 }).initialConfig - t.same(initialConfig.requestTimeout, 5000) + t.assert.strictEqual(initialConfig.requestTimeout, 5000) }) test('requestTimeout should 0', async (t) => { t.plan(1) const initialConfig = Fastify().initialConfig - t.same(initialConfig.requestTimeout, 0) + t.assert.strictEqual(initialConfig.requestTimeout, 0) }) diff --git a/test/route-hooks.test.js b/test/route-hooks.test.js index 43c78631a8e..107b561c453 100644 --- a/test/route-hooks.test.js +++ b/test/route-hooks.test.js @@ -1,8 +1,7 @@ 'use strict' -const { Readable } = require('stream') -const test = require('tap').test -const sget = require('simple-get').concat +const { Readable } = require('node:stream') +const { test } = require('node:test') const Fastify = require('../') process.removeAllListeners('warning') @@ -16,13 +15,13 @@ function endRouteHook (doneOrPayload, done, doneValue) { } function testExecutionHook (hook) { - test(`${hook}`, t => { + test(`${hook}`, (t, testDone) => { t.plan(3) const fastify = Fastify() fastify.post('/', { [hook]: (req, reply, doneOrPayload, done) => { - t.pass('hook called') + t.assert.ok('hook called') endRouteHook(doneOrPayload, done) } }, (req, reply) => { @@ -34,13 +33,14 @@ function testExecutionHook (hook) { url: '/', payload: { hello: 'world' } }, (err, res) => { - t.error(err) + t.assert.ifError(err) const payload = JSON.parse(res.payload) - t.same(payload, { hello: 'world' }) + t.assert.deepStrictEqual(payload, { hello: 'world' }) + testDone() }) }) - test(`${hook} option should be called after ${hook} hook`, t => { + test(`${hook} option should be called after ${hook} hook`, (t, testDone) => { t.plan(3) const fastify = Fastify() const checker = Object.defineProperty({ calledTimes: 0 }, 'check', { @@ -48,13 +48,13 @@ function testExecutionHook (hook) { }) fastify.addHook(hook, (req, reply, doneOrPayload, done) => { - t.equal(checker.check, 1) + t.assert.strictEqual(checker.check, 1) endRouteHook(doneOrPayload, done) }) fastify.post('/', { [hook]: (req, reply, doneOrPayload, done) => { - t.equal(checker.check, 2) + t.assert.strictEqual(checker.check, 2) endRouteHook(doneOrPayload, done) } }, (req, reply) => { @@ -66,11 +66,12 @@ function testExecutionHook (hook) { url: '/', payload: { hello: 'world' } }, (err, res) => { - t.error(err) + t.assert.ifError(err) + testDone() }) }) - test(`${hook} option could accept an array of functions`, t => { + test(`${hook} option could accept an array of functions`, (t, testDone) => { t.plan(3) const fastify = Fastify() const checker = Object.defineProperty({ calledTimes: 0 }, 'check', { @@ -80,11 +81,11 @@ function testExecutionHook (hook) { fastify.post('/', { [hook]: [ (req, reply, doneOrPayload, done) => { - t.equal(checker.check, 1) + t.assert.strictEqual(checker.check, 1) endRouteHook(doneOrPayload, done) }, (req, reply, doneOrPayload, done) => { - t.equal(checker.check, 2) + t.assert.strictEqual(checker.check, 2) endRouteHook(doneOrPayload, done) } ] @@ -97,11 +98,42 @@ function testExecutionHook (hook) { url: '/', payload: { hello: 'world' } }, (err, res) => { - t.error(err) + t.assert.ifError(err) + testDone() }) }) - test(`${hook} option does not interfere with ${hook} hook`, t => { + test(`${hook} option could accept an array of async functions`, (t, testDone) => { + t.plan(3) + const fastify = Fastify() + const checker = Object.defineProperty({ calledTimes: 0 }, 'check', { + get: function () { return ++this.calledTimes } + }) + + fastify.post('/', { + [hook]: [ + async (req, reply) => { + t.assert.strictEqual(checker.check, 1) + }, + async (req, reply) => { + t.assert.strictEqual(checker.check, 2) + } + ] + }, (req, reply) => { + reply.send({}) + }) + + fastify.inject({ + method: 'POST', + url: '/', + payload: { hello: 'world' } + }, (err, res) => { + t.assert.ifError(err) + testDone() + }) + }) + + test(`${hook} option does not interfere with ${hook} hook`, (t, testDone) => { t.plan(7) const fastify = Fastify() const checker = Object.defineProperty({ calledTimes: 0 }, 'check', { @@ -109,13 +141,13 @@ function testExecutionHook (hook) { }) fastify.addHook(hook, (req, reply, doneOrPayload, done) => { - t.equal(checker.check, 1) + t.assert.strictEqual(checker.check, 1) endRouteHook(doneOrPayload, done) }) fastify.post('/', { [hook]: (req, reply, doneOrPayload, done) => { - t.equal(checker.check, 2) + t.assert.strictEqual(checker.check, 2) endRouteHook(doneOrPayload, done) } }, handler) @@ -130,8 +162,8 @@ function testExecutionHook (hook) { method: 'post', url: '/' }, (err, res) => { - t.error(err) - t.equal(checker.calledTimes, 2) + t.assert.ifError(err) + t.assert.strictEqual(checker.calledTimes, 2) checker.calledTimes = 0 @@ -139,15 +171,16 @@ function testExecutionHook (hook) { method: 'post', url: '/no' }, (err, res) => { - t.error(err) - t.equal(checker.calledTimes, 1) + t.assert.ifError(err) + t.assert.strictEqual(checker.calledTimes, 1) + testDone() }) }) }) } function testBeforeHandlerHook (hook) { - test(`${hook} option should be unique per route`, t => { + test(`${hook} option should be unique per route`, (t, testDone) => { t.plan(4) const fastify = Fastify() @@ -169,9 +202,9 @@ function testBeforeHandlerHook (hook) { url: '/', payload: { hello: 'world' } }, (err, res) => { - t.error(err) + t.assert.ifError(err) const payload = JSON.parse(res.payload) - t.same(payload, { hello: 'earth' }) + t.assert.deepStrictEqual(payload, { hello: 'earth' }) }) fastify.inject({ @@ -179,13 +212,14 @@ function testBeforeHandlerHook (hook) { url: '/no', payload: { hello: 'world' } }, (err, res) => { - t.error(err) + t.assert.ifError(err) const payload = JSON.parse(res.payload) - t.same(payload, { hello: 'world' }) + t.assert.deepStrictEqual(payload, { hello: 'world' }) + testDone() }) }) - test(`${hook} option should handle errors`, t => { + test(`${hook} option should handle errors`, (t, testDone) => { t.plan(3) const fastify = Fastify() @@ -202,48 +236,49 @@ function testBeforeHandlerHook (hook) { url: '/', payload: { hello: 'world' } }, (err, res) => { - t.error(err) + t.assert.ifError(err) const payload = JSON.parse(res.payload) - t.equal(res.statusCode, 500) - t.same(payload, { + t.assert.strictEqual(res.statusCode, 500) + t.assert.deepStrictEqual(payload, { message: 'kaboom', error: 'Internal Server Error', statusCode: 500 }) + testDone() }) }) - test(`${hook} option should handle throwing objects`, t => { + test(`${hook} option should handle throwing objects`, (t, testDone) => { t.plan(4) const fastify = Fastify() const myError = { myError: 'kaboom' } fastify.setErrorHandler(async (error, request, reply) => { - t.same(error, myError, 'the error object throws by the user') + t.assert.deepStrictEqual(error, myError, 'the error object throws by the user') return reply.code(500).send({ this: 'is', my: 'error' }) }) fastify.get('/', { [hook]: async () => { - // eslint-disable-next-line no-throw-literal throw myError } }, (req, reply) => { - t.fail('the handler must not be called') + t.assert.fail('the handler must not be called') }) fastify.inject({ url: '/', method: 'GET' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 500) - t.same(res.json(), { this: 'is', my: 'error' }) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 500) + t.assert.deepStrictEqual(res.json(), { this: 'is', my: 'error' }) + testDone() }) }) - test(`${hook} option should handle throwing objects by default`, t => { + test(`${hook} option should handle throwing objects by default`, (t, testDone) => { t.plan(3) const fastify = Fastify() @@ -253,20 +288,21 @@ function testBeforeHandlerHook (hook) { throw { myError: 'kaboom', message: 'i am an error' } } }, (req, reply) => { - t.fail('the handler must not be called') + t.assert.fail('the handler must not be called') }) fastify.inject({ url: '/', method: 'GET' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 500) - t.same(res.json(), { myError: 'kaboom', message: 'i am an error' }) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 500) + t.assert.deepStrictEqual(res.json(), { myError: 'kaboom', message: 'i am an error' }) + testDone() }) }) - test(`${hook} option should handle errors with custom status code`, t => { + test(`${hook} option should handle errors with custom status code`, (t, testDone) => { t.plan(3) const fastify = Fastify() @@ -284,18 +320,19 @@ function testBeforeHandlerHook (hook) { url: '/', payload: { hello: 'world' } }, (err, res) => { - t.error(err) + t.assert.ifError(err) const payload = JSON.parse(res.payload) - t.equal(res.statusCode, 401) - t.same(payload, { + t.assert.strictEqual(res.statusCode, 401) + t.assert.deepStrictEqual(payload, { message: 'go away', error: 'Unauthorized', statusCode: 401 }) + testDone() }) }) - test(`${hook} option should keep the context`, t => { + test(`${hook} option should keep the context`, (t, testDone) => { t.plan(3) const fastify = Fastify() @@ -303,7 +340,7 @@ function testBeforeHandlerHook (hook) { fastify.post('/', { [hook]: function (req, reply, doneOrPayload, done) { - t.equal(this.foo, 42) + t.assert.strictEqual(this.foo, 42) this.foo += 1 endRouteHook(doneOrPayload, done) } @@ -316,13 +353,14 @@ function testBeforeHandlerHook (hook) { url: '/', payload: { hello: 'world' } }, (err, res) => { - t.error(err) + t.assert.ifError(err) const payload = JSON.parse(res.payload) - t.same(payload, { foo: 43 }) + t.assert.deepStrictEqual(payload, { foo: 43 }) + testDone() }) }) - test(`${hook} option should keep the context (array)`, t => { + test(`${hook} option should keep the context (array)`, (t, testDone) => { t.plan(3) const fastify = Fastify() @@ -330,7 +368,7 @@ function testBeforeHandlerHook (hook) { fastify.post('/', { [hook]: [function (req, reply, doneOrPayload, done) { - t.equal(this.foo, 42) + t.assert.strictEqual(this.foo, 42) this.foo += 1 endRouteHook(doneOrPayload, done) }] @@ -343,9 +381,10 @@ function testBeforeHandlerHook (hook) { url: '/', payload: { hello: 'world' } }, (err, res) => { - t.error(err) + t.assert.ifError(err) const payload = JSON.parse(res.payload) - t.same(payload, { foo: 43 }) + t.assert.deepStrictEqual(payload, { foo: 43 }) + testDone() }) }) } @@ -362,12 +401,12 @@ testBeforeHandlerHook('onRequest') testBeforeHandlerHook('preValidation') testBeforeHandlerHook('preParsing') -test('preValidation option should be called before preHandler hook', t => { +test('preValidation option should be called before preHandler hook', (t, testDone) => { t.plan(3) const fastify = Fastify() fastify.addHook('preHandler', (req, reply, done) => { - t.ok(req.called) + t.assert.ok(req.called) done() }) @@ -385,13 +424,14 @@ test('preValidation option should be called before preHandler hook', t => { url: '/', payload: { hello: 'world' } }, (err, res) => { - t.error(err) + t.assert.ifError(err) const payload = JSON.parse(res.payload) - t.same(payload, { hello: 'world' }) + t.assert.deepStrictEqual(payload, { hello: 'world' }) + testDone() }) }) -test('preSerialization option should be able to modify the payload', t => { +test('preSerialization option should be able to modify the payload', (t, testDone) => { t.plan(3) const fastify = Fastify() @@ -407,18 +447,19 @@ test('preSerialization option should be able to modify the payload', t => { method: 'GET', url: '/only' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same(JSON.parse(res.payload), { hello: 'another world' }) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'another world' }) + testDone() }) }) -test('preParsing option should be called before preValidation hook', t => { +test('preParsing option should be called before preValidation hook', (t, testDone) => { t.plan(3) const fastify = Fastify() fastify.addHook('preValidation', (req, reply, done) => { - t.ok(req.called) + t.assert.ok(req.called) done() }) @@ -436,13 +477,14 @@ test('preParsing option should be called before preValidation hook', t => { url: '/', payload: { hello: 'world' } }, (err, res) => { - t.error(err) + t.assert.ifError(err) const payload = JSON.parse(res.payload) - t.same(payload, { hello: 'world' }) + t.assert.deepStrictEqual(payload, { hello: 'world' }) + testDone() }) }) -test('preParsing option should be able to modify the payload', t => { +test('preParsing option should be able to modify the payload', (t, testDone) => { t.plan(3) const fastify = Fastify() @@ -463,18 +505,58 @@ test('preParsing option should be able to modify the payload', t => { url: '/only', payload: { hello: 'world' } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same(JSON.parse(res.payload), { hello: 'another world' }) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'another world' }) + testDone() }) }) -test('onRequest option should be called before preParsing', t => { +test('preParsing option should be able to supply statusCode', (t, testDone) => { + t.plan(4) + const fastify = Fastify() + + fastify.post('/only', { + preParsing: async (req, reply, payload) => { + const stream = new Readable({ + read () { + const error = new Error('kaboom') + error.statusCode = 408 + this.destroy(error) + } + }) + stream.receivedEncodedLength = 20 + return stream + }, + onError: async (req, res, err) => { + t.assert.strictEqual(err.statusCode, 408) + } + }, (req, reply) => { + t.assert.fail('should not be called') + }) + + fastify.inject({ + method: 'POST', + url: '/only', + payload: { hello: 'world' } + }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 408) + t.assert.deepStrictEqual(JSON.parse(res.payload), { + statusCode: 408, + error: 'Request Timeout', + message: 'kaboom' + }) + testDone() + }) +}) + +test('onRequest option should be called before preParsing', (t, testDone) => { t.plan(3) const fastify = Fastify() fastify.addHook('preParsing', (req, reply, payload, done) => { - t.ok(req.called) + t.assert.ok(req.called) done() }) @@ -492,39 +574,38 @@ test('onRequest option should be called before preParsing', t => { url: '/', payload: { hello: 'world' } }, (err, res) => { - t.error(err) + t.assert.ifError(err) const payload = JSON.parse(res.payload) - t.same(payload, { hello: 'world' }) + t.assert.deepStrictEqual(payload, { hello: 'world' }) + testDone() }) }) -test('onTimeout on route', t => { - t.plan(4) +test('onTimeout on route', async (t) => { + t.plan(3) const fastify = Fastify({ connectionTimeout: 500 }) fastify.get('/timeout', { handler (request, reply) { }, onTimeout (request, reply, done) { - t.pass('onTimeout called') + t.assert.ok('onTimeout called') done() } }) - fastify.listen({ port: 0 }, (err, address) => { - t.error(err) - t.teardown(() => fastify.close()) + const address = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) - sget({ - method: 'GET', - url: `${address}/timeout` - }, (err, response, body) => { - t.type(err, Error) - t.equal(err.message, 'socket hang up') - }) - }) + try { + await fetch(`${address}/timeout`) + t.assert.fail('Should have thrown an error') + } catch (err) { + t.assert.ok(err instanceof Error) + t.assert.strictEqual(err.message, 'fetch failed') + } }) -test('onError on route', t => { +test('onError on route', (t, testDone) => { t.plan(3) const fastify = Fastify() @@ -534,7 +615,7 @@ test('onError on route', t => { fastify.get('/', { onError (request, reply, error, done) { - t.match(error, err) + t.assert.deepStrictEqual(error, err) done() } }, @@ -543,11 +624,12 @@ test('onError on route', t => { }) fastify.inject('/', (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { error: 'Internal Server Error', message: 'kaboom', statusCode: 500 }) + testDone() }) }) diff --git a/test/route-prefix.test.js b/test/route-prefix.test.js index 7999d849564..d910a9a6d7a 100644 --- a/test/route-prefix.test.js +++ b/test/route-prefix.test.js @@ -1,10 +1,10 @@ 'use strict' -const t = require('tap') -const test = t.test +const { test } = require('node:test') const Fastify = require('..') +const { waitForCb } = require('./toolkit') -test('Prefix options should add a prefix for all the routes inside a register / 1', t => { +test('Prefix options should add a prefix for all the routes inside a register / 1', (t, testDone) => { t.plan(6) const fastify = Fastify() @@ -27,32 +27,37 @@ test('Prefix options should add a prefix for all the routes inside a register / done() }, { prefix: '/v1' }) + const completion = waitForCb({ steps: 3 }) fastify.inject({ method: 'GET', url: '/first' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { route: '/first' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { route: '/first' }) + completion.stepIn() }) fastify.inject({ method: 'GET', url: '/v1/first' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { route: '/v1/first' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { route: '/v1/first' }) + completion.stepIn() }) fastify.inject({ method: 'GET', url: '/v1/v2/first' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { route: '/v1/v2/first' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { route: '/v1/v2/first' }) + completion.stepIn() }) + completion.patience.then(testDone) }) -test('Prefix options should add a prefix for all the routes inside a register / 2', t => { +test('Prefix options should add a prefix for all the routes inside a register / 2', (t, testDone) => { t.plan(4) const fastify = Fastify() @@ -67,24 +72,27 @@ test('Prefix options should add a prefix for all the routes inside a register / done() }, { prefix: '/v1' }) + const completion = waitForCb({ steps: 2 }) fastify.inject({ method: 'GET', url: '/v1/first' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { route: '/v1/first' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { route: '/v1/first' }) + completion.stepIn() }) - fastify.inject({ method: 'GET', url: '/v1/second' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { route: '/v1/second' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { route: '/v1/second' }) + completion.stepIn() }) + completion.patience.then(testDone) }) -test('Prefix options should add a prefix for all the chained routes inside a register / 3', t => { +test('Prefix options should add a prefix for all the chained routes inside a register / 3', (t, testDone) => { t.plan(4) const fastify = Fastify() @@ -100,24 +108,27 @@ test('Prefix options should add a prefix for all the chained routes inside a reg done() }, { prefix: '/v1' }) + const completion = waitForCb({ steps: 2 }) fastify.inject({ method: 'GET', url: '/v1/first' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { route: '/v1/first' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { route: '/v1/first' }) + completion.stepIn() }) - fastify.inject({ method: 'GET', url: '/v1/second' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { route: '/v1/second' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { route: '/v1/second' }) + completion.stepIn() }) + completion.patience.then(testDone) }) -test('Prefix should support parameters as well', t => { +test('Prefix should support parameters as well', (t, testDone) => { t.plan(2) const fastify = Fastify() @@ -132,12 +143,13 @@ test('Prefix should support parameters as well', t => { method: 'GET', url: '/v1/param/hello' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { id: 'param' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { id: 'param' }) + testDone() }) }) -test('Prefix should support /', t => { +test('Prefix should support /', (t, testDone) => { t.plan(2) const fastify = Fastify() @@ -152,12 +164,13 @@ test('Prefix should support /', t => { method: 'GET', url: '/v1' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + testDone() }) }) -test('Prefix without /', t => { +test('Prefix without /', (t, testDone) => { t.plan(2) const fastify = Fastify() @@ -172,12 +185,13 @@ test('Prefix without /', t => { method: 'GET', url: '/v1' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + testDone() }) }) -test('Prefix with trailing /', t => { +test('Prefix with trailing /', (t, testDone) => { t.plan(6) const fastify = Fastify() @@ -199,32 +213,35 @@ test('Prefix with trailing /', t => { done() }, { prefix: '/v1/' }) + const completion = waitForCb({ steps: 3 }) fastify.inject({ method: 'GET', url: '/v1/route1' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world1' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world1' }) + completion.stepIn() }) - fastify.inject({ method: 'GET', url: '/v1/route2' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world2' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world2' }) + completion.stepIn() }) - fastify.inject({ method: 'GET', url: '/v1/inner/route3' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world3' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world3' }) + completion.stepIn() }) + completion.patience.then(testDone) }) -test('Prefix works multiple levels deep', t => { +test('Prefix works multiple levels deep', (t, testDone) => { t.plan(2) const fastify = Fastify() @@ -248,12 +265,13 @@ test('Prefix works multiple levels deep', t => { method: 'GET', url: '/v1/v2/v3' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + testDone() }) }) -test('Different register - encapsulation check', t => { +test('Different register - encapsulation check', (t, testDone) => { t.plan(4) const fastify = Fastify() @@ -281,24 +299,27 @@ test('Different register - encapsulation check', t => { done() }, { prefix: '/v3' }) + const completion = waitForCb({ steps: 2 }) fastify.inject({ method: 'GET', url: '/v1/v2' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { route: '/v1/v2' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { route: '/v1/v2' }) + completion.stepIn() }) - fastify.inject({ method: 'GET', url: '/v3/v4' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { route: '/v3/v4' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { route: '/v3/v4' }) + completion.stepIn() }) + completion.patience.then(testDone) }) -test('Can retrieve prefix within encapsulated instances', t => { +test('Can retrieve prefix within encapsulated instances', (t, testDone) => { t.plan(4) const fastify = Fastify() @@ -317,24 +338,27 @@ test('Can retrieve prefix within encapsulated instances', t => { done() }, { prefix: '/v1' }) + const completion = waitForCb({ steps: 2 }) fastify.inject({ method: 'GET', url: '/v1/one' }, (err, res) => { - t.error(err) - t.equal(res.payload, '/v1') + t.assert.ifError(err) + t.assert.deepStrictEqual(res.payload, '/v1') + completion.stepIn() }) - fastify.inject({ method: 'GET', url: '/v1/v2/two' }, (err, res) => { - t.error(err) - t.equal(res.payload, '/v1/v2') + t.assert.ifError(err) + t.assert.deepStrictEqual(res.payload, '/v1/v2') + completion.stepIn() }) + completion.patience.then(testDone) }) -test('matches both /prefix and /prefix/ with a / route', t => { +test('matches both /prefix and /prefix/ with a / route', (t, testDone) => { t.plan(4) const fastify = Fastify() @@ -346,24 +370,27 @@ test('matches both /prefix and /prefix/ with a / route', t => { done() }, { prefix: '/prefix' }) + const completion = waitForCb({ steps: 2 }) fastify.inject({ method: 'GET', url: '/prefix' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + completion.stepIn() }) - fastify.inject({ method: 'GET', url: '/prefix/' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + completion.stepIn() }) + completion.patience.then(testDone) }) -test('prefix "/prefix/" does not match "/prefix" with a / route', t => { +test('prefix "/prefix/" does not match "/prefix" with a / route', (t, testDone) => { t.plan(4) const fastify = Fastify() @@ -375,24 +402,27 @@ test('prefix "/prefix/" does not match "/prefix" with a / route', t => { done() }, { prefix: '/prefix/' }) + const completion = waitForCb({ steps: 2 }) fastify.inject({ method: 'GET', url: '/prefix' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 404) + t.assert.ifError(err) + t.assert.deepStrictEqual(res.statusCode, 404) + completion.stepIn() }) - fastify.inject({ method: 'GET', url: '/prefix/' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + completion.stepIn() }) + completion.patience.then(testDone) }) -test('matches both /prefix and /prefix/ with a / route - ignoreTrailingSlash: true', t => { +test('matches both /prefix and /prefix/ with a / route - ignoreTrailingSlash: true', (t, testDone) => { t.plan(4) const fastify = Fastify({ ignoreTrailingSlash: true @@ -406,24 +436,27 @@ test('matches both /prefix and /prefix/ with a / route - ignoreTrailingSlash: tr done() }, { prefix: '/prefix' }) + const completion = waitForCb({ steps: 2 }) fastify.inject({ method: 'GET', url: '/prefix' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + completion.stepIn() }) - fastify.inject({ method: 'GET', url: '/prefix/' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + completion.stepIn() }) + completion.patience.then(testDone) }) -test('matches both /prefix and /prefix/ with a / route - ignoreDuplicateSlashes: true', t => { +test('matches both /prefix and /prefix/ with a / route - ignoreDuplicateSlashes: true', (t, testDone) => { t.plan(4) const fastify = Fastify({ ignoreDuplicateSlashes: true @@ -437,24 +470,27 @@ test('matches both /prefix and /prefix/ with a / route - ignoreDuplicateSlashes: done() }, { prefix: '/prefix' }) + const completion = waitForCb({ steps: 2 }) fastify.inject({ method: 'GET', url: '/prefix' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + completion.stepIn() }) - fastify.inject({ method: 'GET', url: '/prefix/' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + completion.stepIn() }) + completion.patience.then(testDone) }) -test('matches both /prefix and /prefix/ with a / route - prefixTrailingSlash: "both", ignoreTrailingSlash: false', t => { +test('matches both /prefix and /prefix/ with a / route - prefixTrailingSlash: "both", ignoreTrailingSlash: false', (t, testDone) => { t.plan(4) const fastify = Fastify({ ignoreTrailingSlash: false @@ -473,24 +509,27 @@ test('matches both /prefix and /prefix/ with a / route - prefixTrailingSlash: " done() }, { prefix: '/prefix' }) + const completion = waitForCb({ steps: 2 }) fastify.inject({ method: 'GET', url: '/prefix' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + completion.stepIn() }) - fastify.inject({ method: 'GET', url: '/prefix/' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + completion.stepIn() }) + completion.patience.then(testDone) }) -test('matches both /prefix and /prefix/ with a / route - prefixTrailingSlash: "both", ignoreDuplicateSlashes: false', t => { +test('matches both /prefix and /prefix/ with a / route - prefixTrailingSlash: "both", ignoreDuplicateSlashes: false', (t, testDone) => { t.plan(4) const fastify = Fastify({ ignoreDuplicateSlashes: false @@ -509,24 +548,27 @@ test('matches both /prefix and /prefix/ with a / route - prefixTrailingSlash: " done() }, { prefix: '/prefix' }) + const completion = waitForCb({ steps: 2 }) fastify.inject({ method: 'GET', url: '/prefix' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + completion.stepIn() }) - fastify.inject({ method: 'GET', url: '/prefix/' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + completion.stepIn() }) + completion.patience.then(testDone) }) -test('matches both /prefix and /prefix/ with a / route - ignoreTrailingSlash: true, ignoreDuplicateSlashes: true', t => { +test('matches both /prefix and /prefix/ with a / route - ignoreTrailingSlash: true, ignoreDuplicateSlashes: true', (t, testDone) => { t.plan(4) const fastify = Fastify({ ignoreTrailingSlash: true, @@ -541,24 +583,27 @@ test('matches both /prefix and /prefix/ with a / route - ignoreTrailingSlash: tr done() }, { prefix: '/prefix' }) + const completion = waitForCb({ steps: 2 }) fastify.inject({ method: 'GET', url: '/prefix' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + completion.stepIn() }) - fastify.inject({ method: 'GET', url: '/prefix/' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + completion.stepIn() }) + completion.patience.then(testDone) }) -test('matches both /prefix and /prefix/ with a / route - ignoreTrailingSlash: true, ignoreDuplicateSlashes: false', t => { +test('matches both /prefix and /prefix/ with a / route - ignoreTrailingSlash: true, ignoreDuplicateSlashes: false', (t, testDone) => { t.plan(4) const fastify = Fastify({ ignoreTrailingSlash: true, @@ -573,24 +618,27 @@ test('matches both /prefix and /prefix/ with a / route - ignoreTrailingSlash: tr done() }, { prefix: '/prefix' }) + const completion = waitForCb({ steps: 2 }) fastify.inject({ method: 'GET', url: '/prefix' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + completion.stepIn() }) - fastify.inject({ method: 'GET', url: '/prefix/' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + completion.stepIn() }) + completion.patience.then(testDone) }) -test('returns 404 status code with /prefix/ and / route - prefixTrailingSlash: "both" (default), ignoreTrailingSlash: true', t => { +test('returns 404 status code with /prefix/ and / route - prefixTrailingSlash: "both" (default), ignoreTrailingSlash: true', (t, testDone) => { t.plan(2) const fastify = Fastify({ ignoreTrailingSlash: true @@ -612,16 +660,17 @@ test('returns 404 status code with /prefix/ and / route - prefixTrailingSlash: " method: 'GET', url: '/prefix//' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { error: 'Not Found', message: 'Route GET:/prefix// not found', statusCode: 404 }) + testDone() }) }) -test('matches both /prefix and /prefix/ with a / route - prefixTrailingSlash: "both", ignoreDuplicateSlashes: true', t => { +test('matches both /prefix and /prefix/ with a / route - prefixTrailingSlash: "both", ignoreDuplicateSlashes: true', (t, testDone) => { t.plan(2) const fastify = Fastify({ ignoreDuplicateSlashes: true @@ -643,12 +692,13 @@ test('matches both /prefix and /prefix/ with a / route - prefixTrailingSlash: " method: 'GET', url: '/prefix//' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + testDone() }) }) -test('matches both /prefix and /prefix/ with a / route - prefixTrailingSlash: "both", ignoreTrailingSlash: true, ignoreDuplicateSlashes: true', t => { +test('matches both /prefix and /prefix/ with a / route - prefixTrailingSlash: "both", ignoreTrailingSlash: true, ignoreDuplicateSlashes: true', (t, testDone) => { t.plan(2) const fastify = Fastify({ ignoreTrailingSlash: true, @@ -671,12 +721,13 @@ test('matches both /prefix and /prefix/ with a / route - prefixTrailingSlash: " method: 'GET', url: '/prefix//' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + testDone() }) }) -test('matches both /prefix and /prefix/ with a / route - prefixTrailingSlash: "both", ignoreDuplicateSlashes: true', t => { +test('matches both /prefix and /prefix/ with a / route - prefixTrailingSlash: "both", ignoreDuplicateSlashes: true', (t, testDone) => { t.plan(2) const fastify = Fastify({ ignoreTrailingSlash: true, @@ -699,12 +750,13 @@ test('matches both /prefix and /prefix/ with a / route - prefixTrailingSlash: " method: 'GET', url: '/prefix//' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + testDone() }) }) -test('matches only /prefix with a / route - prefixTrailingSlash: "no-slash", ignoreTrailingSlash: false', t => { +test('matches only /prefix with a / route - prefixTrailingSlash: "no-slash", ignoreTrailingSlash: false', (t, testDone) => { t.plan(4) const fastify = Fastify({ ignoreTrailingSlash: false @@ -723,24 +775,27 @@ test('matches only /prefix with a / route - prefixTrailingSlash: "no-slash", ig done() }, { prefix: '/prefix' }) + const completion = waitForCb({ steps: 2 }) fastify.inject({ method: 'GET', url: '/prefix' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + completion.stepIn() }) - fastify.inject({ method: 'GET', url: '/prefix/' }, (err, res) => { - t.error(err) - t.equal(JSON.parse(res.payload).statusCode, 404) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload).statusCode, 404) + completion.stepIn() }) + completion.patience.then(testDone) }) -test('matches only /prefix with a / route - prefixTrailingSlash: "no-slash", ignoreDuplicateSlashes: false', t => { +test('matches only /prefix with a / route - prefixTrailingSlash: "no-slash", ignoreDuplicateSlashes: false', (t, testDone) => { t.plan(4) const fastify = Fastify({ ignoreDuplicateSlashes: false @@ -759,24 +814,27 @@ test('matches only /prefix with a / route - prefixTrailingSlash: "no-slash", ig done() }, { prefix: '/prefix' }) + const completion = waitForCb({ steps: 2 }) fastify.inject({ method: 'GET', url: '/prefix' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + completion.stepIn() }) - fastify.inject({ method: 'GET', url: '/prefix/' }, (err, res) => { - t.error(err) - t.equal(JSON.parse(res.payload).statusCode, 404) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload).statusCode, 404) + completion.stepIn() }) + completion.patience.then(testDone) }) -test('matches only /prefix/ with a / route - prefixTrailingSlash: "slash", ignoreTrailingSlash: false', t => { +test('matches only /prefix/ with a / route - prefixTrailingSlash: "slash", ignoreTrailingSlash: false', (t, testDone) => { t.plan(4) const fastify = Fastify({ ignoreTrailingSlash: false @@ -795,21 +853,24 @@ test('matches only /prefix/ with a / route - prefixTrailingSlash: "slash", igno done() }, { prefix: '/prefix' }) + const completion = waitForCb({ steps: 2 }) fastify.inject({ method: 'GET', url: '/prefix/' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + completion.stepIn() }) - fastify.inject({ method: 'GET', url: '/prefix' }, (err, res) => { - t.error(err) - t.equal(JSON.parse(res.payload).statusCode, 404) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload).statusCode, 404) + completion.stepIn() }) + completion.patience.then(testDone) }) test('calls onRoute only once when prefixing', async t => { @@ -839,5 +900,5 @@ test('calls onRoute only once when prefixing', async t => { await fastify.ready() - t.same(onRouteCalled, 1) + t.assert.deepStrictEqual(onRouteCalled, 1) }) diff --git a/test/route-shorthand.test.js b/test/route-shorthand.test.js new file mode 100644 index 00000000000..46e1b3662be --- /dev/null +++ b/test/route-shorthand.test.js @@ -0,0 +1,48 @@ +'use strict' + +const { describe, test } = require('node:test') +const { Client } = require('undici') +const Fastify = require('..') + +describe('route-shorthand', () => { + const methodsReader = new Fastify() + const supportedMethods = methodsReader.supportedMethods + + for (const method of supportedMethods) { + test(`route-shorthand - ${method.toLowerCase()}`, async (t) => { + t.plan(2) + const fastify = new Fastify() + fastify[method.toLowerCase()]('/', (req, reply) => { + t.assert.strictEqual(req.method, method) + reply.send() + }) + await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const instance = new Client(`http://localhost:${fastify.server.address().port}`) + + const response = await instance.request({ path: '/', method }) + t.assert.strictEqual(response.statusCode, 200) + }) + } + + test('route-shorthand - all', async (t) => { + t.plan(2 * supportedMethods.length) + const fastify = new Fastify() + let currentMethod = '' + fastify.all('/', function (req, reply) { + t.assert.strictEqual(req.method, currentMethod) + reply.send() + }) + await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + for (const method of supportedMethods) { + currentMethod = method + const instance = new Client(`http://localhost:${fastify.server.address().port}`) + + const response = await instance.request({ path: '/', method }) + t.assert.strictEqual(response.statusCode, 200) + } + }) +}) diff --git a/test/route.1.test.js b/test/route.1.test.js new file mode 100644 index 00000000000..36907519861 --- /dev/null +++ b/test/route.1.test.js @@ -0,0 +1,259 @@ +'use strict' + +const { test } = require('node:test') +const Fastify = require('..') +const { + FST_ERR_INSTANCE_ALREADY_LISTENING, + FST_ERR_ROUTE_METHOD_INVALID +} = require('../lib/errors') +const { getServerUrl } = require('./helper') + +test('route', async t => { + t.plan(10) + + await t.test('route - get', async (t) => { + t.plan(4) + + const fastify = Fastify() + t.assert.doesNotThrow(() => + fastify.route({ + method: 'GET', + url: '/', + schema: { + response: { + '2xx': { + type: 'object', + properties: { + hello: { + type: 'string' + } + } + } + } + }, + handler: function (req, reply) { + reply.send({ hello: 'world' }) + } + }) + ) + + await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) + + const response = await fetch(getServerUrl(fastify) + '/') + t.assert.ok(response.ok) + t.assert.strictEqual(response.status, 200) + t.assert.deepStrictEqual(await response.json(), { hello: 'world' }) + }) + + await t.test('missing schema - route', async (t) => { + t.plan(4) + + const fastify = Fastify() + t.assert.doesNotThrow(() => + fastify.route({ + method: 'GET', + url: '/missing', + handler: function (req, reply) { + reply.send({ hello: 'world' }) + } + }) + ) + + await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) + + const response = await fetch(getServerUrl(fastify) + '/missing') + t.assert.ok(response.ok) + t.assert.strictEqual(response.status, 200) + t.assert.deepStrictEqual(await response.json(), { hello: 'world' }) + }) + + await t.test('invalid handler attribute - route', t => { + t.plan(1) + + const fastify = Fastify() + t.assert.throws(() => fastify.get('/', { handler: 'not a function' }, () => { })) + }) + + await t.test('Add Multiple methods per route all uppercase', async (t) => { + t.plan(7) + + const fastify = Fastify() + t.assert.doesNotThrow(() => + fastify.route({ + method: ['GET', 'DELETE'], + url: '/multiple', + handler: function (req, reply) { + reply.send({ hello: 'world' }) + } + })) + + await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) + + const getResponse = await fetch(getServerUrl(fastify) + '/multiple') + t.assert.ok(getResponse.ok) + t.assert.strictEqual(getResponse.status, 200) + t.assert.deepStrictEqual(await getResponse.json(), { hello: 'world' }) + + const deleteResponse = await fetch(getServerUrl(fastify) + '/multiple', { method: 'DELETE' }) + t.assert.ok(deleteResponse.ok) + t.assert.strictEqual(deleteResponse.status, 200) + t.assert.deepStrictEqual(await deleteResponse.json(), { hello: 'world' }) + }) + + await t.test('Add Multiple methods per route all lowercase', async (t) => { + t.plan(7) + + const fastify = Fastify() + t.assert.doesNotThrow(() => + fastify.route({ + method: ['get', 'delete'], + url: '/multiple', + handler: function (req, reply) { + reply.send({ hello: 'world' }) + } + })) + + await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) + + const getResponse = await fetch(getServerUrl(fastify) + '/multiple') + t.assert.ok(getResponse.ok) + t.assert.strictEqual(getResponse.status, 200) + t.assert.deepStrictEqual(await getResponse.json(), { hello: 'world' }) + + const deleteResponse = await fetch(getServerUrl(fastify) + '/multiple', { method: 'DELETE' }) + t.assert.ok(deleteResponse.ok) + t.assert.strictEqual(deleteResponse.status, 200) + t.assert.deepStrictEqual(await deleteResponse.json(), { hello: 'world' }) + }) + + await t.test('Add Multiple methods per route mixed uppercase and lowercase', async (t) => { + t.plan(7) + + const fastify = Fastify() + t.assert.doesNotThrow(() => + fastify.route({ + method: ['GET', 'delete'], + url: '/multiple', + handler: function (req, reply) { + reply.send({ hello: 'world' }) + } + })) + + await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) + + const getResponse = await fetch(getServerUrl(fastify) + '/multiple') + t.assert.ok(getResponse.ok) + t.assert.strictEqual(getResponse.status, 200) + t.assert.deepStrictEqual(await getResponse.json(), { hello: 'world' }) + + const deleteResponse = await fetch(getServerUrl(fastify) + '/multiple', { method: 'DELETE' }) + t.assert.ok(deleteResponse.ok) + t.assert.strictEqual(deleteResponse.status, 200) + t.assert.deepStrictEqual(await deleteResponse.json(), { hello: 'world' }) + }) + + t.test('Add invalid Multiple methods per route', t => { + t.plan(1) + + const fastify = Fastify() + t.assert.throws(() => + fastify.route({ + method: ['GET', 1], + url: '/invalid-method', + handler: function (req, reply) { + reply.send({ hello: 'world' }) + } + }), new FST_ERR_ROUTE_METHOD_INVALID()) + }) + + await t.test('Add method', t => { + t.plan(1) + + const fastify = Fastify() + t.assert.throws(() => + fastify.route({ + method: 1, + url: '/invalid-method', + handler: function (req, reply) { + reply.send({ hello: 'world' }) + } + }), new FST_ERR_ROUTE_METHOD_INVALID()) + }) + + await t.test('Add additional multiple methods to existing route', async (t) => { + t.plan(7) + + const fastify = Fastify() + t.assert.doesNotThrow(() => { + fastify.get('/add-multiple', function (req, reply) { + reply.send({ hello: 'Bob!' }) + }) + fastify.route({ + method: ['PUT', 'DELETE'], + url: '/add-multiple', + handler: function (req, reply) { + reply.send({ hello: 'world' }) + } + }) + }) + + await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) + + const putResponse = await fetch(getServerUrl(fastify) + '/add-multiple', { method: 'PUT' }) + t.assert.ok(putResponse.ok) + t.assert.strictEqual(putResponse.status, 200) + t.assert.deepStrictEqual(await putResponse.json(), { hello: 'world' }) + + const deleteResponse = await fetch(getServerUrl(fastify) + '/add-multiple', { method: 'DELETE' }) + t.assert.ok(deleteResponse.ok) + t.assert.strictEqual(deleteResponse.status, 200) + t.assert.deepStrictEqual(await deleteResponse.json(), { hello: 'world' }) + }) + + await t.test('cannot add another route after binding', async (t) => { + t.plan(1) + + const fastify = Fastify() + + await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) + + t.assert.throws(() => fastify.route({ + method: 'GET', + url: '/another-get-route', + handler: function (req, reply) { + reply.send({ hello: 'world' }) + } + }), new FST_ERR_INSTANCE_ALREADY_LISTENING('Cannot add route!')) + }) +}) + +test('invalid schema - route', (t, done) => { + t.plan(3) + + const fastify = Fastify() + fastify.route({ + handler: () => { }, + method: 'GET', + url: '/invalid', + schema: { + querystring: { + id: 'string' + } + } + }) + fastify.after(err => { + t.assert.ok(!err, 'the error is throw on preReady') + }) + fastify.ready(err => { + t.assert.strictEqual(err.code, 'FST_ERR_SCH_VALIDATION_BUILD') + t.assert.match(err.message, /Failed building the validation schema for GET: \/invalid/) + done() + }) +}) diff --git a/test/route.2.test.js b/test/route.2.test.js new file mode 100644 index 00000000000..f5d305e0712 --- /dev/null +++ b/test/route.2.test.js @@ -0,0 +1,100 @@ +'use strict' + +const { test } = require('node:test') +const Fastify = require('../fastify') + +test('same route definition object on multiple prefixes', async t => { + t.plan(2) + + const routeObject = { + handler: () => { }, + method: 'GET', + url: '/simple' + } + + const fastify = Fastify({ exposeHeadRoutes: false }) + + fastify.register(async function (f) { + f.addHook('onRoute', (routeOptions) => { + t.assert.strictEqual(routeOptions.url, '/v1/simple') + }) + f.route(routeObject) + }, { prefix: '/v1' }) + fastify.register(async function (f) { + f.addHook('onRoute', (routeOptions) => { + t.assert.strictEqual(routeOptions.url, '/v2/simple') + }) + f.route(routeObject) + }, { prefix: '/v2' }) + + await fastify.ready() +}) + +test('path can be specified in place of uri', (t, done) => { + t.plan(3) + const fastify = Fastify() + + fastify.route({ + method: 'GET', + path: '/path', + handler: function (req, reply) { + reply.send({ hello: 'world' }) + } + }) + + const reqOpts = { + method: 'GET', + url: '/path' + } + + fastify.inject(reqOpts, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + done() + }) +}) + +test('invalid bodyLimit option - route', t => { + t.plan(2) + const fastify = Fastify() + + try { + fastify.route({ + bodyLimit: false, + method: 'PUT', + handler: () => null + }) + t.assert.fail('bodyLimit must be an integer') + } catch (err) { + t.assert.strictEqual(err.message, "'bodyLimit' option must be an integer > 0. Got 'false'") + } + + try { + fastify.post('/url', { bodyLimit: 10000.1 }, () => null) + t.assert.fail('bodyLimit must be an integer') + } catch (err) { + t.assert.strictEqual(err.message, "'bodyLimit' option must be an integer > 0. Got '10000.1'") + } +}) + +test('handler function in options of shorthand route should works correctly', (t, done) => { + t.plan(3) + + const fastify = Fastify() + fastify.get('/foo', { + handler: (req, reply) => { + reply.send({ hello: 'world' }) + } + }) + + fastify.inject({ + method: 'GET', + url: '/foo' + }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + done() + }) +}) diff --git a/test/route.3.test.js b/test/route.3.test.js new file mode 100644 index 00000000000..d324b972bef --- /dev/null +++ b/test/route.3.test.js @@ -0,0 +1,213 @@ +'use strict' + +const { test } = require('node:test') +const joi = require('joi') +const Fastify = require('..') + +test('does not mutate joi schemas', (t, done) => { + t.plan(5) + + const fastify = Fastify() + function validatorCompiler ({ schema, method, url, httpPart }) { + // Needed to extract the params part, + // without the JSON-schema encapsulation + // that is automatically added by the short + // form of params. + schema = joi.object(schema.properties) + + return validateHttpData + + function validateHttpData (data) { + return schema.validate(data) + } + } + + fastify.setValidatorCompiler(validatorCompiler) + + fastify.route({ + path: '/foo/:an_id', + method: 'GET', + schema: { + params: { an_id: joi.number() } + }, + handler (req, res) { + t.assert.strictEqual(Object.keys(req.params).length, 1) + t.assert.strictEqual(req.params.an_id, '42') + res.send({ hello: 'world' }) + } + }) + + fastify.inject({ + method: 'GET', + url: '/foo/42' + }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.json(), { hello: 'world' }) + done() + }) +}) + +test('multiple routes with one schema', (t, done) => { + t.plan(2) + + const fastify = Fastify() + + const schema = { + query: { + type: 'object', + properties: { + id: { type: 'number' } + } + } + } + + fastify.route({ + schema, + method: 'GET', + path: '/first/:id', + handler (req, res) { + res.send({ hello: 'world' }) + } + }) + + fastify.route({ + schema, + method: 'GET', + path: '/second/:id', + handler (req, res) { + res.send({ hello: 'world' }) + } + }) + + fastify.ready(error => { + t.assert.ifError(error) + t.assert.deepStrictEqual(schema, schema) + done() + }) +}) + +test('route error handler overrides default error handler', (t, done) => { + t.plan(4) + + const fastify = Fastify() + + const customRouteErrorHandler = (error, request, reply) => { + t.assert.strictEqual(error.message, 'Wrong Pot Error') + + reply.code(418).send({ + message: 'Make a brew', + statusCode: 418, + error: 'Wrong Pot Error' + }) + } + + fastify.route({ + method: 'GET', + path: '/coffee', + handler: (req, res) => { + res.send(new Error('Wrong Pot Error')) + }, + errorHandler: customRouteErrorHandler + }) + + fastify.inject({ + method: 'GET', + url: '/coffee' + }, (error, res) => { + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 418) + t.assert.deepStrictEqual(res.json(), { + message: 'Make a brew', + statusCode: 418, + error: 'Wrong Pot Error' + }) + done() + }) +}) + +test('route error handler does not affect other routes', (t, done) => { + t.plan(3) + + const fastify = Fastify() + + const customRouteErrorHandler = (error, request, reply) => { + t.assert.strictEqual(error.message, 'Wrong Pot Error') + + reply.code(418).send({ + message: 'Make a brew', + statusCode: 418, + error: 'Wrong Pot Error' + }) + } + + fastify.route({ + method: 'GET', + path: '/coffee', + handler: (req, res) => { + res.send(new Error('Wrong Pot Error')) + }, + errorHandler: customRouteErrorHandler + }) + + fastify.route({ + method: 'GET', + path: '/tea', + handler: (req, res) => { + res.send(new Error('No tea today')) + } + }) + + fastify.inject({ + method: 'GET', + url: '/tea' + }, (error, res) => { + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 500) + t.assert.deepStrictEqual(res.json(), { + message: 'No tea today', + statusCode: 500, + error: 'Internal Server Error' + }) + done() + }) +}) + +test('async error handler for a route', (t, done) => { + t.plan(4) + + const fastify = Fastify() + + const customRouteErrorHandler = async (error, request, reply) => { + t.assert.strictEqual(error.message, 'Delayed Pot Error') + reply.code(418) + return { + message: 'Make a brew sometime later', + statusCode: 418, + error: 'Delayed Pot Error' + } + } + + fastify.route({ + method: 'GET', + path: '/late-coffee', + handler: (req, res) => { + res.send(new Error('Delayed Pot Error')) + }, + errorHandler: customRouteErrorHandler + }) + + fastify.inject({ + method: 'GET', + url: '/late-coffee' + }, (error, res) => { + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 418) + t.assert.deepStrictEqual(res.json(), { + message: 'Make a brew sometime later', + statusCode: 418, + error: 'Delayed Pot Error' + }) + done() + }) +}) diff --git a/test/route.4.test.js b/test/route.4.test.js new file mode 100644 index 00000000000..38d48de8488 --- /dev/null +++ b/test/route.4.test.js @@ -0,0 +1,127 @@ +'use strict' + +const { test } = require('node:test') +const Fastify = require('..') + +test('route error handler overrides global custom error handler', async t => { + t.plan(3) + + const fastify = Fastify() + + const customGlobalErrorHandler = (error, request, reply) => { + t.assert.ifError(error) + reply.code(429).send({ message: 'Too much coffee' }) + } + + const customRouteErrorHandler = (error, request, reply) => { + t.assert.strictEqual(error.message, 'Wrong Pot Error') + reply.code(418).send({ + message: 'Make a brew', + statusCode: 418, + error: 'Wrong Pot Error' + }) + } + + fastify.setErrorHandler(customGlobalErrorHandler) + + fastify.route({ + method: 'GET', + path: '/more-coffee', + handler: (req, res) => { + res.send(new Error('Wrong Pot Error')) + }, + errorHandler: customRouteErrorHandler + }) + + const res = await fastify.inject({ + method: 'GET', + url: '/more-coffee' + }) + t.assert.strictEqual(res.statusCode, 418) + t.assert.deepStrictEqual(JSON.parse(res.payload), { + message: 'Make a brew', + statusCode: 418, + error: 'Wrong Pot Error' + }) +}) + +test('throws when route with empty url', async t => { + t.plan(1) + + const fastify = Fastify() + try { + fastify.route({ + method: 'GET', + url: '', + handler: (req, res) => { + res.send('hi!') + } + }) + } catch (err) { + t.assert.strictEqual(err.message, 'The path could not be empty') + } +}) + +test('throws when route with empty url in shorthand declaration', async t => { + t.plan(1) + + const fastify = Fastify() + try { + fastify.get( + '', + async function handler () { return {} } + ) + } catch (err) { + t.assert.strictEqual(err.message, 'The path could not be empty') + } +}) + +test('throws when route-level error handler is not a function', t => { + t.plan(1) + + const fastify = Fastify() + + try { + fastify.route({ + method: 'GET', + url: '/tea', + handler: (req, res) => { + res.send('hi!') + }, + errorHandler: 'teapot' + }) + } catch (err) { + t.assert.strictEqual(err.message, 'Error Handler for GET:/tea route, if defined, must be a function') + } +}) + +test('route child logger factory overrides default child logger factory', async t => { + t.plan(2) + + const fastify = Fastify() + + const customRouteChildLogger = (logger, bindings, opts, req) => { + const child = logger.child(bindings, opts) + child.customLog = function (message) { + t.assert.strictEqual(message, 'custom') + } + return child + } + + fastify.route({ + method: 'GET', + path: '/coffee', + handler: (req, res) => { + req.log.customLog('custom') + res.send() + }, + childLoggerFactory: customRouteChildLogger + }) + + const res = await fastify.inject({ + method: 'GET', + url: '/coffee' + }) + + t.assert.strictEqual(res.statusCode, 200) +}) diff --git a/test/route.5.test.js b/test/route.5.test.js new file mode 100644 index 00000000000..edcf7712e6e --- /dev/null +++ b/test/route.5.test.js @@ -0,0 +1,211 @@ +'use strict' + +const { test } = require('node:test') +const Fastify = require('..') + +test('route child logger factory does not affect other routes', async t => { + t.plan(4) + + const fastify = Fastify() + + const customRouteChildLogger = (logger, bindings, opts, req) => { + const child = logger.child(bindings, opts) + child.customLog = function (message) { + t.assert.strictEqual(message, 'custom') + } + return child + } + + fastify.route({ + method: 'GET', + path: '/coffee', + handler: (req, res) => { + req.log.customLog('custom') + res.send() + }, + childLoggerFactory: customRouteChildLogger + }) + + fastify.route({ + method: 'GET', + path: '/tea', + handler: (req, res) => { + t.assert.ok(req.log.customLog instanceof Function) + res.send() + } + }) + + let res = await fastify.inject({ + method: 'GET', + url: '/coffee' + }) + t.assert.strictEqual(res.statusCode, 200) + + res = await fastify.inject({ + method: 'GET', + url: '/tea' + }) + t.assert.strictEqual(res.statusCode, 200) +}) +test('route child logger factory overrides global custom error handler', async t => { + t.plan(4) + + const fastify = Fastify() + + const customGlobalChildLogger = (logger, bindings, opts, req) => { + const child = logger.child(bindings, opts) + child.globalLog = function (message) { + t.assert.strictEqual(message, 'global') + } + return child + } + const customRouteChildLogger = (logger, bindings, opts, req) => { + const child = logger.child(bindings, opts) + child.customLog = function (message) { + t.assert.strictEqual(message, 'custom') + } + return child + } + + fastify.setChildLoggerFactory(customGlobalChildLogger) + + fastify.route({ + method: 'GET', + path: '/coffee', + handler: (req, res) => { + req.log.customLog('custom') + res.send() + }, + childLoggerFactory: customRouteChildLogger + }) + fastify.route({ + method: 'GET', + path: '/more-coffee', + handler: (req, res) => { + req.log.globalLog('global') + res.send() + } + }) + + let res = await fastify.inject({ + method: 'GET', + url: '/coffee' + }) + t.assert.strictEqual(res.statusCode, 200) + + res = await fastify.inject({ + method: 'GET', + url: '/more-coffee' + }) + t.assert.strictEqual(res.statusCode, 200) +}) + +test('Creates a HEAD route for each GET one (default)', async t => { + t.plan(6) + + const fastify = Fastify() + + fastify.route({ + method: 'GET', + path: '/more-coffee', + handler: (req, reply) => { + reply.send({ here: 'is coffee' }) + } + }) + + fastify.route({ + method: 'GET', + path: '/some-light', + handler: (req, reply) => { + reply.send('Get some light!') + } + }) + + let res = await fastify.inject({ + method: 'HEAD', + url: '/more-coffee' + }) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['content-type'], 'application/json; charset=utf-8') + t.assert.deepStrictEqual(res.body, '') + + res = await fastify.inject({ + method: 'HEAD', + url: '/some-light' + }) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['content-type'], 'text/plain; charset=utf-8') + t.assert.strictEqual(res.body, '') +}) + +test('Do not create a HEAD route for each GET one (exposeHeadRoutes: false)', async t => { + t.plan(2) + + const fastify = Fastify({ exposeHeadRoutes: false }) + + fastify.route({ + method: 'GET', + path: '/more-coffee', + handler: (req, reply) => { + reply.send({ here: 'is coffee' }) + } + }) + + fastify.route({ + method: 'GET', + path: '/some-light', + handler: (req, reply) => { + reply.send('Get some light!') + } + }) + + let res = await fastify.inject({ + method: 'HEAD', + url: '/more-coffee' + }) + t.assert.strictEqual(res.statusCode, 404) + + res = await fastify.inject({ + method: 'HEAD', + url: '/some-light' + }) + t.assert.strictEqual(res.statusCode, 404) +}) + +test('Creates a HEAD route for each GET one', async t => { + t.plan(6) + + const fastify = Fastify({ exposeHeadRoutes: true }) + + fastify.route({ + method: 'GET', + path: '/more-coffee', + handler: (req, reply) => { + reply.send({ here: 'is coffee' }) + } + }) + + fastify.route({ + method: 'GET', + path: '/some-light', + handler: (req, reply) => { + reply.send('Get some light!') + } + }) + + let res = await fastify.inject({ + method: 'HEAD', + url: '/more-coffee' + }) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['content-type'], 'application/json; charset=utf-8') + t.assert.deepStrictEqual(res.body, '') + + res = await fastify.inject({ + method: 'HEAD', + url: '/some-light' + }) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['content-type'], 'text/plain; charset=utf-8') + t.assert.strictEqual(res.body, '') +}) diff --git a/test/route.6.test.js b/test/route.6.test.js new file mode 100644 index 00000000000..07ea4a03654 --- /dev/null +++ b/test/route.6.test.js @@ -0,0 +1,306 @@ +'use strict' + +const stream = require('node:stream') +const { ReadableStream } = require('node:stream/web') +const { test } = require('node:test') +const Fastify = require('..') + +test('Creates a HEAD route for a GET one with prefixTrailingSlash', async (t) => { + t.plan(1) + + const fastify = Fastify() + + const arr = [] + fastify.register((instance, opts, next) => { + instance.addHook('onRoute', (routeOptions) => { + arr.push(`${routeOptions.method} ${routeOptions.url}`) + }) + + instance.route({ + method: 'GET', + path: '/', + exposeHeadRoute: true, + prefixTrailingSlash: 'both', + handler: (req, reply) => { + reply.send({ here: 'is coffee' }) + } + }) + + next() + }, { prefix: '/v1' }) + + await fastify.ready() + + t.assert.ok(true) +}) + +test('Will not create a HEAD route that is not GET', async t => { + t.plan(8) + + const fastify = Fastify({ exposeHeadRoutes: true }) + + fastify.route({ + method: 'GET', + path: '/more-coffee', + handler: (req, reply) => { + reply.send({ here: 'is coffee' }) + } + }) + + fastify.route({ + method: 'GET', + path: '/some-light', + handler: (req, reply) => { + reply.send() + } + }) + + fastify.route({ + method: 'POST', + path: '/something', + handler: (req, reply) => { + reply.send({ look: 'It is something!' }) + } + }) + + let res = await fastify.inject({ + method: 'HEAD', + url: '/more-coffee' + }) + + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['content-type'], 'application/json; charset=utf-8') + t.assert.deepStrictEqual(res.body, '') + + res = await fastify.inject({ + method: 'HEAD', + url: '/some-light' + }) + + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['content-type'], undefined) + t.assert.strictEqual(res.headers['content-length'], '0') + t.assert.strictEqual(res.body, '') + + res = await fastify.inject({ + method: 'HEAD', + url: '/something' + }) + + t.assert.strictEqual(res.statusCode, 404) +}) + +test('HEAD route should handle properly each response type', async t => { + t.plan(24) + + const fastify = Fastify({ exposeHeadRoutes: true }) + const resString = 'Found me!' + const resJSON = { here: 'is Johnny' } + const resBuffer = Buffer.from('I am a buffer!') + const resStream = stream.Readable.from('I am a stream!') + const resWebStream = ReadableStream.from('I am a web stream!') + + fastify.route({ + method: 'GET', + path: '/json', + handler: (req, reply) => { + reply.send(resJSON) + } + }) + + fastify.route({ + method: 'GET', + path: '/string', + handler: (req, reply) => { + reply.send(resString) + } + }) + + fastify.route({ + method: 'GET', + path: '/buffer', + handler: (req, reply) => { + reply.send(resBuffer) + } + }) + + fastify.route({ + method: 'GET', + path: '/buffer-with-content-type', + handler: (req, reply) => { + reply.headers({ 'content-type': 'image/jpeg' }) + reply.send(resBuffer) + } + }) + + fastify.route({ + method: 'GET', + path: '/stream', + handler: (req, reply) => { + return resStream + } + }) + + fastify.route({ + method: 'GET', + path: '/web-stream', + handler: (req, reply) => { + return resWebStream + } + }) + + let res = await fastify.inject({ + method: 'HEAD', + url: '/json' + }) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['content-type'], 'application/json; charset=utf-8') + t.assert.strictEqual(res.headers['content-length'], `${Buffer.byteLength(JSON.stringify(resJSON))}`) + t.assert.deepStrictEqual(res.body, '') + + res = await fastify.inject({ + method: 'HEAD', + url: '/string' + }) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['content-type'], 'text/plain; charset=utf-8') + t.assert.strictEqual(res.headers['content-length'], `${Buffer.byteLength(resString)}`) + t.assert.strictEqual(res.body, '') + + res = await fastify.inject({ + method: 'HEAD', + url: '/buffer' + }) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['content-type'], 'application/octet-stream') + t.assert.strictEqual(res.headers['content-length'], `${resBuffer.byteLength}`) + t.assert.strictEqual(res.body, '') + + res = await fastify.inject({ + method: 'HEAD', + url: '/buffer-with-content-type' + }) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['content-type'], 'image/jpeg') + t.assert.strictEqual(res.headers['content-length'], `${resBuffer.byteLength}`) + t.assert.strictEqual(res.body, '') + + res = await fastify.inject({ + method: 'HEAD', + url: '/stream' + }) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['content-type'], undefined) + t.assert.strictEqual(res.headers['content-length'], undefined) + t.assert.strictEqual(res.body, '') + + res = await fastify.inject({ + method: 'HEAD', + url: '/web-stream' + }) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['content-type'], undefined) + t.assert.strictEqual(res.headers['content-length'], undefined) + t.assert.strictEqual(res.body, '') +}) + +test('HEAD route should respect custom onSend handlers', async t => { + t.plan(5) + + let counter = 0 + const resBuffer = Buffer.from('I am a coffee!') + const fastify = Fastify({ exposeHeadRoutes: true }) + const customOnSend = (res, reply, payload, done) => { + counter = counter + 1 + done(null, payload) + } + + fastify.route({ + method: 'GET', + path: '/more-coffee', + handler: (req, reply) => { + reply.send(resBuffer) + }, + onSend: [customOnSend, customOnSend] + }) + + const res = await fastify.inject({ + method: 'HEAD', + url: '/more-coffee' + }) + + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['content-type'], 'application/octet-stream') + t.assert.strictEqual(res.headers['content-length'], `${resBuffer.byteLength}`) + t.assert.strictEqual(res.body, '') + t.assert.strictEqual(counter, 2) +}) + +test('route onSend can be function or array of functions', async t => { + t.plan(10) + const counters = { single: 0, multiple: 0 } + + const resBuffer = Buffer.from('I am a coffee!') + const fastify = Fastify({ exposeHeadRoutes: true }) + + fastify.route({ + method: 'GET', + path: '/coffee', + handler: () => resBuffer, + onSend: (res, reply, payload, done) => { + counters.single += 1 + done(null, payload) + } + }) + + const customOnSend = (res, reply, payload, done) => { + counters.multiple += 1 + done(null, payload) + } + + fastify.route({ + method: 'GET', + path: '/more-coffee', + handler: () => resBuffer, + onSend: [customOnSend, customOnSend] + }) + + let res = await fastify.inject({ method: 'HEAD', url: '/coffee' }) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['content-type'], 'application/octet-stream') + t.assert.strictEqual(res.headers['content-length'], `${resBuffer.byteLength}`) + t.assert.strictEqual(res.body, '') + t.assert.strictEqual(counters.single, 1) + + res = await fastify.inject({ method: 'HEAD', url: '/more-coffee' }) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['content-type'], 'application/octet-stream') + t.assert.strictEqual(res.headers['content-length'], `${resBuffer.byteLength}`) + t.assert.strictEqual(res.body, '') + t.assert.strictEqual(counters.multiple, 2) +}) + +test('no warning for exposeHeadRoute', async t => { + const fastify = Fastify() + + fastify.route({ + method: 'GET', + path: '/more-coffee', + exposeHeadRoute: true, + async handler () { + return 'hello world' + } + }) + + const listener = (w) => { + t.assert.fail('no warning') + } + + process.on('warning', listener) + + await fastify.listen({ port: 0 }) + + process.removeListener('warning', listener) + + await fastify.close() +}) diff --git a/test/route.7.test.js b/test/route.7.test.js new file mode 100644 index 00000000000..44e7a091556 --- /dev/null +++ b/test/route.7.test.js @@ -0,0 +1,406 @@ +'use strict' + +const stream = require('node:stream') +const { ReadableStream } = require('node:stream/web') +const split = require('split2') +const { test } = require('node:test') +const Fastify = require('..') +const createError = require('@fastify/error') + +test("HEAD route should handle stream.on('error')", (t, done) => { + t.plan(6) + + const resStream = stream.Readable.from('Hello with error!') + const logStream = split(JSON.parse) + const expectedError = new Error('Hello!') + const fastify = Fastify({ + logger: { + stream: logStream, + level: 'error' + } + }) + + fastify.route({ + method: 'GET', + path: '/more-coffee', + exposeHeadRoute: true, + handler: (req, reply) => { + process.nextTick(() => resStream.emit('error', expectedError)) + return resStream + } + }) + + logStream.once('data', line => { + const { message, stack } = expectedError + t.assert.deepStrictEqual(line.err, { type: 'Error', message, stack }) + t.assert.strictEqual(line.msg, 'Error on Stream found for HEAD route') + t.assert.strictEqual(line.level, 50) + }) + + fastify.inject({ + method: 'HEAD', + url: '/more-coffee' + }, (error, res) => { + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['content-type'], undefined) + done() + }) +}) + +test('HEAD route should handle ReadableStream.cancel() error', (t, done) => { + t.plan(7) + + const logStream = split(JSON.parse) + const expectedError = new Error('Cancel error!') + const fastify = Fastify({ + logger: { + stream: logStream, + level: 'error' + } + }) + + fastify.route({ + method: 'GET', + path: '/web-stream', + exposeHeadRoute: true, + handler: (req, reply) => { + const webStream = new ReadableStream({ + start (controller) { + controller.enqueue('Hello from web stream!') + }, + cancel (reason) { + t.assert.strictEqual(reason, 'Stream cancelled by HEAD route') + throw expectedError + } + }) + return webStream + } + }) + + logStream.once('data', line => { + const { message, stack } = expectedError + t.assert.deepStrictEqual(line.err, { type: 'Error', message, stack }) + t.assert.strictEqual(line.msg, 'Error on Stream found for HEAD route') + t.assert.strictEqual(line.level, 50) + }) + + fastify.inject({ + method: 'HEAD', + url: '/web-stream' + }, (error, res) => { + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['content-type'], undefined) + done() + }) +}) + +test('HEAD route should be exposed by default', async t => { + t.plan(5) + + const resStream = stream.Readable.from('Hello with error!') + const resJson = { hello: 'world' } + const fastify = Fastify() + + fastify.route({ + method: 'GET', + path: '/without-flag', + handler: (req, reply) => { + return resStream + } + }) + + fastify.route({ + exposeHeadRoute: true, + method: 'GET', + path: '/with-flag', + handler: (req, reply) => { + return resJson + } + }) + + let res = await fastify.inject({ + method: 'HEAD', + url: '/without-flag' + }) + t.assert.strictEqual(res.statusCode, 200) + + res = await fastify.inject({ + method: 'HEAD', + url: '/with-flag' + }) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['content-type'], 'application/json; charset=utf-8') + t.assert.strictEqual(res.headers['content-length'], `${Buffer.byteLength(JSON.stringify(resJson))}`) + t.assert.strictEqual(res.body, '') +}) + +test('HEAD route should be exposed if route exposeHeadRoute is set', async t => { + t.plan(5) + + const resBuffer = Buffer.from('I am a coffee!') + const resJson = { hello: 'world' } + const fastify = Fastify({ exposeHeadRoutes: false }) + + fastify.route({ + exposeHeadRoute: true, + method: 'GET', + path: '/one', + handler: (req, reply) => { + return resBuffer + } + }) + + fastify.route({ + method: 'GET', + path: '/two', + handler: (req, reply) => { + return resJson + } + }) + + let res = await fastify.inject({ + method: 'HEAD', + url: '/one' + }) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['content-type'], 'application/octet-stream') + t.assert.strictEqual(res.headers['content-length'], `${resBuffer.byteLength}`) + t.assert.strictEqual(res.body, '') + + res = await fastify.inject({ + method: 'HEAD', + url: '/two' + }) + t.assert.strictEqual(res.statusCode, 404) +}) + +test('Set a custom HEAD route before GET one without disabling exposeHeadRoutes (global)', (t, done) => { + t.plan(6) + + const resBuffer = Buffer.from('I am a coffee!') + const fastify = Fastify({ + exposeHeadRoutes: true + }) + + fastify.route({ + method: 'HEAD', + path: '/one', + handler: (req, reply) => { + reply.header('content-type', 'application/pdf') + reply.header('content-length', `${resBuffer.byteLength}`) + reply.header('x-custom-header', 'some-custom-header') + reply.send() + } + }) + + fastify.route({ + method: 'GET', + path: '/one', + handler: (req, reply) => { + return resBuffer + } + }) + + fastify.inject({ + method: 'HEAD', + url: '/one' + }, (error, res) => { + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['content-type'], 'application/pdf') + t.assert.strictEqual(res.headers['content-length'], `${resBuffer.byteLength}`) + t.assert.strictEqual(res.headers['x-custom-header'], 'some-custom-header') + t.assert.strictEqual(res.body, '') + done() + }) +}) + +test('Set a custom HEAD route before GET one without disabling exposeHeadRoutes (route)', (t, done) => { + t.plan(6) + + const fastify = Fastify() + + const resBuffer = Buffer.from('I am a coffee!') + + fastify.route({ + method: 'HEAD', + path: '/one', + handler: (req, reply) => { + reply.header('content-type', 'application/pdf') + reply.header('content-length', `${resBuffer.byteLength}`) + reply.header('x-custom-header', 'some-custom-header') + reply.send() + } + }) + + fastify.route({ + method: 'GET', + exposeHeadRoute: true, + path: '/one', + handler: (req, reply) => { + return resBuffer + } + }) + + fastify.inject({ + method: 'HEAD', + url: '/one' + }, (error, res) => { + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers['content-type'], 'application/pdf') + t.assert.strictEqual(res.headers['content-length'], `${resBuffer.byteLength}`) + t.assert.strictEqual(res.headers['x-custom-header'], 'some-custom-header') + t.assert.strictEqual(res.body, '') + done() + }) +}) + +test('HEAD routes properly auto created for GET routes when prefixTrailingSlash: \'no-slash\'', (t, done) => { + t.plan(2) + + const fastify = Fastify() + + fastify.register(function routes (f, opts, next) { + f.route({ + method: 'GET', + url: '/', + exposeHeadRoute: true, + prefixTrailingSlash: 'no-slash', + handler: (req, reply) => { + reply.send({ hello: 'world' }) + } + }) + + next() + }, { prefix: '/prefix' }) + + fastify.inject({ url: '/prefix/prefix', method: 'HEAD' }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 404) + done() + }) +}) + +test('HEAD routes properly auto created for GET routes when prefixTrailingSlash: \'both\'', async t => { + t.plan(3) + + const fastify = Fastify() + + fastify.register(function routes (f, opts, next) { + f.route({ + method: 'GET', + url: '/', + exposeHeadRoute: true, + prefixTrailingSlash: 'both', + handler: (req, reply) => { + reply.send({ hello: 'world' }) + } + }) + + next() + }, { prefix: '/prefix' }) + + const doublePrefixReply = await fastify.inject({ url: '/prefix/prefix', method: 'HEAD' }) + const trailingSlashReply = await fastify.inject({ url: '/prefix/', method: 'HEAD' }) + const noneTrailingReply = await fastify.inject({ url: '/prefix', method: 'HEAD' }) + + t.assert.strictEqual(doublePrefixReply.statusCode, 404) + t.assert.strictEqual(trailingSlashReply.statusCode, 200) + t.assert.strictEqual(noneTrailingReply.statusCode, 200) +}) + +test('GET route with body schema should throw', t => { + t.plan(1) + + const fastify = Fastify() + + t.assert.throws(() => { + fastify.route({ + method: 'GET', + path: '/get', + schema: { + body: {} + }, + handler: function (req, reply) { + reply.send({ hello: 'world' }) + } + }) + }, createError('FST_ERR_ROUTE_BODY_VALIDATION_SCHEMA_NOT_SUPPORTED', 'Body validation schema for GET:/get route is not supported!')()) +}) + +test('HEAD route with body schema should throw', t => { + t.plan(1) + + const fastify = Fastify() + + t.assert.throws(() => { + fastify.route({ + method: 'HEAD', + path: '/shouldThrow', + schema: { + body: {} + }, + handler: function (req, reply) { + reply.send({ hello: 'world' }) + } + }) + }, createError('FST_ERR_ROUTE_BODY_VALIDATION_SCHEMA_NOT_SUPPORTED', 'Body validation schema for HEAD:/shouldThrow route is not supported!')()) +}) + +test('[HEAD, GET] route with body schema should throw', t => { + t.plan(1) + + const fastify = Fastify() + + t.assert.throws(() => { + fastify.route({ + method: ['HEAD', 'GET'], + path: '/shouldThrowHead', + schema: { + body: {} + }, + handler: function (req, reply) { + reply.send({ hello: 'world' }) + } + }) + }, createError('FST_ERR_ROUTE_BODY_VALIDATION_SCHEMA_NOT_SUPPORTED', 'Body validation schema for HEAD:/shouldThrowHead route is not supported!')()) +}) + +test('GET route with body schema should throw - shorthand', t => { + t.plan(1) + + const fastify = Fastify() + + t.assert.throws(() => { + fastify.get('/shouldThrow', { + schema: { + body: {} + } + }, + function (req, reply) { + reply.send({ hello: 'world' }) + } + ) + }, createError('FST_ERR_ROUTE_BODY_VALIDATION_SCHEMA_NOT_SUPPORTED', 'Body validation schema for GET:/shouldThrow route is not supported!')()) +}) + +test('HEAD route with body schema should throw - shorthand', t => { + t.plan(1) + + const fastify = Fastify() + + t.assert.throws(() => { + fastify.head('/shouldThrow2', { + schema: { + body: {} + } + }, + function (req, reply) { + reply.send({ hello: 'world' }) + } + ) + }, createError('FST_ERR_ROUTE_BODY_VALIDATION_SCHEMA_NOT_SUPPORTED', 'Body validation schema for HEAD:/shouldThrow2 route is not supported!')()) +}) diff --git a/test/route.8.test.js b/test/route.8.test.js new file mode 100644 index 00000000000..2f479eaf4dc --- /dev/null +++ b/test/route.8.test.js @@ -0,0 +1,225 @@ +'use strict' + +const { test } = require('node:test') +const Fastify = require('..') +const { + FST_ERR_INVALID_URL +} = require('../lib/errors') + +test('Request and Reply share the route options', async t => { + t.plan(3) + + const fastify = Fastify() + + const config = { + this: 'is a string', + thisIs: function aFunction () {} + } + + fastify.route({ + method: 'GET', + url: '/', + config, + handler: (req, reply) => { + t.assert.deepStrictEqual(req.routeOptions, reply.routeOptions) + t.assert.deepStrictEqual(req.routeOptions.config, reply.routeOptions.config) + t.assert.match(req.routeOptions.config, config, 'there are url and method additional properties') + + reply.send({ hello: 'world' }) + } + }) + + await fastify.inject('/') +}) + +test('Will not try to re-createprefixed HEAD route if it already exists and exposeHeadRoutes is true', async (t) => { + t.plan(1) + + const fastify = Fastify({ exposeHeadRoutes: true }) + + fastify.register((scope, opts, next) => { + scope.route({ + method: 'HEAD', + path: '/route', + handler: (req, reply) => { + reply.header('content-type', 'text/plain') + reply.send('custom HEAD response') + } + }) + scope.route({ + method: 'GET', + path: '/route', + handler: (req, reply) => { + reply.send({ ok: true }) + } + }) + + next() + }, { prefix: '/prefix' }) + + await fastify.ready() + + t.assert.ok(true) +}) + +test('route with non-english characters', async (t) => { + t.plan(3) + + const fastify = Fastify() + + fastify.get('/föö', (request, reply) => { + reply.send('here /föö') + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const response = await fetch(fastifyServer + encodeURI('/föö')) + t.assert.ok(response.ok) + t.assert.strictEqual(response.status, 200) + const body = await response.text() + t.assert.strictEqual(body, 'here /föö') +}) + +test('invalid url attribute - non string URL', t => { + t.plan(1) + const fastify = Fastify() + + try { + fastify.get(/^\/(donations|skills|blogs)/, () => { }) + } catch (error) { + t.assert.strictEqual(error.code, FST_ERR_INVALID_URL().code) + } +}) + +test('exposeHeadRoute should not reuse the same route option', async t => { + t.plan(2) + + const fastify = Fastify() + + // we update the onRequest hook in onRoute hook + // if we reuse the same route option + // that means we will append another function inside the array + fastify.addHook('onRoute', function (routeOption) { + if (Array.isArray(routeOption.onRequest)) { + routeOption.onRequest.push(() => {}) + } else { + routeOption.onRequest = [() => {}] + } + }) + + fastify.addHook('onRoute', function (routeOption) { + t.assert.strictEqual(routeOption.onRequest.length, 1) + }) + + fastify.route({ + method: 'GET', + path: '/more-coffee', + async handler () { + return 'hello world' + } + }) +}) + +test('using fastify.all when a catchall is defined does not degrade performance', { timeout: 30000 }, async t => { + t.plan(1) + + const fastify = Fastify() + + fastify.get('/*', async (_, reply) => reply.json({ ok: true })) + + for (let i = 0; i < 100; i++) { + fastify.all(`/${i}`, async (_, reply) => reply.json({ ok: true })) + } + + t.assert.ok("fastify.all doesn't degrade performance") +}) + +test('Adding manually HEAD route after GET with the same path throws Fastify duplicated route instance error', t => { + t.plan(1) + + const fastify = Fastify() + + fastify.route({ + method: 'GET', + path: '/:param1', + handler: (req, reply) => { + reply.send({ hello: 'world' }) + } + }) + + try { + fastify.route({ + method: 'HEAD', + path: '/:param2', + handler: (req, reply) => { + reply.send({ hello: 'world' }) + } + }) + t.assert.fail('Should throw fastify duplicated route declaration') + } catch (error) { + t.assert.strictEqual(error.code, 'FST_ERR_DUPLICATED_ROUTE') + } +}) + +test('Will pass onSend hook to HEAD method if exposeHeadRoutes is true /1', async (t) => { + t.plan(1) + + const fastify = Fastify({ exposeHeadRoutes: true }) + + await fastify.register((scope, opts, next) => { + scope.route({ + method: 'GET', + path: '/route', + handler: (req, reply) => { + reply.send({ ok: true }) + }, + onSend: (req, reply, payload, done) => { + reply.header('x-content-type', 'application/fastify') + done(null, payload) + } + }) + + next() + }, { prefix: '/prefix' }) + + await fastify.ready() + + const result = await fastify.inject({ + url: '/prefix/route', + method: 'HEAD' + }) + + t.assert.strictEqual(result.headers['x-content-type'], 'application/fastify') +}) + +test('Will pass onSend hook to HEAD method if exposeHeadRoutes is true /2', async (t) => { + t.plan(1) + + const fastify = Fastify({ exposeHeadRoutes: true }) + + await fastify.register((scope, opts, next) => { + scope.route({ + method: 'get', + path: '/route', + handler: (req, reply) => { + reply.send({ ok: true }) + }, + onSend: (req, reply, payload, done) => { + reply.header('x-content-type', 'application/fastify') + done(null, payload) + } + }) + + next() + }, { prefix: '/prefix' }) + + await fastify.ready() + + const result = await fastify.inject({ + url: '/prefix/route', + method: 'HEAD' + }) + + t.assert.strictEqual(result.headers['x-content-type'], 'application/fastify') +}) diff --git a/test/route.test.js b/test/route.test.js deleted file mode 100644 index 9bf16eb1b2a..00000000000 --- a/test/route.test.js +++ /dev/null @@ -1,1466 +0,0 @@ -'use strict' - -const stream = require('stream') -const split = require('split2') -const t = require('tap') -const test = t.test -const sget = require('simple-get').concat -const joi = require('joi') -const Fastify = require('..') -const proxyquire = require('proxyquire') -const { FST_ERR_INVALID_URL } = require('../lib/errors') - -function getUrl (app) { - const { address, port } = app.server.address() - if (address === '::1') { - return `http://[${address}]:${port}` - } else { - return `http://${address}:${port}` - } -} - -test('route', t => { - t.plan(9) - const test = t.test - const fastify = Fastify() - - test('route - get', t => { - t.plan(1) - try { - fastify.route({ - method: 'GET', - url: '/', - schema: { - response: { - '2xx': { - type: 'object', - properties: { - hello: { - type: 'string' - } - } - } - } - }, - handler: function (req, reply) { - reply.send({ hello: 'world' }) - } - }) - t.pass() - } catch (e) { - t.fail() - } - }) - - test('missing schema - route', t => { - t.plan(1) - try { - fastify.route({ - method: 'GET', - url: '/missing', - handler: function (req, reply) { - reply.send({ hello: 'world' }) - } - }) - t.pass() - } catch (e) { - t.fail() - } - }) - - test('invalid handler attribute - route', t => { - t.plan(1) - try { - fastify.get('/', { handler: 'not a function' }, () => {}) - t.fail() - } catch (e) { - t.pass() - } - }) - - test('Multiple methods', t => { - t.plan(1) - try { - fastify.route({ - method: ['GET', 'DELETE'], - url: '/multiple', - handler: function (req, reply) { - reply.send({ hello: 'world' }) - } - }) - t.pass() - } catch (e) { - t.fail() - } - }) - - test('Add multiple methods', t => { - t.plan(1) - try { - fastify.get('/add-multiple', function (req, reply) { - reply.send({ hello: 'Bob!' }) - }) - fastify.route({ - method: ['PUT', 'DELETE'], - url: '/add-multiple', - handler: function (req, reply) { - reply.send({ hello: 'world' }) - } - }) - t.pass() - } catch (e) { - t.fail() - } - }) - - fastify.listen({ port: 0 }, function (err) { - if (err) t.error(err) - t.teardown(() => { fastify.close() }) - - test('cannot add another route after binding', t => { - t.plan(1) - try { - fastify.route({ - method: 'GET', - url: '/another-get-route', - handler: function (req, reply) { - reply.send({ hello: 'world' }) - } - }) - t.fail() - } catch (e) { - t.pass() - } - }) - - test('route - get', t => { - t.plan(3) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(JSON.parse(body), { hello: 'world' }) - }) - }) - - test('route - missing schema', t => { - t.plan(3) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/missing' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(JSON.parse(body), { hello: 'world' }) - }) - }) - - test('route - multiple methods', t => { - t.plan(6) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/multiple' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(JSON.parse(body), { hello: 'world' }) - }) - - sget({ - method: 'DELETE', - url: 'http://localhost:' + fastify.server.address().port + '/multiple' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(JSON.parse(body), { hello: 'world' }) - }) - }) - }) -}) - -test('invalid schema - route', t => { - t.plan(3) - - const fastify = Fastify() - fastify.route({ - handler: () => {}, - method: 'GET', - url: '/invalid', - schema: { - querystring: { - id: 'string' - } - } - }) - fastify.after(err => { - t.notOk(err, 'the error is throw on preReady') - }) - fastify.ready(err => { - t.equal(err.code, 'FST_ERR_SCH_VALIDATION_BUILD') - t.match(err.message, /Failed building the validation schema for GET: \/invalid/) - }) -}) - -test('same route definition object on multiple prefixes', async t => { - t.plan(2) - - const routeObject = { - handler: () => {}, - method: 'GET', - url: '/simple' - } - - const fastify = Fastify({ exposeHeadRoutes: false }) - - fastify.register(async function (f) { - f.addHook('onRoute', (routeOptions) => { - t.equal(routeOptions.url, '/v1/simple') - }) - f.route(routeObject) - }, { prefix: '/v1' }) - fastify.register(async function (f) { - f.addHook('onRoute', (routeOptions) => { - t.equal(routeOptions.url, '/v2/simple') - }) - f.route(routeObject) - }, { prefix: '/v2' }) - - await fastify.ready() -}) - -test('path can be specified in place of uri', t => { - t.plan(3) - const fastify = Fastify() - - fastify.route({ - method: 'GET', - path: '/path', - handler: function (req, reply) { - reply.send({ hello: 'world' }) - } - }) - - const reqOpts = { - method: 'GET', - url: '/path' - } - - fastify.inject(reqOpts, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same(JSON.parse(res.payload), { hello: 'world' }) - }) -}) - -test('invalid bodyLimit option - route', t => { - t.plan(2) - const fastify = Fastify() - - try { - fastify.route({ - bodyLimit: false, - method: 'PUT', - handler: () => null - }) - t.fail('bodyLimit must be an integer') - } catch (err) { - t.equal(err.message, "'bodyLimit' option must be an integer > 0. Got 'false'") - } - - try { - fastify.post('/url', { bodyLimit: 10000.1 }, () => null) - t.fail('bodyLimit must be an integer') - } catch (err) { - t.equal(err.message, "'bodyLimit' option must be an integer > 0. Got '10000.1'") - } -}) - -test('handler function in options of shorthand route should works correctly', t => { - t.plan(3) - - const fastify = Fastify() - fastify.get('/foo', { - handler: (req, reply) => { - reply.send({ hello: 'world' }) - } - }) - - fastify.inject({ - method: 'GET', - url: '/foo' - }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same(JSON.parse(res.payload), { hello: 'world' }) - }) -}) - -test('does not mutate joi schemas', t => { - t.plan(4) - - const fastify = Fastify() - function validatorCompiler ({ schema, method, url, httpPart }) { - // Needed to extract the params part, - // without the JSON-schema encapsulation - // that is automatically added by the short - // form of params. - schema = joi.object(schema.properties) - - return validateHttpData - - function validateHttpData (data) { - return schema.validate(data) - } - } - - fastify.setValidatorCompiler(validatorCompiler) - - fastify.route({ - path: '/foo/:an_id', - method: 'GET', - schema: { - params: { an_id: joi.number() } - }, - handler (req, res) { - t.same(req.params, { an_id: 42 }) - res.send({ hello: 'world' }) - } - }) - - fastify.inject({ - method: 'GET', - url: '/foo/42' - }, (err, result) => { - t.error(err) - t.equal(result.statusCode, 200) - t.same(JSON.parse(result.payload), { hello: 'world' }) - }) -}) - -test('multiple routes with one schema', t => { - t.plan(2) - - const fastify = Fastify() - - const schema = { - query: { - id: { type: 'number' } - } - } - - fastify.route({ - schema, - method: 'GET', - path: '/first/:id', - handler (req, res) { - res.send({ hello: 'world' }) - } - }) - - fastify.route({ - schema, - method: 'GET', - path: '/second/:id', - handler (req, res) { - res.send({ hello: 'world' }) - } - }) - - fastify.ready(error => { - t.error(error) - t.same(schema, schema) - }) -}) - -test('route error handler overrides default error handler', t => { - t.plan(4) - - const fastify = Fastify() - - const customRouteErrorHandler = (error, request, reply) => { - t.equal(error.message, 'Wrong Pot Error') - - reply.code(418).send({ - message: 'Make a brew', - statusCode: 418, - error: 'Wrong Pot Error' - }) - } - - fastify.route({ - method: 'GET', - path: '/coffee', - handler: (req, res) => { - res.send(new Error('Wrong Pot Error')) - }, - errorHandler: customRouteErrorHandler - }) - - fastify.inject({ - method: 'GET', - url: '/coffee' - }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 418) - t.same(JSON.parse(res.payload), { - message: 'Make a brew', - statusCode: 418, - error: 'Wrong Pot Error' - }) - }) -}) - -test('route error handler does not affect other routes', t => { - t.plan(3) - - const fastify = Fastify() - - const customRouteErrorHandler = (error, request, reply) => { - t.equal(error.message, 'Wrong Pot Error') - - reply.code(418).send({ - message: 'Make a brew', - statusCode: 418, - error: 'Wrong Pot Error' - }) - } - - fastify.route({ - method: 'GET', - path: '/coffee', - handler: (req, res) => { - res.send(new Error('Wrong Pot Error')) - }, - errorHandler: customRouteErrorHandler - }) - - fastify.route({ - method: 'GET', - path: '/tea', - handler: (req, res) => { - res.send(new Error('No tea today')) - } - }) - - fastify.inject({ - method: 'GET', - url: '/tea' - }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 500) - t.same(JSON.parse(res.payload), { - message: 'No tea today', - statusCode: 500, - error: 'Internal Server Error' - }) - }) -}) - -test('async error handler for a route', t => { - t.plan(4) - - const fastify = Fastify() - - const customRouteErrorHandler = async (error, request, reply) => { - t.equal(error.message, 'Delayed Pot Error') - reply.code(418) - return { - message: 'Make a brew sometime later', - statusCode: 418, - error: 'Delayed Pot Error' - } - } - - fastify.route({ - method: 'GET', - path: '/late-coffee', - handler: (req, res) => { - res.send(new Error('Delayed Pot Error')) - }, - errorHandler: customRouteErrorHandler - }) - - fastify.inject({ - method: 'GET', - url: '/late-coffee' - }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 418) - t.same(JSON.parse(res.payload), { - message: 'Make a brew sometime later', - statusCode: 418, - error: 'Delayed Pot Error' - }) - }) -}) - -test('route error handler overrides global custom error handler', t => { - t.plan(4) - - const fastify = Fastify() - - const customGlobalErrorHandler = (error, request, reply) => { - t.error(error) - reply.code(429).send({ message: 'Too much coffee' }) - } - - const customRouteErrorHandler = (error, request, reply) => { - t.equal(error.message, 'Wrong Pot Error') - reply.code(418).send({ - message: 'Make a brew', - statusCode: 418, - error: 'Wrong Pot Error' - }) - } - - fastify.setErrorHandler(customGlobalErrorHandler) - - fastify.route({ - method: 'GET', - path: '/more-coffee', - handler: (req, res) => { - res.send(new Error('Wrong Pot Error')) - }, - errorHandler: customRouteErrorHandler - }) - - fastify.inject({ - method: 'GET', - url: '/more-coffee' - }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 418) - t.same(JSON.parse(res.payload), { - message: 'Make a brew', - statusCode: 418, - error: 'Wrong Pot Error' - }) - }) -}) - -test('throws when route with empty url', async t => { - t.plan(1) - - const fastify = Fastify() - try { - fastify.route({ - method: 'GET', - url: '', - handler: (req, res) => { - res.send('hi!') - } - }) - } catch (err) { - t.equal(err.message, 'The path could not be empty') - } -}) - -test('throws when route with empty url in shorthand declaration', async t => { - t.plan(1) - - const fastify = Fastify() - try { - fastify.get( - '', - async function handler () { return {} } - ) - } catch (err) { - t.equal(err.message, 'The path could not be empty') - } -}) - -test('throws when route-level error handler is not a function', t => { - t.plan(1) - - const fastify = Fastify() - - try { - fastify.route({ - method: 'GET', - url: '/tea', - handler: (req, res) => { - res.send('hi!') - }, - errorHandler: 'teapot' - }) - } catch (err) { - t.equal(err.message, 'Error Handler for GET:/tea route, if defined, must be a function') - } -}) - -test('Creates a HEAD route for each GET one (default)', t => { - t.plan(8) - - const fastify = Fastify() - - fastify.route({ - method: 'GET', - path: '/more-coffee', - handler: (req, reply) => { - reply.send({ here: 'is coffee' }) - } - }) - - fastify.route({ - method: 'GET', - path: '/some-light', - handler: (req, reply) => { - reply.send('Get some light!') - } - }) - - fastify.inject({ - method: 'HEAD', - url: '/more-coffee' - }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 200) - t.equal(res.headers['content-type'], 'application/json; charset=utf-8') - t.same(res.body, '') - }) - - fastify.inject({ - method: 'HEAD', - url: '/some-light' - }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 200) - t.equal(res.headers['content-type'], 'text/plain; charset=utf-8') - t.equal(res.body, '') - }) -}) - -test('Do not create a HEAD route for each GET one (exposeHeadRoutes: false)', t => { - t.plan(4) - - const fastify = Fastify({ exposeHeadRoutes: false }) - - fastify.route({ - method: 'GET', - path: '/more-coffee', - handler: (req, reply) => { - reply.send({ here: 'is coffee' }) - } - }) - - fastify.route({ - method: 'GET', - path: '/some-light', - handler: (req, reply) => { - reply.send('Get some light!') - } - }) - - fastify.inject({ - method: 'HEAD', - url: '/more-coffee' - }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 404) - }) - - fastify.inject({ - method: 'HEAD', - url: '/some-light' - }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 404) - }) -}) - -test('Creates a HEAD route for each GET one', t => { - t.plan(8) - - const fastify = Fastify({ exposeHeadRoutes: true }) - - fastify.route({ - method: 'GET', - path: '/more-coffee', - handler: (req, reply) => { - reply.send({ here: 'is coffee' }) - } - }) - - fastify.route({ - method: 'GET', - path: '/some-light', - handler: (req, reply) => { - reply.send('Get some light!') - } - }) - - fastify.inject({ - method: 'HEAD', - url: '/more-coffee' - }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 200) - t.equal(res.headers['content-type'], 'application/json; charset=utf-8') - t.same(res.body, '') - }) - - fastify.inject({ - method: 'HEAD', - url: '/some-light' - }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 200) - t.equal(res.headers['content-type'], 'text/plain; charset=utf-8') - t.equal(res.body, '') - }) -}) - -test('Creates a HEAD route for a GET one with prefixTrailingSlash', async (t) => { - t.plan(1) - - const fastify = Fastify() - - const arr = [] - fastify.register((instance, opts, next) => { - instance.addHook('onRoute', (routeOptions) => { - arr.push(`${routeOptions.method} ${routeOptions.url}`) - }) - - instance.route({ - method: 'GET', - path: '/', - exposeHeadRoute: true, - prefixTrailingSlash: 'both', - handler: (req, reply) => { - reply.send({ here: 'is coffee' }) - } - }) - - next() - }, { prefix: '/v1' }) - - await fastify.ready() - - t.ok(true) -}) - -test('Will not create a HEAD route that is not GET', t => { - t.plan(11) - - const fastify = Fastify({ exposeHeadRoutes: true }) - - fastify.route({ - method: 'GET', - path: '/more-coffee', - handler: (req, reply) => { - reply.send({ here: 'is coffee' }) - } - }) - - fastify.route({ - method: 'GET', - path: '/some-light', - handler: (req, reply) => { - reply.send() - } - }) - - fastify.route({ - method: 'POST', - path: '/something', - handler: (req, reply) => { - reply.send({ look: 'It is something!' }) - } - }) - - fastify.inject({ - method: 'HEAD', - url: '/more-coffee' - }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 200) - t.equal(res.headers['content-type'], 'application/json; charset=utf-8') - t.same(res.body, '') - }) - - fastify.inject({ - method: 'HEAD', - url: '/some-light' - }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 200) - t.equal(res.headers['content-type'], undefined) - t.equal(res.headers['content-length'], '0') - t.equal(res.body, '') - }) - - fastify.inject({ - method: 'HEAD', - url: '/something' - }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 404) - }) -}) - -test('HEAD route should handle properly each response type', t => { - t.plan(25) - - const fastify = Fastify({ exposeHeadRoutes: true }) - const resString = 'Found me!' - const resJSON = { here: 'is Johnny' } - const resBuffer = Buffer.from('I am a buffer!') - const resStream = stream.Readable.from('I am a stream!') - - fastify.route({ - method: 'GET', - path: '/json', - handler: (req, reply) => { - reply.send(resJSON) - } - }) - - fastify.route({ - method: 'GET', - path: '/string', - handler: (req, reply) => { - reply.send(resString) - } - }) - - fastify.route({ - method: 'GET', - path: '/buffer', - handler: (req, reply) => { - reply.send(resBuffer) - } - }) - - fastify.route({ - method: 'GET', - path: '/buffer-with-content-type', - handler: (req, reply) => { - reply.headers({ 'content-type': 'image/jpeg' }) - reply.send(resBuffer) - } - }) - - fastify.route({ - method: 'GET', - path: '/stream', - handler: (req, reply) => { - return resStream - } - }) - - fastify.inject({ - method: 'HEAD', - url: '/json' - }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 200) - t.equal(res.headers['content-type'], 'application/json; charset=utf-8') - t.equal(res.headers['content-length'], `${Buffer.byteLength(JSON.stringify(resJSON))}`) - t.same(res.body, '') - }) - - fastify.inject({ - method: 'HEAD', - url: '/string' - }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 200) - t.equal(res.headers['content-type'], 'text/plain; charset=utf-8') - t.equal(res.headers['content-length'], `${Buffer.byteLength(resString)}`) - t.equal(res.body, '') - }) - - fastify.inject({ - method: 'HEAD', - url: '/buffer' - }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 200) - t.equal(res.headers['content-type'], 'application/octet-stream') - t.equal(res.headers['content-length'], `${resBuffer.byteLength}`) - t.equal(res.body, '') - }) - - fastify.inject({ - method: 'HEAD', - url: '/buffer-with-content-type' - }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 200) - t.equal(res.headers['content-type'], 'image/jpeg') - t.equal(res.headers['content-length'], `${resBuffer.byteLength}`) - t.equal(res.body, '') - }) - - fastify.inject({ - method: 'HEAD', - url: '/stream' - }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 200) - t.equal(res.headers['content-type'], undefined) - t.equal(res.headers['content-length'], undefined) - t.equal(res.body, '') - }) -}) - -test('HEAD route should respect custom onSend handlers', t => { - t.plan(6) - - let counter = 0 - const resBuffer = Buffer.from('I am a coffee!') - const fastify = Fastify({ exposeHeadRoutes: true }) - const customOnSend = (res, reply, payload, done) => { - counter = counter + 1 - done(null, payload) - } - - fastify.route({ - method: 'GET', - path: '/more-coffee', - handler: (req, reply) => { - reply.send(resBuffer) - }, - onSend: [customOnSend, customOnSend] - }) - - fastify.inject({ - method: 'HEAD', - url: '/more-coffee' - }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 200) - t.equal(res.headers['content-type'], 'application/octet-stream') - t.equal(res.headers['content-length'], `${resBuffer.byteLength}`) - t.equal(res.body, '') - t.equal(counter, 2) - }) -}) - -test('route onSend can be function or array of functions', t => { - t.plan(12) - const counters = { single: 0, multiple: 0 } - - const resBuffer = Buffer.from('I am a coffee!') - const fastify = Fastify({ exposeHeadRoutes: true }) - - fastify.route({ - method: 'GET', - path: '/coffee', - handler: () => resBuffer, - onSend: (res, reply, payload, done) => { - counters.single += 1 - done(null, payload) - } - }) - - const customOnSend = (res, reply, payload, done) => { - counters.multiple += 1 - done(null, payload) - } - - fastify.route({ - method: 'GET', - path: '/more-coffee', - handler: () => resBuffer, - onSend: [customOnSend, customOnSend] - }) - - fastify.inject({ method: 'HEAD', url: '/coffee' }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 200) - t.equal(res.headers['content-type'], 'application/octet-stream') - t.equal(res.headers['content-length'], `${resBuffer.byteLength}`) - t.equal(res.body, '') - t.equal(counters.single, 1) - }) - - fastify.inject({ method: 'HEAD', url: '/more-coffee' }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 200) - t.equal(res.headers['content-type'], 'application/octet-stream') - t.equal(res.headers['content-length'], `${resBuffer.byteLength}`) - t.equal(res.body, '') - t.equal(counters.multiple, 2) - }) -}) - -test('no warning for exposeHeadRoute', async t => { - const fastify = Fastify() - - fastify.route({ - method: 'GET', - path: '/more-coffee', - exposeHeadRoute: true, - async handler () { - return 'hello world' - } - }) - - const listener = (w) => { - t.fail('no warning') - } - - process.on('warning', listener) - - await fastify.listen({ port: 0 }) - - process.removeListener('warning', listener) - - await fastify.close() -}) - -test("HEAD route should handle stream.on('error')", t => { - t.plan(6) - - const resStream = stream.Readable.from('Hello with error!') - const logStream = split(JSON.parse) - const expectedError = new Error('Hello!') - const fastify = Fastify({ - logger: { - stream: logStream, - level: 'error' - } - }) - - fastify.route({ - method: 'GET', - path: '/more-coffee', - exposeHeadRoute: true, - handler: (req, reply) => { - process.nextTick(() => resStream.emit('error', expectedError)) - return resStream - } - }) - - logStream.once('data', line => { - const { message, stack } = expectedError - t.same(line.err, { type: 'Error', message, stack }) - t.equal(line.msg, 'Error on Stream found for HEAD route') - t.equal(line.level, 50) - }) - - fastify.inject({ - method: 'HEAD', - url: '/more-coffee' - }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 200) - t.equal(res.headers['content-type'], undefined) - }) -}) - -test('HEAD route should be exposed by default', t => { - t.plan(7) - - const resStream = stream.Readable.from('Hello with error!') - const resJson = { hello: 'world' } - const fastify = Fastify() - - fastify.route({ - method: 'GET', - path: '/without-flag', - handler: (req, reply) => { - return resStream - } - }) - - fastify.route({ - exposeHeadRoute: true, - method: 'GET', - path: '/with-flag', - handler: (req, reply) => { - return resJson - } - }) - - fastify.inject({ - method: 'HEAD', - url: '/without-flag' - }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 200) - }) - - fastify.inject({ - method: 'HEAD', - url: '/with-flag' - }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 200) - t.equal(res.headers['content-type'], 'application/json; charset=utf-8') - t.equal(res.headers['content-length'], `${Buffer.byteLength(JSON.stringify(resJson))}`) - t.equal(res.body, '') - }) -}) - -test('HEAD route should be exposed if route exposeHeadRoute is set', t => { - t.plan(7) - - const resBuffer = Buffer.from('I am a coffee!') - const resJson = { hello: 'world' } - const fastify = Fastify({ exposeHeadRoutes: false }) - - fastify.route({ - exposeHeadRoute: true, - method: 'GET', - path: '/one', - handler: (req, reply) => { - return resBuffer - } - }) - - fastify.route({ - method: 'GET', - path: '/two', - handler: (req, reply) => { - return resJson - } - }) - - fastify.inject({ - method: 'HEAD', - url: '/one' - }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 200) - t.equal(res.headers['content-type'], 'application/octet-stream') - t.equal(res.headers['content-length'], `${resBuffer.byteLength}`) - t.equal(res.body, '') - }) - - fastify.inject({ - method: 'HEAD', - url: '/two' - }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 404) - }) -}) - -test('Set a custom HEAD route before GET one without disabling exposeHeadRoutes (global)', t => { - t.plan(6) - - const resBuffer = Buffer.from('I am a coffee!') - const fastify = Fastify({ - exposeHeadRoutes: true - }) - - fastify.route({ - method: 'HEAD', - path: '/one', - handler: (req, reply) => { - reply.header('content-type', 'application/pdf') - reply.header('content-length', `${resBuffer.byteLength}`) - reply.header('x-custom-header', 'some-custom-header') - reply.send() - } - }) - - fastify.route({ - method: 'GET', - path: '/one', - handler: (req, reply) => { - return resBuffer - } - }) - - fastify.inject({ - method: 'HEAD', - url: '/one' - }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 200) - t.equal(res.headers['content-type'], 'application/pdf') - t.equal(res.headers['content-length'], `${resBuffer.byteLength}`) - t.equal(res.headers['x-custom-header'], 'some-custom-header') - t.equal(res.body, '') - }) -}) - -test('Set a custom HEAD route before GET one without disabling exposeHeadRoutes (route)', t => { - t.plan(7) - - function onWarning (code) { - t.equal(code, 'FSTDEP007') - } - const warning = { - emit: onWarning - } - - const route = proxyquire('../lib/route', { './warnings': warning }) - const fastify = proxyquire('..', { './lib/route.js': route })() - - const resBuffer = Buffer.from('I am a coffee!') - - fastify.route({ - method: 'HEAD', - path: '/one', - handler: (req, reply) => { - reply.header('content-type', 'application/pdf') - reply.header('content-length', `${resBuffer.byteLength}`) - reply.header('x-custom-header', 'some-custom-header') - reply.send() - } - }) - - fastify.route({ - method: 'GET', - exposeHeadRoute: true, - path: '/one', - handler: (req, reply) => { - return resBuffer - } - }) - - fastify.inject({ - method: 'HEAD', - url: '/one' - }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 200) - t.equal(res.headers['content-type'], 'application/pdf') - t.equal(res.headers['content-length'], `${resBuffer.byteLength}`) - t.equal(res.headers['x-custom-header'], 'some-custom-header') - t.equal(res.body, '') - }) -}) - -test('HEAD routes properly auto created for GET routes when prefixTrailingSlash: \'no-slash\'', t => { - t.plan(2) - - const fastify = Fastify() - - fastify.register(function routes (f, opts, next) { - f.route({ - method: 'GET', - url: '/', - exposeHeadRoute: true, - prefixTrailingSlash: 'no-slash', - handler: (req, reply) => { - reply.send({ hello: 'world' }) - } - }) - - next() - }, { prefix: '/prefix' }) - - fastify.inject({ url: '/prefix/prefix', method: 'HEAD' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 404) - }) -}) - -test('HEAD routes properly auto created for GET routes when prefixTrailingSlash: \'both\'', async t => { - t.plan(3) - - const fastify = Fastify() - - fastify.register(function routes (f, opts, next) { - f.route({ - method: 'GET', - url: '/', - exposeHeadRoute: true, - prefixTrailingSlash: 'both', - handler: (req, reply) => { - reply.send({ hello: 'world' }) - } - }) - - next() - }, { prefix: '/prefix' }) - - const doublePrefixReply = await fastify.inject({ url: '/prefix/prefix', method: 'HEAD' }) - const trailingSlashReply = await fastify.inject({ url: '/prefix/', method: 'HEAD' }) - const noneTrailingReply = await fastify.inject({ url: '/prefix', method: 'HEAD' }) - - t.equal(doublePrefixReply.statusCode, 404) - t.equal(trailingSlashReply.statusCode, 200) - t.equal(noneTrailingReply.statusCode, 200) -}) - -test('Request and Reply share the route config', async t => { - t.plan(3) - - const fastify = Fastify() - - const config = { - this: 'is a string', - thisIs: function aFunction () {} - } - - fastify.route({ - method: 'GET', - url: '/', - config, - handler: (req, reply) => { - t.same(req.context, reply.context) - t.same(req.context.config, reply.context.config) - t.match(req.context.config, config, 'there are url and method additional properties') - - reply.send({ hello: 'world' }) - } - }) - - await fastify.inject('/') -}) - -test('Will not try to re-createprefixed HEAD route if it already exists and exposeHeadRoutes is true', async (t) => { - t.plan(1) - - const fastify = Fastify({ exposeHeadRoutes: true }) - - fastify.register((scope, opts, next) => { - scope.route({ - method: 'HEAD', - path: '/route', - handler: (req, reply) => { - reply.header('content-type', 'text/plain') - reply.send('custom HEAD response') - } - }) - scope.route({ - method: 'GET', - path: '/route', - handler: (req, reply) => { - reply.send({ ok: true }) - } - }) - - next() - }, { prefix: '/prefix' }) - - await fastify.ready() - - t.ok(true) -}) - -test('GET route with body schema should throw', t => { - t.plan(1) - - const fastify = Fastify() - - t.throws(() => { - fastify.route({ - method: 'GET', - path: '/get', - schema: { - body: {} - }, - handler: function (req, reply) { - reply.send({ hello: 'world' }) - } - }) - }, new Error('Body validation schema for GET:/get route is not supported!')) -}) - -test('HEAD route with body schema should throw', t => { - t.plan(1) - - const fastify = Fastify() - - t.throws(() => { - fastify.route({ - method: 'HEAD', - path: '/shouldThrow', - schema: { - body: {} - }, - handler: function (req, reply) { - reply.send({ hello: 'world' }) - } - }) - }, new Error('Body validation schema for HEAD:/shouldThrow route is not supported!')) -}) - -test('[HEAD, GET] route with body schema should throw', t => { - t.plan(1) - - const fastify = Fastify() - - t.throws(() => { - fastify.route({ - method: ['HEAD', 'GET'], - path: '/shouldThrowHead', - schema: { - body: {} - }, - handler: function (req, reply) { - reply.send({ hello: 'world' }) - } - }) - }, new Error('Body validation schema for HEAD:/shouldThrowHead route is not supported!')) -}) - -test('GET route with body schema should throw - shorthand', t => { - t.plan(1) - - const fastify = Fastify() - - t.throws(() => { - fastify.get('/shouldThrow', { - schema: { - body: {} - } - }, - function (req, reply) { - reply.send({ hello: 'world' }) - } - ) - }, new Error('Body validation schema for GET:/shouldThrow route is not supported!')) -}) - -test('HEAD route with body schema should throw - shorthand', t => { - t.plan(1) - - const fastify = Fastify() - - t.throws(() => { - fastify.head('/shouldThrow2', { - schema: { - body: {} - } - }, - function (req, reply) { - reply.send({ hello: 'world' }) - } - ) - }, new Error('Body validation schema for HEAD:/shouldThrow2 route is not supported!')) -}) - -test('route with non-english characters', t => { - t.plan(4) - - const fastify = Fastify() - - fastify.get('/föö', (request, reply) => { - reply.send('here /föö') - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - sget({ - method: 'GET', - url: getUrl(fastify) + encodeURI('/föö') - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.equal(body.toString(), 'here /föö') - }) - }) -}) - -test('invalid url attribute - non string URL', t => { - t.plan(1) - const fastify = Fastify() - - try { - fastify.get(/^\/(donations|skills|blogs)/, () => {}) - } catch (error) { - t.equal(error.code, FST_ERR_INVALID_URL().code) - } -}) diff --git a/test/router-options.test.js b/test/router-options.test.js index ee7523de696..0da3ec4193e 100644 --- a/test/router-options.test.js +++ b/test/router-options.test.js @@ -1,20 +1,15 @@ 'use strict' -const test = require('tap').test -const sget = require('simple-get') +const split = require('split2') +const { test } = require('node:test') +const querystring = require('node:querystring') const Fastify = require('../') -const { FST_ERR_BAD_URL } = require('../lib/errors') - -function getUrl (app) { - const { address, port } = app.server.address() - if (address === '::1') { - return `http://[${address}]:${port}` - } else { - return `http://${address}:${port}` - } -} +const { + FST_ERR_BAD_URL, + FST_ERR_ASYNC_CONSTRAINT +} = require('../lib/errors') -test('Should honor ignoreTrailingSlash option', t => { +test('Should honor ignoreTrailingSlash option', async t => { t.plan(4) const fastify = Fastify({ ignoreTrailingSlash: true @@ -24,27 +19,16 @@ test('Should honor ignoreTrailingSlash option', t => { res.send('test') }) - fastify.listen({ port: 0 }, (err) => { - t.teardown(() => { fastify.close() }) - if (err) t.threw(err) - - const baseUrl = getUrl(fastify) + let res = await fastify.inject('/test') + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload.toString(), 'test') - sget.concat(baseUrl + '/test', (err, res, data) => { - if (err) t.threw(err) - t.equal(res.statusCode, 200) - t.equal(data.toString(), 'test') - }) - - sget.concat(baseUrl + '/test/', (err, res, data) => { - if (err) t.threw(err) - t.equal(res.statusCode, 200) - t.equal(data.toString(), 'test') - }) - }) + res = await fastify.inject('/test/') + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload.toString(), 'test') }) -test('Should honor ignoreDuplicateSlashes option', t => { +test('Should honor ignoreDuplicateSlashes option', async t => { t.plan(4) const fastify = Fastify({ ignoreDuplicateSlashes: true @@ -54,27 +38,16 @@ test('Should honor ignoreDuplicateSlashes option', t => { res.send('test') }) - fastify.listen({ port: 0 }, (err) => { - t.teardown(() => { fastify.close() }) - if (err) t.threw(err) + let res = await fastify.inject('/test/test/test') + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload.toString(), 'test') - const baseUrl = getUrl(fastify) - - sget.concat(baseUrl + '/test/test/test', (err, res, data) => { - if (err) t.threw(err) - t.equal(res.statusCode, 200) - t.equal(data.toString(), 'test') - }) - - sget.concat(baseUrl + '/test//test///test', (err, res, data) => { - if (err) t.threw(err) - t.equal(res.statusCode, 200) - t.equal(data.toString(), 'test') - }) - }) + res = await fastify.inject('/test//test///test') + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload.toString(), 'test') }) -test('Should honor ignoreTrailingSlash and ignoreDuplicateSlashes options', t => { +test('Should honor ignoreTrailingSlash and ignoreDuplicateSlashes options', async t => { t.plan(4) const fastify = Fastify({ ignoreTrailingSlash: true, @@ -85,61 +58,57 @@ test('Should honor ignoreTrailingSlash and ignoreDuplicateSlashes options', t => res.send('test') }) - fastify.listen({ port: 0 }, (err) => { - t.teardown(() => { fastify.close() }) - if (err) t.threw(err) - - const baseUrl = getUrl(fastify) + let res = await fastify.inject('/test/test/test/') + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload.toString(), 'test') - sget.concat(baseUrl + '/test/test/test/', (err, res, data) => { - if (err) t.threw(err) - t.equal(res.statusCode, 200) - t.equal(data.toString(), 'test') - }) - - sget.concat(baseUrl + '/test//test///test//', (err, res, data) => { - if (err) t.threw(err) - t.equal(res.statusCode, 200) - t.equal(data.toString(), 'test') - }) - }) + res = await fastify.inject('/test//test///test//') + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload.toString(), 'test') }) -test('Should honor maxParamLength option', t => { - t.plan(4) +test('Should honor maxParamLength option', async (t) => { const fastify = Fastify({ maxParamLength: 10 }) fastify.get('/test/:id', (req, reply) => { reply.send({ hello: 'world' }) }) - fastify.inject({ + const res = await fastify.inject({ method: 'GET', url: '/test/123456789' - }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 200) }) + t.assert.strictEqual(res.statusCode, 200) - fastify.inject({ + const resError = await fastify.inject({ method: 'GET', url: '/test/123456789abcd' - }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 404) }) + t.assert.strictEqual(resError.statusCode, 404) }) -test('Should expose router options via getters on request and reply', t => { - t.plan(7) +test('Should expose router options via getters on request and reply', (t, done) => { + t.plan(9) const fastify = Fastify() + const expectedSchema = { + params: { + type: 'object', + properties: { + id: { type: 'integer' } + } + } + } - fastify.get('/test/:id', (req, reply) => { - t.equal(reply.context.config.url, '/test/:id') - t.equal(reply.context.config.method, 'GET') - t.equal(req.routerPath, '/test/:id') - t.equal(req.routerMethod, 'GET') - t.equal(req.is404, false) + fastify.get('/test/:id', { + schema: expectedSchema + }, (req, reply) => { + t.assert.strictEqual(reply.routeOptions.config.url, '/test/:id') + t.assert.strictEqual(reply.routeOptions.config.method, 'GET') + t.assert.deepStrictEqual(req.routeOptions.schema, expectedSchema) + t.assert.strictEqual(typeof req.routeOptions.handler, 'function') + t.assert.strictEqual(req.routeOptions.config.url, '/test/:id') + t.assert.strictEqual(req.routeOptions.config.method, 'GET') + t.assert.strictEqual(req.is404, false) reply.send({ hello: 'world' }) }) @@ -147,17 +116,18 @@ test('Should expose router options via getters on request and reply', t => { method: 'GET', url: '/test/123456789' }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 200) + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 200) + done() }) }) -test('Should set is404 flag for unmatched paths', t => { +test('Should set is404 flag for unmatched paths', (t, done) => { t.plan(3) const fastify = Fastify() fastify.setNotFoundHandler((req, reply) => { - t.equal(req.is404, true) + t.assert.strictEqual(req.is404, true) reply.code(404).send({ error: 'Not Found', message: 'Four oh for', statusCode: 404 }) }) @@ -165,19 +135,20 @@ test('Should set is404 flag for unmatched paths', t => { method: 'GET', url: '/nonexist/123456789' }, (error, res) => { - t.error(error) - t.equal(res.statusCode, 404) + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 404) + done() }) }) -test('Should honor frameworkErrors option', t => { +test('Should honor frameworkErrors option - FST_ERR_BAD_URL', (t, done) => { t.plan(3) const fastify = Fastify({ frameworkErrors: function (err, req, res) { if (err instanceof FST_ERR_BAD_URL) { - t.ok(true) + t.assert.ok(true) } else { - t.fail() + t.assert.fail() } res.send(`${err.message} - ${err.code}`) } @@ -193,8 +164,945 @@ test('Should honor frameworkErrors option', t => { url: '/test/%world' }, (err, res) => { - t.error(err) - t.equal(res.body, '\'/test/%world\' is not a valid url component - FST_ERR_BAD_URL') + t.assert.ifError(err) + t.assert.strictEqual(res.body, '\'/test/%world\' is not a valid url component - FST_ERR_BAD_URL') + done() + } + ) +}) + +test('Should supply Fastify request to the logger in frameworkErrors wrapper - FST_ERR_BAD_URL', (t, done) => { + t.plan(8) + + const REQ_ID = 'REQ-1234' + const logStream = split(JSON.parse) + + const fastify = Fastify({ + frameworkErrors: function (err, req, res) { + t.assert.deepStrictEqual(req.id, REQ_ID) + t.assert.deepStrictEqual(req.raw.httpVersion, '1.1') + res.send(`${err.message} - ${err.code}`) + }, + logger: { + stream: logStream, + serializers: { + req (request) { + t.assert.deepStrictEqual(request.id, REQ_ID) + return { httpVersion: request.raw.httpVersion } + } + } + }, + genReqId: () => REQ_ID + }) + + fastify.get('/test/:id', (req, res) => { + res.send('{ hello: \'world\' }') + }) + + logStream.on('data', (json) => { + t.assert.deepStrictEqual(json.msg, 'incoming request') + t.assert.deepStrictEqual(json.reqId, REQ_ID) + t.assert.deepStrictEqual(json.req.httpVersion, '1.1') + }) + + fastify.inject( + { + method: 'GET', + url: '/test/%world' + }, + (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.body, '\'/test/%world\' is not a valid url component - FST_ERR_BAD_URL') + done() + } + ) +}) + +test('Should honor disableRequestLogging option in frameworkErrors wrapper - FST_ERR_BAD_URL', (t, done) => { + t.plan(2) + + const logStream = split(JSON.parse) + + const fastify = Fastify({ + disableRequestLogging: true, + frameworkErrors: function (err, req, res) { + res.send(`${err.message} - ${err.code}`) + }, + logger: { + stream: logStream, + serializers: { + req () { + t.assert.fail('should not be called') + }, + res () { + t.assert.fail('should not be called') + } + } + } + }) + + fastify.get('/test/:id', (req, res) => { + res.send('{ hello: \'world\' }') + }) + + logStream.on('data', (json) => { + t.assert.fail('should not be called') + }) + + fastify.inject( + { + method: 'GET', + url: '/test/%world' + }, + (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.body, '\'/test/%world\' is not a valid url component - FST_ERR_BAD_URL') + done() } ) }) + +test('Should honor frameworkErrors option - FST_ERR_ASYNC_CONSTRAINT', (t, done) => { + t.plan(3) + + const constraint = { + name: 'secret', + storage: function () { + const secrets = {} + return { + get: (secret) => { return secrets[secret] || null }, + set: (secret, store) => { secrets[secret] = store } + } + }, + deriveConstraint: (req, ctx, done) => { + done(Error('kaboom')) + }, + validate () { return true } + } + + const fastify = Fastify({ + frameworkErrors: function (err, req, res) { + if (err instanceof FST_ERR_ASYNC_CONSTRAINT) { + t.assert.ok(true) + } else { + t.assert.fail() + } + res.send(`${err.message} - ${err.code}`) + }, + constraints: { secret: constraint } + }) + + fastify.route({ + method: 'GET', + url: '/', + constraints: { secret: 'alpha' }, + handler: (req, reply) => { + reply.send({ hello: 'from alpha' }) + } + }) + + fastify.inject( + { + method: 'GET', + url: '/' + }, + (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.body, 'Unexpected error from async constraint - FST_ERR_ASYNC_CONSTRAINT') + done() + } + ) +}) + +test('Should supply Fastify request to the logger in frameworkErrors wrapper - FST_ERR_ASYNC_CONSTRAINT', (t, done) => { + t.plan(8) + + const constraint = { + name: 'secret', + storage: function () { + const secrets = {} + return { + get: (secret) => { return secrets[secret] || null }, + set: (secret, store) => { secrets[secret] = store } + } + }, + deriveConstraint: (req, ctx, done) => { + done(Error('kaboom')) + }, + validate () { return true } + } + + const REQ_ID = 'REQ-1234' + const logStream = split(JSON.parse) + + const fastify = Fastify({ + constraints: { secret: constraint }, + frameworkErrors: function (err, req, res) { + t.assert.deepStrictEqual(req.id, REQ_ID) + t.assert.deepStrictEqual(req.raw.httpVersion, '1.1') + res.send(`${err.message} - ${err.code}`) + }, + logger: { + stream: logStream, + serializers: { + req (request) { + t.assert.deepStrictEqual(request.id, REQ_ID) + return { httpVersion: request.raw.httpVersion } + } + } + }, + genReqId: () => REQ_ID + }) + + fastify.route({ + method: 'GET', + url: '/', + constraints: { secret: 'alpha' }, + handler: (req, reply) => { + reply.send({ hello: 'from alpha' }) + } + }) + + logStream.on('data', (json) => { + t.assert.deepStrictEqual(json.msg, 'incoming request') + t.assert.deepStrictEqual(json.reqId, REQ_ID) + t.assert.deepStrictEqual(json.req.httpVersion, '1.1') + }) + + fastify.inject( + { + method: 'GET', + url: '/' + }, + (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.body, 'Unexpected error from async constraint - FST_ERR_ASYNC_CONSTRAINT') + done() + } + ) +}) + +test('Should honor disableRequestLogging option in frameworkErrors wrapper - FST_ERR_ASYNC_CONSTRAINT', (t, done) => { + t.plan(2) + + const constraint = { + name: 'secret', + storage: function () { + const secrets = {} + return { + get: (secret) => { return secrets[secret] || null }, + set: (secret, store) => { secrets[secret] = store } + } + }, + deriveConstraint: (req, ctx, done) => { + done(Error('kaboom')) + }, + validate () { return true } + } + + const logStream = split(JSON.parse) + + const fastify = Fastify({ + constraints: { secret: constraint }, + disableRequestLogging: true, + frameworkErrors: function (err, req, res) { + res.send(`${err.message} - ${err.code}`) + }, + logger: { + stream: logStream, + serializers: { + req () { + t.assert.fail('should not be called') + }, + res () { + t.assert.fail('should not be called') + } + } + } + }) + + fastify.route({ + method: 'GET', + url: '/', + constraints: { secret: 'alpha' }, + handler: (req, reply) => { + reply.send({ hello: 'from alpha' }) + } + }) + + logStream.on('data', (json) => { + t.assert.fail('should not be called') + }) + + fastify.inject( + { + method: 'GET', + url: '/' + }, + (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.body, 'Unexpected error from async constraint - FST_ERR_ASYNC_CONSTRAINT') + done() + } + ) +}) + +test('Should honor disableRequestLogging function in frameworkErrors wrapper - FST_ERR_BAD_URL', (t, done) => { + t.plan(4) + + let logCallCount = 0 + const logStream = split(JSON.parse) + + const fastify = Fastify({ + disableRequestLogging: (req) => { + // Disable logging for URLs containing 'silent' + return req.url.includes('silent') + }, + frameworkErrors: function (err, req, res) { + res.send(`${err.message} - ${err.code}`) + }, + logger: { + stream: logStream, + level: 'info' + } + }) + + fastify.get('/test/:id', (req, res) => { + res.send('{ hello: \'world\' }') + }) + + logStream.on('data', (json) => { + if (json.msg === 'incoming request') { + logCallCount++ + } + }) + + // First request: URL does not contain 'silent', so logging should happen + fastify.inject( + { + method: 'GET', + url: '/test/%world' + }, + (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.body, '\'/test/%world\' is not a valid url component - FST_ERR_BAD_URL') + + // Second request: URL contains 'silent', so logging should be disabled + fastify.inject( + { + method: 'GET', + url: '/silent/%world' + }, + (err2, res2) => { + t.assert.ifError(err2) + // Give time for any potential log events + setImmediate(() => { + // Only the first request should have logged + t.assert.strictEqual(logCallCount, 1) + done() + }) + } + ) + } + ) +}) + +test('Should honor disableRequestLogging function in frameworkErrors wrapper - FST_ERR_ASYNC_CONSTRAINT', (t, done) => { + t.plan(4) + + let logCallCount = 0 + + const constraint = { + name: 'secret', + storage: function () { + const secrets = {} + return { + get: (secret) => { return secrets[secret] || null }, + set: (secret, store) => { secrets[secret] = store } + } + }, + deriveConstraint: (req, ctx, done) => { + done(Error('kaboom')) + }, + validate () { return true } + } + + const logStream = split(JSON.parse) + + const fastify = Fastify({ + constraints: { secret: constraint }, + disableRequestLogging: (req) => { + // Disable logging for URLs containing 'silent' + return req.url.includes('silent') + }, + frameworkErrors: function (err, req, res) { + res.send(`${err.message} - ${err.code}`) + }, + logger: { + stream: logStream, + level: 'info' + } + }) + + fastify.route({ + method: 'GET', + url: '/', + constraints: { secret: 'alpha' }, + handler: (req, reply) => { + reply.send({ hello: 'from alpha' }) + } + }) + + fastify.route({ + method: 'GET', + url: '/silent', + constraints: { secret: 'alpha' }, + handler: (req, reply) => { + reply.send({ hello: 'from alpha' }) + } + }) + + logStream.on('data', (json) => { + if (json.msg === 'incoming request') { + logCallCount++ + } + }) + + // First request: URL does not contain 'silent', so logging should happen + fastify.inject( + { + method: 'GET', + url: '/' + }, + (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.body, 'Unexpected error from async constraint - FST_ERR_ASYNC_CONSTRAINT') + + // Second request: URL contains 'silent', so logging should be disabled + fastify.inject( + { + method: 'GET', + url: '/silent' + }, + (err2, res2) => { + t.assert.ifError(err2) + // Give time for any potential log events + setImmediate(() => { + // Only the first request should have logged + t.assert.strictEqual(logCallCount, 1) + done() + }) + } + ) + } + ) +}) + +test('Should honor routerOptions.defaultRoute', async t => { + t.plan(3) + const fastify = Fastify({ + routerOptions: { + defaultRoute: function (_, res) { + t.assert.ok('default route called') + res.statusCode = 404 + res.end('default route') + } + } + }) + + const res = await fastify.inject('/') + t.assert.strictEqual(res.statusCode, 404) + t.assert.strictEqual(res.payload, 'default route') +}) + +test('Should honor routerOptions.badUrl', async t => { + t.plan(3) + const fastify = Fastify({ + routerOptions: { + defaultRoute: function (_, res) { + t.asset.fail('default route should not be called') + }, + onBadUrl: function (path, _, res) { + t.assert.ok('bad url called') + res.statusCode = 400 + res.end(`Bath URL: ${path}`) + } + } + }) + + fastify.get('/hello/:id', (req, res) => { + res.send({ hello: 'world' }) + }) + + const res = await fastify.inject('/hello/%world') + t.assert.strictEqual(res.statusCode, 400) + t.assert.strictEqual(res.payload, 'Bath URL: /hello/%world') +}) + +test('Should honor routerOptions.ignoreTrailingSlash', async t => { + t.plan(4) + const fastify = Fastify({ + routerOptions: { + ignoreTrailingSlash: true + } + }) + + fastify.get('/test', (req, res) => { + res.send('test') + }) + + let res = await fastify.inject('/test') + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload.toString(), 'test') + + res = await fastify.inject('/test/') + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload.toString(), 'test') +}) + +test('Should honor routerOptions.ignoreDuplicateSlashes', async t => { + t.plan(4) + const fastify = Fastify({ + routerOptions: { + ignoreDuplicateSlashes: true + } + }) + + fastify.get('/test//test///test', (req, res) => { + res.send('test') + }) + + let res = await fastify.inject('/test/test/test') + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload.toString(), 'test') + + res = await fastify.inject('/test//test///test') + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload.toString(), 'test') +}) + +test('Should honor routerOptions.ignoreTrailingSlash and routerOptions.ignoreDuplicateSlashes', async t => { + t.plan(4) + const fastify = Fastify({ + routerOptions: { + ignoreTrailingSlash: true, + ignoreDuplicateSlashes: true + } + }) + + t.after(() => fastify.close()) + + fastify.get('/test//test///test', (req, res) => { + res.send('test') + }) + + let res = await fastify.inject('/test/test/test/') + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload.toString(), 'test') + + res = await fastify.inject('/test//test///test//') + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload.toString(), 'test') +}) + +test('Should honor routerOptions.maxParamLength', async (t) => { + const fastify = Fastify({ + routerOptions: + { + maxParamLength: 10 + } + }) + + fastify.get('/test/:id', (req, reply) => { + reply.send({ hello: 'world' }) + }) + + const res = await fastify.inject({ + method: 'GET', + url: '/test/123456789' + }) + t.assert.strictEqual(res.statusCode, 200) + + const resError = await fastify.inject({ + method: 'GET', + url: '/test/123456789abcd' + }) + t.assert.strictEqual(resError.statusCode, 404) +}) + +test('Should honor routerOptions.allowUnsafeRegex', async (t) => { + const fastify = Fastify({ + routerOptions: + { + allowUnsafeRegex: true + } + }) + + fastify.get('/test/:id(([a-f0-9]{3},?)+)', (req, reply) => { + reply.send({ hello: 'world' }) + }) + + let res = await fastify.inject({ + method: 'GET', + url: '/test/bac,1ea' + }) + t.assert.strictEqual(res.statusCode, 200) + + res = await fastify.inject({ + method: 'GET', + url: '/test/qwerty' + }) + + t.assert.strictEqual(res.statusCode, 404) +}) + +test('Should honor routerOptions.caseSensitive', async (t) => { + const fastify = Fastify({ + routerOptions: + { + caseSensitive: false + } + }) + + fastify.get('/TeSt', (req, reply) => { + reply.send('test') + }) + + let res = await fastify.inject({ + method: 'GET', + url: '/test' + }) + t.assert.strictEqual(res.statusCode, 200) + + res = await fastify.inject({ + method: 'GET', + url: '/tEsT' + }) + t.assert.strictEqual(res.statusCode, 200) + + res = await fastify.inject({ + method: 'GET', + url: '/TEST' + }) + t.assert.strictEqual(res.statusCode, 200) +}) + +test('Should honor routerOptions.queryStringParser', async (t) => { + t.plan(4) + const fastify = Fastify({ + routerOptions: + { + querystringParser: function (str) { + t.assert.ok('custom query string parser called') + return querystring.parse(str) + } + } + }) + + fastify.get('/test', (req, reply) => { + t.assert.deepStrictEqual(req.query.foo, 'bar') + t.assert.deepStrictEqual(req.query.baz, 'faz') + reply.send('test') + }) + + const res = await fastify.inject({ + method: 'GET', + url: '/test?foo=bar&baz=faz' + }) + t.assert.strictEqual(res.statusCode, 200) +}) + +test('Should honor routerOptions.useSemicolonDelimiter', async (t) => { + t.plan(6) + const fastify = Fastify({ + routerOptions: + { + useSemicolonDelimiter: true + } + }) + + fastify.get('/test', (req, reply) => { + t.assert.deepStrictEqual(req.query.foo, 'bar') + t.assert.deepStrictEqual(req.query.baz, 'faz') + reply.send('test') + }) + + // Support semicolon delimiter + let res = await fastify.inject({ + method: 'GET', + url: '/test;foo=bar&baz=faz' + }) + t.assert.strictEqual(res.statusCode, 200) + + // Support query string `?` delimiter + res = await fastify.inject({ + method: 'GET', + url: '/test?foo=bar&baz=faz' + }) + t.assert.strictEqual(res.statusCode, 200) +}) + +test('Should honor routerOptions.buildPrettyMeta', async (t) => { + t.plan(10) + const fastify = Fastify({ + routerOptions: + { + buildPrettyMeta: function (route) { + t.assert.ok('custom buildPrettyMeta called') + return { metaKey: route.path } + } + } + }) + + fastify.get('/test', () => {}) + fastify.get('/test/hello', () => {}) + fastify.get('/testing', () => {}) + fastify.get('/testing/:param', () => {}) + fastify.put('/update', () => {}) + + await fastify.ready() + + const result = fastify.printRoutes({ includeMeta: true }) + const expected = `\ +└── / + ├── test (GET, HEAD) + │ • (metaKey) "/test" + │ ├── /hello (GET, HEAD) + │ │ • (metaKey) "/test/hello" + │ └── ing (GET, HEAD) + │ • (metaKey) "/testing" + │ └── / + │ └── :param (GET, HEAD) + │ • (metaKey) "/testing/:param" + └── update (PUT) + • (metaKey) "/update" +` + + t.assert.strictEqual(result, expected) +}) + +test('Should honor routerOptions.ignoreTrailingSlash and routerOptions.ignoreDuplicateSlashes over top level options', async t => { + t.plan(4) + const fastify = Fastify({ + ignoreTrailingSlash: false, + ignoreDuplicateSlashes: false, + routerOptions: { + ignoreTrailingSlash: true, + ignoreDuplicateSlashes: true + } + }) + + fastify.get('/test//test///test', (req, res) => { + res.send('test') + }) + + let res = await fastify.inject('/test/test/test/') + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload.toString(), 'test') + + res = await fastify.inject('/test//test///test//') + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload.toString(), 'test') +}) + +test('Should honor routerOptions.maxParamLength over maxParamLength option', async (t) => { + const fastify = Fastify({ + maxParamLength: 0, + routerOptions: + { + maxParamLength: 10 + } + }) + + fastify.get('/test/:id', (req, reply) => { + reply.send({ hello: 'world' }) + }) + + const res = await fastify.inject({ + method: 'GET', + url: '/test/123456789' + }) + t.assert.strictEqual(res.statusCode, 200) + + const resError = await fastify.inject({ + method: 'GET', + url: '/test/123456789abcd' + }) + t.assert.strictEqual(resError.statusCode, 404) +}) + +test('Should honor routerOptions.allowUnsafeRegex over allowUnsafeRegex option', async (t) => { + const fastify = Fastify({ + allowUnsafeRegex: false, + routerOptions: + { + allowUnsafeRegex: true + } + }) + + fastify.get('/test/:id(([a-f0-9]{3},?)+)', (req, reply) => { + reply.send({ hello: 'world' }) + }) + + let res = await fastify.inject({ + method: 'GET', + url: '/test/bac,1ea' + }) + t.assert.strictEqual(res.statusCode, 200) + + res = await fastify.inject({ + method: 'GET', + url: '/test/qwerty' + }) + + t.assert.strictEqual(res.statusCode, 404) +}) + +test('Should honor routerOptions.caseSensitive over caseSensitive option', async (t) => { + const fastify = Fastify({ + caseSensitive: true, + routerOptions: + { + caseSensitive: false + } + }) + + fastify.get('/TeSt', (req, reply) => { + reply.send('test') + }) + + let res = await fastify.inject({ + method: 'GET', + url: '/test' + }) + t.assert.strictEqual(res.statusCode, 200) + + res = await fastify.inject({ + method: 'GET', + url: '/tEsT' + }) + t.assert.strictEqual(res.statusCode, 200) + + res = await fastify.inject({ + method: 'GET', + url: '/TEST' + }) + t.assert.strictEqual(res.statusCode, 200) +}) + +test('Should honor routerOptions.queryStringParser over queryStringParser option', async (t) => { + t.plan(4) + const fastify = Fastify({ + queryStringParser: undefined, + routerOptions: + { + querystringParser: function (str) { + t.assert.ok('custom query string parser called') + return querystring.parse(str) + } + } + }) + + fastify.get('/test', (req, reply) => { + t.assert.deepStrictEqual(req.query.foo, 'bar') + t.assert.deepStrictEqual(req.query.baz, 'faz') + reply.send('test') + }) + + const res = await fastify.inject({ + method: 'GET', + url: '/test?foo=bar&baz=faz' + }) + t.assert.strictEqual(res.statusCode, 200) +}) + +test('Should honor routerOptions.useSemicolonDelimiter over useSemicolonDelimiter option', async (t) => { + t.plan(6) + const fastify = Fastify({ + useSemicolonDelimiter: false, + routerOptions: + { + useSemicolonDelimiter: true + } + }) + + fastify.get('/test', (req, reply) => { + t.assert.deepStrictEqual(req.query.foo, 'bar') + t.assert.deepStrictEqual(req.query.baz, 'faz') + reply.send('test') + }) + + // Support semicolon delimiter + let res = await fastify.inject({ + method: 'GET', + url: '/test;foo=bar&baz=faz' + }) + t.assert.strictEqual(res.statusCode, 200) + + // Support query string `?` delimiter + res = await fastify.inject({ + method: 'GET', + url: '/test?foo=bar&baz=faz' + }) + t.assert.strictEqual(res.statusCode, 200) +}) + +test('Should support extra find-my-way options', async t => { + t.plan(1) + // Use a real upstream option from find-my-way + const fastify = Fastify({ + routerOptions: { + buildPrettyMeta: (route) => { + const cleanMeta = Object.assign({}, route.store) + return cleanMeta + } + } + }) + + t.after(() => fastify.close()) + + await fastify.ready() + + // Ensure the option is preserved after validation + t.assert.strictEqual(typeof fastify.initialConfig.routerOptions.buildPrettyMeta, 'function') +}) + +test('Should allow reusing a routerOptions object across instances', async t => { + t.plan(1) + + const options = { + routerOptions: { + maxParamLength: 2048 + } + } + + const app1 = Fastify(options) + const app2 = Fastify(options) + + t.after(() => Promise.all([ + app1.close(), + app2.close() + ])) + + const response = await app2.inject('/not-found') + t.assert.strictEqual(response.statusCode, 404) +}) + +test('Should not mutate user-provided routerOptions object', async t => { + t.plan(4) + + const routerOptions = { + maxParamLength: 2048 + } + const options = { routerOptions } + + const app = Fastify(options) + t.after(() => app.close()) + + await app.ready() + + t.assert.deepStrictEqual(Object.keys(routerOptions), ['maxParamLength']) + t.assert.strictEqual(routerOptions.maxParamLength, 2048) + t.assert.strictEqual(routerOptions.defaultRoute, undefined) + t.assert.strictEqual(routerOptions.onBadUrl, undefined) +}) diff --git a/test/same-shape.test.js b/test/same-shape.test.js index 3157c71eaf7..f4162713339 100644 --- a/test/same-shape.test.js +++ b/test/same-shape.test.js @@ -1,6 +1,6 @@ 'use strict' -const { test } = require('tap') +const { test } = require('node:test') const fastify = require('..') test('same shape on Request', async (t) => { @@ -21,7 +21,7 @@ test('same shape on Request', async (t) => { app.get('/', (req, reply) => { if (request) { - t.equal(%HaveSameMap(request, req), true) + t.assert.deepStrictEqual(request, req) } request = req @@ -51,7 +51,7 @@ test('same shape on Request when object', async (t) => { app.get('/', (req, reply) => { if (request) { - t.equal(%HaveSameMap(request, req), true) + t.assert.deepStrictEqual(request, req) } request = req @@ -81,7 +81,7 @@ test('same shape on Reply', async (t) => { app.get('/', (req, reply) => { if (_reply) { - t.equal(%HaveSameMap(_reply, reply), true) + t.assert.deepStrictEqual(_reply, reply) } _reply = reply @@ -111,7 +111,7 @@ test('same shape on Reply when object', async (t) => { app.get('/', (req, reply) => { if (_reply) { - t.equal(%HaveSameMap(_reply, reply), true) + t.assert.deepStrictEqual(_reply, reply) } _reply = reply diff --git a/test/schema-examples.test.js b/test/schema-examples.test.js index 5e5977f0ce3..7292a8136ae 100644 --- a/test/schema-examples.test.js +++ b/test/schema-examples.test.js @@ -1,14 +1,14 @@ 'use strict' -const { test } = require('tap') +const { test } = require('node:test') const localize = require('ajv-i18n') const Fastify = require('..') -test('Example - URI $id', t => { +test('Example - URI $id', (t, done) => { t.plan(1) const fastify = Fastify() fastify.addSchema({ - $id: 'http://example.com/', + $id: 'http://fastify.test/', type: 'object', properties: { hello: { type: 'string' } @@ -20,15 +20,18 @@ test('Example - URI $id', t => { schema: { body: { type: 'array', - items: { $ref: 'http://example.com#/properties/hello' } + items: { $ref: 'http://fastify.test#/properties/hello' } } } }) - fastify.ready(err => t.error(err)) + fastify.ready(err => { + t.assert.ifError(err) + done() + }) }) -test('Example - string $id', t => { +test('Example - string $id', (t, done) => { t.plan(1) const fastify = Fastify() fastify.addSchema({ @@ -47,10 +50,13 @@ test('Example - string $id', t => { } }) - fastify.ready(err => t.error(err)) + fastify.ready(err => { + t.assert.ifError(err) + done() + }) }) -test('Example - get schema', t => { +test('Example - get schema', (t, done) => { t.plan(1) const fastify = Fastify() fastify.addSchema({ @@ -63,7 +69,8 @@ test('Example - get schema', t => { const mySchemas = fastify.getSchemas() const mySchema = fastify.getSchema('schemaId') - t.same(mySchemas.schemaId, mySchema) + t.assert.deepStrictEqual(mySchemas.schemaId, mySchema) + done() }) test('Example - get schema encapsulated', async t => { @@ -91,12 +98,12 @@ test('Example - get schema encapsulated', async t => { const r2 = await fastify.inject('/sub') const r3 = await fastify.inject('/deep') - t.same(Object.keys(r1.json()), ['one']) - t.same(Object.keys(r2.json()), ['one', 'two']) - t.same(Object.keys(r3.json()), ['one', 'two', 'three']) + t.assert.deepStrictEqual(Object.keys(r1.json()), ['one']) + t.assert.deepStrictEqual(Object.keys(r2.json()), ['one', 'two']) + t.assert.deepStrictEqual(Object.keys(r3.json()), ['one', 'two', 'three']) }) -test('Example - validation', t => { +test('Example - validation', (t, done) => { t.plan(1) const fastify = Fastify({ ajv: { @@ -137,13 +144,19 @@ test('Example - validation', t => { } const queryStringJsonSchema = { - name: { type: 'string' }, - excitement: { type: 'integer' } + type: 'object', + properties: { + name: { type: 'string' }, + excitement: { type: 'integer' } + } } const paramsJsonSchema = { - par1: { type: 'string' }, - par2: { type: 'number' } + type: 'object', + properties: { + par1: { type: 'string' }, + par2: { type: 'number' } + } } const headersJsonSchema = { @@ -162,10 +175,13 @@ test('Example - validation', t => { } fastify.post('/the/url', { schema }, handler) - fastify.ready(err => t.error(err)) + fastify.ready(err => { + t.assert.ifError(err) + done() + }) }) -test('Example - ajv config', t => { +test('Example - ajv config', (t, done) => { t.plan(1) const fastify = Fastify({ @@ -222,10 +238,13 @@ test('Example - ajv config', t => { } }) - fastify.ready(err => t.error(err)) + fastify.ready(err => { + t.assert.ifError(err) + done() + }) }) -test('Example Joi', t => { +test('Example Joi', (t, done) => { t.plan(1) const fastify = Fastify() const handler = () => { } @@ -242,10 +261,13 @@ test('Example Joi', t => { } }, handler) - fastify.ready(err => t.error(err)) + fastify.ready(err => { + t.assert.ifError(err) + done() + }) }) -test('Example yup', t => { +test('Example yup', (t, done) => { t.plan(1) const fastify = Fastify() const handler = () => { } @@ -281,10 +303,13 @@ test('Example yup', t => { } }, handler) - fastify.ready(err => t.error(err)) + fastify.ready(err => { + t.assert.ifError(err) + done() + }) }) -test('Example - serialization', t => { +test('Example - serialization', (t, done) => { t.plan(1) const fastify = Fastify() const handler = () => { } @@ -302,10 +327,13 @@ test('Example - serialization', t => { } fastify.post('/the/url', { schema }, handler) - fastify.ready(err => t.error(err)) + fastify.ready(err => { + t.assert.ifError(err) + done() + }) }) -test('Example - serialization 2', t => { +test('Example - serialization 2', (t, done) => { t.plan(1) const fastify = Fastify() const handler = () => { } @@ -320,17 +348,20 @@ test('Example - serialization 2', t => { } }, 201: { - // the contract sintax + // the contract syntax value: { type: 'string' } } } } fastify.post('/the/url', { schema }, handler) - fastify.ready(err => t.error(err)) + fastify.ready(err => { + t.assert.ifError(err) + done() + }) }) -test('Example - serializator', t => { +test('Example - serializator', (t, done) => { t.plan(1) const fastify = Fastify() @@ -345,17 +376,23 @@ test('Example - serializator', t => { schema: { response: { '2xx': { - id: { type: 'number' }, - name: { type: 'string' } + type: 'object', + properties: { + id: { type: 'number' }, + name: { type: 'string' } + } } } } }) - fastify.ready(err => t.error(err)) + fastify.ready(err => { + t.assert.ifError(err) + done() + }) }) -test('Example - schemas examples', t => { +test('Example - schemas examples', (t, done) => { t.plan(1) const fastify = Fastify() const handler = () => { } @@ -448,10 +485,13 @@ test('Example - schemas examples', t => { }) - fastify.ready(err => t.error(err)) + fastify.ready(err => { + t.assert.ifError(err) + done() + }) }) -test('should return custom error messages with ajv-errors', t => { +test('should return custom error messages with ajv-errors', (t, done) => { t.plan(3) const fastify = Fastify({ @@ -499,17 +539,75 @@ test('should return custom error messages with ajv-errors', t => { }, url: '/' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { statusCode: 400, + code: 'FST_ERR_VALIDATION', error: 'Bad Request', message: 'body/age bad age - should be num, body name please, body work please' }) - t.equal(res.statusCode, 400) + t.assert.strictEqual(res.statusCode, 400) + done() + }) +}) + +test('should be able to handle formats of ajv-formats when added by plugins option', (t, done) => { + t.plan(3) + + const fastify = Fastify({ + ajv: { + plugins: [ + require('ajv-formats') + ] + } + }) + + const schema = { + body: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + email: { type: 'string', format: 'email' } + }, + required: ['id', 'email'] + } + } + + fastify.post('/', { schema }, function (req, reply) { + reply.code(200).send(req.body.id) + }) + + fastify.inject({ + method: 'POST', + payload: { + id: '254381a5-888c-4b41-8116-e3b1a54980bd', + email: 'info@fastify.dev' + }, + url: '/' + }, (_err, res) => { + t.assert.strictEqual(res.body, '254381a5-888c-4b41-8116-e3b1a54980bd') + t.assert.strictEqual(res.statusCode, 200) + }) + + fastify.inject({ + method: 'POST', + payload: { + id: 'invalid', + email: 'info@fastify.dev' + }, + url: '/' + }, (_err, res) => { + t.assert.deepStrictEqual(JSON.parse(res.payload), { + statusCode: 400, + code: 'FST_ERR_VALIDATION', + error: 'Bad Request', + message: 'body/id must match format "uuid"' + }) + done() }) }) -test('should return localized error messages with ajv-i18n', t => { +test('should return localized error messages with ajv-i18n', (t, done) => { t.plan(3) const schema = { @@ -549,14 +647,15 @@ test('should return localized error messages with ajv-i18n', t => { }, url: '/' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), [{ + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), [{ instancePath: '', keyword: 'required', message: 'должно иметь обязательное поле work', params: { missingProperty: 'work' }, schemaPath: '#/required' }]) - t.equal(res.statusCode, 400) + t.assert.strictEqual(res.statusCode, 400) + done() }) }) diff --git a/test/schema-feature.test.js b/test/schema-feature.test.js index af82cd46514..6e54d1817f7 100644 --- a/test/schema-feature.test.js +++ b/test/schema-feature.test.js @@ -1,35 +1,38 @@ 'use strict' -const { test } = require('tap') +const { test } = require('node:test') const Fastify = require('..') const fp = require('fastify-plugin') const deepClone = require('rfdc')({ circles: true, proto: false }) const Ajv = require('ajv') const { kSchemaController } = require('../lib/symbols.js') +const { FSTWRN001 } = require('../lib/warnings') +const { waitForCb } = require('./toolkit') const echoParams = (req, reply) => { reply.send(req.params) } const echoBody = (req, reply) => { reply.send(req.body) } -;['addSchema', 'getSchema', 'getSchemas', 'setValidatorCompiler', 'setSerializerCompiler'].forEach(f => { + ;['addSchema', 'getSchema', 'getSchemas', 'setValidatorCompiler', 'setSerializerCompiler'].forEach(f => { test(`Should expose ${f} function`, t => { t.plan(1) const fastify = Fastify() - t.equal(typeof fastify[f], 'function') + t.assert.strictEqual(typeof fastify[f], 'function') }) }) ;['setValidatorCompiler', 'setSerializerCompiler'].forEach(f => { - test(`cannot call ${f} after binding`, t => { + test(`cannot call ${f} after binding`, (t, testDone) => { t.plan(2) const fastify = Fastify() - t.teardown(fastify.close.bind(fastify)) + t.after(() => fastify.close()) fastify.listen({ port: 0 }, err => { - t.error(err) + t.assert.ifError(err) try { fastify[f](() => { }) - t.fail() + t.assert.fail() } catch (e) { - t.pass() + t.assert.ok(true) + testDone() } }) }) @@ -40,7 +43,7 @@ test('The schemas should be added to an internal storage', t => { const fastify = Fastify() const schema = { $id: 'id', my: 'schema' } fastify.addSchema(schema) - t.same(fastify[kSchemaController].schemaBucket.store, { id: schema }) + t.assert.deepStrictEqual(fastify[kSchemaController].schemaBucket.store, { id: schema }) }) test('The schemas should be accessible via getSchemas', t => { @@ -54,10 +57,10 @@ test('The schemas should be accessible via getSchemas', t => { } Object.values(schemas).forEach(schema => { fastify.addSchema(schema) }) - t.same(fastify.getSchemas(), schemas) + t.assert.deepStrictEqual(fastify.getSchemas(), schemas) }) -test('The schema should be accessible by id via getSchema', t => { +test('The schema should be accessible by id via getSchema', (t, testDone) => { t.plan(5) const fastify = Fastify() @@ -67,41 +70,50 @@ test('The schema should be accessible by id via getSchema', t => { { $id: 'bcd', my: 'schema', properties: { a: 'a', b: 1 } } ] schemas.forEach(schema => { fastify.addSchema(schema) }) - t.same(fastify.getSchema('abc'), schemas[1]) - t.same(fastify.getSchema('id'), schemas[0]) - t.same(fastify.getSchema('foo'), undefined) + t.assert.deepStrictEqual(fastify.getSchema('abc'), schemas[1]) + t.assert.deepStrictEqual(fastify.getSchema('id'), schemas[0]) + t.assert.deepStrictEqual(fastify.getSchema('foo'), undefined) fastify.register((instance, opts, done) => { const pluginSchema = { $id: 'cde', my: 'schema' } instance.addSchema(pluginSchema) - t.same(instance.getSchema('cde'), pluginSchema) + t.assert.deepStrictEqual(instance.getSchema('cde'), pluginSchema) done() }) - fastify.ready(err => t.error(err)) + fastify.ready(err => { + t.assert.ifError(err) + testDone() + }) }) -test('Get validatorCompiler after setValidatorCompiler', t => { +test('Get validatorCompiler after setValidatorCompiler', (t, testDone) => { t.plan(2) const myCompiler = () => { } const fastify = Fastify() fastify.setValidatorCompiler(myCompiler) const sc = fastify.validatorCompiler - t.ok(Object.is(myCompiler, sc)) - fastify.ready(err => t.error(err)) + t.assert.ok(Object.is(myCompiler, sc)) + fastify.ready(err => { + t.assert.ifError(err) + testDone() + }) }) -test('Get serializerCompiler after setSerializerCompiler', t => { +test('Get serializerCompiler after setSerializerCompiler', (t, testDone) => { t.plan(2) const myCompiler = () => { } const fastify = Fastify() fastify.setSerializerCompiler(myCompiler) const sc = fastify.serializerCompiler - t.ok(Object.is(myCompiler, sc)) - fastify.ready(err => t.error(err)) + t.assert.ok(Object.is(myCompiler, sc)) + fastify.ready(err => { + t.assert.ifError(err) + testDone() + }) }) -test('Get compilers is empty when settle on routes', t => { +test('Get compilers is empty when settle on routes', (t, testDone) => { t.plan(3) const fastify = Fastify() @@ -109,10 +121,17 @@ test('Get compilers is empty when settle on routes', t => { fastify.post('/', { schema: { body: { type: 'object', properties: { hello: { type: 'string' } } }, - response: { '2xx': { foo: { type: 'array', items: { type: 'string' } } } } + response: { + '2xx': { + type: 'object', + properties: { + foo: { type: 'array', items: { type: 'string' } } + } + } + } }, - validatorCompiler: ({ schema, method, url, httpPart }) => {}, - serializerCompiler: ({ schema, method, url, httpPart }) => {} + validatorCompiler: ({ schema, method, url, httpPart }) => { }, + serializerCompiler: ({ schema, method, url, httpPart }) => { } }, function (req, reply) { reply.send('ok') }) @@ -122,9 +141,10 @@ test('Get compilers is empty when settle on routes', t => { payload: {}, url: '/' }, (err, res) => { - t.error(err) - t.equal(fastify.validatorCompiler, undefined) - t.equal(fastify.serializerCompiler, undefined) + t.assert.ifError(err) + t.assert.strictEqual(fastify.validatorCompiler, undefined) + t.assert.strictEqual(fastify.serializerCompiler, undefined) + testDone() }) }) @@ -133,9 +153,9 @@ test('Should throw if the $id property is missing', t => { const fastify = Fastify() try { fastify.addSchema({ type: 'string' }) - t.fail() + t.assert.fail() } catch (err) { - t.equal(err.code, 'FST_ERR_SCH_MISSING_ID') + t.assert.strictEqual(err.code, 'FST_ERR_SCH_MISSING_ID') } }) @@ -147,30 +167,41 @@ test('Cannot add multiple times the same id', t => { try { fastify.addSchema({ $id: 'id' }) } catch (err) { - t.equal(err.code, 'FST_ERR_SCH_ALREADY_PRESENT') - t.equal(err.message, 'Schema with id \'id\' already declared!') + t.assert.strictEqual(err.code, 'FST_ERR_SCH_ALREADY_PRESENT') + t.assert.strictEqual(err.message, 'Schema with id \'id\' already declared!') } }) -test('Cannot add schema for query and querystring', t => { +test('Cannot add schema for query and querystring', (t, testDone) => { t.plan(2) const fastify = Fastify() fastify.get('/', { - handler: () => {}, + handler: () => { }, schema: { - query: { foo: { type: 'string' } }, - querystring: { foo: { type: 'string' } } + query: { + type: 'object', + properties: { + foo: { type: 'string' } + } + }, + querystring: { + type: 'object', + properties: { + foo: { type: 'string' } + } + } } }) fastify.ready(err => { - t.equal(err.code, 'FST_ERR_SCH_DUPLICATE') - t.equal(err.message, 'Schema with \'querystring\' already present!') + t.assert.strictEqual(err.code, 'FST_ERR_SCH_DUPLICATE') + t.assert.strictEqual(err.message, 'Schema with \'querystring\' already present!') + testDone() }) }) -test('Should throw of the schema does not exists in input', t => { +test('Should throw of the schema does not exists in input', (t, testDone) => { t.plan(2) const fastify = Fastify() @@ -178,18 +209,44 @@ test('Should throw of the schema does not exists in input', t => { handler: echoParams, schema: { params: { - name: { $ref: '#notExist' } + type: 'object', + properties: { + name: { $ref: '#notExist' } + } + } + } + }) + + fastify.ready(err => { + t.assert.strictEqual(err.code, 'FST_ERR_SCH_VALIDATION_BUILD') + t.assert.strictEqual(err.message, "Failed building the validation schema for GET: /:id, due to error can't resolve reference #notExist from id #") + testDone() + }) +}) + +test('Should throw if schema is missing for content type', (t, testDone) => { + t.plan(2) + + const fastify = Fastify() + fastify.post('/', { + handler: echoBody, + schema: { + body: { + content: { + 'application/json': {} + } } } }) fastify.ready(err => { - t.equal(err.code, 'FST_ERR_SCH_VALIDATION_BUILD') - t.equal(err.message, "Failed building the validation schema for GET: /:id, due to error can't resolve reference #notExist from id #") + t.assert.strictEqual(err.code, 'FST_ERR_SCH_CONTENT_MISSING_SCHEMA') + t.assert.strictEqual(err.message, "Schema is missing for the content type 'application/json'") + testDone() }) }) -test('Should throw of the schema does not exists in output', t => { +test('Should throw of the schema does not exists in output', (t, testDone) => { t.plan(2) const fastify = Fastify() @@ -198,19 +255,23 @@ test('Should throw of the schema does not exists in output', t => { schema: { response: { '2xx': { - name: { $ref: '#notExist' } + type: 'object', + properties: { + name: { $ref: '#notExist' } + } } } } }) fastify.ready(err => { - t.equal(err.code, 'FST_ERR_SCH_SERIALIZATION_BUILD') - t.match(err.message, /^Failed building the serialization schema for GET: \/:id, due to error Cannot find reference.*/) // error from fast-json-strinfigy + t.assert.strictEqual(err.code, 'FST_ERR_SCH_SERIALIZATION_BUILD') + t.assert.match(err.message, /^Failed building the serialization schema for GET: \/:id, due to error Cannot find reference.*/) // error from fast-json-stringify + testDone() }) }) -test('Should not change the input schemas', t => { +test('Should not change the input schemas', (t, testDone) => { t.plan(4) const theSchema = { @@ -234,7 +295,10 @@ test('Should not change the input schemas', t => { }, response: { '2xx': { - name: { $ref: 'helloSchema#/definitions/hello' } + type: 'object', + properties: { + name: { $ref: 'helloSchema#/definitions/hello' } + } } } } @@ -246,14 +310,197 @@ test('Should not change the input schemas', t => { method: 'POST', payload: { name: 'Foo', surname: 'Bar' } }, (err, res) => { - t.error(err) - t.same(res.json(), { name: 'Foo' }) - t.ok(theSchema.$id, 'the $id is not removed') - t.same(fastify.getSchema('helloSchema'), theSchema) + t.assert.ifError(err) + t.assert.deepStrictEqual(res.json(), { name: 'Foo' }) + t.assert.ok(theSchema.$id, 'the $id is not removed') + t.assert.deepStrictEqual(fastify.getSchema('helloSchema'), theSchema) + testDone() + }) +}) + +test('Should emit warning if the schema headers is undefined', (t, testDone) => { + t.plan(4) + const fastify = Fastify() + + process.on('warning', onWarning) + function onWarning (warning) { + t.assert.strictEqual(warning.name, 'FastifyWarning') + t.assert.strictEqual(warning.code, FSTWRN001.code) + } + + t.after(() => { + process.removeListener('warning', onWarning) + FSTWRN001.emitted = false + }) + + fastify.post('/:id', { + handler: echoParams, + schema: { + headers: undefined + } + }) + + fastify.inject({ + method: 'POST', + url: '/123' + }, (error, res) => { + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 200) + testDone() + }) +}) + +test('Should emit warning if the schema body is undefined', (t, testDone) => { + t.plan(4) + const fastify = Fastify() + + process.on('warning', onWarning) + function onWarning (warning) { + t.assert.strictEqual(warning.name, 'FastifyWarning') + t.assert.strictEqual(warning.code, FSTWRN001.code) + } + + t.after(() => { + process.removeListener('warning', onWarning) + FSTWRN001.emitted = false + }) + + fastify.post('/:id', { + handler: echoParams, + schema: { + body: undefined + } + }) + + fastify.inject({ + method: 'POST', + url: '/123' + }, (error, res) => { + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 200) + testDone() + }) +}) + +test('Should emit warning if the schema query is undefined', (t, testDone) => { + t.plan(4) + const fastify = Fastify() + + process.on('warning', onWarning) + function onWarning (warning) { + t.assert.strictEqual(warning.name, 'FastifyWarning') + t.assert.strictEqual(warning.code, FSTWRN001.code) + } + + t.after(() => { + process.removeListener('warning', onWarning) + FSTWRN001.emitted = false + }) + + fastify.post('/:id', { + handler: echoParams, + schema: { + querystring: undefined + } + }) + + fastify.inject({ + method: 'POST', + url: '/123' + }, (error, res) => { + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 200) + testDone() + }) +}) + +test('Should emit warning if the schema params is undefined', (t, testDone) => { + t.plan(4) + const fastify = Fastify() + + process.on('warning', onWarning) + function onWarning (warning) { + t.assert.strictEqual(warning.name, 'FastifyWarning') + t.assert.strictEqual(warning.code, FSTWRN001.code) + } + + t.after(() => { + process.removeListener('warning', onWarning) + FSTWRN001.emitted = false + }) + + fastify.post('/:id', { + handler: echoParams, + schema: { + params: undefined + } + }) + + fastify.inject({ + method: 'POST', + url: '/123' + }, (error, res) => { + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 200) + testDone() + }) +}) + +test('Should emit a warning for every route with undefined schema', (t, testDone) => { + t.plan(16) + const fastify = Fastify() + + let runs = 0 + const expectedWarningEmitted = [0, 1, 2, 3] + // It emits 4 warnings: + // - 2 - GET and HEAD for /undefinedParams/:id + // - 2 - GET and HEAD for /undefinedBody/:id + // => 3 x 4 assertions = 12 assertions + function onWarning (warning) { + t.assert.strictEqual(warning.name, 'FastifyWarning') + t.assert.strictEqual(warning.code, FSTWRN001.code) + t.assert.strictEqual(runs++, expectedWarningEmitted.shift()) + } + + process.on('warning', onWarning) + t.after(() => { + process.removeListener('warning', onWarning) + FSTWRN001.emitted = false + }) + + fastify.get('/undefinedParams/:id', { + handler: echoParams, + schema: { + params: undefined + } + }) + + fastify.get('/undefinedBody/:id', { + handler: echoParams, + schema: { + body: undefined + } + }) + + fastify.inject({ + method: 'GET', + url: '/undefinedParams/123' + }, (error, res) => { + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 200) + }) + + fastify.inject({ + method: 'GET', + url: '/undefinedBody/123' + }, (error, res) => { + t.assert.ifError(error) + t.assert.strictEqual(res.statusCode, 200) + testDone() }) }) -test('First level $ref', t => { +test('First level $ref', (t, testDone) => { t.plan(2) const fastify = Fastify() @@ -281,8 +528,9 @@ test('First level $ref', t => { method: 'GET', url: '/123' }, (err, res) => { - t.error(err) - t.same(res.json(), { id: 246 }) + t.assert.ifError(err) + t.assert.deepStrictEqual(res.json(), { id: 246 }) + testDone() }) }) @@ -291,50 +539,75 @@ test('Customize validator compiler in instance and route', t => { const fastify = Fastify({ exposeHeadRoutes: false }) fastify.setValidatorCompiler(({ schema, method, url, httpPart }) => { - t.equal(method, 'POST') // run 4 times - t.equal(url, '/:id') // run 4 times + t.assert.strictEqual(method, 'POST') // run 4 times + t.assert.strictEqual(url, '/:id') // run 4 times switch (httpPart) { case 'body': - t.pass('body evaluated') + t.assert.ok('body evaluated') return body => { - t.same(body, { foo: ['bar', 'BAR'] }) + t.assert.deepStrictEqual(body, { foo: ['bar', 'BAR'] }) return true } case 'params': - t.pass('params evaluated') + t.assert.ok('params evaluated') return params => { - t.same(params, { id: 1234 }) + t.assert.strictEqual(params.id, '1234') return true } case 'querystring': - t.pass('querystring evaluated') + t.assert.ok('querystring evaluated') return query => { - t.same(query, { lang: 'en' }) + t.assert.strictEqual(query.lang, 'en') return true } case 'headers': - t.pass('headers evaluated') + t.assert.ok('headers evaluated') return headers => { - t.match(headers, { x: 'hello' }) + t.assert.strictEqual(headers.x, 'hello') return true } case '2xx': - t.fail('the validator doesn\'t process the response') + t.assert.fail('the validator doesn\'t process the response') break default: - t.fail(`unknown httpPart ${httpPart}`) + t.assert.fail(`unknown httpPart ${httpPart}`) } }) fastify.post('/:id', { handler: echoBody, schema: { - query: { lang: { type: 'string', enum: ['it', 'en'] } }, - headers: { x: { type: 'string' } }, - params: { id: { type: 'number' } }, - body: { foo: { type: 'array' } }, + query: { + type: 'object', + properties: { + lang: { type: 'string', enum: ['it', 'en'] } + } + }, + headers: { + type: 'object', + properties: { + x: { type: 'string' } + } + }, + params: { + type: 'object', + properties: { + id: { type: 'number' } + } + }, + body: { + type: 'object', + properties: { + foo: { type: 'array' } + } + }, response: { - '2xx': { foo: { type: 'array', items: { type: 'string' } } } + '2xx': { + type: 'object', + properties: { + foo: { type: 'array', items: { type: 'string' } } + } + } } } }) @@ -342,18 +615,42 @@ test('Customize validator compiler in instance and route', t => { fastify.get('/wow/:id', { handler: echoParams, validatorCompiler: ({ schema, method, url, httpPart }) => { - t.equal(method, 'GET') // run 3 times (params, headers, query) - t.equal(url, '/wow/:id') // run 4 times + t.assert.strictEqual(method, 'GET') // run 3 times (params, headers, query) + t.assert.strictEqual(url, '/wow/:id') // run 4 times return () => { return true } // ignore the validation }, schema: { - query: { lang: { type: 'string', enum: ['it', 'en'] } }, - headers: { x: { type: 'string' } }, - params: { id: { type: 'number' } }, - response: { '2xx': { foo: { type: 'array', items: { type: 'string' } } } } + query: { + type: 'object', + properties: { + lang: { type: 'string', enum: ['it', 'en'] } + } + }, + headers: { + type: 'object', + properties: { + x: { type: 'string' } + } + }, + params: { + type: 'object', + properties: { + id: { type: 'number' } + } + }, + response: { + '2xx': { + type: 'object', + properties: { + foo: { type: 'array', items: { type: 'string' } } + } + } + } } }) + const { stepIn, patience } = waitForCb({ steps: 2 }) + fastify.inject({ url: '/1234', method: 'POST', @@ -361,9 +658,10 @@ test('Customize validator compiler in instance and route', t => { query: { lang: 'en' }, payload: { foo: ['bar', 'BAR'] } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same(res.json(), { foo: ['bar', 'BAR'] }) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.json(), { foo: ['bar', 'BAR'] }) + stepIn() }) fastify.inject({ @@ -372,13 +670,16 @@ test('Customize validator compiler in instance and route', t => { headers: { x: 'hello' }, query: { lang: 'jp' } // not in the enum }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) // the validation is always true - t.same(res.json(), {}) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) // the validation is always true + t.assert.deepStrictEqual(res.json(), {}) + stepIn() }) + + return patience }) -test('Use the same schema across multiple routes', t => { +test('Use the same schema across multiple routes', (t, testDone) => { t.plan(4) const fastify = Fastify() @@ -392,7 +693,12 @@ test('Use the same schema across multiple routes', t => { fastify.get('/first/:id', { schema: { - params: { id: { $ref: 'test#/properties/id' } } + params: { + type: 'object', + properties: { + id: { $ref: 'test#/properties/id' } + } + } }, handler: (req, reply) => { reply.send(typeof req.params.id) @@ -401,7 +707,12 @@ test('Use the same schema across multiple routes', t => { fastify.get('/second/:id', { schema: { - params: { id: { $ref: 'test#/properties/id' } } + params: { + type: 'object', + properties: { + id: { $ref: 'test#/properties/id' } + } + } }, handler: (req, reply) => { reply.send(typeof req.params.id) @@ -412,20 +723,21 @@ test('Use the same schema across multiple routes', t => { method: 'GET', url: '/first/123' }, (err, res) => { - t.error(err) - t.equal(res.payload, 'number') + t.assert.ifError(err) + t.assert.strictEqual(res.payload, 'number') }) fastify.inject({ method: 'GET', url: '/second/123' }, (err, res) => { - t.error(err) - t.equal(res.payload, 'number') + t.assert.ifError(err) + t.assert.strictEqual(res.payload, 'number') + testDone() }) }) -test('Encapsulation should intervene', t => { +test('Encapsulation should intervene', (t, testDone) => { t.plan(2) const fastify = Fastify() @@ -444,19 +756,25 @@ test('Encapsulation should intervene', t => { instance.get('/:id', { handler: echoParams, schema: { - params: { id: { $ref: 'encapsulation#/properties/id' } } + params: { + type: 'object', + properties: { + id: { $ref: 'encapsulation#/properties/id' } + } + } } }) done() }) fastify.ready(err => { - t.equal(err.code, 'FST_ERR_SCH_VALIDATION_BUILD') - t.equal(err.message, "Failed building the validation schema for GET: /:id, due to error can't resolve reference encapsulation#/properties/id from id #") + t.assert.strictEqual(err.code, 'FST_ERR_SCH_VALIDATION_BUILD') + t.assert.strictEqual(err.message, "Failed building the validation schema for GET: /:id, due to error can't resolve reference encapsulation#/properties/id from id #") + testDone() }) }) -test('Encapsulation isolation', t => { +test('Encapsulation isolation', (t, testDone) => { t.plan(1) const fastify = Fastify() @@ -470,10 +788,13 @@ test('Encapsulation isolation', t => { done() }) - fastify.ready(err => t.error(err)) + fastify.ready(err => { + t.assert.ifError(err) + testDone() + }) }) -test('Add schema after register', t => { +test('Add schema after register', (t, testDone) => { t.plan(5) const fastify = Fastify() @@ -497,8 +818,8 @@ test('Add schema after register', t => { try { instance.addSchema({ $id: 'test' }) } catch (err) { - t.equal(err.code, 'FST_ERR_SCH_ALREADY_PRESENT') - t.equal(err.message, 'Schema with id \'test\' already declared!') + t.assert.strictEqual(err.code, 'FST_ERR_SCH_ALREADY_PRESENT') + t.assert.strictEqual(err.message, 'Schema with id \'test\' already declared!') } done() }) @@ -507,13 +828,14 @@ test('Add schema after register', t => { method: 'GET', url: '/4242' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same(res.json(), { id: 4242 }) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.json(), { id: 4242 }) + testDone() }) }) -test('Encapsulation isolation for getSchemas', t => { +test('Encapsulation isolation for getSchemas', (t, testDone) => { t.plan(5) const fastify = Fastify() @@ -548,15 +870,16 @@ test('Encapsulation isolation for getSchemas', t => { }) fastify.ready(err => { - t.error(err) - t.same(fastify.getSchemas(), { z: schemas.z }) - t.same(pluginDeepOneSide.getSchemas(), { z: schemas.z, a: schemas.a }) - t.same(pluginDeepOne.getSchemas(), { z: schemas.z, b: schemas.b }) - t.same(pluginDeepTwo.getSchemas(), { z: schemas.z, b: schemas.b, c: schemas.c }) + t.assert.ifError(err) + t.assert.deepStrictEqual(fastify.getSchemas(), { z: schemas.z }) + t.assert.deepStrictEqual(pluginDeepOneSide.getSchemas(), { z: schemas.z, a: schemas.a }) + t.assert.deepStrictEqual(pluginDeepOne.getSchemas(), { z: schemas.z, b: schemas.b }) + t.assert.deepStrictEqual(pluginDeepTwo.getSchemas(), { z: schemas.z, b: schemas.b, c: schemas.c }) + testDone() }) }) -test('Use the same schema id in different places', t => { +test('Use the same schema id in different places', (t, testDone) => { t.plan(1) const fastify = Fastify() @@ -583,17 +906,30 @@ test('Use the same schema id in different places', t => { fastify.post('/:id', { handler: echoBody, schema: { - body: { id: { $ref: 'test#/properties/id' } }, + body: { + type: 'object', + properties: { + id: { $ref: 'test#/properties/id' } + } + }, response: { - 200: { id: { $ref: 'test#/properties/id' } } + 200: { + type: 'object', + properties: { + id: { $ref: 'test#/properties/id' } + } + } } } }) - fastify.ready(err => t.error(err)) + fastify.ready(err => { + t.assert.ifError(err) + testDone() + }) }) -test('Get schema anyway should not add `properties` if allOf is present', t => { +test('Get schema anyway should not add `properties` if allOf is present', (t, testDone) => { t.plan(1) const fastify = Fastify() @@ -620,17 +956,20 @@ test('Get schema anyway should not add `properties` if allOf is present', t => { }) fastify.get('/', { - handler: () => {}, + handler: () => { }, schema: { querystring: fastify.getSchema('second'), response: { 200: fastify.getSchema('second') } } }) - fastify.ready(err => t.error(err)) + fastify.ready(err => { + t.assert.ifError(err) + testDone() + }) }) -test('Get schema anyway should not add `properties` if oneOf is present', t => { +test('Get schema anyway should not add `properties` if oneOf is present', (t, testDone) => { t.plan(1) const fastify = Fastify() @@ -657,17 +996,20 @@ test('Get schema anyway should not add `properties` if oneOf is present', t => { }) fastify.get('/', { - handler: () => {}, + handler: () => { }, schema: { querystring: fastify.getSchema('second'), response: { 200: fastify.getSchema('second') } } }) - fastify.ready(err => t.error(err)) + fastify.ready(err => { + t.assert.ifError(err) + testDone() + }) }) -test('Get schema anyway should not add `properties` if anyOf is present', t => { +test('Get schema anyway should not add `properties` if anyOf is present', (t, testDone) => { t.plan(1) const fastify = Fastify() @@ -694,17 +1036,20 @@ test('Get schema anyway should not add `properties` if anyOf is present', t => { }) fastify.get('/', { - handler: () => {}, + handler: () => { }, schema: { querystring: fastify.getSchema('second'), response: { 200: fastify.getSchema('second') } } }) - fastify.ready(err => t.error(err)) + fastify.ready(err => { + t.assert.ifError(err) + testDone() + }) }) -test('Shared schema should be ignored in string enum', t => { +test('Shared schema should be ignored in string enum', (t, testDone) => { t.plan(2) const fastify = Fastify() @@ -724,12 +1069,13 @@ test('Shared schema should be ignored in string enum', t => { }) fastify.inject('/C%23', (err, res) => { - t.error(err) - t.same(res.json(), { lang: 'C#' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(res.json(), { lang: 'C#' }) + testDone() }) }) -test('Shared schema should NOT be ignored in != string enum', t => { +test('Shared schema should NOT be ignored in != string enum', (t, testDone) => { t.plan(2) const fastify = Fastify() @@ -756,12 +1102,13 @@ test('Shared schema should NOT be ignored in != string enum', t => { method: 'POST', payload: { lang: 'C#' } }, (err, res) => { - t.error(err) - t.same(res.json(), { lang: 'C#' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(res.json(), { lang: 'C#' }) + testDone() }) }) -test('Case insensitive header validation', t => { +test('Case insensitive header validation', (t, testDone) => { t.plan(2) const fastify = Fastify() fastify.get('/', { @@ -785,12 +1132,13 @@ test('Case insensitive header validation', t => { FooBar: 'Baz' } }, (err, res) => { - t.error(err) - t.equal(res.payload, 'Baz') + t.assert.ifError(err) + t.assert.strictEqual(res.payload, 'Baz') + testDone() }) }) -test('Not evaluate json-schema $schema keyword', t => { +test('Not evaluate json-schema $schema keyword', (t, testDone) => { t.plan(2) const fastify = Fastify() fastify.post('/', { @@ -813,19 +1161,20 @@ test('Not evaluate json-schema $schema keyword', t => { method: 'POST', body: { hello: 'world', foo: 'bar' } }, (err, res) => { - t.error(err) - t.same(res.json(), { hello: 'world' }) + t.assert.ifError(err) + t.assert.deepStrictEqual(res.json(), { hello: 'world' }) + testDone() }) }) -test('Validation context in validation result', t => { +test('Validation context in validation result', (t, testDone) => { t.plan(5) const fastify = Fastify() // custom error handler to expose validation context in response, so we can test it later fastify.setErrorHandler((err, request, reply) => { - t.equal(err instanceof Error, true) - t.ok(err.validation, 'detailed errors') - t.equal(err.validationContext, 'body') + t.assert.strictEqual(err instanceof Error, true) + t.assert.ok(err.validation, 'detailed errors') + t.assert.strictEqual(err.validationContext, 'body') reply.code(400).send() }) fastify.post('/', { @@ -845,12 +1194,13 @@ test('Validation context in validation result', t => { url: '/', payload: {} // body lacks required field, will fail validation }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) + testDone() }) }) -test('The schema build should not modify the input', t => { +test('The schema build should not modify the input', (t, testDone) => { t.plan(3) const fastify = Fastify() @@ -908,14 +1258,15 @@ test('The schema build should not modify the input', t => { } }) - t.ok(first.$id) + t.assert.ok(first.$id) fastify.ready(err => { - t.error(err) - t.ok(first.$id) + t.assert.ifError(err) + t.assert.ok(first.$id) + testDone() }) }) -test('Cross schema reference with encapsulation references', t => { +test('Cross schema reference with encapsulation references', (t, testDone) => { t.plan(1) const fastify = Fastify() @@ -965,29 +1316,30 @@ test('Cross schema reference with encapsulation references', t => { fastify.get('/get', { schema: { params: refItem, response: { 200: refItem } } }, () => { }) fastify.ready(err => { - t.error(err) + t.assert.ifError(err) + testDone() }) }) -test('Check how many AJV instances are built #1', t => { +test('Check how many AJV instances are built #1', (t, testDone) => { t.plan(12) const fastify = Fastify() addRandomRoute(fastify) // this trigger the schema validation creation - t.notOk(fastify.validatorCompiler, 'validator not initialized') + t.assert.ok(!fastify.validatorCompiler, 'validator not initialized') const instances = [] fastify.register((instance, opts, done) => { - t.notOk(fastify.validatorCompiler, 'validator not initialized') + t.assert.ok(!fastify.validatorCompiler, 'validator not initialized') instances.push(instance) done() }) fastify.register((instance, opts, done) => { - t.notOk(fastify.validatorCompiler, 'validator not initialized') + t.assert.ok(!fastify.validatorCompiler, 'validator not initialized') addRandomRoute(instance) instances.push(instance) done() instance.register((instance, opts, done) => { - t.notOk(fastify.validatorCompiler, 'validator not initialized') + t.assert.ok(!fastify.validatorCompiler, 'validator not initialized') addRandomRoute(instance) instances.push(instance) done() @@ -995,18 +1347,19 @@ test('Check how many AJV instances are built #1', t => { }) fastify.ready(err => { - t.error(err) + t.assert.ifError(err) - t.ok(fastify.validatorCompiler, 'validator initialized on preReady') + t.assert.ok(fastify.validatorCompiler, 'validator initialized on preReady') fastify.validatorCompiler.checkPointer = true instances.forEach(i => { - t.ok(i.validatorCompiler, 'validator initialized on preReady') - t.equal(i.validatorCompiler.checkPointer, true, 'validator is only one for all the instances') + t.assert.ok(i.validatorCompiler, 'validator initialized on preReady') + t.assert.strictEqual(i.validatorCompiler.checkPointer, true, 'validator is only one for all the instances') }) + testDone() }) }) -test('onReady hook has the compilers ready', t => { +test('onReady hook has the compilers ready', (t, testDone) => { t.plan(6) const fastify = Fastify() @@ -1020,20 +1373,20 @@ test('onReady hook has the compilers ready', t => { }) fastify.addHook('onReady', function (done) { - t.ok(this.validatorCompiler) - t.ok(this.serializerCompiler) + t.assert.ok(this.validatorCompiler) + t.assert.ok(this.serializerCompiler) done() }) let hookCallCounter = 0 fastify.register(async (i, o) => { i.addHook('onReady', function (done) { - t.ok(this.validatorCompiler) - t.ok(this.serializerCompiler) + t.assert.ok(this.validatorCompiler) + t.assert.ok(this.serializerCompiler) done() }) - i.register(async (i, o) => {}) + i.register(async (i, o) => { }) i.addHook('onReady', function (done) { hookCallCounter++ @@ -1042,44 +1395,45 @@ test('onReady hook has the compilers ready', t => { }) fastify.ready(err => { - t.error(err) - t.equal(hookCallCounter, 1, 'it is called once') + t.assert.ifError(err) + t.assert.strictEqual(hookCallCounter, 1, 'it is called once') + testDone() }) }) -test('Check how many AJV instances are built #2 - verify validatorPool', t => { +test('Check how many AJV instances are built #2 - verify validatorPool', (t, testDone) => { t.plan(13) const fastify = Fastify() - t.notOk(fastify.validatorCompiler, 'validator not initialized') + t.assert.ok(!fastify.validatorCompiler, 'validator not initialized') fastify.register(function sibling1 (instance, opts, done) { addRandomRoute(instance) - t.notOk(instance.validatorCompiler, 'validator not initialized') + t.assert.ok(!instance.validatorCompiler, 'validator not initialized') instance.ready(() => { - t.ok(instance.validatorCompiler, 'validator is initialized') + t.assert.ok(instance.validatorCompiler, 'validator is initialized') instance.validatorCompiler.sharedPool = 1 }) instance.after(() => { - t.notOk(instance.validatorCompiler, 'validator not initialized') + t.assert.ok(!instance.validatorCompiler, 'validator not initialized') }) done() }) fastify.register(function sibling2 (instance, opts, done) { addRandomRoute(instance) - t.notOk(instance.validatorCompiler, 'validator not initialized') + t.assert.ok(!instance.validatorCompiler, 'validator not initialized') instance.ready(() => { - t.equal(instance.validatorCompiler.sharedPool, 1, 'this context must share the validator with the same schemas') + t.assert.strictEqual(instance.validatorCompiler.sharedPool, 1, 'this context must share the validator with the same schemas') instance.validatorCompiler.sharedPool = 2 }) instance.after(() => { - t.notOk(instance.validatorCompiler, 'validator not initialized') + t.assert.ok(!instance.validatorCompiler, 'validator not initialized') }) instance.register((instance, opts, done) => { - t.notOk(instance.validatorCompiler, 'validator not initialized') + t.assert.ok(!instance.validatorCompiler, 'validator not initialized') instance.ready(() => { - t.equal(instance.validatorCompiler.sharedPool, 2, 'this context must share the validator of the parent') + t.assert.strictEqual(instance.validatorCompiler.sharedPool, 2, 'this context must share the validator of the parent') }) done() }) @@ -1089,18 +1443,21 @@ test('Check how many AJV instances are built #2 - verify validatorPool', t => { fastify.register(function sibling3 (instance, opts, done) { addRandomRoute(instance) - // this trigger to dont't reuse the same compiler pool + // this trigger to don't reuse the same compiler pool instance.addSchema({ $id: 'diff', type: 'object' }) - t.notOk(instance.validatorCompiler, 'validator not initialized') + t.assert.ok(!instance.validatorCompiler, 'validator not initialized') instance.ready(() => { - t.ok(instance.validatorCompiler, 'validator is initialized') - t.notOk(instance.validatorCompiler.sharedPool, 'this context has its own compiler') + t.assert.ok(instance.validatorCompiler, 'validator is initialized') + t.assert.ok(!instance.validatorCompiler.sharedPool, 'this context has its own compiler') }) done() }) - fastify.ready(err => { t.error(err) }) + fastify.ready(err => { + t.assert.ifError(err) + testDone() + }) }) function addRandomRoute (server) { @@ -1110,15 +1467,15 @@ function addRandomRoute (server) { ) } -test('Add schema order should not break the startup', t => { +test('Add schema order should not break the startup', (t, testDone) => { t.plan(1) const fastify = Fastify() - fastify.get('/', { schema: { random: 'options' } }, () => {}) + fastify.get('/', { schema: { random: 'options' } }, () => { }) fastify.register(fp((f, opts) => { f.addSchema({ - $id: 'https://example.com/bson/objectId', + $id: 'https://fastify.test/bson/objectId', type: 'string', pattern: '\\b[0-9A-Fa-f]{24}\\b' }) @@ -1130,16 +1487,19 @@ test('Add schema order should not break the startup', t => { params: { type: 'object', properties: { - id: { $ref: 'https://example.com/bson/objectId#' } + id: { $ref: 'https://fastify.test/bson/objectId#' } } } } - }, () => {}) + }, () => { }) - fastify.ready(err => { t.error(err) }) + fastify.ready(err => { + t.assert.ifError(err) + testDone() + }) }) -test('The schema compiler recreate itself if needed', t => { +test('The schema compiler recreate itself if needed', (t, testDone) => { t.plan(1) const fastify = Fastify() @@ -1155,7 +1515,10 @@ test('The schema compiler recreate itself if needed', t => { fastify.get('/:foobarId', { schema: { params: { - foobarId: { $ref: 'identifier#' } + type: 'object', + properties: { + foobarId: { $ref: 'identifier#' } + } } } }, echoBody) @@ -1163,23 +1526,26 @@ test('The schema compiler recreate itself if needed', t => { done() }) - fastify.ready(err => { t.error(err) }) + fastify.ready(err => { + t.assert.ifError(err) + testDone() + }) }) test('Schema controller setter', t => { t.plan(2) Fastify({ schemaController: {} }) - t.pass('allow empty object') + t.assert.ok('allow empty object') try { Fastify({ schemaController: { bucket: {} } }) t.fail('the bucket option must be a function') } catch (err) { - t.equal(err.message, "schemaController.bucket option should be a function, instead got 'object'") + t.assert.strictEqual(err.message, "schemaController.bucket option should be a function, instead got 'object'") } }) -test('Schema controller bucket', t => { +test('Schema controller bucket', (t, testDone) => { t.plan(10) let added = 0 @@ -1189,7 +1555,7 @@ test('Schema controller bucket', t => { function factoryBucket (storeInit) { builtBucket++ - t.same(initStoreQueue.pop(), storeInit) + t.assert.deepStrictEqual(initStoreQueue.pop(), storeInit) const store = new Map(storeInit) return { add (schema) { @@ -1216,13 +1582,13 @@ test('Schema controller bucket', t => { fastify.register(async (instance) => { instance.addSchema({ $id: 'b', type: 'string' }) instance.addHook('onReady', function (done) { - t.equal(instance.getSchemas().size, 2) + t.assert.strictEqual(instance.getSchemas().size, 2) done() }) instance.register(async (subinstance) => { subinstance.addSchema({ $id: 'c', type: 'string' }) subinstance.addHook('onReady', function (done) { - t.equal(subinstance.getSchemas().size, 3) + t.assert.strictEqual(subinstance.getSchemas().size, 3) done() }) }) @@ -1230,7 +1596,7 @@ test('Schema controller bucket', t => { fastify.register(async (instance) => { instance.addHook('onReady', function (done) { - t.equal(instance.getSchemas().size, 1) + t.assert.strictEqual(instance.getSchemas().size, 1) done() }) }) @@ -1238,20 +1604,21 @@ test('Schema controller bucket', t => { fastify.addSchema({ $id: 'a', type: 'string' }) fastify.ready(err => { - t.error(err) - t.equal(added, 3, 'three schema added') - t.equal(builtBucket, 4, 'one bucket built for every register call + 1 for the root instance') + t.assert.ifError(err) + t.assert.strictEqual(added, 3, 'three schema added') + t.assert.strictEqual(builtBucket, 4, 'one bucket built for every register call + 1 for the root instance') + testDone() }) }) -test('setSchemaController per instance', t => { +test('setSchemaController per instance', (t, testDone) => { t.plan(7) const fastify = Fastify({}) fastify.register(async (instance1) => { instance1.setSchemaController({ bucket: function factoryBucket (storeInit) { - t.pass('instance1 has created the bucket') + t.assert.ok('instance1 has created the bucket') return { add (schema) { t.fail('add is not called') }, getSchema (id) { t.fail('getSchema is not called') }, @@ -1266,19 +1633,19 @@ test('setSchemaController per instance', t => { instance2.setSchemaController({ bucket: function factoryBucket (storeInit) { - t.pass('instance2 has created the bucket') + t.assert.ok('instance2 has created the bucket') const map = {} return { add (schema) { - t.equal(schema.$id, bSchema.$id, 'add is called') + t.assert.strictEqual(schema.$id, bSchema.$id, 'add is called') map[schema.$id] = schema }, getSchema (id) { - t.pass('getSchema is called') + t.assert.ok('getSchema is called') return map[id] }, getSchemas () { - t.pass('getSchemas is called') + t.assert.ok('getSchemas is called') } } } @@ -1288,12 +1655,15 @@ test('setSchemaController per instance', t => { instance2.addHook('onReady', function (done) { instance2.getSchemas() - t.same(instance2.getSchema('b'), bSchema, 'the schema are loaded') + t.assert.deepStrictEqual(instance2.getSchema('b'), bSchema, 'the schema are loaded') done() }) }) - fastify.ready(err => { t.error(err) }) + fastify.ready(err => { + t.assert.ifError(err) + testDone() + }) }) test('setSchemaController: Inherits correctly parent schemas with a customized validator instance', async t => { @@ -1328,8 +1698,8 @@ test('setSchemaController: Inherits correctly parent schemas with a customized v compilersFactory: { buildValidator: function (externalSchemas) { const schemaKeys = Object.keys(externalSchemas) - t.equal(schemaKeys.length, 2, 'Contains same number of schemas') - t.hasStrict([someSchema, errorResponseSchema], Object.values(externalSchemas), 'Contains expected schemas') + t.assert.strictEqual(schemaKeys.length, 2, 'Contains same number of schemas') + t.assert.deepStrictEqual([someSchema, errorResponseSchema], Object.values(externalSchemas), 'Contains expected schemas') for (const key of schemaKeys) { if (customAjv.getSchema(key) == null) { customAjv.addSchema(externalSchemas[key], key) @@ -1347,8 +1717,11 @@ test('setSchemaController: Inherits correctly parent schemas with a customized v { schema: { querystring: { - msg: { - $ref: 'some#' + type: 'object', + properties: { + msg: { + $ref: 'some#' + } } }, response: { @@ -1375,9 +1748,9 @@ test('setSchemaController: Inherits correctly parent schemas with a customized v }) const json = res.json() - t.equal(json.message, 'querystring/msg must be array') - t.equal(json.statusCode, 400) - t.equal(res.statusCode, 400, 'Should not coearce the string into array') + t.assert.strictEqual(json.message, 'querystring/msg must be array') + t.assert.strictEqual(json.statusCode, 400) + t.assert.strictEqual(res.statusCode, 400, 'Should not coerce the string into array') }) test('setSchemaController: Inherits buildSerializer from parent if not present within the instance', async t => { @@ -1457,8 +1830,11 @@ test('setSchemaController: Inherits buildSerializer from parent if not present w { schema: { querystring: { - msg: { - $ref: 'some#' + type: 'object', + properties: { + msg: { + $ref: 'some#' + } } }, response: { @@ -1480,17 +1856,17 @@ test('setSchemaController: Inherits buildSerializer from parent if not present w method: 'GET', url: '/', query: { - msg: 'string' + msg: ['string'] } }) const json = res.json() - t.equal(json.statusCode, 400) - t.equal(json.message, 'querystring/msg must be array') - t.equal(rootSerializerCalled, 1, 'Should be called from the child') - t.equal(rootValidatorCalled, 0, 'Should not be called from the child') - t.equal(childValidatorCalled, 1, 'Should be called from the child') - t.equal(res.statusCode, 400, 'Should not coerce the string into array') + t.assert.strictEqual(json.statusCode, 400) + t.assert.strictEqual(json.message, 'querystring/msg must be array') + t.assert.strictEqual(rootSerializerCalled, 1, 'Should be called from the child') + t.assert.strictEqual(rootValidatorCalled, 0, 'Should not be called from the child') + t.assert.strictEqual(childValidatorCalled, 1, 'Should be called from the child') + t.assert.strictEqual(res.statusCode, 400, 'Should not coerce the string into array') }) test('setSchemaController: Inherits buildValidator from parent if not present within the instance', async t => { @@ -1568,8 +1944,11 @@ test('setSchemaController: Inherits buildValidator from parent if not present wi { schema: { querystring: { - msg: { - $ref: 'some#' + type: 'object', + properties: { + msg: { + $ref: 'some#' + } } }, response: { @@ -1602,12 +1981,12 @@ test('setSchemaController: Inherits buildValidator from parent if not present wi }) const json = res.json() - t.equal(json.statusCode, 400) - t.equal(json.message, 'querystring/msg must be array') - t.equal(rootSerializerCalled, 0, 'Should be called from the child') - t.equal(rootValidatorCalled, 1, 'Should not be called from the child') - t.equal(childSerializerCalled, 1, 'Should be called from the child') - t.equal(res.statusCode, 400, 'Should not coearce the string into array') + t.assert.strictEqual(json.statusCode, 400) + t.assert.strictEqual(json.message, 'querystring/msg must be array') + t.assert.strictEqual(rootSerializerCalled, 0, 'Should be called from the child') + t.assert.strictEqual(rootValidatorCalled, 1, 'Should not be called from the child') + t.assert.strictEqual(childSerializerCalled, 1, 'Should be called from the child') + t.assert.strictEqual(res.statusCode, 400, 'Should not coerce the string into array') }) test('Should throw if not default validator passed', async t => { @@ -1629,8 +2008,8 @@ test('Should throw if not default validator passed', async t => { compilersFactory: { buildValidator: function (externalSchemas) { const schemaKeys = Object.keys(externalSchemas) - t.equal(schemaKeys.length, 2) - t.same(schemaKeys, ['some', 'another']) + t.assert.strictEqual(schemaKeys.length, 2) + t.assert.deepStrictEqual(schemaKeys, ['some', 'another']) for (const key of schemaKeys) { if (customAjv.getSchema(key) == null) { @@ -1660,13 +2039,19 @@ test('Should throw if not default validator passed', async t => { { schema: { query: { - msg: { - $ref: 'some#' + type: 'object', + properties: { + msg: { + $ref: 'some#' + } } }, headers: { - 'x-another': { - $ref: 'another#' + type: 'object', + properties: { + 'x-another': { + $ref: 'another#' + } } } } @@ -1688,10 +2073,10 @@ test('Should throw if not default validator passed', async t => { } }) - t.equal(res.json().message, 'querystring/msg must be array') - t.equal(res.statusCode, 400, 'Should not coearce the string into array') + t.assert.strictEqual(res.json().message, 'querystring/msg must be array') + t.assert.strictEqual(res.statusCode, 400, 'Should not coerce the string into array') } catch (err) { - t.error(err) + t.assert.ifError(err) } }) @@ -1721,13 +2106,19 @@ test('Should coerce the array if the default validator is used', async t => { { schema: { query: { - msg: { - $ref: 'some#' + type: 'object', + properties: { + msg: { + $ref: 'some#' + } } }, headers: { - 'x-another': { - $ref: 'another#' + type: 'object', + properties: { + 'x-another': { + $ref: 'another#' + } } } } @@ -1749,14 +2140,14 @@ test('Should coerce the array if the default validator is used', async t => { } }) - t.equal(res.statusCode, 200) - t.same(res.json(), { msg: ['string'] }, 'Should coearce the string into array') + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.json(), { msg: ['string'] }, 'Should coerce the string into array') } catch (err) { - t.error(err) + t.assert.ifError(err) } }) -test('Should return a human-friendly error if response status codes are not specified', t => { +test('Should return a human-friendly error if response status codes are not specified', (t, testDone) => { t.plan(2) const fastify = Fastify() @@ -1775,7 +2166,33 @@ test('Should return a human-friendly error if response status codes are not spec }) fastify.ready(err => { - t.equal(err.code, 'FST_ERR_SCH_SERIALIZATION_BUILD') - t.match(err.message, 'Failed building the serialization schema for GET: /, due to error response schemas should be nested under a valid status code, e.g { 2xx: { type: "object" } }') + t.assert.strictEqual(err.code, 'FST_ERR_SCH_SERIALIZATION_BUILD') + t.assert.strictEqual(err.message, 'Failed building the serialization schema for GET: /, due to error response schemas should be nested under a valid status code, e.g { 2xx: { type: "object" } }') + testDone() + }) +}) + +test('setSchemaController: custom validator instance should not mutate headers schema', async t => { + t.plan(2) + class Headers { } + const fastify = Fastify() + + fastify.setSchemaController({ + compilersFactory: { + buildValidator: function () { + return ({ schema, method, url, httpPart }) => { + t.assert.ok(schema instanceof Headers) + return () => { } + } + } + } }) + + fastify.get('/', { + schema: { + headers: new Headers() + } + }, () => { }) + + await fastify.ready() }) diff --git a/test/schema-serialization.test.js b/test/schema-serialization.test.js index 38c25f2dee0..d2ed451e38d 100644 --- a/test/schema-serialization.test.js +++ b/test/schema-serialization.test.js @@ -1,12 +1,12 @@ 'use strict' -const t = require('tap') +const { test } = require('node:test') const Fastify = require('..') -const test = t.test +const { waitForCb } = require('./toolkit') const echoBody = (req, reply) => { reply.send(req.body) } -test('basic test', t => { +test('basic test', (t, testDone) => { t.plan(3) const fastify = Fastify() @@ -27,13 +27,14 @@ test('basic test', t => { }) fastify.inject('/', (err, res) => { - t.error(err) - t.same(res.json(), { name: 'Foo', work: 'Bar' }) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.deepStrictEqual(res.json(), { name: 'Foo', work: 'Bar' }) + t.assert.strictEqual(res.statusCode, 200) + testDone() }) }) -test('custom serializer options', t => { +test('custom serializer options', (t, testDone) => { t.plan(3) const fastify = Fastify({ @@ -54,13 +55,353 @@ test('custom serializer options', t => { }) fastify.inject('/', (err, res) => { - t.error(err) - t.equal(res.payload, '5', 'it must use the ceil rouding') - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.strictEqual(res.payload, '5', 'it must use the ceil rounding') + t.assert.strictEqual(res.statusCode, 200) + testDone() }) }) -test('Use the same schema id in different places', t => { +test('Different content types', (t, testDone) => { + t.plan(46) + + const fastify = Fastify() + fastify.addSchema({ + $id: 'test', + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + verified: { type: 'boolean' } + } + }) + + fastify.get('/', { + schema: { + response: { + 200: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + name: { type: 'string' }, + image: { type: 'string' }, + address: { type: 'string' } + } + } + }, + 'application/vnd.v1+json': { + schema: { + type: 'array', + items: { $ref: 'test' } + } + } + } + }, + 201: { + content: { + '*/*': { + schema: { type: 'string' } + } + } + }, + 202: { + content: { + '*/*': { + schema: { const: 'Processing exclusive content' } + } + } + }, + '3xx': { + content: { + 'application/vnd.v2+json': { + schema: { + type: 'object', + properties: { + fullName: { type: 'string' }, + phone: { type: 'string' } + } + } + } + } + }, + '4xx': { + content: { + '*/*': { + schema: { + type: 'object', + properties: { + details: { type: 'string' } + } + } + } + } + }, + default: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + details: { type: 'string' } + } + } + }, + '*/*': { + schema: { + type: 'object', + properties: { + desc: { type: 'string' }, + details: { type: 'string' } + } + } + } + } + } + } + } + }, function (req, reply) { + switch (req.headers.accept) { + case 'application/json': + reply.header('Content-Type', 'application/json') + reply.send({ id: 1, name: 'Foo', image: 'profile picture', address: 'New Node' }) + break + case 'application/vnd.v1+json': + reply.header('Content-Type', 'application/vnd.v1+json') + reply.send([{ id: 2, name: 'Boo', age: 18, verified: false }, { id: 3, name: 'Woo', age: 30, verified: true }]) + break + case 'application/vnd.v2+json': + reply.header('Content-Type', 'application/vnd.v2+json') + reply.code(300) + reply.send({ fullName: 'Jhon Smith', phone: '01090000000', authMethod: 'google' }) + break + case 'application/vnd.v3+json': + reply.header('Content-Type', 'application/vnd.v3+json') + reply.code(300) + reply.send({ firstName: 'New', lastName: 'Hoo', country: 'eg', city: 'node' }) + break + case 'application/vnd.v4+json': + reply.header('Content-Type', 'application/vnd.v4+json') + reply.code(201) + reply.send({ boxId: 1, content: 'Games' }) + break + case 'application/vnd.v5+json': + reply.header('Content-Type', 'application/vnd.v5+json') + reply.code(202) + reply.send({ content: 'interesting content' }) + break + case 'application/vnd.v6+json': + reply.header('Content-Type', 'application/vnd.v6+json') + reply.code(400) + reply.send({ desc: 'age is missing', details: 'validation error' }) + break + case 'application/vnd.v7+json': + reply.code(400) + reply.send({ details: 'validation error' }) + break + case 'application/vnd.v8+json': + reply.header('Content-Type', 'application/vnd.v8+json') + reply.code(500) + reply.send({ desc: 'age is missing', details: 'validation error' }) + break + case 'application/vnd.v9+json': + reply.code(500) + reply.send({ details: 'validation error' }) + break + default: + // to test if schema not found + reply.header('Content-Type', 'application/vnd.v3+json') + reply.code(200) + reply.send([{ type: 'student', grade: 6 }, { type: 'student', grade: 9 }]) + } + }) + + fastify.get('/test', { + serializerCompiler: ({ contentType }) => { + t.assert.strictEqual(contentType, 'application/json') + return data => JSON.stringify(data) + }, + schema: { + response: { + 200: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + name: { type: 'string' }, + image: { type: 'string' }, + address: { type: 'string' } + } + } + } + } + }, + default: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + details: { type: 'string' } + } + } + } + } + } + } + } + }, function (req, reply) { + switch (req.headers['code']) { + case '200': { + reply.header('Content-Type', 'application/json') + reply.code(200).send({ age: 18, city: 'AU' }) + break + } + case '201': { + reply.header('Content-Type', 'application/json') + reply.code(201).send({ details: 'validation error' }) + break + } + default: { + reply.header('Content-Type', 'application/vnd.v1+json') + reply.code(201).send({ created: true }) + break + } + } + }) + + const completion = waitForCb({ steps: 14 }) + fastify.inject({ method: 'GET', url: '/', headers: { Accept: 'application/json' } }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.payload, JSON.stringify({ name: 'Foo', image: 'profile picture', address: 'New Node' })) + t.assert.strictEqual(res.statusCode, 200) + completion.stepIn() + }) + fastify.inject({ method: 'GET', url: '/', headers: { Accept: 'application/vnd.v1+json' } }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.payload, JSON.stringify([{ name: 'Boo', age: 18, verified: false }, { name: 'Woo', age: 30, verified: true }])) + t.assert.strictEqual(res.statusCode, 200) + completion.stepIn() + }) + fastify.inject({ method: 'GET', url: '/' }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.payload, JSON.stringify([{ type: 'student', grade: 6 }, { type: 'student', grade: 9 }])) + t.assert.strictEqual(res.statusCode, 200) + completion.stepIn() + }) + fastify.inject({ method: 'GET', url: '/', headers: { Accept: 'application/vnd.v2+json' } }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.payload, JSON.stringify({ fullName: 'Jhon Smith', phone: '01090000000' })) + t.assert.strictEqual(res.statusCode, 300) + completion.stepIn() + }) + fastify.inject({ method: 'GET', url: '/', headers: { Accept: 'application/vnd.v3+json' } }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.payload, JSON.stringify({ firstName: 'New', lastName: 'Hoo', country: 'eg', city: 'node' })) + t.assert.strictEqual(res.statusCode, 300) + completion.stepIn() + }) + fastify.inject({ method: 'GET', url: '/', headers: { Accept: 'application/vnd.v4+json' } }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.payload, '"[object Object]"') + t.assert.strictEqual(res.statusCode, 201) + completion.stepIn() + }) + fastify.inject({ method: 'GET', url: '/', headers: { Accept: 'application/vnd.v5+json' } }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.payload, '"Processing exclusive content"') + t.assert.strictEqual(res.statusCode, 202) + completion.stepIn() + }) + fastify.inject({ method: 'GET', url: '/', headers: { Accept: 'application/vnd.v6+json' } }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.payload, JSON.stringify({ details: 'validation error' })) + t.assert.strictEqual(res.statusCode, 400) + completion.stepIn() + }) + fastify.inject({ method: 'GET', url: '/', headers: { Accept: 'application/vnd.v7+json' } }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.payload, JSON.stringify({ details: 'validation error' })) + t.assert.strictEqual(res.statusCode, 400) + completion.stepIn() + }) + fastify.inject({ method: 'GET', url: '/', headers: { Accept: 'application/vnd.v8+json' } }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.payload, JSON.stringify({ desc: 'age is missing', details: 'validation error' })) + t.assert.strictEqual(res.statusCode, 500) + completion.stepIn() + }) + fastify.inject({ method: 'GET', url: '/', headers: { Accept: 'application/vnd.v9+json' } }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.payload, JSON.stringify({ details: 'validation error' })) + t.assert.strictEqual(res.statusCode, 500) + completion.stepIn() + }) + fastify.inject({ method: 'GET', url: '/test', headers: { Code: '200' } }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.payload, JSON.stringify({ age: 18, city: 'AU' })) + t.assert.strictEqual(res.statusCode, 200) + completion.stepIn() + }) + fastify.inject({ method: 'GET', url: '/test', headers: { Code: '201' } }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.payload, JSON.stringify({ details: 'validation error' })) + t.assert.strictEqual(res.statusCode, 201) + completion.stepIn() + }) + fastify.inject({ method: 'GET', url: '/test', headers: { Accept: 'application/vnd.v1+json' } }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.payload, JSON.stringify({ created: true })) + t.assert.strictEqual(res.statusCode, 201) + completion.stepIn() + }) + + completion.patience.then(testDone) +}) + +test('Invalid multiple content schema, throw FST_ERR_SCH_CONTENT_MISSING_SCHEMA error', (t, testDone) => { + t.plan(3) + const fastify = Fastify() + + fastify.get('/testInvalid', { + schema: { + response: { + 200: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + fullName: { type: 'string' }, + phone: { type: 'string' } + } + }, + example: { + fullName: 'John Doe', + phone: '201090243795' + } + }, + type: 'string' + } + } + } + } + }, function (req, reply) { + reply.header('Content-Type', 'application/json') + reply.send({ fullName: 'Any name', phone: '0109001010' }) + }) + + fastify.ready((err) => { + t.assert.strictEqual(err.message, "Schema is missing for the content type 'type'") + t.assert.strictEqual(err.statusCode, 500) + t.assert.strictEqual(err.code, 'FST_ERR_SCH_CONTENT_MISSING_SCHEMA') + testDone() + }) +}) + +test('Use the same schema id in different places', (t, testDone) => { t.plan(2) const fastify = Fastify() @@ -90,12 +431,13 @@ test('Use the same schema id in different places', t => { method: 'GET', url: '/123' }, (err, res) => { - t.error(err) - t.same(res.json(), [{ id: 1 }, { id: 2 }, { }]) + t.assert.ifError(err) + t.assert.deepStrictEqual(res.json(), [{ id: 1 }, { id: 2 }, {}]) + testDone() }) }) -test('Use shared schema and $ref with $id in response ($ref to $id)', t => { +test('Use shared schema and $ref with $id in response ($ref to $id)', (t, testDone) => { t.plan(5) const fastify = Fastify() @@ -145,36 +487,41 @@ test('Use shared schema and $ref with $id in response ($ref to $id)', t => { test: { id: Date.now() } } + const completion = waitForCb({ steps: 2 }) fastify.inject({ method: 'POST', url: '/', payload }, (err, res) => { - t.error(err) - t.same(res.json(), payload) + t.assert.ifError(err) + t.assert.deepStrictEqual(res.json(), payload) + completion.stepIn() }) - fastify.inject({ method: 'POST', url: '/', payload: { test: { id: Date.now() } } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) - t.same(res.json(), { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) + t.assert.deepStrictEqual(res.json(), { error: 'Bad Request', message: "body must have required property 'address'", - statusCode: 400 + statusCode: 400, + code: 'FST_ERR_VALIDATION' }) + completion.stepIn() }) + + completion.patience.then(testDone) }) -test('Shared schema should be pass to serializer and validator ($ref to shared schema /definitions)', t => { +test('Shared schema should be pass to serializer and validator ($ref to shared schema /definitions)', (t, testDone) => { t.plan(5) const fastify = Fastify() fastify.addSchema({ - $id: 'http://example.com/asset.json', + $id: 'http://fastify.test/asset.json', $schema: 'http://json-schema.org/draft-07/schema#', title: 'Physical Asset', description: 'A generic representation of a physical asset', @@ -192,7 +539,7 @@ test('Shared schema should be pass to serializer and validator ($ref to shared s model: { type: 'string' }, - location: { $ref: 'http://example.com/point.json#' } + location: { $ref: 'http://fastify.test/point.json#' } }, definitions: { inner: { @@ -204,7 +551,7 @@ test('Shared schema should be pass to serializer and validator ($ref to shared s }) fastify.addSchema({ - $id: 'http://example.com/point.json', + $id: 'http://fastify.test/point.json', $schema: 'http://json-schema.org/draft-07/schema#', title: 'Longitude and Latitude Values', description: 'A geographical coordinate.', @@ -214,7 +561,7 @@ test('Shared schema should be pass to serializer and validator ($ref to shared s 'longitude' ], properties: { - email: { $ref: 'http://example.com/asset.json#/definitions/inner' }, + email: { $ref: 'http://fastify.test/asset.json#/definitions/inner' }, latitude: { type: 'number', minimum: -90, @@ -232,11 +579,11 @@ test('Shared schema should be pass to serializer and validator ($ref to shared s }) const schemaLocations = { - $id: 'http://example.com/locations.json', + $id: 'http://fastify.test/locations.json', $schema: 'http://json-schema.org/draft-07/schema#', title: 'List of Asset locations', type: 'array', - items: { $ref: 'http://example.com/asset.json#' } + items: { $ref: 'http://fastify.test/asset.json#' } } fastify.post('/', { @@ -257,8 +604,8 @@ test('Shared schema should be pass to serializer and validator ($ref to shared s url: '/', payload: locations }, (err, res) => { - t.error(err) - t.same(res.json(), locations) + t.assert.ifError(err) + t.assert.deepStrictEqual(res.json(), locations) fastify.inject({ method: 'POST', @@ -268,18 +615,20 @@ test('Shared schema should be pass to serializer and validator ($ref to shared s return _ }) }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) - t.same(res.json(), { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) + t.assert.deepStrictEqual(res.json(), { error: 'Bad Request', message: 'body/0/location/email must match format "email"', - statusCode: 400 + statusCode: 400, + code: 'FST_ERR_VALIDATION' }) + testDone() }) }) }) -test('Custom setSerializerCompiler', t => { +test('Custom setSerializerCompiler', (t, testDone) => { t.plan(7) const fastify = Fastify({ exposeHeadRoutes: false }) @@ -290,10 +639,10 @@ test('Custom setSerializerCompiler', t => { } fastify.setSerializerCompiler(({ schema, method, url, httpStatus }) => { - t.equal(method, 'GET') - t.equal(url, '/foo/:id') - t.equal(httpStatus, '200') - t.same(schema, outSchema) + t.assert.strictEqual(method, 'GET') + t.assert.strictEqual(url, '/foo/:id') + t.assert.strictEqual(httpStatus, '200') + t.assert.deepStrictEqual(schema, outSchema) return data => JSON.stringify(data) }) @@ -308,7 +657,7 @@ test('Custom setSerializerCompiler', t => { } } }) - t.ok(instance.serializerCompiler, 'the serializer is set by the parent') + t.assert.ok(instance.serializerCompiler, 'the serializer is set by the parent') done() }, { prefix: '/foo' }) @@ -316,12 +665,13 @@ test('Custom setSerializerCompiler', t => { method: 'GET', url: '/foo/123' }, (err, res) => { - t.error(err) - t.equal(res.payload, JSON.stringify({ id: 1 })) + t.assert.ifError(err) + t.assert.strictEqual(res.payload, JSON.stringify({ id: 1 })) + testDone() }) }) -test('Custom setSerializerCompiler returns bad serialized output', t => { +test('Custom setSerializerCompiler returns bad serialized output', (t, testDone) => { t.plan(4) const fastify = Fastify() @@ -333,7 +683,7 @@ test('Custom setSerializerCompiler returns bad serialized output', t => { fastify.setSerializerCompiler(({ schema, method, url, httpStatus }) => { return data => { - t.pass('returning an invalid serialization') + t.assert.ok('returning an invalid serialization') return { not: 'a string' } } }) @@ -351,13 +701,56 @@ test('Custom setSerializerCompiler returns bad serialized output', t => { method: 'GET', url: '/123' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 500) - t.strictSame(res.json(), { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 500) + t.assert.deepStrictEqual(res.json(), { code: 'FST_ERR_REP_INVALID_PAYLOAD_TYPE', message: 'Attempted to send payload of invalid type \'object\'. Expected a string or Buffer.', statusCode: 500 }) + testDone() + }) +}) + +test('Custom setSerializerCompiler with addSchema', (t, testDone) => { + t.plan(6) + const fastify = Fastify({ exposeHeadRoutes: false }) + + const outSchema = { + $id: 'test', + type: 'object', + whatever: 'need to be parsed by the custom serializer' + } + + fastify.setSerializerCompiler(({ schema, method, url, httpStatus }) => { + t.assert.strictEqual(method, 'GET') + t.assert.strictEqual(url, '/foo/:id') + t.assert.strictEqual(httpStatus, '200') + t.assert.deepStrictEqual(schema, outSchema) + return _data => JSON.stringify({ id: 2 }) + }) + + // provoke re-creation of serialization compiler in setupSerializer + fastify.addSchema({ $id: 'dummy', type: 'object' }) + + fastify.get('/foo/:id', { + handler (_req, reply) { + reply.send({ id: 1 }) + }, + schema: { + response: { + 200: outSchema + } + } + }) + + fastify.inject({ + method: 'GET', + url: '/foo/123' + }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.payload, JSON.stringify({ id: 2 })) + testDone() }) }) @@ -400,23 +793,23 @@ test('Custom serializer per route', async t => { }) let res = await fastify.inject('/default') - t.equal(res.json().mean, 'default') + t.assert.strictEqual(res.json().mean, 'default') res = await fastify.inject('/custom') - t.equal(res.json().mean, 'custom') + t.assert.strictEqual(res.json().mean, 'custom') res = await fastify.inject('/route') - t.equal(res.json().mean, 'route') + t.assert.strictEqual(res.json().mean, 'route') - t.equal(hit, 4, 'the custom and route serializer has been called') + t.assert.strictEqual(hit, 4, 'the custom and route serializer has been called') }) -test('Reply serializer win over serializer ', t => { +test('Reply serializer win over serializer ', (t, testDone) => { t.plan(6) const fastify = Fastify() fastify.setReplySerializer(function (payload, statusCode) { - t.same(payload, { name: 'Foo', work: 'Bar', nick: 'Boo' }) + t.assert.deepStrictEqual(payload, { name: 'Foo', work: 'Bar', nick: 'Boo' }) return 'instance serializator' }) @@ -433,9 +826,9 @@ test('Reply serializer win over serializer ', t => { } }, serializerCompiler: ({ schema, method, url, httpPart }) => { - t.ok(method, 'the custom compiler has been created') + t.assert.ok(method, 'the custom compiler has been created') return () => { - t.fail('the serializer must not be called when there is a reply serializer') + t.assert.fail('the serializer must not be called when there is a reply serializer') return 'fail' } } @@ -444,18 +837,19 @@ test('Reply serializer win over serializer ', t => { }) fastify.inject('/', (err, res) => { - t.error(err) - t.same(res.payload, 'instance serializator') - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.deepStrictEqual(res.payload, 'instance serializator') + t.assert.strictEqual(res.statusCode, 200) + testDone() }) }) -test('Reply serializer win over serializer ', t => { +test('Reply serializer win over serializer ', (t, testDone) => { t.plan(6) const fastify = Fastify() fastify.setReplySerializer(function (payload, statusCode) { - t.same(payload, { name: 'Foo', work: 'Bar', nick: 'Boo' }) + t.assert.deepStrictEqual(payload, { name: 'Foo', work: 'Bar', nick: 'Boo' }) return 'instance serializator' }) @@ -472,9 +866,9 @@ test('Reply serializer win over serializer ', t => { } }, serializerCompiler: ({ schema, method, url, httpPart }) => { - t.ok(method, 'the custom compiler has been created') + t.assert.ok(method, 'the custom compiler has been created') return () => { - t.fail('the serializer must not be called when there is a reply serializer') + t.assert.fail('the serializer must not be called when there is a reply serializer') return 'fail' } } @@ -483,13 +877,14 @@ test('Reply serializer win over serializer ', t => { }) fastify.inject('/', (err, res) => { - t.error(err) - t.same(res.payload, 'instance serializator') - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.deepStrictEqual(res.payload, 'instance serializator') + t.assert.strictEqual(res.statusCode, 200) + testDone() }) }) -test('The schema compiler recreate itself if needed', t => { +test('The schema compiler recreate itself if needed', (t, testDone) => { t.plan(1) const fastify = Fastify() @@ -519,7 +914,10 @@ test('The schema compiler recreate itself if needed', t => { done() }) - fastify.ready(err => { t.error(err) }) + fastify.ready(err => { + t.assert.ifError(err) + testDone() + }) }) test('The schema changes the default error handler output', async t => { @@ -556,12 +954,12 @@ test('The schema changes the default error handler output', async t => { }) let res = await fastify.inject('/501') - t.equal(res.statusCode, 501) - t.same(res.json(), { message: '501 message' }) + t.assert.strictEqual(res.statusCode, 501) + t.assert.deepStrictEqual(res.json(), { message: '501 message' }) res = await fastify.inject('/500') - t.equal(res.statusCode, 500) - t.same(res.json(), { error: 'Internal Server Error', message: '500 message', customId: 42 }) + t.assert.strictEqual(res.statusCode, 500) + t.assert.deepStrictEqual(res.json(), { error: 'Internal Server Error', message: '500 message', customId: 42 }) }) test('do not crash if status code serializer errors', async t => { @@ -576,9 +974,9 @@ test('do not crash if status code serializer errors', async t => { const someUserErrorType2 = { type: 'object', properties: { - code: { type: 'number' } + customCode: { type: 'number' } }, - required: ['code'] + required: ['customCode'] } fastify.get( @@ -590,7 +988,7 @@ test('do not crash if status code serializer errors', async t => { } }, (request, reply) => { - t.fail('handler, should not be called') + t.assert.fail('handler, should not be called') } ) @@ -600,11 +998,12 @@ test('do not crash if status code serializer errors', async t => { notfoo: true } }) - t.equal(res.statusCode, 500) - t.same(res.json(), { + t.assert.strictEqual(res.statusCode, 500) + t.assert.deepStrictEqual(res.json(), { statusCode: 500, - error: 'Internal Server Error', - message: '"code" is required!' + code: 'FST_ERR_FAILED_ERROR_SERIALIZATION', + message: 'Failed to serialize an error. Error: "customCode" is required!. ' + + 'Original error: querystring must have required property \'foo\'' }) }) @@ -631,11 +1030,11 @@ test('custom schema serializer error, empty message', async t => { }) const res = await fastify.inject('/501') - t.equal(res.statusCode, 501) - t.same(res.json(), { message: '' }) + t.assert.strictEqual(res.statusCode, 501) + t.assert.deepStrictEqual(res.json(), { message: '' }) }) -test('error in custom schema serialize compiler, throw FST_ERR_SCH_SERIALIZATION_BUILD error', t => { +test('error in custom schema serialize compiler, throw FST_ERR_SCH_SERIALIZATION_BUILD error', (t, testDone) => { t.plan(3) const fastify = Fastify() @@ -665,13 +1064,14 @@ test('error in custom schema serialize compiler, throw FST_ERR_SCH_SERIALIZATION }) fastify.ready((err) => { - t.equal(err.message, 'Failed building the serialization schema for GET: /, due to error CUSTOM_ERROR') - t.equal(err.statusCode, 500) - t.equal(err.code, 'FST_ERR_SCH_SERIALIZATION_BUILD') + t.assert.strictEqual(err.message, 'Failed building the serialization schema for GET: /, due to error CUSTOM_ERROR') + t.assert.strictEqual(err.statusCode, 500) + t.assert.strictEqual(err.code, 'FST_ERR_SCH_SERIALIZATION_BUILD') + testDone() }) }) -test('Errors in searilizer sended to errorHandler', async t => { +test('Errors in serializer send to errorHandler', async t => { let savedError const fastify = Fastify() @@ -699,20 +1099,19 @@ test('Errors in searilizer sended to errorHandler', async t => { const res = await fastify.inject('/') - t.equal(res.statusCode, 500) + t.assert.strictEqual(res.statusCode, 500) - // t.same(savedError, new Error('"name" is required!')); - t.same(res.json(), { + // t.assert.deepStrictEqual(savedError, new Error('"name" is required!')); + t.assert.deepStrictEqual(res.json(), { statusCode: 500, error: 'Internal Server Error', message: '"name" is required!' }) - t.ok(savedError, 'error presents') - t.ok(savedError.serialization, 'Serialization sign presents') - t.end() + t.assert.ok(savedError, 'error presents') + t.assert.ok(savedError.serialization, 'Serialization sign presents') }) -test('capital X', t => { +test('capital X', (t, testDone) => { t.plan(3) const fastify = Fastify() @@ -733,13 +1132,14 @@ test('capital X', t => { }) fastify.inject('/', (err, res) => { - t.error(err) - t.same(res.json(), { name: 'Foo', work: 'Bar' }) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.deepStrictEqual(res.json(), { name: 'Foo', work: 'Bar' }) + t.assert.strictEqual(res.statusCode, 200) + testDone() }) }) -test('allow default as status code and used as last fallback', t => { +test('allow default as status code and used as last fallback', (t, testDone) => { t.plan(3) const fastify = Fastify() @@ -763,8 +1163,9 @@ test('allow default as status code and used as last fallback', t => { }) fastify.inject('/', (err, res) => { - t.error(err) - t.same(res.json(), { name: 'Foo', work: 'Bar' }) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.deepStrictEqual(res.json(), { name: 'Foo', work: 'Bar' }) + t.assert.strictEqual(res.statusCode, 200) + testDone() }) }) diff --git a/test/schema-special-usage.test.js b/test/schema-special-usage.test.js index d2cb65d56b5..85b466a1ba8 100644 --- a/test/schema-special-usage.test.js +++ b/test/schema-special-usage.test.js @@ -1,14 +1,17 @@ 'use strict' -const { test } = require('tap') +const { test } = require('node:test') const Joi = require('joi') +const yup = require('yup') const AJV = require('ajv') const S = require('fluent-json-schema') const Fastify = require('..') const ajvMergePatch = require('ajv-merge-patch') const ajvErrors = require('ajv-errors') +const proxyquire = require('proxyquire') +const { waitForCb } = require('./toolkit') -test('Ajv plugins array parameter', t => { +test('Ajv plugins array parameter', (t, testDone) => { t.plan(3) const fastify = Fastify({ ajv: { @@ -49,13 +52,14 @@ test('Ajv plugins array parameter', t => { url: '/', payload: { foo: 99 } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) - t.equal(res.json().message, 'body/foo should be <= 10@@@@should be multipleOf 2') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) + t.assert.strictEqual(res.json().message, 'body/foo should be <= 10@@@@should be multipleOf 2') + testDone() }) }) -test('Should handle root $merge keywords in header', t => { +test('Should handle root $merge keywords in header', (t, testDone) => { t.plan(5) const fastify = Fastify({ ajv: { @@ -85,14 +89,14 @@ test('Should handle root $merge keywords in header', t => { }) fastify.ready(err => { - t.error(err) + t.assert.ifError(err) fastify.inject({ method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) }) fastify.inject({ @@ -100,13 +104,14 @@ test('Should handle root $merge keywords in header', t => { url: '/', headers: { q: 'foo' } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + testDone() }) }) }) -test('Should handle root $patch keywords in header', t => { +test('Should handle root $patch keywords in header', (t, testDone) => { t.plan(5) const fastify = Fastify({ ajv: { @@ -142,7 +147,7 @@ test('Should handle root $patch keywords in header', t => { }) fastify.ready(err => { - t.error(err) + t.assert.ifError(err) fastify.inject({ method: 'GET', @@ -151,8 +156,8 @@ test('Should handle root $patch keywords in header', t => { q: 'foo' } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) }) fastify.inject({ @@ -160,13 +165,14 @@ test('Should handle root $patch keywords in header', t => { url: '/', headers: { q: 10 } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + testDone() }) }) }) -test('Should handle $merge keywords in body', t => { +test('Should handle $merge keywords in body', (t, testDone) => { t.plan(5) const fastify = Fastify({ ajv: { @@ -196,14 +202,14 @@ test('Should handle $merge keywords in body', t => { }) fastify.ready(err => { - t.error(err) + t.assert.ifError(err) fastify.inject({ method: 'POST', url: '/' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) }) fastify.inject({ @@ -211,13 +217,14 @@ test('Should handle $merge keywords in body', t => { url: '/', payload: { q: 'foo' } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + testDone() }) }) }) -test('Should handle $patch keywords in body', t => { +test('Should handle $patch keywords in body', (t, testDone) => { t.plan(5) const fastify = Fastify({ ajv: { @@ -251,34 +258,37 @@ test('Should handle $patch keywords in body', t => { }) fastify.ready(err => { - t.error(err) + t.assert.ifError(err) + const completion = waitForCb({ steps: 2 }) fastify.inject({ method: 'POST', url: '/', payload: { q: 'foo' } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) + completion.stepIn() }) - fastify.inject({ method: 'POST', url: '/', payload: { q: 10 } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + completion.stepIn() }) + completion.patience.then(testDone) }) }) -test("serializer read validator's schemas", t => { +test("serializer read validator's schemas", (t, testDone) => { t.plan(4) const ajvInstance = new AJV() const baseSchema = { - $id: 'http://example.com/schemas/base', + $id: 'http://fastify.test/schemas/base', definitions: { hello: { type: 'string' } }, @@ -289,10 +299,10 @@ test("serializer read validator's schemas", t => { } const refSchema = { - $id: 'http://example.com/schemas/ref', + $id: 'http://fastify.test/schemas/ref', type: 'object', properties: { - hello: { $ref: 'http://example.com/schemas/base#/definitions/hello' } + hello: { $ref: 'http://fastify.test/schemas/base#/definitions/hello' } } } @@ -302,7 +312,7 @@ test("serializer read validator's schemas", t => { const fastify = Fastify({ schemaController: { bucket: function factory (storeInit) { - t.notOk(storeInit, 'is always empty because fastify.addSchema is not called') + t.assert.ok(!storeInit, 'is always empty because fastify.addSchema is not called') return { getSchemas () { return { @@ -322,20 +332,21 @@ test("serializer read validator's schemas", t => { fastify.get('/', { schema: { response: { - '2xx': ajvInstance.getSchema('http://example.com/schemas/ref').schema + '2xx': ajvInstance.getSchema('http://fastify.test/schemas/ref').schema } }, handler (req, res) { res.send({ hello: 'world', evict: 'this' }) } }) fastify.inject('/', (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same(res.json(), { hello: 'world' }) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.json(), { hello: 'world' }) + testDone() }) }) -test('setSchemaController in a plugin', t => { +test('setSchemaController in a plugin', (t, testDone) => { t.plan(5) const baseSchema = { $id: 'urn:schema:base', @@ -375,15 +386,16 @@ test('setSchemaController in a plugin', t => { }) fastify.inject('/', (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same(res.json(), { hello: 'world' }) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.json(), { hello: 'world' }) + testDone() }) async function schemaPlugin (server) { server.setSchemaController({ bucket () { - t.pass('the bucket is created') + t.assert.ok('the bucket is created') return { addSchema (source) { ajvInstance.addSchema(source) @@ -401,7 +413,7 @@ test('setSchemaController in a plugin', t => { } }) server.setValidatorCompiler(function ({ schema }) { - t.pass('the querystring schema is compiled') + t.assert.ok('the querystring schema is compiled') return ajvInstance.compile(schema) }) } @@ -505,7 +517,7 @@ test('only response schema trigger AJV pollution #2', async t => { await fastify.ready() }) -test('setSchemaController in a plugin with head routes', t => { +test('setSchemaController in a plugin with head routes', (t, testDone) => { t.plan(6) const baseSchema = { $id: 'urn:schema:base', @@ -545,15 +557,16 @@ test('setSchemaController in a plugin with head routes', t => { }) fastify.inject('/', (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same(res.json(), { hello: 'world' }) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.json(), { hello: 'world' }) + testDone() }) async function schemaPlugin (server) { server.setSchemaController({ bucket () { - t.pass('the bucket is created') + t.assert.ok('the bucket is created') return { addSchema (source) { ajvInstance.addSchema(source) @@ -574,11 +587,11 @@ test('setSchemaController in a plugin with head routes', t => { if (schema.$id) { const stored = ajvInstance.getSchema(schema.$id) if (stored) { - t.pass('the schema is reused') + t.assert.ok('the schema is reused') return stored } } - t.pass('the schema is compiled') + t.assert.ok('the schema is compiled') return ajvInstance.compile(schema) }) @@ -586,7 +599,7 @@ test('setSchemaController in a plugin with head routes', t => { schemaPlugin[Symbol.for('skip-override')] = true }) -test('multiple refs with the same ids', t => { +test('multiple refs with the same ids', (t, testDone) => { t.plan(3) const baseSchema = { $id: 'urn:schema:base', @@ -637,13 +650,14 @@ test('multiple refs with the same ids', t => { }) fastify.inject('/', (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same(res.json(), { hello: 'world' }) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.json(), { hello: 'world' }) + testDone() }) }) -test('JOI validation overwrite request headers', t => { +test('JOI validation overwrite request headers', (t, testDone) => { t.plan(3) const schemaValidator = ({ schema }) => data => { const validationResult = schema.validate(data) @@ -665,11 +679,670 @@ test('JOI validation overwrite request headers', t => { }) fastify.inject('/', (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same(res.json(), { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.json(), { + 'user-agent': 'lightMyRequest', + host: 'localhost:80' + }) + testDone() + }) +}) + +test('Custom schema object should not trigger FST_ERR_SCH_DUPLICATE', async t => { + const fastify = Fastify() + const handler = () => { } + + fastify.get('/the/url', { + schema: { + query: yup.object({ + foo: yup.string() + }) + }, + validatorCompiler: ({ schema, method, url, httpPart }) => { + return function (data) { + // with option strict = false, yup `validateSync` function returns the coerced value if validation was successful, or throws if validation failed + try { + const result = schema.validateSync(data, {}) + return { value: result } + } catch (e) { + return { error: e } + } + } + }, + handler + }) + + await fastify.ready() + t.assert.ok('fastify is ready') +}) + +test('The default schema compilers should not be called when overwritten by the user', async t => { + const Fastify = proxyquire('../', { + '@fastify/ajv-compiler': () => { + t.assert.fail('The default validator compiler should not be called') + }, + '@fastify/fast-json-stringify-compiler': () => { + t.assert.fail('The default serializer compiler should not be called') + } + }) + + const fastify = Fastify({ + schemaController: { + compilersFactory: { + buildValidator: function factory () { + t.assert.ok('The custom validator compiler should be called') + return function validatorCompiler () { + return () => { return true } + } + }, + buildSerializer: function factory () { + t.assert.ok('The custom serializer compiler should be called') + return function serializerCompiler () { + return () => { return true } + } + } + } + } + }) + + fastify.get('/', + { + schema: { + query: { foo: { type: 'string' } }, + response: { + 200: { type: 'object' } + } + } + }, () => { }) + + await fastify.ready() +}) + +test('Supports async JOI validation', (t, testDone) => { + t.plan(7) + + const schemaValidator = ({ schema }) => async data => { + const validationResult = await schema.validateAsync(data) + return validationResult + } + + const fastify = Fastify({ + exposeHeadRoutes: false + }) + fastify.setValidatorCompiler(schemaValidator) + + fastify.get('/', { + schema: { + headers: Joi.object({ + 'user-agent': Joi.string().external(async (val) => { + if (val !== 'lightMyRequest') { + throw new Error('Invalid user-agent') + } + + t.assert.strictEqual(val, 'lightMyRequest') + return val + }), + host: Joi.string().required() + }) + } + }, (request, reply) => { + reply.send(request.headers) + }) + + const completion = waitForCb({ steps: 2 }) + fastify.inject('/', (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.json(), { 'user-agent': 'lightMyRequest', host: 'localhost:80' }) + completion.stepIn() + }) + fastify.inject({ + url: '/', + headers: { + 'user-agent': 'invalid' + } + }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) + t.assert.deepStrictEqual(res.json(), { + statusCode: 400, + code: 'FST_ERR_VALIDATION', + error: 'Bad Request', + message: 'Invalid user-agent (user-agent)' + }) + completion.stepIn() }) + + completion.patience.then(testDone) +}) + +test('Supports async AJV validation', (t, testDone) => { + t.plan(12) + + const fastify = Fastify({ + exposeHeadRoutes: false, + ajv: { + customOptions: { + allErrors: true, + keywords: [ + { + keyword: 'idExists', + async: true, + type: 'number', + validate: checkIdExists + } + ] + }, + plugins: [ + [ajvErrors, { singleError: '@@@@' }] + ] + } + }) + + async function checkIdExists (schema, data) { + const res = await Promise.resolve(data) + switch (res) { + case 42: + return true + + case 500: + throw new Error('custom error') + + default: + return false + } + } + + const schema = { + $async: true, + type: 'object', + properties: { + userId: { + type: 'integer', + idExists: { table: 'users' } + }, + postId: { + type: 'integer', + idExists: { table: 'posts' } + } + } + } + + fastify.post('/', { + schema: { + body: schema + }, + handler (req, reply) { reply.send(req.body) } + }) + + const completion = waitForCb({ steps: 4 }) + + fastify.inject({ + method: 'POST', + url: '/', + payload: { userId: 99 } + }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) + t.assert.deepStrictEqual(res.json(), { + statusCode: 400, + code: 'FST_ERR_VALIDATION', + error: 'Bad Request', + message: 'validation failed' + }) + completion.stepIn() + }) + fastify.inject({ + method: 'POST', + url: '/', + payload: { userId: 500 } + }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) + t.assert.deepStrictEqual(res.json(), { + statusCode: 400, + code: 'FST_ERR_VALIDATION', + error: 'Bad Request', + message: 'custom error' + }) + completion.stepIn() + }) + fastify.inject({ + method: 'POST', + url: '/', + payload: { userId: 42 } + }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.json(), { userId: 42 }) + completion.stepIn() + }) + fastify.inject({ + method: 'POST', + url: '/', + payload: { userId: 42, postId: 19 } + }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) + t.assert.deepStrictEqual(res.json(), { + statusCode: 400, + code: 'FST_ERR_VALIDATION', + error: 'Bad Request', + message: 'validation failed' + }) + completion.stepIn() + }) + completion.patience.then(testDone) +}) + +test('Check all the async AJV validation paths', async (t) => { + const fastify = Fastify({ + exposeHeadRoutes: false, + ajv: { + customOptions: { + allErrors: true, + keywords: [ + { + keyword: 'idExists', + async: true, + type: 'number', + validate: checkIdExists + } + ] + } + } + }) + + async function checkIdExists (schema, data) { + const res = await Promise.resolve(data) + switch (res) { + case 200: + return true + + default: + return false + } + } + + const schema = { + $async: true, + type: 'object', + properties: { + id: { + type: 'integer', + idExists: { table: 'posts' } + } + } + } + + fastify.post('/:id', { + schema: { + params: schema, + body: schema, + query: schema, + headers: schema + }, + handler (req, reply) { reply.send(req.body) } + }) + + const testCases = [ + { + params: 400, + body: 200, + querystring: 200, + headers: 200, + response: 400 + }, + { + params: 200, + body: 400, + querystring: 200, + headers: 200, + response: 400 + }, + { + params: 200, + body: 200, + querystring: 400, + headers: 200, + response: 400 + }, + { + params: 200, + body: 200, + querystring: 200, + headers: 400, + response: 400 + }, + { + params: 200, + body: 200, + querystring: 200, + headers: 200, + response: 200 + } + ] + t.plan(testCases.length) + for (const testCase of testCases) { + await validate(testCase) + } + + async function validate ({ + params, + body, + querystring, + headers, + response + }) { + try { + const res = await fastify.inject({ + method: 'POST', + url: `/${params}`, + headers: { id: headers }, + query: { id: querystring }, + payload: { id: body } + }) + t.assert.strictEqual(res.statusCode, response) + } catch (error) { + t.assert.fail('should not throw') + } + } +}) + +test('Check mixed sync and async AJV validations', async (t) => { + const fastify = Fastify({ + exposeHeadRoutes: false, + ajv: { + customOptions: { + allErrors: true, + keywords: [ + { + keyword: 'idExists', + async: true, + type: 'number', + validate: checkIdExists + } + ] + } + } + }) + + async function checkIdExists (schema, data) { + const res = await Promise.resolve(data) + switch (res) { + case 200: + return true + + default: + return false + } + } + + const schemaSync = { + type: 'object', + properties: { + id: { type: 'integer' } + } + } + + const schemaAsync = { + $async: true, + type: 'object', + properties: { + id: { + type: 'integer', + idExists: { table: 'posts' } + } + } + } + + fastify.post('/queryAsync/:id', { + schema: { + params: schemaSync, + body: schemaSync, + query: schemaAsync, + headers: schemaSync + }, + handler (req, reply) { reply.send(req.body) } + }) + + fastify.post('/paramsAsync/:id', { + schema: { + params: schemaAsync, + body: schemaSync + }, + handler (req, reply) { reply.send(req.body) } + }) + + fastify.post('/bodyAsync/:id', { + schema: { + params: schemaAsync, + body: schemaAsync, + query: schemaSync + }, + handler (req, reply) { reply.send(req.body) } + }) + + fastify.post('/headersSync/:id', { + schema: { + params: schemaSync, + body: schemaSync, + query: schemaAsync, + headers: schemaSync + }, + handler (req, reply) { reply.send(req.body) } + }) + + fastify.post('/noHeader/:id', { + schema: { + params: schemaSync, + body: schemaSync, + query: schemaAsync + }, + handler (req, reply) { reply.send(req.body) } + }) + + fastify.post('/noBody/:id', { + schema: { + params: schemaSync, + query: schemaAsync, + headers: schemaSync + }, + handler (req, reply) { reply.send(req.body) } + }) + + const testCases = [ + { + url: '/queryAsync', + params: 200, + body: 200, + querystring: 200, + headers: 'not a number sync', + response: 400 + }, + { + url: '/paramsAsync', + params: 200, + body: 'not a number sync', + querystring: 200, + headers: 200, + response: 400 + }, + { + url: '/bodyAsync', + params: 200, + body: 200, + querystring: 'not a number sync', + headers: 200, + response: 400 + }, + { + url: '/headersSync', + params: 200, + body: 200, + querystring: 200, + headers: 'not a number sync', + response: 400 + }, + { + url: '/noHeader', + params: 200, + body: 200, + querystring: 200, + headers: 'not a number sync, but not validated', + response: 200 + }, + { + url: '/noBody', + params: 200, + body: 'not a number sync, but not validated', + querystring: 200, + headers: 'not a number sync', + response: 400 + } + ] + t.plan(testCases.length) + for (const testCase of testCases) { + await validate(testCase) + } + + async function validate ({ + url, + params, + body, + querystring, + headers, + response + }) { + try { + const res = await fastify.inject({ + method: 'POST', + url: `${url}/${params || ''}`, + headers: { id: headers }, + query: { id: querystring }, + payload: { id: body } + }) + t.assert.strictEqual(res.statusCode, response) + } catch (error) { + t.assert.fail('should not fail') + } + } +}) + +test('Check if hooks and attachValidation work with AJV validations', async (t) => { + const fastify = Fastify({ + exposeHeadRoutes: false, + ajv: { + customOptions: { + allErrors: true, + keywords: [ + { + keyword: 'idExists', + async: true, + type: 'number', + validate: checkIdExists + } + ] + } + } + }) + + async function checkIdExists (schema, data) { + const res = await Promise.resolve(data) + switch (res) { + case 200: + return true + + default: + return false + } + } + + const schemaAsync = { + $async: true, + type: 'object', + properties: { + id: { + type: 'integer', + idExists: { table: 'posts' } + } + } + } + + fastify.post('/:id', { + preHandler: function hook (request, reply, done) { + t.assert.strictEqual(request.validationError.message, 'validation failed') + t.assert.ok('preHandler called') + + reply.code(400).send(request.body) + }, + attachValidation: true, + schema: { + params: schemaAsync, + body: schemaAsync, + query: schemaAsync, + headers: schemaAsync + }, + handler (req, reply) { reply.send(req.body) } + }) + + const testCases = [ + { + params: 200, + body: 200, + querystring: 200, + headers: 400, + response: 400 + }, + { + params: 200, + body: 400, + querystring: 200, + headers: 200, + response: 400 + }, + { + params: 200, + body: 200, + querystring: 400, + headers: 200, + response: 400 + }, + { + params: 200, + body: 200, + querystring: 200, + headers: 400, + response: 400 + } + ] + t.plan(testCases.length * 3) + for (const testCase of testCases) { + await validate(testCase) + } + + async function validate ({ + params, + body, + querystring, + headers, + response + }) { + try { + const res = await fastify.inject({ + method: 'POST', + url: `/${params}`, + headers: { id: headers }, + query: { id: querystring }, + payload: { id: body } + }) + t.assert.strictEqual(res.statusCode, response) + } catch (error) { + t.assert.fail('should not fail') + } + } }) diff --git a/test/schema-validation.test.js b/test/schema-validation.test.js index 05ce41be5a6..c5006463255 100644 --- a/test/schema-validation.test.js +++ b/test/schema-validation.test.js @@ -1,10 +1,11 @@ 'use strict' -const { test } = require('tap') +const { test } = require('node:test') const Fastify = require('..') const AJV = require('ajv') const Schema = require('fluent-json-schema') +const { waitForCb } = require('./toolkit') const customSchemaCompilers = { body: new AJV({ @@ -69,7 +70,7 @@ const schemaArtist = { required: ['name', 'work'] } -test('Basic validation test', t => { +test('Basic validation test', (t, testDone) => { t.plan(6) const fastify = Fastify() @@ -81,6 +82,7 @@ test('Basic validation test', t => { reply.code(200).send(req.body.name) }) + const completion = waitForCb({ steps: 2 }) fastify.inject({ method: 'POST', payload: { @@ -89,24 +91,165 @@ test('Basic validation test', t => { }, url: '/' }, (err, res) => { - t.error(err) - t.same(res.payload, 'michelangelo') - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.deepStrictEqual(res.payload, 'michelangelo') + t.assert.strictEqual(res.statusCode, 200) + completion.stepIn() }) - fastify.inject({ method: 'POST', payload: { name: 'michelangelo' }, url: '/' }, (err, res) => { - t.error(err) - t.same(res.json(), { statusCode: 400, error: 'Bad Request', message: "body must have required property 'work'" }) - t.equal(res.statusCode, 400) + t.assert.ifError(err) + t.assert.deepStrictEqual(res.json(), { statusCode: 400, code: 'FST_ERR_VALIDATION', error: 'Bad Request', message: "body must have required property 'work'" }) + t.assert.strictEqual(res.statusCode, 400) + completion.stepIn() }) + completion.patience.then(testDone) }) -test('External AJV instance', t => { - t.plan(4) +test('Different schema per content type', (t, testDone) => { + t.plan(12) + + const fastify = Fastify() + fastify.addContentTypeParser('application/octet-stream', { + parseAs: 'buffer' + }, async function (_, payload) { + return payload + }) + fastify.post('/', { + schema: { + body: { + content: { + 'application/json': { + schema: schemaArtist + }, + 'application/octet-stream': { + schema: {} // Skip validation + }, + 'text/plain': { + schema: { type: 'string' } + } + } + } + } + }, async function (req, reply) { + return reply.send(req.body) + }) + + const completion = waitForCb({ steps: 4 }) + fastify.inject({ + url: '/', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: { + name: 'michelangelo', + work: 'sculptor, painter, architect and poet' + } + }, (err, res) => { + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload).name, 'michelangelo') + t.assert.strictEqual(res.statusCode, 200) + completion.stepIn() + }) + fastify.inject({ + url: '/', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: { name: 'michelangelo' } + }, (err, res) => { + t.assert.ifError(err) + t.assert.deepStrictEqual(res.json(), { statusCode: 400, code: 'FST_ERR_VALIDATION', error: 'Bad Request', message: "body must have required property 'work'" }) + t.assert.strictEqual(res.statusCode, 400) + completion.stepIn() + }) + fastify.inject({ + url: '/', + method: 'POST', + headers: { 'Content-Type': 'application/octet-stream' }, + body: Buffer.from('AAAAAAAA') + }, (err, res) => { + t.assert.ifError(err) + t.assert.deepStrictEqual(res.payload, 'AAAAAAAA') + t.assert.strictEqual(res.statusCode, 200) + completion.stepIn() + }) + fastify.inject({ + url: '/', + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: 'AAAAAAAA' + }, (err, res) => { + t.assert.ifError(err) + t.assert.deepStrictEqual(res.payload, 'AAAAAAAA') + t.assert.strictEqual(res.statusCode, 200) + completion.stepIn() + }) + completion.patience.then(testDone) +}) + +test('Skip validation if no schema for content type', (t, testDone) => { + t.plan(3) + + const fastify = Fastify() + fastify.post('/', { + schema: { + body: { + content: { + 'application/json': { + schema: schemaArtist + } + // No schema for 'text/plain' + } + } + } + }, async function (req, reply) { + return reply.send(req.body) + }) + fastify.inject({ + url: '/', + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: 'AAAAAAAA' + }, (err, res) => { + t.assert.ifError(err) + t.assert.deepStrictEqual(res.payload, 'AAAAAAAA') + t.assert.strictEqual(res.statusCode, 200) + testDone() + }) +}) + +test('Skip validation if no content type schemas', (t, testDone) => { + t.plan(3) + + const fastify = Fastify() + fastify.post('/', { + schema: { + body: { + content: { + // No schemas + } + } + } + }, async function (req, reply) { + return reply.send(req.body) + }) + fastify.inject({ + url: '/', + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: 'AAAAAAAA' + }, (err, res) => { + t.assert.ifError(err) + t.assert.deepStrictEqual(res.payload, 'AAAAAAAA') + t.assert.strictEqual(res.statusCode, 200) + testDone() + }) +}) + +test('External AJV instance', (t, testDone) => { + t.plan(5) const fastify = Fastify() const ajv = new AJV() @@ -118,6 +261,7 @@ test('External AJV instance', t => { fastify.addSchema(schemaBRefToA) fastify.setValidatorCompiler(({ schema, method, url, httpPart }) => { + t.assert.ok('custom validator compiler called') return ajv.compile(schema) }) @@ -131,27 +275,30 @@ test('External AJV instance', t => { } }) + const completion = waitForCb({ steps: 2 }) fastify.inject({ method: 'POST', url: '/', payload: { foo: 42 } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + completion.stepIn() }) - fastify.inject({ method: 'POST', url: '/', payload: { foo: 'not a number' } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) + completion.stepIn() }) + completion.patience.then(testDone) }) -test('Encapsulation', t => { - t.plan(19) +test('Encapsulation', (t, testDone) => { + t.plan(21) const fastify = Fastify() const ajv = new AJV() @@ -164,6 +311,7 @@ test('Encapsulation', t => { fastify.register((instance, opts, done) => { const validator = ({ schema, method, url, httpPart }) => { + t.assert.ok('custom validator compiler called') return ajv.compile(schema) } instance.setValidatorCompiler(validator) @@ -177,7 +325,7 @@ test('Encapsulation', t => { instance.register((instance, opts, done) => { instance.post('/two', { handler (req, reply) { - t.same(instance.validatorCompiler, validator) + t.assert.deepStrictEqual(instance.validatorCompiler, validator) reply.send({ foo: 'two' }) }, schema: { @@ -191,7 +339,7 @@ test('Encapsulation', t => { instance.post('/three', { validatorCompiler: anotherValidator, handler (req, reply) { - t.same(instance.validatorCompiler, validator, 'the route validator does not change the instance one') + t.assert.deepStrictEqual(instance.validatorCompiler, validator, 'the route validator does not change the instance one') reply.send({ foo: 'three' }) }, schema: { @@ -205,73 +353,76 @@ test('Encapsulation', t => { fastify.register((instance, opts, done) => { instance.post('/clean', function (req, reply) { - t.equal(instance.validatorCompiler, undefined) + t.assert.strictEqual(instance.validatorCompiler, undefined) reply.send({ foo: 'bar' }) }) done() }) + const completion = waitForCb({ steps: 6 }) fastify.inject({ method: 'POST', url: '/one', payload: { foo: 1 } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same(res.json(), { foo: 'one' }) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.json(), { foo: 'one' }) + completion.stepIn() }) - fastify.inject({ method: 'POST', url: '/one', payload: { wrongFoo: 'bar' } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) + completion.stepIn() }) - fastify.inject({ method: 'POST', url: '/two', payload: { foo: 2 } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same(res.json(), { foo: 'two' }) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.json(), { foo: 'two' }) + completion.stepIn() }) - fastify.inject({ method: 'POST', url: '/two', payload: { wrongFoo: 'bar' } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) + completion.stepIn() }) - fastify.inject({ method: 'POST', url: '/three', payload: { wrongFoo: 'but works' } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same(res.json(), { foo: 'three' }) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.json(), { foo: 'three' }) + completion.stepIn() }) - fastify.inject({ method: 'POST', url: '/clean', payload: { wrongFoo: 'bar' } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same(res.json(), { foo: 'bar' }) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.json(), { foo: 'bar' }) + completion.stepIn() }) + completion.patience.then(testDone) }) -test('Triple $ref with a simple $id', t => { - t.plan(6) +test('Triple $ref with a simple $id', (t, testDone) => { + t.plan(7) const fastify = Fastify() const ajv = new AJV() @@ -285,6 +436,7 @@ test('Triple $ref with a simple $id', t => { fastify.addSchema(schemaCRefToB) fastify.setValidatorCompiler(({ schema, method, url, httpPart }) => { + t.assert.ok('custom validator compiler called') return ajv.compile(schema) }) @@ -298,28 +450,31 @@ test('Triple $ref with a simple $id', t => { } }) + const completion = waitForCb({ steps: 2 }) fastify.inject({ method: 'POST', url: '/', payload: { foo: 43 } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same(res.json(), { foo: 105 }) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.json(), { foo: 105 }) + completion.stepIn() }) - fastify.inject({ method: 'POST', url: '/', payload: { fool: 'bar' } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) - t.same(res.json().message, "body must have required property 'foo'") + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) + t.assert.deepStrictEqual(res.json().message, "body must have required property 'foo'") + completion.stepIn() }) + completion.patience.then(testDone) }) -test('Extending schema', t => { +test('Extending schema', (t, testDone) => { t.plan(4) const fastify = Fastify() @@ -359,7 +514,6 @@ test('Extending schema', t => { } } }) - fastify.inject({ method: 'POST', url: '/', @@ -370,10 +524,9 @@ test('Extending schema', t => { } } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) }) - fastify.inject({ method: 'POST', url: '/', @@ -385,12 +538,13 @@ test('Extending schema', t => { } } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + testDone() }) }) -test('Should work with nested ids', t => { +test('Should work with nested ids', (t, testDone) => { t.plan(6) const fastify = Fastify() @@ -420,6 +574,7 @@ test('Should work with nested ids', t => { } }) + const completion = waitForCb({ steps: 2 }) fastify.inject({ method: 'POST', url: '/123', @@ -427,11 +582,11 @@ test('Should work with nested ids', t => { hello: 'world' } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.equal(res.payload, 'number') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload, 'number') + completion.stepIn() }) - fastify.inject({ method: 'POST', url: '/abc', @@ -439,14 +594,16 @@ test('Should work with nested ids', t => { hello: 'world' } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) - t.equal(res.json().message, 'params/id must be number') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) + t.assert.strictEqual(res.json().message, 'params/id must be number') + completion.stepIn() }) + completion.patience.then(testDone) }) -test('Use the same schema across multiple routes', t => { - t.plan(8) +test('Use the same schema across multiple routes', async (t) => { + t.plan(4) const fastify = Fastify() fastify.addSchema({ @@ -471,34 +628,35 @@ test('Use the same schema across multiple routes', t => { } }) - ;[ + const validTestCases = [ '/first/123', '/second/123' - ].forEach(url => { - fastify.inject({ + ] + + for (const url of validTestCases) { + const res = await fastify.inject({ url, method: 'GET' - }, (err, res) => { - t.error(err) - t.equal(res.payload, 'number') }) - }) - ;[ + t.assert.strictEqual(res.payload, 'number') + } + + const invalidTestCases = [ '/first/abc', '/second/abc' - ].forEach(url => { - fastify.inject({ + ] + + for (const url of invalidTestCases) { + const res = await fastify.inject({ url, method: 'GET' - }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) }) - }) + t.assert.strictEqual(res.statusCode, 400) + } }) -test('JSON Schema validation keywords', t => { +test('JSON Schema validation keywords', (t, testDone) => { t.plan(6) const fastify = Fastify() @@ -520,30 +678,34 @@ test('JSON Schema validation keywords', t => { } }) + const completion = waitForCb({ steps: 2 }) fastify.inject({ method: 'GET', url: '/127.0.0.1' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.equal(res.payload, 'string') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload, 'string') + completion.stepIn() }) - fastify.inject({ method: 'GET', url: '/localhost' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) - t.same(res.json(), { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) + t.assert.deepStrictEqual(res.json(), { statusCode: 400, + code: 'FST_ERR_VALIDATION', error: 'Bad Request', message: 'params/ip must match format "ipv4"' }) + completion.stepIn() }) + completion.patience.then(testDone) }) -test('Nested id calls', t => { +test('Nested id calls', (t, testDone) => { t.plan(6) const fastify = Fastify() @@ -573,32 +735,36 @@ test('Nested id calls', t => { } }) + const completion = waitForCb({ steps: 2 }) fastify.inject({ method: 'POST', url: '/', payload: { host: { ip: '127.0.0.1' } } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.equal(res.payload, 'string') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload, 'string') + completion.stepIn() }) - fastify.inject({ method: 'POST', url: '/', payload: { host: { ip: 'localhost' } } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) - t.same(res.json(), { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) + t.assert.deepStrictEqual(res.json(), { error: 'Bad Request', message: 'body/host/ip must match format "ipv4"', - statusCode: 400 + statusCode: 400, + code: 'FST_ERR_VALIDATION' }) + completion.stepIn() }) + completion.patience.then(testDone) }) -test('Use the same schema id in different places', t => { +test('Use the same schema id in different places', (t, testDone) => { t.plan(2) const fastify = Fastify() @@ -619,18 +785,18 @@ test('Use the same schema id in different places', t => { } } }) - fastify.inject({ method: 'POST', url: '/', payload: { id: 42 } }, (err, res) => { - t.error(err) - t.same(res.json(), { id: 21 }) + t.assert.ifError(err) + t.assert.deepStrictEqual(res.json(), { id: 21 }) + testDone() }) }) -test('Use shared schema and $ref with $id ($ref to $id)', t => { +test('Use shared schema and $ref with $id ($ref to $id)', (t, testDone) => { t.plan(5) const fastify = Fastify() @@ -673,6 +839,7 @@ test('Use shared schema and $ref with $id ($ref to $id)', t => { }) const id = Date.now() + const completion = waitForCb({ steps: 2 }) fastify.inject({ method: 'POST', url: '/', @@ -681,31 +848,34 @@ test('Use shared schema and $ref with $id ($ref to $id)', t => { test: { id } } }, (err, res) => { - t.error(err) - t.same(res.json(), { id }) + t.assert.ifError(err) + t.assert.deepStrictEqual(res.json(), { id }) + completion.stepIn() }) - fastify.inject({ method: 'POST', url: '/', payload: { test: { id } } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) - t.same(res.json(), { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) + t.assert.deepStrictEqual(res.json(), { error: 'Bad Request', message: "body must have required property 'address'", - statusCode: 400 + statusCode: 400, + code: 'FST_ERR_VALIDATION' }) + completion.stepIn() }) + completion.patience.then(testDone) }) -test('Use items with $ref', t => { +test('Use items with $ref', (t, testDone) => { t.plan(4) const fastify = Fastify() fastify.addSchema({ - $id: 'http://example.com/ref-to-external-validator.json', + $id: 'http://fastify.test/ref-to-external-validator.json', type: 'object', properties: { hello: { type: 'string' } @@ -714,7 +884,7 @@ test('Use items with $ref', t => { const body = { type: 'array', - items: { $ref: 'http://example.com/ref-to-external-validator.json#' } + items: { $ref: 'http://fastify.test/ref-to-external-validator.json#' } } fastify.post('/', { @@ -722,26 +892,29 @@ test('Use items with $ref', t => { handler: (_, r) => { r.send('ok') } }) + const completion = waitForCb({ steps: 2 }) fastify.inject({ method: 'POST', url: '/', payload: [{ hello: 'world' }] }, (err, res) => { - t.error(err) - t.equal(res.payload, 'ok') + t.assert.ifError(err) + t.assert.strictEqual(res.payload, 'ok') + completion.stepIn() }) - fastify.inject({ method: 'POST', url: '/', payload: { hello: 'world' } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) + completion.stepIn() }) + completion.patience.then(testDone) }) -test('Use $ref to /definitions', t => { +test('Use $ref to /definitions', (t, testDone) => { t.plan(6) const fastify = Fastify() @@ -788,16 +961,17 @@ test('Use $ref to /definitions', t => { address: { city: 'New Node' }, test: { id: Date.now() } } + const completion = waitForCb({ steps: 2 }) fastify.inject({ method: 'POST', url: '/', payload }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same(res.json(), payload) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.json(), payload) + completion.stepIn() }) - fastify.inject({ method: 'POST', url: '/', @@ -806,30 +980,37 @@ test('Use $ref to /definitions', t => { test: { id: 'wrong' } } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) - t.same(res.json(), { + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) + t.assert.deepStrictEqual(res.json(), { error: 'Bad Request', message: 'body/test/id must be number', - statusCode: 400 + statusCode: 400, + code: 'FST_ERR_VALIDATION' }) + completion.stepIn() }) + completion.patience.then(testDone) }) -test('Custom AJV settings - pt1', t => { +test('Custom AJV settings - pt1', (t, testDone) => { t.plan(4) const fastify = Fastify() fastify.post('/', { schema: { - body: { num: { type: 'integer' } } + body: { + type: 'object', + properties: { + num: { type: 'integer' } + } + } }, handler: (req, reply) => { - t.equal(req.body.num, 12) + t.assert.strictEqual(req.body.num, 12) reply.send(req.body) } }) - fastify.inject({ method: 'POST', url: '/', @@ -837,13 +1018,14 @@ test('Custom AJV settings - pt1', t => { num: '12' } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.same(res.json(), { num: 12 }) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.json(), { num: 12 }) + testDone() }) }) -test('Custom AJV settings - pt2', t => { +test('Custom AJV settings - pt2', (t, testDone) => { t.plan(2) const fastify = Fastify({ ajv: { @@ -855,13 +1037,17 @@ test('Custom AJV settings - pt2', t => { fastify.post('/', { schema: { - body: { num: { type: 'integer' } } + body: { + type: 'object', + properties: { + num: { type: 'integer' } + } + } }, handler: (req, reply) => { t.fail('the handler is not called because the "12" is not coerced to number') } }) - fastify.inject({ method: 'POST', url: '/', @@ -869,12 +1055,13 @@ test('Custom AJV settings - pt2', t => { num: '12' } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) + testDone() }) }) -test('Custom AJV settings on different parameters - pt1', t => { +test('Custom AJV settings on different parameters - pt1', (t, testDone) => { t.plan(2) const fastify = Fastify() @@ -882,7 +1069,12 @@ test('Custom AJV settings on different parameters - pt1', t => { fastify.post('/api/:id', { schema: { - querystring: { id: { type: 'integer' } }, + querystring: { + type: 'object', + properties: { + id: { type: 'integer' } + } + }, body: { type: 'object', properties: { @@ -895,7 +1087,6 @@ test('Custom AJV settings on different parameters - pt1', t => { t.fail('the handler is not called because the "12" is not coerced to number') } }) - fastify.inject({ method: 'POST', url: '/api/42', @@ -903,12 +1094,13 @@ test('Custom AJV settings on different parameters - pt1', t => { num: '12' } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 400) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 400) + testDone() }) }) -test('Custom AJV settings on different parameters - pt2', t => { +test('Custom AJV settings on different parameters - pt2', (t, testDone) => { t.plan(4) const fastify = Fastify() @@ -932,13 +1124,13 @@ test('Custom AJV settings on different parameters - pt2', t => { } }, handler: (req, reply) => { - t.same(typeof req.params.id, 'number') - t.same(typeof req.body.num, 'number') - t.same(req.params.id, 42) - t.same(req.body.num, 12) + t.assert.deepStrictEqual(typeof req.params.id, 'number') + t.assert.deepStrictEqual(typeof req.body.num, 'number') + t.assert.deepStrictEqual(req.params.id, 42) + t.assert.deepStrictEqual(req.body.num, 12) + testDone() } }) - fastify.inject({ method: 'POST', url: '/api/42', @@ -948,7 +1140,7 @@ test('Custom AJV settings on different parameters - pt2', t => { }) }) -test("The same $id in route's schema must not overwrite others", t => { +test("The same $id in route's schema must not overwrite others", (t, testDone) => { t.plan(4) const fastify = Fastify() @@ -999,21 +1191,382 @@ test("The same $id in route's schema must not overwrite others", t => { handler: () => { return 'ok' } }) + const completion = waitForCb({ steps: 2 }) fastify.inject({ method: 'POST', url: '/user', body: {} }, (err, res) => { - t.error(err) - t.same(res.json().message, "body must have required property 'username'") + t.assert.ifError(err) + t.assert.deepStrictEqual(res.json().message, "body must have required property 'username'") + completion.stepIn() }) - fastify.inject({ url: '/user/1', method: 'PATCH', body: {} }, (err, res) => { - t.error(err) - t.same(res.payload, 'ok') + t.assert.ifError(err) + t.assert.deepStrictEqual(res.payload, 'ok') + completion.stepIn() + }) + completion.patience.then(testDone) +}) + +test('Custom validator compiler should not mutate schema', async t => { + t.plan(2) + class Headers { } + const fastify = Fastify() + + fastify.setValidatorCompiler(({ schema, method, url, httpPart }) => { + t.assert.ok(schema instanceof Headers) + return () => { } + }) + + fastify.get('/', { + schema: { + headers: new Headers() + } + }, () => { }) + + await fastify.ready() +}) + +test('Custom validator builder override by custom validator compiler', async t => { + t.plan(3) + const ajvDefaults = { + removeAdditional: true, + coerceTypes: true, + allErrors: true + } + const ajv1 = new AJV(ajvDefaults).addKeyword({ keyword: 'extended_one', type: 'object', validator: () => true }) + const ajv2 = new AJV(ajvDefaults).addKeyword({ keyword: 'extended_two', type: 'object', validator: () => true }) + const fastify = Fastify({ + schemaController: { + compilersFactory: { + buildValidator: () => (routeSchemaDef) => ajv1.compile(routeSchemaDef.schema) + } + } + }) + + fastify.setValidatorCompiler((routeSchemaDef) => ajv2.compile(routeSchemaDef.schema)) + + fastify.post('/two/:id', { + schema: { + params: { + type: 'object', + extended_two: true, + properties: { + id: { type: 'number' } + }, + required: ['id'] + } + }, + handler: (req, _reply) => { + t.assert.deepStrictEqual(typeof req.params.id, 'number') + t.assert.deepStrictEqual(req.params.id, 43) + return 'ok' + } + }) + + await fastify.ready() + + const two = await fastify.inject({ + method: 'POST', + url: '/two/43' + }) + t.assert.strictEqual(two.statusCode, 200) +}) + +test('Custom validator builder override by custom validator compiler in child instance', async t => { + t.plan(6) + const ajvDefaults = { + removeAdditional: true, + coerceTypes: true, + allErrors: true + } + const ajv1 = new AJV(ajvDefaults).addKeyword({ keyword: 'extended_one', type: 'object', validator: () => true }) + const ajv2 = new AJV(ajvDefaults).addKeyword({ keyword: 'extended_two', type: 'object', validator: () => true }) + const fastify = Fastify({ + schemaController: { + compilersFactory: { + buildValidator: () => (routeSchemaDef) => ajv1.compile(routeSchemaDef.schema) + } + } + }) + + fastify.register((embedded, _opts, done) => { + embedded.setValidatorCompiler((routeSchemaDef) => ajv2.compile(routeSchemaDef.schema)) + embedded.post('/two/:id', { + schema: { + params: { + type: 'object', + extended_two: true, + properties: { + id: { type: 'number' } + }, + required: ['id'] + } + }, + handler: (req, _reply) => { + t.assert.deepStrictEqual(typeof req.params.id, 'number') + t.assert.deepStrictEqual(req.params.id, 43) + return 'ok' + } + }) + done() + }) + + fastify.post('/one/:id', { + schema: { + params: { + type: 'object', + extended_one: true, + properties: { + id: { type: 'number' } + }, + required: ['id'] + } + }, + handler: (req, _reply) => { + t.assert.deepStrictEqual(typeof req.params.id, 'number') + t.assert.deepStrictEqual(req.params.id, 42) + return 'ok' + } + }) + + await fastify.ready() + + const one = await fastify.inject({ + method: 'POST', + url: '/one/42' + }) + t.assert.strictEqual(one.statusCode, 200) + + const two = await fastify.inject({ + method: 'POST', + url: '/two/43' + }) + t.assert.strictEqual(two.statusCode, 200) +}) + +test('Schema validation when no content type is provided', async t => { + // this case should not be happened in normal use-case, + // it is added for the completeness of code branch + const fastify = Fastify() + + fastify.post('/', { + schema: { + body: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + foo: { type: 'string' } + }, + required: ['foo'], + additionalProperties: false + } + } + } + } + }, + preValidation: async (request) => { + request.headers['content-type'] = undefined + } + }, async () => 'ok') + + await fastify.ready() + + const invalid = await fastify.inject({ + method: 'POST', + url: '/', + headers: { + 'content-type': 'application/json' + }, + body: { invalid: 'string' } + }) + t.assert.strictEqual(invalid.statusCode, 200) +}) + +test('Schema validation will not be bypass by different content type', async t => { + const fastify = Fastify() + + fastify.post('/', { + schema: { + body: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + foo: { type: 'string' } + }, + required: ['foo'], + additionalProperties: false + } + } + } + } + } + }, async () => 'ok') + + await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + const address = fastify.listeningOrigin + + let found = await fetch(address, { + method: 'POST', + url: '/', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ foo: 'string' }) + }) + t.assert.strictEqual(found.status, 200) + await found.bytes() + + found = await fetch(address, { + method: 'POST', + url: '/', + headers: { + 'content-type': 'application/json; charset=utf-8' + }, + body: JSON.stringify({ foo: 'string' }) + }) + t.assert.strictEqual(found.status, 200) + await found.bytes() + + found = await fetch(address, { + method: 'POST', + url: '/', + headers: { + 'content-type': 'application/json\t; charset=utf-8' + }, + body: JSON.stringify({ foo: 'string' }) + }) + t.assert.strictEqual(found.status, 200) + await found.bytes() + + found = await fetch(address, { + method: 'POST', + url: '/', + headers: { + 'content-type': 'application/json ;' + }, + body: JSON.stringify({ invalid: 'string' }) + }) + t.assert.strictEqual(found.status, 400) + t.assert.strictEqual((await found.json()).code, 'FST_ERR_VALIDATION') + + found = await fetch(address, { + method: 'POST', + url: '/', + headers: { + 'content-type': 'ApPlIcAtIoN/JsOn;' + }, + body: JSON.stringify({ invalid: 'string' }) + }) + t.assert.strictEqual(found.status, 400) + t.assert.strictEqual((await found.json()).code, 'FST_ERR_VALIDATION') + + found = await fetch(address, { + method: 'POST', + url: '/', + headers: { + 'content-type': 'ApPlIcAtIoN/JsOn ;' + }, + body: JSON.stringify({ invalid: 'string' }) + }) + t.assert.strictEqual(found.status, 400) + t.assert.strictEqual((await found.json()).code, 'FST_ERR_VALIDATION') + + found = await fetch(address, { + method: 'POST', + url: '/', + headers: { + 'content-type': 'ApPlIcAtIoN/JsOn foo;' + }, + body: JSON.stringify({ invalid: 'string' }) + }) + t.assert.strictEqual(found.status, 415) + t.assert.strictEqual((await found.json()).code, 'FST_ERR_CTP_INVALID_MEDIA_TYPE') + + found = await fetch(address, { + method: 'POST', + url: '/', + headers: { + 'content-type': 'ApPlIcAtIoN/JsOn \tfoo;' + }, + body: JSON.stringify({ invalid: 'string' }) + }) + t.assert.strictEqual(found.status, 415) + t.assert.strictEqual((await found.json()).code, 'FST_ERR_CTP_INVALID_MEDIA_TYPE') + + found = await fetch(address, { + method: 'POST', + url: '/', + headers: { + 'content-type': 'ApPlIcAtIoN/JsOn\t foo;' + }, + body: JSON.stringify({ invalid: 'string' }) + }) + t.assert.strictEqual(found.status, 415) + t.assert.strictEqual((await found.json()).code, 'FST_ERR_CTP_INVALID_MEDIA_TYPE') + + found = await fetch(address, { + method: 'POST', + url: '/', + headers: { + 'content-type': 'ApPlIcAtIoN/JsOn \t' + }, + body: JSON.stringify({ invalid: 'string' }) + }) + t.assert.strictEqual(found.status, 400) + t.assert.strictEqual((await found.json()).code, 'FST_ERR_VALIDATION') + + found = await fetch(address, { + method: 'POST', + url: '/', + headers: { + 'content-type': 'ApPlIcAtIoN/JsOn\t' + }, + body: JSON.stringify({ invalid: 'string' }) + }) + t.assert.strictEqual(found.status, 400) + t.assert.strictEqual((await found.json()).code, 'FST_ERR_VALIDATION') + + found = await fetch(address, { + method: 'POST', + url: '/', + headers: { + 'content-type': 'ApPlIcAtIoN/JsOn\ta' + }, + body: JSON.stringify({ invalid: 'string' }) + }) + t.assert.strictEqual(found.status, 415) + t.assert.strictEqual((await found.json()).code, 'FST_ERR_CTP_INVALID_MEDIA_TYPE') + + found = await fetch(address, { + method: 'POST', + url: '/', + headers: { + 'content-type': 'ApPlIcAtIoN/JsOn\ta; charset=utf-8' + }, + body: JSON.stringify({ invalid: 'string' }) + }) + t.assert.strictEqual(found.status, 415) + t.assert.strictEqual((await found.json()).code, 'FST_ERR_CTP_INVALID_MEDIA_TYPE') + + found = await fetch(address, { + method: 'POST', + url: '/', + headers: { + 'content-type': 'application/ json' + }, + body: JSON.stringify({ invalid: 'string' }) }) + t.assert.strictEqual(found.status, 415) + t.assert.strictEqual((await found.json()).code, 'FST_ERR_CTP_INVALID_MEDIA_TYPE') }) diff --git a/test/scripts/validate-ecosystem-links.test.js b/test/scripts/validate-ecosystem-links.test.js new file mode 100644 index 00000000000..9c98b291c5e --- /dev/null +++ b/test/scripts/validate-ecosystem-links.test.js @@ -0,0 +1,339 @@ +'use strict' + +const { describe, it, beforeEach, afterEach } = require('node:test') +const assert = require('node:assert') +const fs = require('node:fs') +const { MockAgent, setGlobalDispatcher, getGlobalDispatcher } = require('undici') + +function loadValidateEcosystemLinksModule () { + const modulePath = require.resolve('../../scripts/validate-ecosystem-links') + delete require.cache[modulePath] + return require(modulePath) +} + +describe('extractGitHubLinks', () => { + const { extractGitHubLinks } = loadValidateEcosystemLinksModule() + + it('extracts simple GitHub repository links', () => { + const content = ` +# Ecosystem + +- [fastify-helmet](https://github.com/fastify/fastify-helmet) - Important security headers for Fastify +- [fastify-cors](https://github.com/fastify/fastify-cors) - CORS support +` + const links = extractGitHubLinks(content) + + assert.strictEqual(links.length, 2) + assert.deepStrictEqual(links[0], { + name: 'fastify-helmet', + url: 'https://github.com/fastify/fastify-helmet', + owner: 'fastify', + repo: 'fastify-helmet' + }) + assert.deepStrictEqual(links[1], { + name: 'fastify-cors', + url: 'https://github.com/fastify/fastify-cors', + owner: 'fastify', + repo: 'fastify-cors' + }) + }) + + it('extracts links with different owner/repo combinations', () => { + const content = ` +- [some-plugin](https://github.com/user123/awesome-plugin) +- [another-lib](https://github.com/org-name/lib-name) +` + const links = extractGitHubLinks(content) + + assert.strictEqual(links.length, 2) + assert.strictEqual(links[0].owner, 'user123') + assert.strictEqual(links[0].repo, 'awesome-plugin') + assert.strictEqual(links[1].owner, 'org-name') + assert.strictEqual(links[1].repo, 'lib-name') + }) + + it('handles links with hash fragments', () => { + const content = ` +- [project](https://github.com/owner/repo#readme) +` + const links = extractGitHubLinks(content) + + assert.strictEqual(links.length, 1) + assert.strictEqual(links[0].repo, 'repo') + assert.strictEqual(links[0].url, 'https://github.com/owner/repo#readme') + }) + + it('handles links with query parameters', () => { + const content = ` +- [project](https://github.com/owner/repo?tab=readme) +` + const links = extractGitHubLinks(content) + + assert.strictEqual(links.length, 1) + assert.strictEqual(links[0].repo, 'repo') + }) + + it('handles links with subpaths', () => { + const content = ` +- [docs](https://github.com/owner/repo/tree/main/docs) +` + const links = extractGitHubLinks(content) + + assert.strictEqual(links.length, 1) + assert.strictEqual(links[0].owner, 'owner') + assert.strictEqual(links[0].repo, 'repo') + }) + + it('returns empty array for content with no GitHub links', () => { + const content = ` +# No GitHub links here + +Just some regular text and [a link](https://example.com). +` + const links = extractGitHubLinks(content) + + assert.strictEqual(links.length, 0) + }) + + it('ignores non-GitHub links', () => { + const content = ` +- [gitlab](https://gitlab.com/owner/repo) +- [github](https://github.com/owner/repo) +- [bitbucket](https://bitbucket.org/owner/repo) +` + const links = extractGitHubLinks(content) + + assert.strictEqual(links.length, 1) + assert.strictEqual(links[0].owner, 'owner') + }) + + it('extracts multiple links from complex markdown', () => { + const content = ` +## Category 1 + +Some description [inline link](https://github.com/a/b). + +| Plugin | Description | +|--------|-------------| +| [plugin1](https://github.com/x/y) | Desc 1 | +| [plugin2](https://github.com/z/w) | Desc 2 | +` + const links = extractGitHubLinks(content) + + assert.strictEqual(links.length, 3) + }) +}) + +describe('checkGitHubRepo', () => { + let originalDispatcher + let mockAgent + let originalFetch + let originalSetTimeout + + beforeEach(() => { + delete process.env.GITHUB_TOKEN + originalDispatcher = getGlobalDispatcher() + mockAgent = new MockAgent() + mockAgent.disableNetConnect() + setGlobalDispatcher(mockAgent) + originalFetch = global.fetch + originalSetTimeout = global.setTimeout + }) + + afterEach(async () => { + global.fetch = originalFetch + global.setTimeout = originalSetTimeout + setGlobalDispatcher(originalDispatcher) + await mockAgent.close() + delete process.env.GITHUB_TOKEN + }) + + it('returns exists: true for status 200', async () => { + const { checkGitHubRepo } = loadValidateEcosystemLinksModule() + const mockPool = mockAgent.get('https://api.github.com') + mockPool.intercept({ + path: '/repos/fastify/fastify', + method: 'HEAD' + }).reply(200) + + const result = await checkGitHubRepo('fastify', 'fastify') + + assert.strictEqual(result.exists, true) + assert.strictEqual(result.status, 200) + assert.strictEqual(result.owner, 'fastify') + assert.strictEqual(result.repo, 'fastify') + }) + + it('returns exists: false for status 404', async () => { + const { checkGitHubRepo } = loadValidateEcosystemLinksModule() + const mockPool = mockAgent.get('https://api.github.com') + mockPool.intercept({ + path: '/repos/nonexistent/repo', + method: 'HEAD' + }).reply(404) + + const result = await checkGitHubRepo('nonexistent', 'repo') + + assert.strictEqual(result.exists, false) + assert.strictEqual(result.status, 404) + }) + + it('returns invalid status for malformed owner or repository names', async () => { + const { checkGitHubRepo } = loadValidateEcosystemLinksModule() + let called = false + + global.fetch = async () => { + called = true + return { status: 200 } + } + + const result = await checkGitHubRepo('owner/evil', 'repo', 1) + + assert.strictEqual(called, false) + assert.strictEqual(result.exists, false) + assert.strictEqual(result.status, 'invalid') + assert.strictEqual(result.error, 'Invalid GitHub repository identifier') + }) + + it('retries on rate limit responses', async () => { + const { checkGitHubRepo } = loadValidateEcosystemLinksModule() + let attempts = 0 + + global.setTimeout = (fn) => { + fn() + return 0 + } + + global.fetch = async () => { + attempts++ + return { + status: attempts === 1 ? 403 : 200 + } + } + + const result = await checkGitHubRepo('owner', 'repo', 1) + + assert.strictEqual(attempts, 2) + assert.strictEqual(result.exists, true) + assert.strictEqual(result.status, 200) + }) + + it('adds authorization header when GITHUB_TOKEN is set', async () => { + process.env.GITHUB_TOKEN = 'my-token' + const { checkGitHubRepo } = loadValidateEcosystemLinksModule() + let authorization + + global.fetch = async (url, options) => { + authorization = options.headers.Authorization + return { + status: 200 + } + } + + const result = await checkGitHubRepo('owner', 'repo') + + assert.strictEqual(authorization, 'token my-token') + assert.strictEqual(result.exists, true) + }) + + it('handles network errors', async () => { + const { checkGitHubRepo } = loadValidateEcosystemLinksModule() + const mockPool = mockAgent.get('https://api.github.com') + mockPool.intercept({ + path: '/repos/owner/repo', + method: 'HEAD' + }).replyWithError(new Error('Network error')) + + const result = await checkGitHubRepo('owner', 'repo') + + assert.strictEqual(result.exists, false) + assert.strictEqual(result.status, 'error') + assert.ok(result.error.length > 0) + }) +}) + +describe('validateAllLinks', () => { + let originalReadFileSync + let originalFetch + let originalSetTimeout + let originalConsoleLog + let originalStdoutWrite + + beforeEach(() => { + originalReadFileSync = fs.readFileSync + originalFetch = global.fetch + originalSetTimeout = global.setTimeout + originalConsoleLog = console.log + originalStdoutWrite = process.stdout.write + + console.log = () => {} + process.stdout.write = () => true + + global.setTimeout = (fn) => { + fn() + return 0 + } + }) + + afterEach(() => { + fs.readFileSync = originalReadFileSync + global.fetch = originalFetch + global.setTimeout = originalSetTimeout + console.log = originalConsoleLog + process.stdout.write = originalStdoutWrite + }) + + it('validates links, deduplicates repositories and groups inaccessible links', async () => { + const { validateAllLinks } = loadValidateEcosystemLinksModule() + + fs.readFileSync = () => ` +- [repo one](https://github.com/owner/repo) +- [repo one duplicate](https://github.com/owner/repo) +- [repo two](https://github.com/another/project) +` + + let requests = 0 + global.fetch = async (url) => { + requests++ + const pathname = new URL(url).pathname + + if (pathname === '/repos/owner/repo') { + return { status: 404 } + } + + if (pathname === '/repos/another/project') { + return { status: 200 } + } + + throw new Error(`Unexpected url: ${url}`) + } + + const result = await validateAllLinks() + + assert.strictEqual(requests, 2) + assert.strictEqual(result.notFound.length, 1) + assert.strictEqual(result.found.length, 1) + assert.strictEqual(result.notFound[0].owner, 'owner') + assert.strictEqual(result.notFound[0].repo, 'repo') + assert.strictEqual(result.found[0].owner, 'another') + assert.strictEqual(result.found[0].repo, 'project') + }) + + it('returns empty result when no GitHub links are present', async () => { + const { validateAllLinks } = loadValidateEcosystemLinksModule() + + fs.readFileSync = () => '# Ecosystem\nNo links here.' + + let requests = 0 + global.fetch = async () => { + requests++ + return { status: 200 } + } + + const result = await validateAllLinks() + + assert.strictEqual(requests, 0) + assert.strictEqual(result.notFound.length, 0) + assert.strictEqual(result.found.length, 0) + }) +}) diff --git a/test/serialize-response.test.js b/test/serialize-response.test.js new file mode 100644 index 00000000000..4adf5ece388 --- /dev/null +++ b/test/serialize-response.test.js @@ -0,0 +1,186 @@ +'use strict' + +const { test } = require('node:test') +const { S } = require('fluent-json-schema') +const Fastify = require('../fastify') +const sjson = require('secure-json-parse') + +const BadRequestSchema = S.object() + .prop('statusCode', S.number()) + .prop('error', S.string()) + .prop('message', S.string()) + +const InternalServerErrorSchema = S.object() + .prop('statusCode', S.number()) + .prop('error', S.string()) + .prop('message', S.string()) + +const NotFoundSchema = S.object() + .prop('statusCode', S.number()) + .prop('error', S.string()) + .prop('message', S.string()) + +const options = { + schema: { + body: { + type: 'object', + properties: { + id: { type: 'string' } + } + }, + response: { + 200: { + type: 'object', + properties: { + id: { type: 'string' } + } + }, + 400: { + description: 'Bad Request', + content: { + 'application/json': { + schema: BadRequestSchema.valueOf() + } + } + }, + 404: { + description: 'Resource not found', + content: { + 'application/json': { + schema: NotFoundSchema.valueOf(), + example: { + statusCode: 404, + error: 'Not Found', + message: 'Not Found' + } + } + } + }, + 500: { + description: 'Internal Server Error', + content: { + 'application/json': { + schema: InternalServerErrorSchema.valueOf(), + example: { + message: 'Internal Server Error' + } + } + } + } + } + } +} + +const handler = (request, reply) => { + if (request.body.id === '400') { + return reply.status(400).send({ + statusCode: 400, + error: 'Bad Request', + message: 'Custom message', + extra: 'This should not be in the response' + }) + } + + if (request.body.id === '404') { + return reply.status(404).send({ + statusCode: 404, + error: 'Not Found', + message: 'Custom Not Found', + extra: 'This should not be in the response' + }) + } + + if (request.body.id === '500') { + reply.status(500).send({ + statusCode: 500, + error: 'Internal Server Error', + message: 'Custom Internal Server Error', + extra: 'This should not be in the response' + }) + } + + reply.send({ + id: request.body.id, + extra: 'This should not be in the response' + }) +} + +test('serialize the response for a Bad Request error, as defined on the schema', async t => { + t.plan(2) + + const fastify = Fastify({}) + + fastify.post('/', options, handler) + const response = await fastify.inject({ + method: 'POST', + url: '/' + }) + + t.assert.strictEqual(response.statusCode, 400) + t.assert.deepStrictEqual(sjson(response.body), { + statusCode: 400, + error: 'Bad Request', + message: 'body must be object' + }) +}) + +test('serialize the response for a Not Found error, as defined on the schema', async t => { + t.plan(2) + + const fastify = Fastify({}) + + fastify.post('/', options, handler) + + const response = await fastify.inject({ + method: 'POST', + url: '/', + body: { id: '404' } + }) + + t.assert.strictEqual(response.statusCode, 404) + t.assert.deepStrictEqual(sjson(response.body), { + statusCode: 404, + error: 'Not Found', + message: 'Custom Not Found' + }) +}) + +test('serialize the response for a Internal Server Error error, as defined on the schema', async t => { + t.plan(2) + + const fastify = Fastify({}) + + fastify.post('/', options, handler) + + const response = await fastify.inject({ + method: 'POST', + url: '/', + body: { id: '500' } + }) + + t.assert.strictEqual(response.statusCode, 500) + t.assert.deepStrictEqual(sjson(response.body), { + statusCode: 500, + error: 'Internal Server Error', + message: 'Custom Internal Server Error' + }) +}) + +test('serialize the success response, as defined on the schema', async t => { + t.plan(2) + + const fastify = Fastify({}) + + fastify.post('/', options, handler) + + const response = await fastify.inject({ + method: 'POST', + url: '/', + body: { id: 'test' } + }) + + t.assert.strictEqual(response.statusCode, 200) + t.assert.deepStrictEqual(sjson(response.body), { + id: 'test' + }) +}) diff --git a/test/server.test.js b/test/server.test.js new file mode 100644 index 00000000000..56c83c1439a --- /dev/null +++ b/test/server.test.js @@ -0,0 +1,347 @@ +'use strict' + +const dns = require('node:dns') +const { networkInterfaces } = require('node:os') +const { test } = require('node:test') +const Fastify = require('..') +const undici = require('undici') +const proxyquire = require('proxyquire') + +const isIPv6Missing = !Object.values(networkInterfaces()).flat().some(({ family }) => family === 'IPv6') + +test('listen should accept null port', async t => { + const fastify = Fastify() + t.after(() => fastify.close()) + + await t.assert.doesNotReject( + fastify.listen({ port: null }) + ) +}) + +test('listen should accept undefined port', async t => { + const fastify = Fastify() + t.after(() => fastify.close()) + + await t.assert.doesNotReject( + fastify.listen({ port: undefined }) + ) +}) + +test('listen should accept stringified number port', async t => { + const fastify = Fastify() + t.after(() => fastify.close()) + + await t.assert.doesNotReject( + fastify.listen({ port: '1234' }) + ) +}) + +test('listen should accept log text resolution function', async t => { + const fastify = Fastify() + t.after(() => fastify.close()) + + await t.assert.doesNotReject( + fastify.listen({ + host: '127.0.0.1', + port: '1234', + listenTextResolver: (address) => { + t.assert.strictEqual(address, 'http://127.0.0.1:1234') + return 'hardcoded text' + } + }) + ) +}) + +test('listen should reject string port', async (t) => { + const fastify = Fastify() + t.after(() => fastify.close()) + + try { + await fastify.listen({ port: 'hello-world' }) + } catch (error) { + t.assert.strictEqual(error.code, 'ERR_SOCKET_BAD_PORT') + } + + try { + await fastify.listen({ port: '1234hello' }) + } catch (error) { + t.assert.strictEqual(error.code, 'ERR_SOCKET_BAD_PORT') + } +}) + +test('Test for hostname and port', async (t) => { + t.plan(3) + const app = Fastify() + t.after(() => app.close()) + app.get('/host', (req, res) => { + t.assert.strictEqual(req.host, 'localhost:8000') + t.assert.strictEqual(req.hostname, 'localhost') + t.assert.strictEqual(req.port, 8000) + res.send('ok') + }) + + await app.listen({ port: 8000 }) + await fetch('http://localhost:8000/host') +}) + +test('Test for IPV6 port', { skip: isIPv6Missing }, async (t) => { + t.plan(3) + const app = Fastify() + t.after(() => app.close()) + app.get('/host', (req, res) => { + t.assert.strictEqual(req.host, '[::1]:3040') + t.assert.strictEqual(req.hostname, '[::1]') + t.assert.strictEqual(req.port, 3040) + res.send('ok') + }) + + await app.listen({ + port: 3040, + host: '::1' + }) + await fetch('http://[::1]:3040/host') +}) + +test('abort signal', async t => { + await t.test('should close server when aborted after', (t, end) => { + t.plan(2) + function onClose (instance, done) { + t.assert.strictEqual(instance, fastify) + done() + end() + } + + const controller = new AbortController() + + const fastify = Fastify() + fastify.addHook('onClose', onClose) + fastify.listen({ port: 1234, signal: controller.signal }, (err) => { + t.assert.ifError(err) + controller.abort() + }) + }) + + await t.test('should close server when aborted after - promise', async (t) => { + t.plan(2) + const resolver = {} + resolver.promise = new Promise(function (resolve) { + resolver.resolve = resolve + }) + function onClose (instance, done) { + t.assert.strictEqual(instance, fastify) + done() + resolver.resolve() + } + + const controller = new AbortController() + + const fastify = Fastify() + fastify.addHook('onClose', onClose) + const address = await fastify.listen({ port: 1234, signal: controller.signal }) + t.assert.ok(address) + controller.abort() + await resolver.promise + }) + + await t.test('should close server when aborted during fastify.ready - promise', async (t) => { + t.plan(2) + const resolver = {} + resolver.promise = new Promise(function (resolve) { + resolver.resolve = resolve + }) + function onClose (instance, done) { + t.assert.strictEqual(instance, fastify) + done() + resolver.resolve() + } + + const controller = new AbortController() + + const fastify = Fastify() + fastify.addHook('onClose', onClose) + const promise = fastify.listen({ port: 1234, signal: controller.signal }) + controller.abort() + const address = await promise + // since the main server is not listening yet, or will not listen + // it should return undefined + t.assert.strictEqual(address, undefined) + await resolver.promise + }) + + await t.test('should close server when aborted during dns.lookup - promise', async (t) => { + t.plan(2) + const Fastify = proxyquire('..', { + './lib/server.js': proxyquire('../lib/server.js', { + 'node:dns': { + lookup: function (host, option, callback) { + controller.abort() + dns.lookup(host, option, callback) + } + } + }) + }) + const resolver = {} + resolver.promise = new Promise(function (resolve) { + resolver.resolve = resolve + }) + function onClose (instance, done) { + t.assert.strictEqual(instance, fastify) + done() + resolver.resolve() + } + + const controller = new AbortController() + + const fastify = Fastify() + fastify.addHook('onClose', onClose) + const address = await fastify.listen({ port: 1234, signal: controller.signal }) + // since the main server is already listening then close + // it should return address + t.assert.ok(address) + await resolver.promise + }) + + await t.test('should close server when aborted before', (t, end) => { + t.plan(1) + function onClose (instance, done) { + t.assert.strictEqual(instance, fastify) + done() + end() + } + + const controller = new AbortController() + controller.abort() + + const fastify = Fastify() + fastify.addHook('onClose', onClose) + fastify.listen({ port: 1234, signal: controller.signal }, () => { + t.assert.fail('should not reach callback') + }) + }) + + await t.test('should close server when aborted before - promise', async (t) => { + t.plan(2) + const resolver = {} + resolver.promise = new Promise(function (resolve) { + resolver.resolve = resolve + }) + function onClose (instance, done) { + t.assert.strictEqual(instance, fastify) + done() + resolver.resolve() + } + + const controller = new AbortController() + controller.abort() + + const fastify = Fastify() + fastify.addHook('onClose', onClose) + const address = await fastify.listen({ port: 1234, signal: controller.signal }) + t.assert.strictEqual(address, undefined) // ensure the API signature + await resolver.promise + }) + + await t.test('listen should not start server', (t, end) => { + t.plan(2) + function onClose (instance, done) { + t.assert.strictEqual(instance, fastify) + done() + end() + } + const controller = new AbortController() + + const fastify = Fastify() + fastify.addHook('onClose', onClose) + fastify.listen({ port: 1234, signal: controller.signal }, (err) => { + t.assert.ifError(err) + }) + controller.abort() + t.assert.strictEqual(fastify.server.listening, false) + }) + + await t.test('listen should not start server if already aborted', (t, end) => { + t.plan(2) + function onClose (instance, done) { + t.assert.strictEqual(instance, fastify) + done() + end() + } + + const controller = new AbortController() + controller.abort() + const fastify = Fastify() + fastify.addHook('onClose', onClose) + fastify.listen({ port: 1234, signal: controller.signal }, (err) => { + t.assert.ifError(err) + }) + t.assert.strictEqual(fastify.server.listening, false) + }) + + await t.test('listen should throw if received invalid signal', t => { + t.plan(2) + const fastify = Fastify() + + try { + fastify.listen({ port: 1234, signal: {} }, (err) => { + t.assert.ifError(err) + }) + t.assert.fail('should throw') + } catch (e) { + t.assert.strictEqual(e.code, 'FST_ERR_LISTEN_OPTIONS_INVALID') + t.assert.strictEqual(e.message, 'Invalid listen options: \'Invalid options.signal\'') + } + }) +}) + +test('#5180 - preClose should be called before closing secondary server', async (t) => { + t.plan(2) + const fastify = Fastify({ forceCloseConnections: true }) + let flag = false + t.after(() => fastify.close()) + + fastify.addHook('preClose', () => { + flag = true + }) + + fastify.get('/', async (req, reply) => { + // request will be pending for 1 second to simulate a slow request + await new Promise((resolve) => { setTimeout(resolve, 1000) }) + return { hello: 'world' } + }) + + fastify.listen({ port: 0 }, (err) => { + t.assert.ifError(err) + const addresses = fastify.addresses() + const mainServerAddress = fastify.server.address() + let secondaryAddress + for (const addr of addresses) { + if (addr.family !== mainServerAddress.family) { + secondaryAddress = addr + secondaryAddress.address = secondaryAddress.family === 'IPv6' + ? `[${secondaryAddress.address}]` + : secondaryAddress.address + break + } + } + + if (!secondaryAddress) { + t.assert.ok(true, 'Secondary address not found') + return + } + + undici.request(`http://${secondaryAddress.address}:${secondaryAddress.port}/`) + .then( + () => { t.assert.fail('Request should not succeed') }, + () => { + t.assert.ok(flag) + } + ) + + // Close the server while the slow request is pending + setTimeout(fastify.close, 250) + }) + + // Wait 1000ms to ensure that the test is finished and async operations are + // completed + await new Promise((resolve) => { setTimeout(resolve, 1000) }) +}) diff --git a/test/set-error-handler.test.js b/test/set-error-handler.test.js new file mode 100644 index 00000000000..9fbe9b27626 --- /dev/null +++ b/test/set-error-handler.test.js @@ -0,0 +1,69 @@ +'use strict' + +const { test } = require('node:test') +const Fastify = require('..') +const { FST_ERR_ERROR_HANDLER_NOT_FN, FST_ERR_ERROR_HANDLER_ALREADY_SET } = require('../lib/errors') + +test('setErrorHandler should throw an error if the handler is not a function', t => { + t.plan(1) + const fastify = Fastify() + + t.assert.throws(() => fastify.setErrorHandler('not a function'), new FST_ERR_ERROR_HANDLER_NOT_FN()) +}) + +test('setErrorHandler can be set independently in parent and child scopes', async t => { + t.plan(1) + + const fastify = Fastify() + + t.assert.doesNotThrow(() => { + fastify.setErrorHandler(() => {}) + fastify.register(async (child) => { + child.setErrorHandler(() => {}) + }) + }) +}) + +test('setErrorHandler can be overridden if allowErrorHandlerOverride is set to true', async t => { + t.plan(2) + + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.register(async (child) => { + child.setErrorHandler(() => {}) + t.assert.doesNotThrow(() => child.setErrorHandler(() => {})) + }) + + fastify.setErrorHandler(() => {}) + t.assert.doesNotThrow(() => fastify.setErrorHandler(() => {})) + + await fastify.ready() +}) + +test('if `allowErrorHandlerOverride` is disabled, setErrorHandler should throw if called more than once in the same scope', t => { + t.plan(1) + + const fastify = Fastify({ + allowErrorHandlerOverride: false + }) + + fastify.setErrorHandler(() => {}) + t.assert.throws(() => fastify.setErrorHandler(() => {}), new FST_ERR_ERROR_HANDLER_ALREADY_SET()) +}) + +test('if `allowErrorHandlerOverride` is disabled, setErrorHandler should throw if called more than once in the same scope 2', async t => { + t.plan(1) + + const fastify = Fastify({ + allowErrorHandlerOverride: false + }) + t.after(() => fastify.close()) + + fastify.register(async (child) => { + child.setErrorHandler(() => {}) + t.assert.throws(() => child.setErrorHandler(() => {}), new FST_ERR_ERROR_HANDLER_ALREADY_SET()) + }) + + await fastify.ready() +}) diff --git a/test/skip-reply-send.test.js b/test/skip-reply-send.test.js index 3ab06088251..506bf62e388 100644 --- a/test/skip-reply-send.test.js +++ b/test/skip-reply-send.test.js @@ -1,8 +1,8 @@ 'use strict' -const { test } = require('tap') +const { test, describe } = require('node:test') const split = require('split2') -const net = require('net') +const net = require('node:net') const Fastify = require('../fastify') process.removeAllListeners('warning') @@ -19,7 +19,7 @@ const lifecycleHooks = [ 'onError' ] -test('skip automatic reply.send() with reply.sent = true and a body', (t) => { +test('skip automatic reply.send() with reply.hijack and a body', async (t) => { const stream = split(JSON.parse) const app = Fastify({ logger: { @@ -28,27 +28,27 @@ test('skip automatic reply.send() with reply.sent = true and a body', (t) => { }) stream.on('data', (line) => { - t.not(line.level, 40) // there are no errors - t.not(line.level, 50) // there are no errors + t.assert.notStrictEqual(line.level, 40) // there are no errors + t.assert.notStrictEqual(line.level, 50) // there are no errors }) app.get('/', (req, reply) => { - reply.sent = true + reply.hijack() reply.raw.end('hello world') return Promise.resolve('this will be skipped') }) - return app.inject({ + await app.inject({ method: 'GET', url: '/' }).then((res) => { - t.equal(res.statusCode, 200) - t.equal(res.body, 'hello world') + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.body, 'hello world') }) }) -test('skip automatic reply.send() with reply.sent = true and no body', (t) => { +test('skip automatic reply.send() with reply.hijack and no body', async (t) => { const stream = split(JSON.parse) const app = Fastify({ logger: { @@ -57,27 +57,27 @@ test('skip automatic reply.send() with reply.sent = true and no body', (t) => { }) stream.on('data', (line) => { - t.not(line.level, 40) // there are no error - t.not(line.level, 50) // there are no error + t.assert.notStrictEqual(line.level, 40) // there are no error + t.assert.notStrictEqual(line.level, 50) // there are no error }) app.get('/', (req, reply) => { - reply.sent = true + reply.hijack() reply.raw.end('hello world') return Promise.resolve() }) - return app.inject({ + await app.inject({ method: 'GET', url: '/' }).then((res) => { - t.equal(res.statusCode, 200) - t.equal(res.body, 'hello world') + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.body, 'hello world') }) }) -test('skip automatic reply.send() with reply.sent = true and an error', (t) => { +test('skip automatic reply.send() with reply.hijack and an error', async (t) => { const stream = split(JSON.parse) const app = Fastify({ logger: { @@ -90,25 +90,25 @@ test('skip automatic reply.send() with reply.sent = true and an error', (t) => { stream.on('data', (line) => { if (line.level === 50) { errorSeen = true - t.equal(line.err.message, 'kaboom') - t.equal(line.msg, 'Promise errored, but reply.sent = true was set') + t.assert.strictEqual(line.err.message, 'kaboom') + t.assert.strictEqual(line.msg, 'Promise errored, but reply.sent = true was set') } }) app.get('/', (req, reply) => { - reply.sent = true + reply.hijack() reply.raw.end('hello world') return Promise.reject(new Error('kaboom')) }) - return app.inject({ + await app.inject({ method: 'GET', url: '/' }).then((res) => { - t.equal(errorSeen, true) - t.equal(res.statusCode, 200) - t.equal(res.body, 'hello world') + t.assert.strictEqual(errorSeen, true) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.body, 'hello world') }) }) @@ -117,11 +117,8 @@ function testHandlerOrBeforeHandlerHook (test, hookOrHandler) { const previousHooks = lifecycleHooks.slice(0, idx) const nextHooks = lifecycleHooks.slice(idx + 1) - test(`Hijacking inside ${hookOrHandler} skips all the following hooks and handler execution`, t => { - t.plan(4) - const test = t.test - - test('Sending a response using reply.raw => onResponse hook is called', t => { + describe(`Hijacking inside ${hookOrHandler} skips all the following hooks and handler execution`, () => { + test('Sending a response using reply.raw => onResponse hook is called', async (t) => { const stream = split(JSON.parse) const app = Fastify({ logger: { @@ -130,11 +127,11 @@ function testHandlerOrBeforeHandlerHook (test, hookOrHandler) { }) stream.on('data', (line) => { - t.not(line.level, 40) // there are no errors - t.not(line.level, 50) // there are no errors + t.assert.notStrictEqual(line.level, 40) // there are no errors + t.assert.notStrictEqual(line.level, 50) // there are no errors }) - previousHooks.forEach(h => app.addHook(h, async (req, reply) => t.pass(`${h} should be called`))) + previousHooks.forEach(h => app.addHook(h, async (req, reply) => t.assert.ok(`${h} should be called`))) if (hookOrHandler === 'handler') { app.get('/', (req, reply) => { @@ -146,41 +143,41 @@ function testHandlerOrBeforeHandlerHook (test, hookOrHandler) { reply.hijack() reply.raw.end(`hello from ${hookOrHandler}`) }) - app.get('/', (req, reply) => t.fail('Handler should not be called')) + app.get('/', (req, reply) => t.assert.fail('Handler should not be called')) } nextHooks.forEach(h => { if (h === 'onResponse') { - app.addHook(h, async (req, reply) => t.pass(`${h} should be called`)) + app.addHook(h, async (req, reply) => t.assert.ok(`${h} should be called`)) } else { - app.addHook(h, async (req, reply) => t.fail(`${h} should not be called`)) + app.addHook(h, async (req, reply) => t.assert.fail(`${h} should not be called`)) } }) - return app.inject({ + await app.inject({ method: 'GET', url: '/' }).then((res) => { - t.equal(res.statusCode, 200) - t.equal(res.body, `hello from ${hookOrHandler}`) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.body, `hello from ${hookOrHandler}`) }) }) - test('Sending a response using req.socket => onResponse not called', t => { + test('Sending a response using req.socket => onResponse not called', (t, testDone) => { const stream = split(JSON.parse) const app = Fastify({ logger: { stream } }) - t.teardown(() => app.close()) + t.after(() => app.close()) stream.on('data', (line) => { - t.not(line.level, 40) // there are no errors - t.not(line.level, 50) // there are no errors + t.assert.notStrictEqual(line.level, 40) // there are no errors + t.assert.notStrictEqual(line.level, 50) // there are no errors }) - previousHooks.forEach(h => app.addHook(h, async (req, reply) => t.pass(`${h} should be called`))) + previousHooks.forEach(h => app.addHook(h, async (req, reply) => t.assert.ok(`${h} should be called`))) if (hookOrHandler === 'handler') { app.get('/', (req, reply) => { @@ -196,15 +193,15 @@ function testHandlerOrBeforeHandlerHook (test, hookOrHandler) { req.socket.write(`hello from ${hookOrHandler}`) req.socket.end() }) - app.get('/', (req, reply) => t.fail('Handler should not be called')) + app.get('/', (req, reply) => t.assert.fail('Handler should not be called')) } - nextHooks.forEach(h => app.addHook(h, async (req, reply) => t.fail(`${h} should not be called`))) + nextHooks.forEach(h => app.addHook(h, async (req, reply) => t.assert.fail(`${h} should not be called`))) app.listen({ port: 0 }, err => { - t.error(err) + t.assert.ifError(err) const client = net.createConnection({ port: (app.server.address()).port }, () => { - client.write('GET / HTTP/1.1\r\n\r\n') + client.write('GET / HTTP/1.1\r\nHost: fastify.test\r\n\r\n') let chunks = '' client.setEncoding('utf8') @@ -213,64 +210,63 @@ function testHandlerOrBeforeHandlerHook (test, hookOrHandler) { }) client.on('end', function () { - t.match(chunks, new RegExp(`hello from ${hookOrHandler}`, 'i')) - t.end() + t.assert.match(chunks, new RegExp(`hello from ${hookOrHandler}`, 'i')) + testDone() }) }) }) }) - test('Throwing an error doesnt trigger any hooks', t => { + test('Throwing an error does not trigger any hooks', async (t) => { const stream = split(JSON.parse) const app = Fastify({ logger: { stream } }) - t.teardown(() => app.close()) + t.after(() => app.close()) let errorSeen = false stream.on('data', (line) => { if (hookOrHandler === 'handler') { if (line.level === 40) { errorSeen = true - t.equal(line.err.code, 'FST_ERR_REP_ALREADY_SENT') + t.assert.strictEqual(line.err.code, 'FST_ERR_REP_ALREADY_SENT') } } else { - t.not(line.level, 40) // there are no errors - t.not(line.level, 50) // there are no errors + t.assert.notStrictEqual(line.level, 40) // there are no errors + t.assert.notStrictEqual(line.level, 50) // there are no errors } }) - previousHooks.forEach(h => app.addHook(h, async (req, reply) => t.pass(`${h} should be called`))) + previousHooks.forEach(h => app.addHook(h, async (req, reply) => t.assert.ok(`${h} should be called`))) if (hookOrHandler === 'handler') { app.get('/', (req, reply) => { reply.hijack() - throw new Error('This wil be skipped') + throw new Error('This will be skipped') }) } else { app.addHook(hookOrHandler, async (req, reply) => { reply.hijack() - throw new Error('This wil be skipped') + throw new Error('This will be skipped') }) - app.get('/', (req, reply) => t.fail('Handler should not be called')) + app.get('/', (req, reply) => t.assert.fail('Handler should not be called')) } - nextHooks.forEach(h => app.addHook(h, async (req, reply) => t.fail(`${h} should not be called`))) + nextHooks.forEach(h => app.addHook(h, async (req, reply) => t.assert.fail(`${h} should not be called`))) - return Promise.race([ + await Promise.race([ app.inject({ method: 'GET', url: '/' }), new Promise((resolve, reject) => setTimeout(resolve, 1000)) - ]).then((err, res) => { - t.error(err) - if (hookOrHandler === 'handler') { - t.equal(errorSeen, true) - } - }) + ]) + + if (hookOrHandler === 'handler') { + t.assert.strictEqual(errorSeen, true) + } }) - test('Calling reply.send() after hijacking logs a warning', t => { + test('Calling reply.send() after hijacking logs a warning', async (t) => { const stream = split(JSON.parse) const app = Fastify({ logger: { @@ -283,11 +279,11 @@ function testHandlerOrBeforeHandlerHook (test, hookOrHandler) { stream.on('data', (line) => { if (line.level === 40) { errorSeen = true - t.equal(line.err.code, 'FST_ERR_REP_ALREADY_SENT') + t.assert.strictEqual(line.err.code, 'FST_ERR_REP_ALREADY_SENT') } }) - previousHooks.forEach(h => app.addHook(h, async (req, reply) => t.pass(`${h} should be called`))) + previousHooks.forEach(h => app.addHook(h, async (req, reply) => t.assert.ok(`${h} should be called`))) if (hookOrHandler === 'handler') { app.get('/', (req, reply) => { @@ -299,18 +295,17 @@ function testHandlerOrBeforeHandlerHook (test, hookOrHandler) { reply.hijack() return reply.send('hello from reply.send()') }) - app.get('/', (req, reply) => t.fail('Handler should not be called')) + app.get('/', (req, reply) => t.assert.fail('Handler should not be called')) } - nextHooks.forEach(h => app.addHook(h, async (req, reply) => t.fail(`${h} should not be called`))) + nextHooks.forEach(h => app.addHook(h, async (req, reply) => t.assert.fail(`${h} should not be called`))) - return Promise.race([ + await Promise.race([ app.inject({ method: 'GET', url: '/' }), new Promise((resolve, reject) => setTimeout(resolve, 1000)) - ]).then((err, res) => { - t.error(err) - t.equal(errorSeen, true) - }) + ]) + + t.assert.strictEqual(errorSeen, true) }) }) } diff --git a/test/stream-serializers.test.js b/test/stream-serializers.test.js new file mode 100644 index 00000000000..2df3ad5feb0 --- /dev/null +++ b/test/stream-serializers.test.js @@ -0,0 +1,40 @@ +'use strict' + +const { test } = require('node:test') +const Fastify = require('..') +const Reply = require('../lib/reply') + +test('should serialize reply when response stream is ended', (t, done) => { + t.plan(5) + + const stream = require('node:stream') + const fastify = Fastify({ + logger: { + serializers: { + res (reply) { + t.assert.strictEqual(reply instanceof Reply, true) + t.assert.ok('passed') + return reply + } + } + } + }) + + fastify.get('/error', function (req, reply) { + const reallyLongStream = new stream.Readable({ + read: () => { } + }) + reply.code(200).send(reallyLongStream) + reply.raw.end(Buffer.from('hello\n')) + }) + + t.after(() => fastify.close()) + + fastify.inject({ + url: '/error', + method: 'GET' + }, (err) => { + t.assert.ifError(err) + done() + }) +}) diff --git a/test/stream.1.test.js b/test/stream.1.test.js new file mode 100644 index 00000000000..8f3902e6a66 --- /dev/null +++ b/test/stream.1.test.js @@ -0,0 +1,94 @@ +'use strict' + +const { test } = require('node:test') +const fs = require('node:fs') +const Fastify = require('../fastify') + +test('should respond with a stream', async t => { + t.plan(4) + const fastify = Fastify() + + fastify.get('/', function (req, reply) { + const stream = fs.createReadStream(__filename, 'utf8') + reply.code(200).send(stream) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) + + const response = await fetch(fastifyServer) + t.assert.ok(response.ok) + t.assert.strictEqual(response.headers.get('content-type'), null) + t.assert.strictEqual(response.status, 200) + + const data = await response.text() + const expected = await fs.promises.readFile(__filename, 'utf8') + t.assert.strictEqual(expected.toString(), data.toString()) +}) + +test('should respond with a stream (error)', async t => { + t.plan(2) + const fastify = Fastify() + + fastify.get('/error', function (req, reply) { + const stream = fs.createReadStream('not-existing-file', 'utf8') + reply.code(200).send(stream) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const response = await fetch(`${fastifyServer}/error`) + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 500) +}) + +test('should trigger the onSend hook', async (t) => { + t.plan(3) + const fastify = Fastify() + + fastify.get('/', (req, reply) => { + reply.send(fs.createReadStream(__filename, 'utf8')) + }) + + fastify.addHook('onSend', (req, reply, payload, done) => { + t.assert.ok(payload._readableState) + reply.header('Content-Type', 'application/javascript') + done() + }) + + const res = await fastify.inject({ + url: '/' + }) + t.assert.strictEqual(res.headers['content-type'], 'application/javascript') + t.assert.strictEqual(res.payload, fs.readFileSync(__filename, 'utf8')) + return fastify.close() +}) + +test('should trigger the onSend hook only twice if pumping the stream fails, first with the stream, second with the serialized error', async t => { + t.plan(4) + const fastify = Fastify() + + fastify.get('/', (req, reply) => { + reply.send(fs.createReadStream('not-existing-file', 'utf8')) + }) + + let counter = 0 + fastify.addHook('onSend', (req, reply, payload, done) => { + if (counter === 0) { + t.assert.ok(payload._readableState) + } else if (counter === 1) { + const error = JSON.parse(payload) + t.assert.strictEqual(error.statusCode, 500) + } + counter++ + done() + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) + + const response = await fetch(fastifyServer) + t.assert.ok(!response.ok) + t.assert.strictEqual(response.status, 500) +}) diff --git a/test/stream.2.test.js b/test/stream.2.test.js new file mode 100644 index 00000000000..7132e78b28f --- /dev/null +++ b/test/stream.2.test.js @@ -0,0 +1,129 @@ +'use strict' + +const { test } = require('node:test') +const proxyquire = require('proxyquire') +const fs = require('node:fs') +const resolve = require('node:path').resolve +const zlib = require('node:zlib') +const pipeline = require('node:stream').pipeline +const Fastify = require('..') +const { waitForCb } = require('./toolkit') + +test('onSend hook stream', t => { + t.plan(4) + const fastify = Fastify() + + fastify.get('/', function (req, reply) { + reply.send({ hello: 'world' }) + }) + + const { stepIn, patience } = waitForCb({ steps: 2 }) + + fastify.addHook('onSend', (req, reply, payload, done) => { + const gzStream = zlib.createGzip() + + reply.header('Content-Encoding', 'gzip') + pipeline( + fs.createReadStream(resolve(__filename), 'utf8'), + gzStream, + (err) => { + t.assert.ifError(err) + stepIn() + } + ) + done(null, gzStream) + }) + + fastify.inject({ + url: '/', + method: 'GET' + }, (err, res) => { + t.assert.ifError(err) + t.assert.strictEqual(res.headers['content-encoding'], 'gzip') + const file = fs.readFileSync(resolve(__filename), 'utf8') + const payload = zlib.gunzipSync(res.rawPayload) + t.assert.strictEqual(payload.toString('utf-8'), file) + fastify.close() + stepIn() + }) + + return patience +}) + +test('onSend hook stream should work even if payload is not a proper stream', (t, testDone) => { + t.plan(1) + + const reply = proxyquire('../lib/reply', { + 'node:stream': { + finished: (...args) => { + if (args.length === 2) { args[1](new Error('test-error')) } + } + } + }) + const Fastify = proxyquire('..', { + './lib/reply.js': reply + }) + const spyLogger = { + fatal: () => { }, + error: () => { }, + warn: (message) => { + t.assert.strictEqual(message, 'stream payload does not end properly') + fastify.close() + testDone() + }, + info: () => { }, + debug: () => { }, + trace: () => { }, + child: () => { return spyLogger } + } + + const fastify = Fastify({ loggerInstance: spyLogger }) + fastify.get('/', function (req, reply) { + reply.send({ hello: 'world' }) + }) + fastify.addHook('onSend', (req, reply, payload, done) => { + const fakeStream = { pipe: () => { } } + done(null, fakeStream) + }) + + fastify.inject({ + url: '/', + method: 'GET' + }) +}) + +test('onSend hook stream should work on payload with "close" ending function', (t, testDone) => { + t.plan(1) + + const reply = proxyquire('../lib/reply', { + 'node:stream': { + finished: (...args) => { + if (args.length === 2) { args[1](new Error('test-error')) } + } + } + }) + const Fastify = proxyquire('..', { + './lib/reply.js': reply + }) + + const fastify = Fastify({ logger: false }) + fastify.get('/', function (req, reply) { + reply.send({ hello: 'world' }) + }) + fastify.addHook('onSend', (req, reply, payload, done) => { + const fakeStream = { + pipe: () => { }, + close: (cb) => { + cb() + t.assert.ok('close callback called') + testDone() + } + } + done(null, fakeStream) + }) + + fastify.inject({ + url: '/', + method: 'GET' + }) +}) diff --git a/test/stream.3.test.js b/test/stream.3.test.js new file mode 100644 index 00000000000..5eb0148148b --- /dev/null +++ b/test/stream.3.test.js @@ -0,0 +1,198 @@ +'use strict' + +const { test } = require('node:test') +const split = require('split2') +const Fastify = require('..') + +test('Destroying streams prematurely', (t, testDone) => { + t.plan(6) + + let fastify = null + const logStream = split(JSON.parse) + try { + fastify = Fastify({ + logger: { + stream: logStream, + level: 'info' + } + }) + } catch (e) { + t.assert.fail() + } + const stream = require('node:stream') + const http = require('node:http') + + // Test that "premature close" errors are logged with level warn + logStream.on('data', line => { + if (line.res) { + t.assert.strictEqual(line.msg, 'stream closed prematurely') + t.assert.strictEqual(line.level, 30) + testDone() + } + }) + + fastify.get('/', function (request, reply) { + t.assert.ok('Received request') + + let sent = false + const reallyLongStream = new stream.Readable({ + read: function () { + if (!sent) { + this.push(Buffer.from('hello\n')) + } + sent = true + } + }) + + reply.send(reallyLongStream) + }) + + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + t.after(() => fastify.close()) + + const port = fastify.server.address().port + + http.get(`http://localhost:${port}`, function (response) { + t.assert.strictEqual(response.statusCode, 200) + response.on('readable', function () { + response.destroy() + }) + + // Node bug? Node never emits 'close' here. + response.on('aborted', function () { + t.assert.ok('Response closed') + }) + }) + }) +}) + +test('Destroying streams prematurely should call close method', (t, testDone) => { + t.plan(7) + + let fastify = null + const logStream = split(JSON.parse) + try { + fastify = Fastify({ + logger: { + stream: logStream, + level: 'info' + } + }) + } catch (e) { + t.assert.fail() + } + const stream = require('node:stream') + const http = require('node:http') + + // Test that "premature close" errors are logged with level warn + logStream.on('data', line => { + if (line.res) { + t.assert.strictEqual(line.msg, 'stream closed prematurely') + t.assert.strictEqual(line.level, 30) + } + }) + + fastify.get('/', function (request, reply) { + t.assert.ok('Received request') + + let sent = false + const reallyLongStream = new stream.Readable({ + read: function () { + if (!sent) { + this.push(Buffer.from('hello\n')) + } + sent = true + } + }) + reallyLongStream.destroy = undefined + reallyLongStream.close = () => { + t.assert.ok('called') + testDone() + } + reply.send(reallyLongStream) + }) + + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + t.after(() => { fastify.close() }) + + const port = fastify.server.address().port + + http.get(`http://localhost:${port}`, function (response) { + t.assert.strictEqual(response.statusCode, 200) + response.on('readable', function () { + response.destroy() + }) + // Node bug? Node never emits 'close' here. + response.on('aborted', function () { + t.assert.ok('Response closed') + }) + }) + }) +}) + +test('Destroying streams prematurely should call close method when destroy is not a function', (t, testDone) => { + t.plan(7) + + let fastify = null + const logStream = split(JSON.parse) + try { + fastify = Fastify({ + logger: { + stream: logStream, + level: 'info' + } + }) + } catch (e) { + t.assert.fail() + } + const stream = require('node:stream') + const http = require('node:http') + + // Test that "premature close" errors are logged with level warn + logStream.on('data', line => { + if (line.res) { + t.assert.strictEqual(line.msg, 'stream closed prematurely') + t.assert.strictEqual(line.level, 30) + } + }) + + fastify.get('/', function (request, reply) { + t.assert.ok('Received request') + + let sent = false + const reallyLongStream = new stream.Readable({ + read: function () { + if (!sent) { + this.push(Buffer.from('hello\n')) + } + sent = true + } + }) + reallyLongStream.destroy = true + reallyLongStream.close = () => { + t.assert.ok('called') + testDone() + } + reply.send(reallyLongStream) + }) + + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + t.after(() => { fastify.close() }) + + const port = fastify.server.address().port + + http.get(`http://localhost:${port}`, function (response) { + t.assert.strictEqual(response.statusCode, 200) + response.on('readable', function () { + response.destroy() + }) + // Node bug? Node never emits 'close' here. + response.on('aborted', function () { + t.assert.ok('Response closed') + }) + }) + }) +}) diff --git a/test/stream.4.test.js b/test/stream.4.test.js new file mode 100644 index 00000000000..9ae386db949 --- /dev/null +++ b/test/stream.4.test.js @@ -0,0 +1,176 @@ +'use strict' + +const { test } = require('node:test') +const errors = require('http-errors') +const JSONStream = require('JSONStream') +const Readable = require('node:stream').Readable +const split = require('split2') +const Fastify = require('..') +const { kDisableRequestLogging } = require('../lib/symbols.js') + +test('Destroying streams prematurely should call abort method', (t, testDone) => { + t.plan(7) + + let fastify = null + const logStream = split(JSON.parse) + try { + fastify = Fastify({ + logger: { + stream: logStream, + level: 'info' + } + }) + } catch (e) { + t.assert.fail() + } + const stream = require('node:stream') + const http = require('node:http') + + // Test that "premature close" errors are logged with level warn + logStream.on('data', line => { + if (line.res) { + t.assert.strictEqual(line.msg, 'stream closed prematurely') + t.assert.strictEqual(line.level, 30) + testDone() + } + }) + + fastify.get('/', function (request, reply) { + t.assert.ok('Received request') + + let sent = false + const reallyLongStream = new stream.Readable({ + read: function () { + if (!sent) { + this.push(Buffer.from('hello\n')) + } + sent = true + } + }) + reallyLongStream.destroy = undefined + reallyLongStream.close = undefined + reallyLongStream.abort = () => t.assert.ok('called') + reply.send(reallyLongStream) + }) + + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + t.after(() => { fastify.close() }) + + const port = fastify.server.address().port + + http.get(`http://localhost:${port}`, function (response) { + t.assert.strictEqual(response.statusCode, 200) + response.on('readable', function () { + response.destroy() + }) + // Node bug? Node never emits 'close' here. + response.on('aborted', function () { + t.assert.ok('Response closed') + }) + }) + }) +}) + +test('Destroying streams prematurely, log is disabled', (t, testDone) => { + t.plan(4) + + let fastify = null + try { + fastify = Fastify({ + logger: false + }) + } catch (e) { + t.assert.fail() + } + const stream = require('node:stream') + const http = require('node:http') + + fastify.get('/', function (request, reply) { + reply.log[kDisableRequestLogging] = true + + let sent = false + const reallyLongStream = new stream.Readable({ + read: function () { + if (!sent) { + this.push(Buffer.from('hello\n')) + } + sent = true + } + }) + reallyLongStream.destroy = true + reallyLongStream.close = () => { + t.assert.ok('called') + testDone() + } + reply.send(reallyLongStream) + }) + + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + t.after(() => { fastify.close() }) + + const port = fastify.server.address().port + + http.get(`http://localhost:${port}`, function (response) { + t.assert.strictEqual(response.statusCode, 200) + response.on('readable', function () { + response.destroy() + }) + // Node bug? Node never emits 'close' here. + response.on('aborted', function () { + t.assert.ok('Response closed') + }) + }) + }) +}) + +test('should respond with a stream1', async (t) => { + t.plan(4) + const fastify = Fastify() + + fastify.get('/', function (req, reply) { + const stream = JSONStream.stringify() + reply.code(200).type('application/json').send(stream) + stream.write({ hello: 'world' }) + stream.end({ a: 42 }) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const response = await fetch(fastifyServer) + t.assert.ok(response.ok) + t.assert.strictEqual(response.headers.get('content-type'), 'application/json') + t.assert.strictEqual(response.status, 200) + const body = await response.text() + t.assert.deepStrictEqual(JSON.parse(body), [{ hello: 'world' }, { a: 42 }]) +}) + +test('return a 404 if the stream emits a 404 error', async (t) => { + t.plan(4) + + const fastify = Fastify() + + fastify.get('/', function (request, reply) { + t.assert.ok('Received request') + + const reallyLongStream = new Readable({ + read: function () { + setImmediate(() => { + this.emit('error', new errors.NotFound()) + }) + } + }) + + reply.send(reallyLongStream) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const response = await fetch(fastifyServer) + t.assert.ok(!response.ok) + t.assert.strictEqual(response.headers.get('content-type'), 'application/json; charset=utf-8') + t.assert.strictEqual(response.status, 404) +}) diff --git a/test/stream.5.test.js b/test/stream.5.test.js new file mode 100644 index 00000000000..4580462d7ed --- /dev/null +++ b/test/stream.5.test.js @@ -0,0 +1,188 @@ +'use strict' + +const { test } = require('node:test') +const proxyquire = require('proxyquire') +const fs = require('node:fs') +const Readable = require('node:stream').Readable +const Fastify = require('..') + +test('should destroy stream when response is ended', async (t) => { + t.plan(3) + const stream = require('node:stream') + const fastify = Fastify() + + fastify.get('/error', function (req, reply) { + const reallyLongStream = new stream.Readable({ + read: function () { }, + destroy: function (err, callback) { + t.assert.ok('called') + callback(err) + } + }) + reply.code(200).send(reallyLongStream) + reply.raw.end(Buffer.from('hello\n')) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => fastify.close()) + + const response = await fetch(`${fastifyServer}/error`) + t.assert.ok(response.ok) + t.assert.strictEqual(response.status, 200) +}) + +test('should mark reply as sent before pumping the payload stream into response for async route handler', async (t) => { + t.plan(2) + t.after(() => fastify.close()) + + const handleRequest = proxyquire('../lib/handle-request', { + './wrap-thenable': (thenable, reply) => { + thenable.then(function (payload) { + t.assert.strictEqual(reply.sent, true) + }) + } + }) + + const route = proxyquire('../lib/route', { + './handle-request': handleRequest + }) + + const Fastify = proxyquire('..', { + './lib/route': route + }) + + const fastify = Fastify() + + fastify.get('/', async function (req, reply) { + const stream = fs.createReadStream(__filename, 'utf8') + return reply.code(200).send(stream) + }) + + const res = await fastify.inject({ + url: '/', + method: 'GET' + }) + t.assert.strictEqual(res.payload, fs.readFileSync(__filename, 'utf8')) +}) + +test('reply.send handles aborted requests', (t, done) => { + t.plan(2) + + const spyLogger = { + level: 'error', + fatal: () => { }, + error: () => { + t.assert.fail('should not log an error') + }, + warn: () => { }, + info: () => { }, + debug: () => { }, + trace: () => { }, + child: () => { return spyLogger } + } + const fastify = Fastify({ + loggerInstance: spyLogger + }) + + fastify.get('/', (req, reply) => { + setTimeout(() => { + const stream = new Readable({ + read: function () { + this.push(null) + } + }) + reply.send(stream) + }, 6) + }) + + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + t.after(() => fastify.close()) + + const port = fastify.server.address().port + const http = require('node:http') + const req = http.get(`http://localhost:${port}`) + .on('error', (err) => { + t.assert.strictEqual(err.code, 'ECONNRESET') + done() + }) + + setTimeout(() => { + req.destroy() + }, 1) + }) +}) + +test('request terminated should not crash fastify', (t, done) => { + t.plan(10) + + const spyLogger = { + level: 'error', + fatal: () => { }, + error: () => { + t.assert.fail('should not log an error') + }, + warn: () => { }, + info: () => { }, + debug: () => { }, + trace: () => { }, + child: () => { return spyLogger } + } + const fastify = Fastify({ + loggerInstance: spyLogger + }) + + fastify.get('/', async (req, reply) => { + const stream = new Readable() + stream._read = () => { } + reply.header('content-type', 'text/html; charset=utf-8') + reply.header('transfer-encoding', 'chunked') + stream.push('

HTML

') + + reply.send(stream) + + await new Promise((resolve) => { setTimeout(resolve, 100).unref() }) + + stream.push('

should display on second stream

') + stream.push(null) + return reply + }) + + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + t.after(() => fastify.close()) + + const port = fastify.server.address().port + const http = require('node:http') + const req = http.get(`http://localhost:${port}`, function (res) { + const { statusCode, headers } = res + t.assert.strictEqual(statusCode, 200) + t.assert.strictEqual(headers['content-type'], 'text/html; charset=utf-8') + t.assert.strictEqual(headers['transfer-encoding'], 'chunked') + res.on('data', function (chunk) { + t.assert.strictEqual(chunk.toString(), '

HTML

') + }) + + setTimeout(() => { + req.destroy() + + // the server is not crash, we can connect it + http.get(`http://localhost:${port}`, function (res) { + const { statusCode, headers } = res + t.assert.strictEqual(statusCode, 200) + t.assert.strictEqual(headers['content-type'], 'text/html; charset=utf-8') + t.assert.strictEqual(headers['transfer-encoding'], 'chunked') + let payload = '' + res.on('data', function (chunk) { + payload += chunk.toString() + }) + res.on('end', function () { + t.assert.strictEqual(payload, '

HTML

should display on second stream

') + t.assert.ok('should end properly') + done() + }) + }) + }, 1) + }) + }) +}) diff --git a/test/stream.test.js b/test/stream.test.js deleted file mode 100644 index 5014244e504..00000000000 --- a/test/stream.test.js +++ /dev/null @@ -1,816 +0,0 @@ -'use strict' - -const t = require('tap') -const test = t.test -const proxyquire = require('proxyquire') -const sget = require('simple-get').concat -const fs = require('fs') -const resolve = require('path').resolve -const zlib = require('zlib') -const pump = require('pump') -const Fastify = require('..') -const errors = require('http-errors') -const JSONStream = require('JSONStream') -const send = require('send') -const Readable = require('stream').Readable -const split = require('split2') -const semver = require('semver') -const { kDisableRequestLogging } = require('../lib/symbols.js') - -function getUrl (app) { - const { address, port } = app.server.address() - if (address === '::1') { - return `http://[${address}]:${port}` - } else { - return `http://${address}:${port}` - } -} - -test('should respond with a stream', t => { - t.plan(6) - const fastify = Fastify() - - fastify.get('/', function (req, reply) { - const stream = fs.createReadStream(__filename, 'utf8') - reply.code(200).send(stream) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - sget(`http://localhost:${fastify.server.address().port}`, function (err, response, data) { - t.error(err) - t.equal(response.headers['content-type'], undefined) - t.equal(response.statusCode, 200) - - fs.readFile(__filename, (err, expected) => { - t.error(err) - t.equal(expected.toString(), data.toString()) - }) - }) - }) -}) - -test('should respond with a stream (error)', t => { - t.plan(3) - const fastify = Fastify() - - fastify.get('/error', function (req, reply) { - const stream = fs.createReadStream('not-existing-file', 'utf8') - reply.code(200).send(stream) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - sget(`http://localhost:${fastify.server.address().port}/error`, function (err, response) { - t.error(err) - t.equal(response.statusCode, 500) - }) - }) -}) - -test('should trigger the onSend hook', t => { - t.plan(4) - const fastify = Fastify() - - fastify.get('/', (req, reply) => { - reply.send(fs.createReadStream(__filename, 'utf8')) - }) - - fastify.addHook('onSend', (req, reply, payload, done) => { - t.ok(payload._readableState) - reply.header('Content-Type', 'application/javascript') - done() - }) - - fastify.inject({ - url: '/' - }, (err, res) => { - t.error(err) - t.equal(res.headers['content-type'], 'application/javascript') - t.equal(res.payload, fs.readFileSync(__filename, 'utf8')) - fastify.close() - }) -}) - -test('should trigger the onSend hook only twice if pumping the stream fails, first with the stream, second with the serialized error', t => { - t.plan(5) - const fastify = Fastify() - - fastify.get('/', (req, reply) => { - reply.send(fs.createReadStream('not-existing-file', 'utf8')) - }) - - let counter = 0 - fastify.addHook('onSend', (req, reply, payload, done) => { - if (counter === 0) { - t.ok(payload._readableState) - } else if (counter === 1) { - const error = JSON.parse(payload) - t.equal(error.statusCode, 500) - } - counter++ - done() - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - sget(`http://localhost:${fastify.server.address().port}`, function (err, response) { - t.error(err) - t.equal(response.statusCode, 500) - }) - }) -}) - -test('onSend hook stream', t => { - t.plan(4) - const fastify = Fastify() - - fastify.get('/', function (req, reply) { - reply.send({ hello: 'world' }) - }) - - fastify.addHook('onSend', (req, reply, payload, done) => { - const gzStream = zlib.createGzip() - - reply.header('Content-Encoding', 'gzip') - pump( - fs.createReadStream(resolve(process.cwd() + '/test/stream.test.js'), 'utf8'), - gzStream, - t.error - ) - done(null, gzStream) - }) - - fastify.inject({ - url: '/', - method: 'GET' - }, (err, res) => { - t.error(err) - t.equal(res.headers['content-encoding'], 'gzip') - const file = fs.readFileSync(resolve(process.cwd() + '/test/stream.test.js'), 'utf8') - const payload = zlib.gunzipSync(res.rawPayload) - t.equal(payload.toString('utf-8'), file) - fastify.close() - }) -}) - -test('onSend hook stream should work even if payload is not a proper stream', t => { - t.plan(1) - - const reply = proxyquire('../lib/reply', { - stream: { - finished: (...args) => { - if (args.length === 2) { args[1](new Error('test-error')) } - } - } - }) - const Fastify = proxyquire('..', { - './lib/reply.js': reply - }) - const spyLogger = { - fatal: () => { }, - error: () => { }, - warn: (message) => { - t.equal(message, 'stream payload does not end properly') - fastify.close() - }, - info: () => { }, - debug: () => { }, - trace: () => { }, - child: () => { return spyLogger } - } - - const fastify = Fastify({ logger: spyLogger }) - fastify.get('/', function (req, reply) { - reply.send({ hello: 'world' }) - }) - fastify.addHook('onSend', (req, reply, payload, done) => { - const fakeStream = { pipe: () => { } } - done(null, fakeStream) - }) - - fastify.inject({ - url: '/', - method: 'GET' - }) -}) - -test('onSend hook stream should work on payload with "close" ending function', t => { - t.plan(1) - - const reply = proxyquire('../lib/reply', { - stream: { - finished: (...args) => { - if (args.length === 2) { args[1](new Error('test-error')) } - } - } - }) - const Fastify = proxyquire('..', { - './lib/reply.js': reply - }) - - const fastify = Fastify({ logger: false }) - fastify.get('/', function (req, reply) { - reply.send({ hello: 'world' }) - }) - fastify.addHook('onSend', (req, reply, payload, done) => { - const fakeStream = { - pipe: () => { }, - close: (cb) => { - cb() - t.pass() - } - } - done(null, fakeStream) - }) - - fastify.inject({ - url: '/', - method: 'GET' - }) -}) - -test('Destroying streams prematurely', t => { - t.plan(6) - - let fastify = null - const logStream = split(JSON.parse) - try { - fastify = Fastify({ - logger: { - stream: logStream, - level: 'info' - } - }) - } catch (e) { - t.fail() - } - const stream = require('stream') - const http = require('http') - - // Test that "premature close" errors are logged with level warn - logStream.on('data', line => { - if (line.res) { - t.equal(line.msg, 'stream closed prematurely') - t.equal(line.level, 30) - } - }) - - fastify.get('/', function (request, reply) { - t.pass('Received request') - - let sent = false - const reallyLongStream = new stream.Readable({ - read: function () { - if (!sent) { - this.push(Buffer.from('hello\n')) - } - sent = true - } - }) - - reply.send(reallyLongStream) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - const port = fastify.server.address().port - - http.get(`http://localhost:${port}`, function (response) { - t.equal(response.statusCode, 200) - response.on('readable', function () { - response.destroy() - }) - - // Node bug? Node never emits 'close' here. - response.on('aborted', function () { - t.pass('Response closed') - }) - }) - }) -}) - -test('Destroying streams prematurely should call close method', t => { - t.plan(7) - - let fastify = null - const logStream = split(JSON.parse) - try { - fastify = Fastify({ - logger: { - stream: logStream, - level: 'info' - } - }) - } catch (e) { - t.fail() - } - const stream = require('stream') - const http = require('http') - - // Test that "premature close" errors are logged with level warn - logStream.on('data', line => { - if (line.res) { - t.equal(line.msg, 'stream closed prematurely') - t.equal(line.level, 30) - } - }) - - fastify.get('/', function (request, reply) { - t.pass('Received request') - - let sent = false - const reallyLongStream = new stream.Readable({ - read: function () { - if (!sent) { - this.push(Buffer.from('hello\n')) - } - sent = true - } - }) - reallyLongStream.destroy = undefined - reallyLongStream.close = () => t.ok('called') - reply.send(reallyLongStream) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - const port = fastify.server.address().port - - http.get(`http://localhost:${port}`, function (response) { - t.equal(response.statusCode, 200) - response.on('readable', function () { - response.destroy() - }) - // Node bug? Node never emits 'close' here. - response.on('aborted', function () { - t.pass('Response closed') - }) - }) - }) -}) - -test('Destroying streams prematurely should call close method when destroy is not a function', t => { - t.plan(7) - - let fastify = null - const logStream = split(JSON.parse) - try { - fastify = Fastify({ - logger: { - stream: logStream, - level: 'info' - } - }) - } catch (e) { - t.fail() - } - const stream = require('stream') - const http = require('http') - - // Test that "premature close" errors are logged with level warn - logStream.on('data', line => { - if (line.res) { - t.equal(line.msg, 'stream closed prematurely') - t.equal(line.level, 30) - } - }) - - fastify.get('/', function (request, reply) { - t.pass('Received request') - - let sent = false - const reallyLongStream = new stream.Readable({ - read: function () { - if (!sent) { - this.push(Buffer.from('hello\n')) - } - sent = true - } - }) - reallyLongStream.destroy = true - reallyLongStream.close = () => t.ok('called') - reply.send(reallyLongStream) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - const port = fastify.server.address().port - - http.get(`http://localhost:${port}`, function (response) { - t.equal(response.statusCode, 200) - response.on('readable', function () { - response.destroy() - }) - // Node bug? Node never emits 'close' here. - response.on('aborted', function () { - t.pass('Response closed') - }) - }) - }) -}) - -test('Destroying streams prematurely should call abort method', t => { - t.plan(7) - - let fastify = null - const logStream = split(JSON.parse) - try { - fastify = Fastify({ - logger: { - stream: logStream, - level: 'info' - } - }) - } catch (e) { - t.fail() - } - const stream = require('stream') - const http = require('http') - - // Test that "premature close" errors are logged with level warn - logStream.on('data', line => { - if (line.res) { - t.equal(line.msg, 'stream closed prematurely') - t.equal(line.level, 30) - } - }) - - fastify.get('/', function (request, reply) { - t.pass('Received request') - - let sent = false - const reallyLongStream = new stream.Readable({ - read: function () { - if (!sent) { - this.push(Buffer.from('hello\n')) - } - sent = true - } - }) - reallyLongStream.destroy = undefined - reallyLongStream.close = undefined - reallyLongStream.abort = () => t.ok('called') - reply.send(reallyLongStream) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - const port = fastify.server.address().port - - http.get(`http://localhost:${port}`, function (response) { - t.equal(response.statusCode, 200) - response.on('readable', function () { - response.destroy() - }) - // Node bug? Node never emits 'close' here. - response.on('aborted', function () { - t.pass('Response closed') - }) - }) - }) -}) - -test('Destroying streams prematurely, log is disabled', t => { - t.plan(4) - - let fastify = null - try { - fastify = Fastify({ - logger: false - }) - } catch (e) { - t.fail() - } - const stream = require('stream') - const http = require('http') - - fastify.get('/', function (request, reply) { - reply.log[kDisableRequestLogging] = true - - let sent = false - const reallyLongStream = new stream.Readable({ - read: function () { - if (!sent) { - this.push(Buffer.from('hello\n')) - } - sent = true - } - }) - reallyLongStream.destroy = true - reallyLongStream.close = () => t.ok('called') - reply.send(reallyLongStream) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - const port = fastify.server.address().port - - http.get(`http://localhost:${port}`, function (response) { - t.equal(response.statusCode, 200) - response.on('readable', function () { - response.destroy() - }) - // Node bug? Node never emits 'close' here. - response.on('aborted', function () { - t.pass('Response closed') - }) - }) - }) -}) - -test('should respond with a stream1', t => { - t.plan(5) - const fastify = Fastify() - - fastify.get('/', function (req, reply) { - const stream = JSONStream.stringify() - reply.code(200).type('application/json').send(stream) - stream.write({ hello: 'world' }) - stream.end({ a: 42 }) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - sget(`http://localhost:${fastify.server.address().port}`, function (err, response, body) { - t.error(err) - t.equal(response.headers['content-type'], 'application/json') - t.equal(response.statusCode, 200) - t.same(JSON.parse(body), [{ hello: 'world' }, { a: 42 }]) - }) - }) -}) - -test('return a 404 if the stream emits a 404 error', t => { - t.plan(5) - - const fastify = Fastify() - - fastify.get('/', function (request, reply) { - t.pass('Received request') - - const reallyLongStream = new Readable({ - read: function () { - setImmediate(() => { - this.emit('error', new errors.NotFound()) - }) - } - }) - - reply.send(reallyLongStream) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - const port = fastify.server.address().port - - sget(`http://localhost:${port}`, function (err, response) { - t.error(err) - t.equal(response.headers['content-type'], 'application/json; charset=utf-8') - t.equal(response.statusCode, 404) - }) - }) -}) - -test('should support send module 200 and 404', { skip: semver.gte(process.versions.node, '17.0.0') }, t => { - t.plan(8) - const fastify = Fastify() - - fastify.get('/', function (req, reply) { - const stream = send(req.raw, __filename) - reply.code(200).send(stream) - }) - - fastify.get('/error', function (req, reply) { - const stream = send(req.raw, 'non-existing-file') - reply.code(200).send(stream) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - const url = getUrl(fastify) - - sget(url, function (err, response, data) { - t.error(err) - t.equal(response.headers['content-type'], 'application/javascript; charset=UTF-8') - t.equal(response.statusCode, 200) - - fs.readFile(__filename, (err, expected) => { - t.error(err) - t.equal(expected.toString(), data.toString()) - }) - }) - - sget(url + '/error', function (err, response) { - t.error(err) - t.equal(response.statusCode, 404) - }) - }) -}) - -test('should destroy stream when response is ended', t => { - t.plan(4) - const stream = require('stream') - const fastify = Fastify() - - fastify.get('/error', function (req, reply) { - const reallyLongStream = new stream.Readable({ - read: function () {}, - destroy: function (err, callback) { - t.ok('called') - callback(err) - } - }) - reply.code(200).send(reallyLongStream) - reply.raw.end(Buffer.from('hello\n')) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - sget(`http://localhost:${fastify.server.address().port}/error`, function (err, response) { - t.error(err) - t.equal(response.statusCode, 200) - }) - }) -}) - -test('should mark reply as sent before pumping the payload stream into response for async route handler', t => { - t.plan(3) - - const handleRequest = proxyquire('../lib/handleRequest', { - './wrapThenable': (thenable, reply) => { - thenable.then(function (payload) { - t.equal(reply.sent, true) - }) - } - }) - - const route = proxyquire('../lib/route', { - './handleRequest': handleRequest - }) - - const Fastify = proxyquire('..', { - './lib/route': route - }) - - const fastify = Fastify() - - fastify.get('/', async function (req, reply) { - const stream = fs.createReadStream(__filename, 'utf8') - return reply.code(200).send(stream) - }) - - fastify.inject({ - url: '/', - method: 'GET' - }, (err, res) => { - t.error(err) - t.equal(res.payload, fs.readFileSync(__filename, 'utf8')) - fastify.close() - }) -}) - -test('reply.send handles aborted requests', t => { - t.plan(2) - - const spyLogger = { - level: 'error', - fatal: () => { }, - error: () => { - t.fail('should not log an error') - }, - warn: () => { }, - info: () => { }, - debug: () => { }, - trace: () => { }, - child: () => { return spyLogger } - } - const fastify = Fastify({ - logger: spyLogger - }) - - fastify.get('/', (req, reply) => { - setTimeout(() => { - const stream = new Readable({ - read: function () { - this.push(null) - } - }) - reply.send(stream) - }, 6) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - const port = fastify.server.address().port - const http = require('http') - const req = http.get(`http://localhost:${port}`) - .on('error', (err) => { - t.equal(err.code, 'ECONNRESET') - fastify.close() - }) - - setTimeout(() => { - req.abort() - }, 1) - }) -}) - -test('request terminated should not crash fastify', t => { - t.plan(10) - - const spyLogger = { - level: 'error', - fatal: () => { }, - error: () => { - t.fail('should not log an error') - }, - warn: () => { }, - info: () => { }, - debug: () => { }, - trace: () => { }, - child: () => { return spyLogger } - } - const fastify = Fastify({ - logger: spyLogger - }) - - fastify.get('/', async (req, reply) => { - const stream = new Readable() - stream._read = () => {} - reply.header('content-type', 'text/html; charset=utf-8') - reply.header('transfer-encoding', 'chunked') - stream.push('

HTML

') - - reply.send(stream) - - await new Promise((resolve) => { setTimeout(resolve, 100).unref() }) - - stream.push('

should disply on second stream

') - stream.push(null) - return reply - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) - - const port = fastify.server.address().port - const http = require('http') - const req = http.get(`http://localhost:${port}`, function (res) { - const { statusCode, headers } = res - t.equal(statusCode, 200) - t.equal(headers['content-type'], 'text/html; charset=utf-8') - t.equal(headers['transfer-encoding'], 'chunked') - res.on('data', function (chunk) { - t.equal(chunk.toString(), '

HTML

') - }) - - setTimeout(() => { - req.destroy() - - // the server is not crash, we can connect it - http.get(`http://localhost:${port}`, function (res) { - const { statusCode, headers } = res - t.equal(statusCode, 200) - t.equal(headers['content-type'], 'text/html; charset=utf-8') - t.equal(headers['transfer-encoding'], 'chunked') - let payload = '' - res.on('data', function (chunk) { - payload += chunk.toString() - }) - res.on('end', function () { - t.equal(payload, '

HTML

should disply on second stream

') - t.pass('should end properly') - }) - }) - }, 1) - }) - }) -}) diff --git a/test/sync-routes.test.js b/test/sync-routes.test.js index 9491a2cfd78..9f47d81c53d 100644 --- a/test/sync-routes.test.js +++ b/test/sync-routes.test.js @@ -1,32 +1,32 @@ 'use strict' -const { test } = require('tap') +const { test } = require('node:test') const Fastify = require('..') test('sync route', async t => { - const app = Fastify() - t.teardown(app.close.bind(app)) - app.get('/', () => 'hello world') - const res = await app.inject('/') - t.equal(res.statusCode, 200) - t.equal(res.body, 'hello world') + const fastify = Fastify() + t.after(() => fastify.close()) + fastify.get('/', () => 'hello world') + const res = await fastify.inject('/') + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.body, 'hello world') }) test('sync route return null', async t => { - const app = Fastify() - t.teardown(app.close.bind(app)) - app.get('/', () => null) - const res = await app.inject('/') - t.equal(res.statusCode, 200) - t.equal(res.body, 'null') + const fastify = Fastify() + t.after(() => fastify.close()) + fastify.get('/', () => null) + const res = await fastify.inject('/') + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.body, 'null') }) test('sync route, error', async t => { - const app = Fastify() - t.teardown(app.close.bind(app)) - app.get('/', () => { + const fastify = Fastify() + t.after(() => fastify.close()) + fastify.get('/', () => { throw new Error('kaboom') }) - const res = await app.inject('/') - t.equal(res.statusCode, 500) + const res = await fastify.inject('/') + t.assert.strictEqual(res.statusCode, 500) }) diff --git a/test/throw.test.js b/test/throw.test.js index 2f08ab081d2..747512e30b3 100644 --- a/test/throw.test.js +++ b/test/throw.test.js @@ -1,20 +1,20 @@ 'use strict' -const { test } = require('tap') +const { test } = require('node:test') const Fastify = require('..') -test('Fastify should throw on wrong options', t => { +test('Fastify should throw on wrong options', (t) => { t.plan(2) try { Fastify('lol') - t.fail() + t.assert.fail() } catch (e) { - t.equal(e.message, 'Options must be an object') - t.pass() + t.assert.strictEqual(e.message, 'Options must be an object') + t.assert.ok(true) } }) -test('Fastify should throw on multiple assignment to the same route', t => { +test('Fastify should throw on multiple assignment to the same route', (t) => { t.plan(1) const fastify = Fastify() @@ -22,14 +22,14 @@ test('Fastify should throw on multiple assignment to the same route', t => { try { fastify.get('/', () => {}) - t.fail('Should throw on duplicated route declaration') + t.assert.fail('Should throw fastify duplicated route declaration') } catch (error) { - t.equal(error.message, "Method 'GET' already declared for route '/'") + t.assert.strictEqual(error.code, 'FST_ERR_DUPLICATED_ROUTE') } }) -test('Fastify should throw for an invalid schema, printing the error route - headers', t => { - t.plan(2) +test('Fastify should throw for an invalid schema, printing the error route - headers', async (t) => { + t.plan(1) const badSchema = { type: 'object', @@ -39,20 +39,18 @@ test('Fastify should throw for an invalid schema, printing the error route - hea } } } - const fastify = Fastify() fastify.get('/', { schema: { headers: badSchema } }, () => {}) fastify.get('/not-loaded', { schema: { headers: badSchema } }, () => {}) - fastify.ready(err => { - t.equal(err.code, 'FST_ERR_SCH_VALIDATION_BUILD') - t.match(err.message, /Failed building the validation schema for GET: \//) + await t.assert.rejects(fastify.ready(), { + code: 'FST_ERR_SCH_VALIDATION_BUILD', + message: /Failed building the validation schema for GET: \// }) }) -test('Fastify should throw for an invalid schema, printing the error route - body', t => { - t.plan(2) - +test('Fastify should throw for an invalid schema, printing the error route - body', async (t) => { + t.plan(1) const badSchema = { type: 'object', properties: { @@ -68,25 +66,13 @@ test('Fastify should throw for an invalid schema, printing the error route - bod done() }, { prefix: 'hello' }) - fastify.ready(err => { - t.equal(err.code, 'FST_ERR_SCH_VALIDATION_BUILD') - t.match(err.message, /Failed building the validation schema for POST: \/hello\/form/) + await t.assert.rejects(fastify.ready(), { + code: 'FST_ERR_SCH_VALIDATION_BUILD', + message: /Failed building the validation schema for POST: \/hello\/form/ }) }) -test('Fastify should throw for an invalid shorthand option type', t => { - t.plan(3) - try { - Fastify({ jsonShorthand: 'hello' }) - t.fail() - } catch (e) { - t.equal(e.code, 'FST_ERR_INIT_OPTS_INVALID') - t.match(e.message, /must be boolean/) - t.pass() - } -}) - -test('Should throw on unsupported method', t => { +test('Should throw on unsupported method', async (t) => { t.plan(1) const fastify = Fastify() try { @@ -96,13 +82,13 @@ test('Should throw on unsupported method', t => { schema: {}, handler: function (req, reply) {} }) - t.fail() + t.assert.fail() } catch (e) { - t.pass() + t.assert.ok(true) } }) -test('Should throw on missing handler', t => { +test('Should throw on missing handler', (t) => { t.plan(1) const fastify = Fastify() try { @@ -110,15 +96,15 @@ test('Should throw on missing handler', t => { method: 'GET', url: '/' }) - t.fail() + t.assert.fail() } catch (e) { - t.pass() + t.assert.ok(true) } }) -test('Should throw if one method is unsupported', t => { - const fastify = Fastify() +test('Should throw if one method is unsupported', async (t) => { t.plan(1) + const fastify = Fastify() try { fastify.route({ method: ['GET', 'TROLL'], @@ -126,28 +112,27 @@ test('Should throw if one method is unsupported', t => { schema: {}, handler: function (req, reply) {} }) - t.fail() + t.assert.fail() } catch (e) { - t.pass() + t.assert.ok(true) } }) -test('Should throw on duplicate content type parser', t => { +test('Should throw on duplicate content type parser', async (t) => { t.plan(1) - const fastify = Fastify() function customParser (req, payload, done) { done(null, '') } fastify.addContentTypeParser('application/qq', customParser) try { fastify.addContentTypeParser('application/qq', customParser) - t.fail() + t.assert.fail() } catch (e) { - t.pass() + t.assert.ok(true) } }) -test('Should throw on duplicate decorator', t => { +test('Should throw on duplicate decorator', async (t) => { t.plan(1) const fastify = Fastify() @@ -156,31 +141,30 @@ test('Should throw on duplicate decorator', t => { fastify.decorate('foo', fooObj) try { fastify.decorate('foo', fooObj) - t.fail() + t.assert.fail() } catch (e) { - t.pass() + t.assert.ok(true) } }) -test('Should not throw on duplicate decorator encapsulation', t => { +test('Should not throw on duplicate decorator encapsulation', async (t) => { t.plan(1) - const fastify = Fastify() const foo2Obj = {} fastify.decorate('foo2', foo2Obj) fastify.register(function (fastify, opts, done) { - t.doesNotThrow(() => { + t.assert.doesNotThrow(() => { fastify.decorate('foo2', foo2Obj) }) done() }) - fastify.ready() + await fastify.ready() }) -test('Should throw on duplicate request decorator', t => { +test('Should throw on duplicate request decorator', async (t) => { t.plan(2) const fastify = Fastify() @@ -188,28 +172,28 @@ test('Should throw on duplicate request decorator', t => { fastify.decorateRequest('foo', null) try { fastify.decorateRequest('foo', null) - t.fail() + t.assert.fail() } catch (e) { - t.equal(e.code, 'FST_ERR_DEC_ALREADY_PRESENT') - t.equal(e.message, 'The decorator \'foo\' has already been added!') + t.assert.strictEqual(e.code, 'FST_ERR_DEC_ALREADY_PRESENT') + t.assert.strictEqual(e.message, 'The decorator \'foo\' has already been added!') } }) -test('Should throw if request decorator dependencies are not met', t => { +test('Should throw if request decorator dependencies are not met', async (t) => { t.plan(2) const fastify = Fastify() try { fastify.decorateRequest('bar', null, ['world']) - t.fail() + t.assert.fail() } catch (e) { - t.equal(e.code, 'FST_ERR_DEC_MISSING_DEPENDENCY') - t.equal(e.message, 'The decorator is missing dependency \'world\'.') + t.assert.strictEqual(e.code, 'FST_ERR_DEC_MISSING_DEPENDENCY') + t.assert.strictEqual(e.message, 'The decorator is missing dependency \'world\'.') } }) -test('Should throw on duplicate reply decorator', t => { +test('Should throw on duplicate reply decorator', async (t) => { t.plan(1) const fastify = Fastify() @@ -217,149 +201,149 @@ test('Should throw on duplicate reply decorator', t => { fastify.decorateReply('foo', null) try { fastify.decorateReply('foo', null) - t.fail() + t.assert.fail() } catch (e) { - t.ok(/has already been added/.test(e.message)) + t.assert.ok(/has already been added/.test(e.message)) } }) -test('Should throw if reply decorator dependencies are not met', t => { +test('Should throw if reply decorator dependencies are not met', async (t) => { t.plan(1) const fastify = Fastify() try { fastify.decorateReply('bar', null, ['world']) - t.fail() + t.assert.fail() } catch (e) { - t.ok(/missing dependency/.test(e.message)) + t.assert.ok(/missing dependency/.test(e.message)) } }) -test('Should throw if handler as the third parameter to the shortcut method is missing and the second parameter is not a function and also not an object', t => { +test('Should throw if handler as the third parameter to the shortcut method is missing and the second parameter is not a function and also not an object', async (t) => { t.plan(5) const fastify = Fastify() try { fastify.get('/foo/1', '') - t.fail() + t.assert.fail() } catch (e) { - t.pass() + t.assert.ok(true) } try { fastify.get('/foo/2', 1) - t.fail() + t.assert.fail() } catch (e) { - t.pass() + t.assert.ok(true) } try { fastify.get('/foo/3', []) - t.fail() + t.assert.fail() } catch (e) { - t.pass() + t.assert.ok(true) } try { fastify.get('/foo/4', undefined) - t.fail() + t.assert.fail() } catch (e) { - t.pass() + t.assert.ok(true) } try { fastify.get('/foo/5', null) - t.fail() + t.assert.fail() } catch (e) { - t.pass() + t.assert.ok(true) } }) -test('Should throw if handler as the third parameter to the shortcut method is missing and the second parameter is not a function and also not an object', t => { +test('Should throw if handler as the third parameter to the shortcut method is missing and the second parameter is not a function and also not an object', async (t) => { t.plan(5) const fastify = Fastify() try { fastify.get('/foo/1', '') - t.fail() + t.assert.fail() } catch (e) { - t.pass() + t.assert.ok(true) } try { fastify.get('/foo/2', 1) - t.fail() + t.assert.fail() } catch (e) { - t.pass() + t.assert.ok(true) } try { fastify.get('/foo/3', []) - t.fail() + t.assert.fail() } catch (e) { - t.pass() + t.assert.ok(true) } try { fastify.get('/foo/4', undefined) - t.fail() + t.assert.fail() } catch (e) { - t.pass() + t.assert.ok(true) } try { fastify.get('/foo/5', null) - t.fail() + t.assert.fail() } catch (e) { - t.pass() + t.assert.ok(true) } }) -test('Should throw if there is handler function as the third parameter to the shortcut method and options as the second parameter is not an object', t => { +test('Should throw if there is handler function as the third parameter to the shortcut method and options as the second parameter is not an object', async (t) => { t.plan(5) const fastify = Fastify() try { fastify.get('/foo/1', '', (req, res) => {}) - t.fail() + t.assert.fail() } catch (e) { - t.pass() + t.assert.ok(true) } try { fastify.get('/foo/2', 1, (req, res) => {}) - t.fail() + t.assert.fail() } catch (e) { - t.pass() + t.assert.ok(true) } try { fastify.get('/foo/3', [], (req, res) => {}) - t.fail() + t.assert.fail() } catch (e) { - t.pass() + t.assert.ok(true) } try { fastify.get('/foo/4', undefined, (req, res) => {}) - t.fail() + t.assert.fail() } catch (e) { - t.pass() + t.assert.ok(true) } try { fastify.get('/foo/5', null, (req, res) => {}) - t.fail() + t.assert.fail() } catch (e) { - t.pass() + t.assert.ok(true) } }) -test('Should throw if found duplicate handler as the third parameter to the shortcut method and in options', t => { +test('Should throw if found duplicate handler as the third parameter to the shortcut method and in options', async (t) => { t.plan(1) const fastify = Fastify() @@ -368,8 +352,8 @@ test('Should throw if found duplicate handler as the third parameter to the shor fastify.get('/foo/abc', { handler: (req, res) => {} }, (req, res) => {}) - t.fail() + t.assert.fail() } catch (e) { - t.pass() + t.assert.ok(true) } }) diff --git a/test/toolkit.js b/test/toolkit.js new file mode 100644 index 00000000000..96ff21e0b0d --- /dev/null +++ b/test/toolkit.js @@ -0,0 +1,63 @@ +'use strict' + +exports.waitForCb = function (options) { + let count = null + let done = false + let iResolve + let iReject + + function stepIn () { + if (done) { + iReject(new Error('Unexpected done call')) + return + } + + if (--count) { + return + } + + done = true + iResolve() + } + + const patience = new Promise((resolve, reject) => { + iResolve = resolve + iReject = reject + }) + + count = options.steps || 1 + done = false + + return { stepIn, patience } +} + +exports.partialDeepStrictEqual = function partialDeepStrictEqual (actual, expected) { + if (typeof expected !== 'object' || expected === null) { + return actual === expected + } + + if (typeof actual !== 'object' || actual === null) { + return false + } + + if (Array.isArray(expected)) { + if (!Array.isArray(actual)) return false + if (expected.length > actual.length) return false + + for (let i = 0; i < expected.length; i++) { + if (!partialDeepStrictEqual(actual[i], expected[i])) { + return false + } + } + return true + } + + for (const key of Object.keys(expected)) { + if (!(key in actual)) return false + if (!partialDeepStrictEqual(actual[key], expected[key])) { + return false + } + } + + return true +} diff --git a/test/trust-proxy.test.js b/test/trust-proxy.test.js index fb6c52d6818..ae7b7568a6d 100644 --- a/test/trust-proxy.test.js +++ b/test/trust-proxy.test.js @@ -1,178 +1,162 @@ 'use strict' -const t = require('tap') -const { test, before } = t -const sget = require('simple-get').concat +const { test, before } = require('node:test') const fastify = require('..') -const dns = require('dns').promises +const helper = require('./helper') -const sgetForwardedRequest = (app, forHeader, path, protoHeader) => { +const fetchForwardedRequest = async (fastifyServer, forHeader, path, protoHeader) => { const headers = { 'X-Forwarded-For': forHeader, - 'X-Forwarded-Host': 'example.com' + 'X-Forwarded-Host': 'fastify.test' } if (protoHeader) { headers['X-Forwarded-Proto'] = protoHeader } - sget({ - method: 'GET', - headers, - url: 'http://localhost:' + app.server.address().port + path - }, () => {}) + + return fetch(fastifyServer + path, { + headers + }) } const testRequestValues = (t, req, options) => { if (options.ip) { - t.ok(req.ip, 'ip is defined') - t.equal(req.ip, options.ip, 'gets ip from x-forwarded-for') + t.assert.ok(req.ip, 'ip is defined') + t.assert.strictEqual(req.ip, options.ip, 'gets ip from x-forwarded-for') } - if (options.hostname) { - t.ok(req.hostname, 'hostname is defined') - t.equal(req.hostname, options.hostname, 'gets hostname from x-forwarded-host') + if (options.host) { + t.assert.ok(req.host, 'host is defined') + t.assert.strictEqual(req.host, options.host, 'gets host from x-forwarded-host') + t.assert.ok(req.hostname) + t.assert.strictEqual(req.hostname, options.host, 'gets hostname from x-forwarded-host') } if (options.ips) { - t.same(req.ips, options.ips, 'gets ips from x-forwarded-for') + t.assert.deepStrictEqual(req.ips, options.ips, 'gets ips from x-forwarded-for') } if (options.protocol) { - t.ok(req.protocol, 'protocol is defined') - t.equal(req.protocol, options.protocol, 'gets protocol from x-forwarded-proto') + t.assert.ok(req.protocol, 'protocol is defined') + t.assert.strictEqual(req.protocol, options.protocol, 'gets protocol from x-forwarded-proto') + } + if (options.port) { + t.assert.ok(req.port, 'port is defined') + t.assert.strictEqual(req.port, options.port, 'port is taken from x-forwarded-for or host') } } let localhost - before(async function () { - const lookup = await dns.lookup('localhost') - localhost = lookup.address + [localhost] = await helper.getLoopbackHost() }) -test('trust proxy, not add properties to node req', (t) => { - t.plan(8) +test('trust proxy, not add properties to node req', async t => { + t.plan(13) const app = fastify({ trustProxy: true }) + t.after(() => app.close()) + app.get('/trustproxy', function (req, reply) { - testRequestValues(t, req, { ip: '1.1.1.1', hostname: 'example.com' }) - reply.code(200).send({ ip: req.ip, hostname: req.hostname }) + testRequestValues(t, req, { ip: '1.1.1.1', host: 'fastify.test', port: app.server.address().port }) + reply.code(200).send({ ip: req.ip, host: req.host }) }) app.get('/trustproxychain', function (req, reply) { - testRequestValues(t, req, { ip: '2.2.2.2', ips: [localhost, '1.1.1.1', '2.2.2.2'] }) - reply.code(200).send({ ip: req.ip, hostname: req.hostname }) + testRequestValues(t, req, { ip: '2.2.2.2', ips: [localhost, '1.1.1.1', '2.2.2.2'], port: app.server.address().port }) + reply.code(200).send({ ip: req.ip, host: req.host }) }) - t.teardown(app.close.bind(app)) + const fastifyServer = await app.listen({ port: 0 }) - app.listen({ port: 0 }, (err) => { - app.server.unref() - t.error(err) - sgetForwardedRequest(app, '1.1.1.1', '/trustproxy') - sgetForwardedRequest(app, '2.2.2.2, 1.1.1.1', '/trustproxychain') - }) + await fetchForwardedRequest(fastifyServer, '1.1.1.1', '/trustproxy', undefined) + await fetchForwardedRequest(fastifyServer, '2.2.2.2, 1.1.1.1', '/trustproxychain', undefined) }) -test('trust proxy chain', (t) => { - t.plan(3) +test('trust proxy chain', async t => { + t.plan(8) const app = fastify({ trustProxy: [localhost, '192.168.1.1'] }) + t.after(() => app.close()) app.get('/trustproxychain', function (req, reply) { - testRequestValues(t, req, { ip: '1.1.1.1' }) - reply.code(200).send({ ip: req.ip, hostname: req.hostname }) + testRequestValues(t, req, { ip: '1.1.1.1', host: 'fastify.test', port: app.server.address().port }) + reply.code(200).send({ ip: req.ip, host: req.host }) }) - t.teardown(app.close.bind(app)) - - app.listen({ port: 0 }, (err) => { - app.server.unref() - t.error(err) - sgetForwardedRequest(app, '192.168.1.1, 1.1.1.1', '/trustproxychain') - }) + const fastifyServer = await app.listen({ port: 0 }) + await fetchForwardedRequest(fastifyServer, '192.168.1.1, 1.1.1.1', '/trustproxychain', undefined) }) -test('trust proxy function', (t) => { - t.plan(3) +test('trust proxy function', async t => { + t.plan(8) const app = fastify({ trustProxy: (address) => address === localhost }) + t.after(() => app.close()) + app.get('/trustproxyfunc', function (req, reply) { - testRequestValues(t, req, { ip: '1.1.1.1' }) - reply.code(200).send({ ip: req.ip, hostname: req.hostname }) + testRequestValues(t, req, { ip: '1.1.1.1', host: 'fastify.test', port: app.server.address().port }) + reply.code(200).send({ ip: req.ip, host: req.host }) }) - t.teardown(app.close.bind(app)) - - app.listen({ port: 0 }, (err) => { - app.server.unref() - t.error(err) - sgetForwardedRequest(app, '1.1.1.1', '/trustproxyfunc') - }) + const fastifyServer = await app.listen({ port: 0 }) + await fetchForwardedRequest(fastifyServer, '1.1.1.1', '/trustproxyfunc', undefined) }) -test('trust proxy number', (t) => { - t.plan(4) +test('trust proxy number', async t => { + t.plan(9) const app = fastify({ trustProxy: 1 }) + t.after(() => app.close()) + app.get('/trustproxynumber', function (req, reply) { - testRequestValues(t, req, { ip: '1.1.1.1', ips: [localhost, '1.1.1.1'] }) - reply.code(200).send({ ip: req.ip, hostname: req.hostname }) + testRequestValues(t, req, { ip: '1.1.1.1', ips: [localhost, '1.1.1.1'], host: 'fastify.test', port: app.server.address().port }) + reply.code(200).send({ ip: req.ip, host: req.host }) }) - t.teardown(app.close.bind(app)) - - app.listen({ port: 0 }, (err) => { - app.server.unref() - t.error(err) - sgetForwardedRequest(app, '2.2.2.2, 1.1.1.1', '/trustproxynumber') - }) + const fastifyServer = await app.listen({ port: 0 }) + await fetchForwardedRequest(fastifyServer, '2.2.2.2, 1.1.1.1', '/trustproxynumber', undefined) }) -test('trust proxy IP addresses', (t) => { - t.plan(4) +test('trust proxy IP addresses', async t => { + t.plan(9) const app = fastify({ trustProxy: `${localhost}, 2.2.2.2` }) + t.after(() => app.close()) + app.get('/trustproxyipaddrs', function (req, reply) { - testRequestValues(t, req, { ip: '1.1.1.1', ips: [localhost, '1.1.1.1'] }) - reply.code(200).send({ ip: req.ip, hostname: req.hostname }) + testRequestValues(t, req, { ip: '1.1.1.1', ips: [localhost, '1.1.1.1'], host: 'fastify.test', port: app.server.address().port }) + reply.code(200).send({ ip: req.ip, host: req.host }) }) - t.teardown(app.close.bind(app)) - - app.listen({ port: 0 }, (err) => { - app.server.unref() - t.error(err) - sgetForwardedRequest(app, '3.3.3.3, 2.2.2.2, 1.1.1.1', '/trustproxyipaddrs') - }) + const fastifyServer = await app.listen({ port: 0 }) + await fetchForwardedRequest(fastifyServer, '3.3.3.3, 2.2.2.2, 1.1.1.1', '/trustproxyipaddrs', undefined) }) -test('trust proxy protocol', (t) => { - t.plan(13) +test('trust proxy protocol', async t => { + t.plan(30) const app = fastify({ trustProxy: true }) + t.after(() => app.close()) + app.get('/trustproxyprotocol', function (req, reply) { - testRequestValues(t, req, { ip: '1.1.1.1', protocol: 'lorem' }) - reply.code(200).send({ ip: req.ip, hostname: req.hostname }) + testRequestValues(t, req, { ip: '1.1.1.1', protocol: 'lorem', host: 'fastify.test', port: app.server.address().port }) + reply.code(200).send({ ip: req.ip, host: req.host }) }) app.get('/trustproxynoprotocol', function (req, reply) { - testRequestValues(t, req, { ip: '1.1.1.1', protocol: 'http' }) - reply.code(200).send({ ip: req.ip, hostname: req.hostname }) + testRequestValues(t, req, { ip: '1.1.1.1', protocol: 'http', host: 'fastify.test', port: app.server.address().port }) + reply.code(200).send({ ip: req.ip, host: req.host }) }) app.get('/trustproxyprotocols', function (req, reply) { - testRequestValues(t, req, { ip: '1.1.1.1', protocol: 'dolor' }) - reply.code(200).send({ ip: req.ip, hostname: req.hostname }) + testRequestValues(t, req, { ip: '1.1.1.1', protocol: 'dolor', host: 'fastify.test', port: app.server.address().port }) + reply.code(200).send({ ip: req.ip, host: req.host }) }) - t.teardown(app.close.bind(app)) + const fastifyServer = await app.listen({ port: 0 }) - app.listen({ port: 0 }, (err) => { - app.server.unref() - t.error(err) - sgetForwardedRequest(app, '1.1.1.1', '/trustproxyprotocol', 'lorem') - sgetForwardedRequest(app, '1.1.1.1', '/trustproxynoprotocol') - sgetForwardedRequest(app, '1.1.1.1', '/trustproxyprotocols', 'ipsum, dolor') - }) + await fetchForwardedRequest(fastifyServer, '1.1.1.1', '/trustproxyprotocol', 'lorem') + await fetchForwardedRequest(fastifyServer, '1.1.1.1', '/trustproxynoprotocol', undefined) + await fetchForwardedRequest(fastifyServer, '1.1.1.1', '/trustproxyprotocols', 'ipsum, dolor') }) diff --git a/test/type-provider.test.js b/test/type-provider.test.js index b691bf95bd0..80e238e7dfb 100644 --- a/test/type-provider.test.js +++ b/test/type-provider.test.js @@ -1,20 +1,22 @@ 'use strict' -const { test } = require('tap') +const { test } = require('node:test') const Fastify = require('..') -test('Should export withTypeProvider function', t => { +test('Should export withTypeProvider function', (t, done) => { t.plan(1) try { Fastify().withTypeProvider() - t.pass() + t.assert.ok('pass') + done() } catch (e) { - t.fail() + t.assert.fail(e) } }) -test('Should return same instance', t => { +test('Should return same instance', (t, done) => { t.plan(1) const fastify = Fastify() - t.equal(fastify, fastify.withTypeProvider()) + t.assert.strictEqual(fastify, fastify.withTypeProvider()) + done() }) diff --git a/test/types/content-type-parser.test-d.ts b/test/types/content-type-parser.test-d.ts index a3f388bc2b2..f75fc01ae79 100644 --- a/test/types/content-type-parser.test-d.ts +++ b/test/types/content-type-parser.test-d.ts @@ -1,6 +1,6 @@ import fastify, { FastifyBodyParser } from '../../fastify' import { expectError, expectType } from 'tsd' -import { IncomingMessage } from 'http' +import { IncomingMessage } from 'node:http' import { FastifyRequest } from '../../types/request' expectType(fastify().addContentTypeParser('contentType', function (request, payload, done) { diff --git a/test/types/dummy-plugin.ts b/test/types/dummy-plugin.ts index 72aaef8805c..e3c8210a7b3 100644 --- a/test/types/dummy-plugin.ts +++ b/test/types/dummy-plugin.ts @@ -1,9 +1,9 @@ -import { FastifyPlugin } from '../../fastify' +import { FastifyPluginAsync } from '../../fastify' export interface DummyPluginOptions { foo?: number } -declare const DummyPlugin: FastifyPlugin +declare const DummyPlugin: FastifyPluginAsync export default DummyPlugin diff --git a/test/types/errors.test-d.ts b/test/types/errors.test-d.ts new file mode 100644 index 00000000000..c14dbdb6f45 --- /dev/null +++ b/test/types/errors.test-d.ts @@ -0,0 +1,90 @@ +import { FastifyErrorConstructor } from '@fastify/error' +import { expectAssignable } from 'tsd' +import { errorCodes } from '../../fastify' + +expectAssignable(errorCodes.FST_ERR_NOT_FOUND) +expectAssignable(errorCodes.FST_ERR_OPTIONS_NOT_OBJ) +expectAssignable(errorCodes.FST_ERR_QSP_NOT_FN) +expectAssignable(errorCodes.FST_ERR_SCHEMA_CONTROLLER_BUCKET_OPT_NOT_FN) +expectAssignable(errorCodes.FST_ERR_SCHEMA_ERROR_FORMATTER_NOT_FN) +expectAssignable(errorCodes.FST_ERR_AJV_CUSTOM_OPTIONS_OPT_NOT_OBJ) +expectAssignable(errorCodes.FST_ERR_AJV_CUSTOM_OPTIONS_OPT_NOT_ARR) +expectAssignable(errorCodes.FST_ERR_VALIDATION) +expectAssignable(errorCodes.FST_ERR_LISTEN_OPTIONS_INVALID) +expectAssignable(errorCodes.FST_ERR_ERROR_HANDLER_NOT_FN) +expectAssignable(errorCodes.FST_ERR_ERROR_HANDLER_ALREADY_SET) +expectAssignable(errorCodes.FST_ERR_CTP_ALREADY_PRESENT) +expectAssignable(errorCodes.FST_ERR_CTP_INVALID_TYPE) +expectAssignable(errorCodes.FST_ERR_CTP_EMPTY_TYPE) +expectAssignable(errorCodes.FST_ERR_CTP_INVALID_HANDLER) +expectAssignable(errorCodes.FST_ERR_CTP_INVALID_PARSE_TYPE) +expectAssignable(errorCodes.FST_ERR_CTP_BODY_TOO_LARGE) +expectAssignable(errorCodes.FST_ERR_CTP_INVALID_MEDIA_TYPE) +expectAssignable(errorCodes.FST_ERR_CTP_INVALID_CONTENT_LENGTH) +expectAssignable(errorCodes.FST_ERR_CTP_EMPTY_JSON_BODY) +expectAssignable(errorCodes.FST_ERR_CTP_INVALID_JSON_BODY) +expectAssignable(errorCodes.FST_ERR_CTP_INSTANCE_ALREADY_STARTED) +expectAssignable(errorCodes.FST_ERR_DEC_ALREADY_PRESENT) +expectAssignable(errorCodes.FST_ERR_DEC_DEPENDENCY_INVALID_TYPE) +expectAssignable(errorCodes.FST_ERR_DEC_MISSING_DEPENDENCY) +expectAssignable(errorCodes.FST_ERR_DEC_AFTER_START) +expectAssignable(errorCodes.FST_ERR_DEC_REFERENCE_TYPE) +expectAssignable(errorCodes.FST_ERR_DEC_UNDECLARED) +expectAssignable(errorCodes.FST_ERR_HOOK_INVALID_TYPE) +expectAssignable(errorCodes.FST_ERR_HOOK_INVALID_HANDLER) +expectAssignable(errorCodes.FST_ERR_HOOK_INVALID_ASYNC_HANDLER) +expectAssignable(errorCodes.FST_ERR_HOOK_NOT_SUPPORTED) +expectAssignable(errorCodes.FST_ERR_MISSING_MIDDLEWARE) +expectAssignable(errorCodes.FST_ERR_HOOK_TIMEOUT) +expectAssignable(errorCodes.FST_ERR_LOG_INVALID_DESTINATION) +expectAssignable(errorCodes.FST_ERR_LOG_INVALID_LOGGER) +expectAssignable(errorCodes.FST_ERR_LOG_INVALID_LOGGER_INSTANCE) +expectAssignable(errorCodes.FST_ERR_LOG_INVALID_LOGGER_CONFIG) +expectAssignable(errorCodes.FST_ERR_LOG_LOGGER_AND_LOGGER_INSTANCE_PROVIDED) +expectAssignable(errorCodes.FST_ERR_REP_INVALID_PAYLOAD_TYPE) +expectAssignable(errorCodes.FST_ERR_REP_RESPONSE_BODY_CONSUMED) +expectAssignable(errorCodes.FST_ERR_REP_READABLE_STREAM_LOCKED) +expectAssignable(errorCodes.FST_ERR_REP_ALREADY_SENT) +expectAssignable(errorCodes.FST_ERR_REP_SENT_VALUE) +expectAssignable(errorCodes.FST_ERR_SEND_INSIDE_ONERR) +expectAssignable(errorCodes.FST_ERR_SEND_UNDEFINED_ERR) +expectAssignable(errorCodes.FST_ERR_BAD_STATUS_CODE) +expectAssignable(errorCodes.FST_ERR_BAD_TRAILER_NAME) +expectAssignable(errorCodes.FST_ERR_BAD_TRAILER_VALUE) +expectAssignable(errorCodes.FST_ERR_FAILED_ERROR_SERIALIZATION) +expectAssignable(errorCodes.FST_ERR_MISSING_SERIALIZATION_FN) +expectAssignable(errorCodes.FST_ERR_MISSING_CONTENTTYPE_SERIALIZATION_FN) +expectAssignable(errorCodes.FST_ERR_REQ_INVALID_VALIDATION_INVOCATION) +expectAssignable(errorCodes.FST_ERR_SCH_MISSING_ID) +expectAssignable(errorCodes.FST_ERR_SCH_ALREADY_PRESENT) +expectAssignable(errorCodes.FST_ERR_SCH_CONTENT_MISSING_SCHEMA) +expectAssignable(errorCodes.FST_ERR_SCH_DUPLICATE) +expectAssignable(errorCodes.FST_ERR_SCH_VALIDATION_BUILD) +expectAssignable(errorCodes.FST_ERR_SCH_SERIALIZATION_BUILD) +expectAssignable(errorCodes.FST_ERR_SCH_RESPONSE_SCHEMA_NOT_NESTED_2XX) +expectAssignable(errorCodes.FST_ERR_INIT_OPTS_INVALID) +expectAssignable(errorCodes.FST_ERR_FORCE_CLOSE_CONNECTIONS_IDLE_NOT_AVAILABLE) +expectAssignable(errorCodes.FST_ERR_DUPLICATED_ROUTE) +expectAssignable(errorCodes.FST_ERR_BAD_URL) +expectAssignable(errorCodes.FST_ERR_ASYNC_CONSTRAINT) +expectAssignable(errorCodes.FST_ERR_INVALID_URL) +expectAssignable(errorCodes.FST_ERR_ROUTE_OPTIONS_NOT_OBJ) +expectAssignable(errorCodes.FST_ERR_ROUTE_DUPLICATED_HANDLER) +expectAssignable(errorCodes.FST_ERR_ROUTE_HANDLER_NOT_FN) +expectAssignable(errorCodes.FST_ERR_ROUTE_MISSING_HANDLER) +expectAssignable(errorCodes.FST_ERR_ROUTE_METHOD_INVALID) +expectAssignable(errorCodes.FST_ERR_ROUTE_METHOD_NOT_SUPPORTED) +expectAssignable(errorCodes.FST_ERR_ROUTE_BODY_VALIDATION_SCHEMA_NOT_SUPPORTED) +expectAssignable(errorCodes.FST_ERR_ROUTE_BODY_LIMIT_OPTION_NOT_INT) +expectAssignable(errorCodes.FST_ERR_ROUTE_REWRITE_NOT_STR) +expectAssignable(errorCodes.FST_ERR_REOPENED_CLOSE_SERVER) +expectAssignable(errorCodes.FST_ERR_REOPENED_SERVER) +expectAssignable(errorCodes.FST_ERR_INSTANCE_ALREADY_LISTENING) +expectAssignable(errorCodes.FST_ERR_PLUGIN_VERSION_MISMATCH) +expectAssignable(errorCodes.FST_ERR_PLUGIN_NOT_PRESENT_IN_INSTANCE) +expectAssignable(errorCodes.FST_ERR_PLUGIN_INVALID_ASYNC_HANDLER) +expectAssignable(errorCodes.FST_ERR_PLUGIN_CALLBACK_NOT_FN) +expectAssignable(errorCodes.FST_ERR_PLUGIN_NOT_VALID) +expectAssignable(errorCodes.FST_ERR_ROOT_PLG_BOOTED) +expectAssignable(errorCodes.FST_ERR_PARENT_PLUGIN_BOOTED) +expectAssignable(errorCodes.FST_ERR_PLUGIN_TIMEOUT) diff --git a/test/types/fastify.test-d.ts b/test/types/fastify.test-d.ts index 66c4d64d179..91fd4a69304 100644 --- a/test/types/fastify.test-d.ts +++ b/test/types/fastify.test-d.ts @@ -1,35 +1,77 @@ +import Ajv, { ErrorObject as AjvErrorObject } from 'ajv' +import * as http from 'node:http' +import * as http2 from 'node:http2' +import * as https from 'node:https' +import { Socket } from 'node:net' +import { expectAssignable, expectError, expectNotAssignable, expectType } from 'tsd' import fastify, { ConnectionError, + FastifyBaseLogger, + FastifyError, + FastifyErrorCodes, FastifyInstance, FastifyPlugin, FastifyPluginAsync, FastifyPluginCallback, + InjectOptions, + LightMyRequestCallback, LightMyRequestChain, LightMyRequestResponse, - LightMyRequestCallback, - InjectOptions, FastifyBaseLogger, - ValidationResult + RawRequestDefaultExpression, + RouteGenericInterface, + SafePromiseLike } from '../../fastify' -import { ErrorObject as AjvErrorObject } from 'ajv' -import * as http from 'http' -import * as https from 'https' -import * as http2 from 'http2' -import { expectType, expectError, expectAssignable } from 'tsd' -import { FastifyLoggerInstance } from '../../types/logger' -import { Socket } from 'net' +import { Bindings, ChildLoggerOptions } from '../../types/logger' // FastifyInstance // http server -expectType & PromiseLike>>(fastify()) -expectType & PromiseLike>>(fastify({})) +expectError< + FastifyInstance & + Promise> +>(fastify()) +expectAssignable< + FastifyInstance & + PromiseLike> +>(fastify()) +expectType< + FastifyInstance & + SafePromiseLike> +>(fastify()) +expectType< + FastifyInstance & + SafePromiseLike> +>(fastify({})) +expectType< + FastifyInstance & + SafePromiseLike> +>(fastify({ http: {} })) // https server -expectType & PromiseLike>>(fastify({ https: {} })) +expectType< + FastifyInstance & + SafePromiseLike> +>(fastify({ https: {} })) +expectType< + FastifyInstance & + SafePromiseLike> +>(fastify({ https: null })) // http2 server -expectType & PromiseLike>>(fastify({ http2: true, http2SessionTimeout: 1000 })) -expectType & PromiseLike>>(fastify({ http2: true, https: {}, http2SessionTimeout: 1000 })) +expectType< + FastifyInstance & + SafePromiseLike> +>(fastify({ http2: true, http2SessionTimeout: 1000 })) +expectType< + FastifyInstance & + SafePromiseLike> +>(fastify({ http2: true, https: {}, http2SessionTimeout: 1000 })) expectType(fastify({ http2: true, https: {} }).inject()) -expectType & PromiseLike>>(fastify({ schemaController: {} })) -expectType & PromiseLike>>( +expectType< + FastifyInstance & + SafePromiseLike> +>(fastify({ schemaController: {} })) +expectType< + FastifyInstance & + SafePromiseLike> +>( fastify({ schemaController: { compilersFactory: {} @@ -52,13 +94,18 @@ expectAssignable({ query: '' }) fastify({ http2: true, https: {} }).inject().then((resp) => { expectAssignable(resp) }) -const lightMyRequestCallback: LightMyRequestCallback = (err: Error, response: LightMyRequestResponse) => { +const lightMyRequestCallback: LightMyRequestCallback = ( + err: Error | undefined, + response: LightMyRequestResponse | undefined +) => { if (err) throw err } fastify({ http2: true, https: {} }).inject({}, lightMyRequestCallback) // server options -expectAssignable>(fastify({ http2: true })) +expectAssignable< + FastifyInstance +>(fastify({ http2: true })) expectAssignable(fastify({ ignoreTrailingSlash: true })) expectAssignable(fastify({ ignoreDuplicateSlashes: true })) expectAssignable(fastify({ connectionTimeout: 1000 })) @@ -66,17 +113,25 @@ expectAssignable(fastify({ forceCloseConnections: true })) expectAssignable(fastify({ keepAliveTimeout: 1000 })) expectAssignable(fastify({ pluginTimeout: 1000 })) expectAssignable(fastify({ bodyLimit: 100 })) +expectAssignable(fastify({ handlerTimeout: 5000 })) expectAssignable(fastify({ maxParamLength: 100 })) expectAssignable(fastify({ disableRequestLogging: true })) +expectAssignable(fastify({ disableRequestLogging: (req) => req.url?.includes('/health') ?? false })) expectAssignable(fastify({ requestIdLogLabel: 'request-id' })) expectAssignable(fastify({ onProtoPoisoning: 'error' })) expectAssignable(fastify({ onConstructorPoisoning: 'error' })) expectAssignable(fastify({ serializerOpts: { rounding: 'ceil' } })) -expectAssignable(fastify({ serializerOpts: { ajv: { missingRefs: 'ignore' } } })) -expectAssignable(fastify({ serializerOpts: { schema: { } } })) -expectAssignable(fastify({ serializerOpts: { otherProp: { } } })) -expectAssignable>(fastify({ logger: true })) -expectAssignable>(fastify({ logger: true })) +expectAssignable( + fastify({ serializerOpts: { ajv: { missingRefs: 'ignore' } } }) +) +expectAssignable(fastify({ serializerOpts: { schema: {} } })) +expectAssignable(fastify({ serializerOpts: { otherProp: {} } })) +expectAssignable< + FastifyInstance +>(fastify({ logger: true })) +expectAssignable< + FastifyInstance +>(fastify({ logger: true })) expectAssignable>(fastify({ logger: { level: 'info', @@ -87,7 +142,7 @@ expectAssignable { }, child: () => customLogger } -expectAssignable>(fastify({ logger: customLogger })) +expectAssignable< + FastifyInstance +>(fastify({ logger: customLogger })) expectAssignable(fastify({ serverFactory: () => http.createServer() })) expectAssignable(fastify({ caseSensitive: true })) expectAssignable(fastify({ requestIdHeader: 'request-id' })) -expectAssignable(fastify({ genReqId: () => 'request-id' })) +expectAssignable(fastify({ requestIdHeader: false })) +expectAssignable(fastify({ + genReqId: (req) => { + expectType(req) + return 'foo' + } +})) expectAssignable(fastify({ trustProxy: true })) expectAssignable(fastify({ querystringParser: () => ({ foo: 'bar' }) })) expectAssignable(fastify({ querystringParser: () => ({ foo: { bar: 'fuzz' } }) })) expectAssignable(fastify({ querystringParser: () => ({ foo: ['bar', 'fuzz'] }) })) -expectAssignable(fastify({ - versioning: { - storage: () => ({ - get: () => 'foo', - set: () => { }, - del: () => { }, - empty: () => { } - }), - deriveVersion: () => 'foo' - } -})) expectAssignable(fastify({ constraints: {} })) expectAssignable(fastify({ constraints: { version: { name: 'version', storage: () => ({ - get: () => () => {}, + get: () => () => { }, set: () => { }, del: () => { }, empty: () => { } }), - validate () {}, + validate () { }, deriveConstraint: () => 'foo' }, host: { name: 'host', storage: () => ({ - get: () => () => {}, + get: () => () => { }, set: () => { }, del: () => { }, empty: () => { } }), - validate () {}, + validate () { }, deriveConstraint: () => 'foo' }, withObjectValue: { name: 'withObjectValue', storage: () => ({ - get: () => () => {}, + get: () => () => { }, set: () => { }, del: () => { }, empty: () => { } }), - validate () {}, - deriveConstraint: () => {} + validate () { }, + deriveConstraint: () => { } } } @@ -182,17 +234,40 @@ expectAssignable(fastify({ customOptions: { removeAdditional: 'all' }, - plugins: [() => { }] + plugins: [(ajv: Ajv): Ajv => ajv] + } +})) +expectAssignable(fastify({ + ajv: { + plugins: [[(ajv: Ajv): Ajv => ajv, ['keyword1', 'keyword2']]] + } +})) +expectError(fastify({ + ajv: { + customOptions: { + removeAdditional: 'all' + }, + plugins: [ + () => { + // error, plugins always return the Ajv instance fluently + } + ] } })) expectAssignable(fastify({ ajv: { - plugins: [[() => { }, ['keyword1', 'keyword2']]] + onCreate: (ajvInstance) => { + expectType(ajvInstance) + return ajvInstance + } } })) expectAssignable(fastify({ frameworkErrors: () => { } })) expectAssignable(fastify({ - rewriteUrl: (req) => req.url === '/hi' ? '/hello' : req.url! + rewriteUrl: function (req) { + this.log.debug('rewrite url') + return req.url === '/hi' ? '/hello' : req.url! + } })) expectAssignable(fastify({ schemaErrorFormatter: (errors, dataVar) => { @@ -212,15 +287,31 @@ expectAssignable(fastify({ expectType(socket) } })) -expectAssignable(fastify({ jsonShorthand: true })) + +expectAssignable(fastify({ + childLoggerFactory: function ( + this: FastifyInstance, + logger: FastifyBaseLogger, + bindings: Bindings, + opts: ChildLoggerOptions, + req: RawRequestDefaultExpression + ) { + expectType(logger) + expectType(bindings) + expectType(opts) + expectType(req) + expectAssignable(this) + return logger.child(bindings, opts) + } +})) // Thenable expectAssignable>(fastify({ return503OnClosing: true })) fastify().then(fastifyInstance => expectAssignable(fastifyInstance)) -expectAssignable(async () => {}) -expectAssignable(() => {}) -expectAssignable(() => {}) +expectAssignable(async () => { }) +expectAssignable(() => { }) +expectAssignable(() => { }) const ajvErrorObject: AjvErrorObject = { keyword: '', @@ -229,4 +320,33 @@ const ajvErrorObject: AjvErrorObject = { params: {}, message: '' } -expectAssignable(ajvErrorObject) +expectNotAssignable({ + keyword: '', + instancePath: '', + schemaPath: '', + params: '', + message: '' +}) + +expectAssignable([ajvErrorObject]) +expectAssignable('body') +expectAssignable('headers') +expectAssignable('params') +expectAssignable('querystring') + +const routeGeneric: RouteGenericInterface = {} +expectType(routeGeneric.Body) +expectType(routeGeneric.Headers) +expectType(routeGeneric.Params) +expectType(routeGeneric.Querystring) +expectType(routeGeneric.Reply) + +// ErrorCodes +expectType(fastify.errorCodes) + +fastify({ allowUnsafeRegex: true }) +fastify({ allowUnsafeRegex: false }) +expectError(fastify({ allowUnsafeRegex: 'invalid' })) + +expectAssignable(fastify({ allowErrorHandlerOverride: true })) +expectAssignable(fastify({ allowErrorHandlerOverride: false })) diff --git a/test/types/hooks.test-d.ts b/test/types/hooks.test-d.ts index d9d17bb506f..312150eb9cf 100644 --- a/test/types/hooks.test-d.ts +++ b/test/types/hooks.test-d.ts @@ -1,21 +1,25 @@ import { FastifyError } from '@fastify/error' import { expectAssignable, expectError, expectType } from 'tsd' import fastify, { + ContextConfigDefault, FastifyContextConfig, FastifyInstance, + FastifyPluginOptions, FastifyReply, FastifyRequest, + FastifySchema, + FastifyTypeProviderDefault, RawReplyDefaultExpression, RawRequestDefaultExpression, - RouteOptions, + RawServerDefault, RegisterOptions, - FastifyPluginOptions, - FastifySchema, - FastifyTypeProviderDefault, - ContextConfigDefault, FastifyContextConfig, RawServerDefault + RouteOptions, + // preClose hook types should be exported correctly https://github.com/fastify/fastify/pull/5335 + /* eslint-disable @typescript-eslint/no-unused-vars */ + preCloseAsyncHookHandler, + preCloseHookHandler } from '../../fastify' -import { preHandlerAsyncHookHandler, RequestPayload } from '../../types/hooks' -import { RouteGenericInterface } from '../../types/route' -import { ResolveFastifyRequestType } from '../../types/type-provider' +import { DoneFuncWithErrOrRes, HookHandlerDoneFunction, RequestPayload, preHandlerAsyncHookHandler } from '../../types/hooks' +import { FastifyRouteConfig, RouteGenericInterface } from '../../types/route' const server = fastify() @@ -114,27 +118,40 @@ server.addHook('onError', function (request, reply, error, done) { expectType(done()) }) +server.addHook('onRequestAbort', function (request, done) { + expectType(this) + expectType(request) + expectAssignable<(err?: FastifyError) => void>(done) + expectAssignable<(err?: NodeJS.ErrnoException) => void>(done) + expectType(done(new Error())) +}) + server.addHook('onRoute', function (opts) { expectType(this) - expectType(opts) + expectType(opts) }) -server.addHook('onRegister', (instance, opts, done) => { +server.addHook('onRegister', function (instance, opts) { + expectType(this) expectType(instance) expectType(opts) +}) + +server.addHook('onReady', function (done) { + expectType(this) expectAssignable<(err?: FastifyError) => void>(done) expectAssignable<(err?: NodeJS.ErrnoException) => void>(done) expectType(done(new Error())) }) -server.addHook('onReady', function (done) { +server.addHook('onListen', function (done) { expectType(this) expectAssignable<(err?: FastifyError) => void>(done) expectAssignable<(err?: NodeJS.ErrnoException) => void>(done) - expectType(done(new Error())) }) -server.addHook('onClose', (instance, done) => { +server.addHook('onClose', function (instance, done) { + expectType(this) expectType(instance) expectAssignable<(err?: FastifyError) => void>(done) expectAssignable<(err?: NodeJS.ErrnoException) => void>(done) @@ -201,6 +218,11 @@ server.addHook('onError', async function (request, reply, error) { expectType(error) }) +server.addHook('onRequestAbort', async function (request) { + expectType(this) + expectType(request) +}) + server.addHook('onRegister', async (instance, opts) => { expectType(instance) expectType(opts) @@ -210,7 +232,12 @@ server.addHook('onReady', async function () { expectType(this) }) -server.addHook('onClose', async (instance) => { +server.addHook('onListen', async function () { + expectType(this) +}) + +server.addHook('onClose', async function (instance) { + expectType(this) expectType(instance) }) @@ -223,8 +250,7 @@ RawReplyDefaultExpression, RouteGenericInterface, ContextConfigDefault, FastifySchema, -FastifyTypeProviderDefault, -ResolveFastifyRequestType +FastifyTypeProviderDefault > = async function (request, reply): Promise { expectType(this) expectAssignable(request) @@ -240,45 +266,285 @@ type CustomContextConfig = FastifyContextConfig & { foo: string; bar: number; } +type CustomContextConfigWithDefault = CustomContextConfig & FastifyRouteConfig server.route({ method: 'GET', url: '/', - handler: () => {}, - onRequest: (request, reply) => { - expectType(request.context.config) - expectType(reply.context.config) - }, - preParsing: (request, reply) => { - expectType(request.context.config) - expectType(reply.context.config) - }, - preValidation: (request, reply) => { - expectType(request.context.config) - expectType(reply.context.config) - }, - preHandler: (request, reply) => { - expectType(request.context.config) - expectType(reply.context.config) - }, - preSerialization: (request, reply) => { - expectType(request.context.config) - expectType(reply.context.config) - }, - onSend: (request, reply) => { - expectType(request.context.config) - expectType(reply.context.config) - }, - onResponse: (request, reply) => { - expectType(request.context.config) - expectType(reply.context.config) - }, - onTimeout: (request, reply) => { - expectType(request.context.config) - expectType(reply.context.config) - }, - onError: (request, reply) => { - expectType(request.context.config) - expectType(reply.context.config) + handler: () => { }, + onRequest: (request, reply, done) => { + expectType(request.routeOptions.config) + expectType(reply.routeOptions.config) + }, + preParsing: (request, reply, payload, done) => { + expectType(request.routeOptions.config) + expectType(reply.routeOptions.config) + }, + preValidation: (request, reply, done) => { + expectType(request.routeOptions.config) + expectType(reply.routeOptions.config) + }, + preHandler: (request, reply, done) => { + expectType(request.routeOptions.config) + expectType(reply.routeOptions.config) + }, + preSerialization: (request, reply, payload, done) => { + expectType(request.routeOptions.config) + expectType(reply.routeOptions.config) + }, + onSend: (request, reply, payload, done) => { + expectType(request.routeOptions.config) + expectType(reply.routeOptions.config) + }, + onResponse: (request, reply, done) => { + expectType(request.routeOptions.config) + expectType(reply.routeOptions.config) + }, + onTimeout: (request, reply, done) => { + expectType(request.routeOptions.config) + expectType(reply.routeOptions.config) + }, + onError: (request, reply, error, done) => { + expectType(request.routeOptions.config) + expectType(reply.routeOptions.config) } }) + +server.get('/', { + onRequest: async (request, reply) => { + expectType(request.routeOptions.config) + expectType(reply.routeOptions.config) + }, + preParsing: async (request, reply) => { + expectType(request.routeOptions.config) + expectType(reply.routeOptions.config) + }, + preValidation: async (request, reply) => { + expectType(request.routeOptions.config) + expectType(reply.routeOptions.config) + }, + preHandler: async (request, reply) => { + expectType(request.routeOptions.config) + expectType(reply.routeOptions.config) + }, + preSerialization: async (request, reply) => { + expectType(request.routeOptions.config) + expectType(reply.routeOptions.config) + }, + onSend: async (request, reply) => { + expectType(request.routeOptions.config) + expectType(reply.routeOptions.config) + }, + onResponse: async (request, reply) => { + expectType(request.routeOptions.config) + expectType(reply.routeOptions.config) + }, + onTimeout: async (request, reply) => { + expectType(request.routeOptions.config) + expectType(reply.routeOptions.config) + }, + onError: async (request, reply) => { + expectType(request.routeOptions.config) + expectType(reply.routeOptions.config) + } +}, async (request, reply) => { + expectType(request.routeOptions.config) + expectType(reply.routeOptions.config) +}) + +type CustomContextRequest = FastifyRequest +type CustomContextReply = FastifyReply +server.route({ + method: 'GET', + url: '/', + handler: () => { }, + onRequest: async (request: CustomContextRequest, reply: CustomContextReply) => { + expectType(request.routeOptions.config) + expectType(reply.routeOptions.config) + }, + preParsing: async (request: CustomContextRequest, reply: CustomContextReply, payload: RequestPayload) => { + expectType(request.routeOptions.config) + expectType(reply.routeOptions.config) + }, + preValidation: async (request: CustomContextRequest, reply: CustomContextReply) => { + expectType(request.routeOptions.config) + expectType(reply.routeOptions.config) + }, + preHandler: async (request: CustomContextRequest, reply: CustomContextReply) => { + expectType(request.routeOptions.config) + expectType(reply.routeOptions.config) + }, + preSerialization: async (request: CustomContextRequest, reply: CustomContextReply, payload: any) => { + expectType(request.routeOptions.config) + expectType(reply.routeOptions.config) + }, + onSend: async (request: CustomContextRequest, reply: CustomContextReply, payload: any) => { + expectType(request.routeOptions.config) + expectType(reply.routeOptions.config) + }, + onResponse: async (request: CustomContextRequest, reply: CustomContextReply) => { + expectType(request.routeOptions.config) + expectType(reply.routeOptions.config) + }, + onTimeout: async (request: CustomContextRequest, reply: CustomContextReply) => { + expectType(request.routeOptions.config) + expectType(reply.routeOptions.config) + }, + onError: async (request: CustomContextRequest, reply: CustomContextReply, error: FastifyError) => { + expectType(request.routeOptions.config) + expectType(reply.routeOptions.config) + } +}) + +server.route({ + method: 'GET', + url: '/', + handler: (request, reply) => { + expectType(request) + expectType(reply) + }, + onRequest: (request, reply, done) => { + expectType(request) + expectType(reply) + expectType(done) + }, + onRequestAbort: (request, done) => { + expectType(request) + expectType(done) + }, + preParsing: (request, reply, payload, done) => { + expectType(request) + expectType(reply) + expectType(payload) + expectType< + ( + err?: TError | null | undefined, + res?: RequestPayload | undefined + ) => void + >(done) + }, + preValidation: (request, reply, done) => { + expectType(request) + expectType(reply) + expectType(done) + }, + preHandler: (request, reply, done) => { + expectType(request) + expectType(reply) + expectType(done) + }, + preSerialization: (request, reply, payload, done) => { + expectType(request) + expectType(reply) + expectType(payload) + expectType(done) + }, + onSend: (request, reply, payload, done) => { + expectType(request) + expectType(reply) + expectType(payload) + expectType(done) + }, + onResponse: (request, reply, done) => { + expectType(request) + expectType(reply) + expectType(done) + }, + onTimeout: (request, reply, done) => { + expectType(request) + expectType(reply) + expectType(done) + }, + onError: (request, reply, error, done) => { + expectType(request) + expectType(reply) + expectType(error) + expectType<() => void>(done) + } +}) + +server.get('/', { + onRequest: async (request, reply) => { + expectType(request) + expectType(reply) + }, + onRequestAbort: async (request, reply) => { + expectType(request) + }, + preParsing: async (request, reply, payload) => { + expectType(request) + expectType(reply) + expectType(payload) + }, + preValidation: async (request, reply) => { + expectType(request) + expectType(reply) + }, + preHandler: async (request, reply) => { + expectType(request) + expectType(reply) + }, + preSerialization: async (request, reply, payload) => { + expectType(request) + expectType(reply) + expectType(payload) + }, + onSend: async (request, reply, payload) => { + expectType(request) + expectType(reply) + expectType(payload) + }, + onResponse: async (request, reply) => { + expectType(request) + expectType(reply) + }, + onTimeout: async (request, reply) => { + expectType(request) + expectType(reply) + }, + onError: async (request, reply, error) => { + expectType(request) + expectType(reply) + expectType(error) + } +}, async (request, reply) => { + expectType(request) + expectType(reply) +}) + +// TODO: Should throw errors +// expectError(server.get('/', { onRequest: async (request, reply, done) => {} }, async (request, reply) => {})) +// expectError(server.get('/', { onRequestAbort: async (request, done) => {} }, async (request, reply) => {})) +// expectError(server.get('/', { preParsing: async (request, reply, payload, done) => {} }, async (request, reply) => {})) +// expectError(server.get('/', { preValidation: async (request, reply, done) => {} }, async (request, reply) => {})) +// expectError(server.get('/', { preHandler: async (request, reply, done) => {} }, async (request, reply) => {})) +// expectError(server.get('/', { preSerialization: async (request, reply, payload, done) => {} }, async (request, reply) => {})) +// expectError(server.get('/', { onSend: async (request, reply, payload, done) => {} }, async (request, reply) => {})) +// expectError(server.get('/', { onResponse: async (request, reply, done) => {} }, async (request, reply) => {})) +// expectError(server.get('/', { onTimeout: async (request, reply, done) => {} }, async (request, reply) => {})) +// expectError(server.get('/', { onError: async (request, reply, error, done) => {} }, async (request, reply) => {})) + +server.addHook('preClose', function (done) { + expectType(this) + expectAssignable<(err?: FastifyError) => void>(done) + expectAssignable<(err?: NodeJS.ErrnoException) => void>(done) + expectType(done(new Error())) +}) + +server.addHook('preClose', async function () { + expectType(this) +}) + +expectError(server.addHook('onClose', async function (instance, done) {})) +expectError(server.addHook('onError', async function (request, reply, error, done) {})) +expectError(server.addHook('onReady', async function (done) {})) +expectError(server.addHook('onListen', async function (done) {})) +expectError(server.addHook('onRequest', async function (request, reply, done) {})) +expectError(server.addHook('onRequestAbort', async function (request, done) {})) +expectError(server.addHook('onResponse', async function (request, reply, done) {})) +expectError(server.addHook('onSend', async function (request, reply, payload, done) {})) +expectError(server.addHook('onTimeout', async function (request, reply, done) {})) +expectError(server.addHook('preClose', async function (done) {})) +expectError(server.addHook('preHandler', async function (request, reply, done) {})) +expectError(server.addHook('preSerialization', async function (request, reply, payload, done) {})) +expectError(server.addHook('preValidation', async function (request, reply, done) {})) diff --git a/test/types/import.ts b/test/types/import.ts index 5f48f5d087d..303ec4d6ccf 100644 --- a/test/types/import.ts +++ b/test/types/import.ts @@ -1 +1,2 @@ -import { FastifyLogFn } from '../../fastify' +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { FastifyListenOptions, FastifyLogFn } from '../../fastify' diff --git a/test/types/instance.test-d.ts b/test/types/instance.test-d.ts index 8f2837b2f86..ce77e8b2bba 100644 --- a/test/types/instance.test-d.ts +++ b/test/types/instance.test-d.ts @@ -1,17 +1,23 @@ -import { expectAssignable, expectDeprecated, expectError, expectNotDeprecated, expectType } from 'tsd' +import { expectAssignable, expectError, expectNotAssignable, expectNotDeprecated, expectType } from 'tsd' import fastify, { + FastifyBaseLogger, FastifyBodyParser, FastifyError, FastifyInstance, + FastifyRouterOptions, RawReplyDefaultExpression, RawRequestDefaultExpression, - RawServerDefault + RawServerDefault, + RouteGenericInterface } from '../../fastify' import { HookHandlerDoneFunction } from '../../types/hooks' import { FastifyReply } from '../../types/reply' import { FastifyRequest } from '../../types/request' -import { DefaultRoute } from '../../types/route' import { FastifySchemaControllerOptions, FastifySchemaCompiler, FastifySerializerCompiler } from '../../types/schema' +import { AddressInfo } from 'node:net' +import { Bindings, ChildLoggerOptions } from '../../types/logger' +import { Config as FindMyWayConfig, ConstraintStrategy } from 'find-my-way' +import { FindMyWayVersion } from '../../types/instance' const server = fastify() @@ -25,14 +31,19 @@ expectAssignable(server.addSchema({ schemas: [] })) +expectType(server.pluginName) + expectType>(server.getSchemas()) +expectType(server.addresses()) expectType(server.getSchema('SchemaId')) expectType(server.printRoutes()) expectType(server.printPlugins()) +expectType(server.listeningOrigin) +expectType(server.supportedMethods) expectAssignable( server.setErrorHandler(function (error, request, reply) { - expectType(error) + expectType(error) expectAssignable(this) }) ) @@ -43,6 +54,18 @@ expectAssignable( }) ) +expectAssignable( + server.setGenReqId(function (req) { + expectType(req) + return 'foo' + }) +) + +function fastifySetGenReqId (req: RawRequestDefaultExpression) { + return 'foo' +} +server.setGenReqId(fastifySetGenReqId) + function fastifyErrorHandler (this: FastifyInstance, error: FastifyError) {} server.setErrorHandler(fastifyErrorHandler) @@ -70,12 +93,12 @@ interface ReplyPayload { // typed sync error handler server.setErrorHandler((error, request, reply) => { expectType(error) - expectType<((payload?: ReplyPayload['Reply']) => FastifyReply, RawReplyDefaultExpression, ReplyPayload>)>(reply.send) + expectType<((...args: [payload: ReplyPayload['Reply']]) => FastifyReply, RawReplyDefaultExpression>)>(reply.send) }) // typed async error handler send server.setErrorHandler(async (error, request, reply) => { expectType(error) - expectType<((payload?: ReplyPayload['Reply']) => FastifyReply, RawReplyDefaultExpression, ReplyPayload>)>(reply.send) + expectType<((...args: [payload: ReplyPayload['Reply']]) => FastifyReply, RawReplyDefaultExpression>)>(reply.send) }) // typed async error handler return server.setErrorHandler(async (error, request, reply) => { @@ -105,30 +128,48 @@ server.setErrorHandler(async (error, request, reply) function notFoundHandler (request: FastifyRequest, reply: FastifyReply) {} async function notFoundAsyncHandler (request: FastifyRequest, reply: FastifyReply) {} -function notFoundpreHandlerHandler (request: FastifyRequest, reply: FastifyReply, done: HookHandlerDoneFunction) { done() } -async function notFoundpreHandlerAsyncHandler (request: FastifyRequest, reply: FastifyReply) {} -function notFoundpreValidationHandler (request: FastifyRequest, reply: FastifyReply, done: HookHandlerDoneFunction) { done() } -async function notFoundpreValidationAsyncHandler (request: FastifyRequest, reply: FastifyReply) {} +function notFoundpreHandlerHandler ( + request: FastifyRequest, + reply: FastifyReply, + done: HookHandlerDoneFunction +) { done() } +async function notFoundpreHandlerAsyncHandler ( + request: FastifyRequest, + reply: FastifyReply +) {} +function notFoundpreValidationHandler ( + request: FastifyRequest, + reply: FastifyReply, + done: HookHandlerDoneFunction +) { done() } +async function notFoundpreValidationAsyncHandler ( + request: FastifyRequest, + reply: FastifyReply +) {} server.setNotFoundHandler(notFoundHandler) server.setNotFoundHandler({ preHandler: notFoundpreHandlerHandler }, notFoundHandler) server.setNotFoundHandler({ preHandler: notFoundpreHandlerAsyncHandler }, notFoundHandler) server.setNotFoundHandler({ preValidation: notFoundpreValidationHandler }, notFoundHandler) server.setNotFoundHandler({ preValidation: notFoundpreValidationAsyncHandler }, notFoundHandler) -server.setNotFoundHandler({ preHandler: notFoundpreHandlerHandler, preValidation: notFoundpreValidationHandler }, notFoundHandler) +server.setNotFoundHandler( + { preHandler: notFoundpreHandlerHandler, preValidation: notFoundpreValidationHandler }, + notFoundHandler +) server.setNotFoundHandler(notFoundAsyncHandler) server.setNotFoundHandler({ preHandler: notFoundpreHandlerHandler }, notFoundAsyncHandler) server.setNotFoundHandler({ preHandler: notFoundpreHandlerAsyncHandler }, notFoundAsyncHandler) server.setNotFoundHandler({ preValidation: notFoundpreValidationHandler }, notFoundAsyncHandler) server.setNotFoundHandler({ preValidation: notFoundpreValidationAsyncHandler }, notFoundAsyncHandler) -server.setNotFoundHandler({ preHandler: notFoundpreHandlerHandler, preValidation: notFoundpreValidationHandler }, notFoundAsyncHandler) - -function invalidErrorHandler (error: number) { - if (error) throw error -} +server.setNotFoundHandler( + { preHandler: notFoundpreHandlerHandler, preValidation: notFoundpreValidationHandler }, + notFoundAsyncHandler +) -expectError(server.setErrorHandler(invalidErrorHandler)) +server.setNotFoundHandler(function (_, reply) { + return reply.send('') +}) server.setSchemaController({ bucket: (parentSchemas: unknown) => { @@ -170,51 +211,12 @@ function invalidSchemaErrorFormatter (err: Error) { } expectError(server.setSchemaErrorFormatter(invalidSchemaErrorFormatter)) -// test listen method callback -expectAssignable(server.listen(3000, '', 0, (err, address) => { - expectType(err) -})) -expectAssignable(server.listen('3000', '', 0, (err, address) => { - expectType(err) -})) -expectAssignable(server.listen(3000, '', (err, address) => { - expectType(err) -})) -expectAssignable(server.listen('3000', '', (err, address) => { - expectType(err) -})) -expectAssignable(server.listen(3000, (err, address) => { - expectType(err) -})) -expectAssignable(server.listen('3000', (err, address) => { - expectType(err) -})) - -// test listen method callback types -expectAssignable(server.listen('3000', (err, address) => { - expectAssignable(err) - expectAssignable(address) -})) - -// test listen method promise -expectAssignable>(server.listen(3000)) -expectAssignable>(server.listen('3000')) -expectAssignable>(server.listen(3000, '', 0)) -expectAssignable>(server.listen('3000', '', 0)) -expectAssignable>(server.listen(3000, '')) -expectAssignable>(server.listen('3000', '')) - -// Test variadic listen signatures Typescript deprecation -expectDeprecated(server.listen(3000)) -expectDeprecated(server.listen('3000')) -expectDeprecated(server.listen(3000, '', 0)) -expectDeprecated(server.listen('3000', '', 0)) -expectDeprecated(server.listen(3000, '')) -expectDeprecated(server.listen('3000', '')) +expectType(server.addHttpMethod('SEARCH', { hasBody: true })) // test listen opts objects expectAssignable>(server.listen()) expectAssignable>(server.listen({ port: 3000 })) +expectAssignable>(server.listen({ port: 3000, listenTextResolver: (address) => { return `address: ${address}` } })) expectAssignable>(server.listen({ port: 3000, host: '0.0.0.0' })) expectAssignable>(server.listen({ port: 3000, host: '0.0.0.0', backlog: 42 })) expectAssignable>(server.listen({ port: 3000, host: '0.0.0.0', backlog: 42, exclusive: true })) @@ -222,12 +224,13 @@ expectAssignable>(server.listen({ port: 3000, host: '::/0', expectAssignable(server.listen(() => {})) expectAssignable(server.listen({ port: 3000 }, () => {})) +expectAssignable(server.listen({ port: 3000, listenTextResolver: (address) => { return `address: ${address}` } }, () => {})) expectAssignable(server.listen({ port: 3000, host: '0.0.0.0' }, () => {})) expectAssignable(server.listen({ port: 3000, host: '0.0.0.0', backlog: 42 }, () => {})) expectAssignable(server.listen({ port: 3000, host: '0.0.0.0', backlog: 42, exclusive: true }, () => {})) expectAssignable(server.listen({ port: 3000, host: '::/0', ipv6Only: true }, () => {})) -// test listen opts objects Typescript deprectation exclusion +// test listen opts objects Typescript deprecation exclusion expectNotDeprecated(server.listen()) expectNotDeprecated(server.listen({ port: 3000 })) expectNotDeprecated(server.listen({ port: 3000, host: '0.0.0.0' })) @@ -242,15 +245,74 @@ expectNotDeprecated(server.listen({ port: 3000, host: '0.0.0.0', backlog: 42 }, expectNotDeprecated(server.listen({ port: 3000, host: '0.0.0.0', backlog: 42, exclusive: true }, () => {})) expectNotDeprecated(server.listen({ port: 3000, host: '::/0', ipv6Only: true }, () => {})) +// test after method +expectAssignable(server.after()) +expectAssignable(server.after((err) => { + expectType(err) +})) + +// test ready method +expectAssignable(server.ready()) +expectAssignable(server.ready((err) => { + expectType(err) +})) +expectAssignable(server.ready(async (err) => { + expectType(err) +})) +expectAssignable[0]>(async (err) => { + expectType(err) +}) + expectAssignable(server.routing({} as RawRequestDefaultExpression, {} as RawReplyDefaultExpression)) -expectType(fastify().get('/', { +expectType(fastify().get('/', { handler: () => {}, errorHandler: (error, request, reply) => { + expectAssignable(error) + expectAssignable(request) + expectAssignable<{ contextKey: string }>(request.routeOptions.config) + expectAssignable(reply) expectAssignable(server.errorHandler(error, request, reply)) } })) +expectType(fastify().get('/', { + handler: () => {}, + childLoggerFactory: (logger, bindings, opts, req) => { + expectAssignable(server.childLoggerFactory(logger, bindings, opts, req)) + return server.childLoggerFactory(logger, bindings, opts, req) + } +})) + +expectAssignable( + server.setChildLoggerFactory(function (logger, bindings, opts, req) { + expectType(logger) + expectType(bindings) + expectType(opts) + expectType(req) + expectAssignable(this) + return logger.child(bindings, opts) + }) +) + +expectAssignable( + server.setErrorHandler(function (error, request, reply) { + expectType(error) + }) +) + +function childLoggerFactory ( + this: FastifyInstance, + logger: FastifyBaseLogger, + bindings: Bindings, + opts: ChildLoggerOptions, + req: RawRequestDefaultExpression +) { + return logger.child(bindings, opts) +} +server.setChildLoggerFactory(childLoggerFactory) +server.setChildLoggerFactory(server.childLoggerFactory) + type InitialConfig = Readonly<{ connectionTimeout?: number, keepAliveTimeout?: number, @@ -262,18 +324,41 @@ type InitialConfig = Readonly<{ https?: boolean | Readonly<{ allowHTTP1: boolean }>, ignoreTrailingSlash?: boolean, ignoreDuplicateSlashes?: boolean, - disableRequestLogging?: boolean, + disableRequestLogging?: boolean | ((req: FastifyRequest) => boolean), maxParamLength?: number, onProtoPoisoning?: 'error' | 'remove' | 'ignore', onConstructorPoisoning?: 'error' | 'remove' | 'ignore', pluginTimeout?: number, - requestIdHeader?: string, + requestIdHeader?: string | false, requestIdLogLabel?: string, - http2SessionTimeout?: number + http2SessionTimeout?: number, + useSemicolonDelimiter?: boolean, + routerOptions?: FastifyRouterOptions }> expectType(fastify().initialConfig) +const routerOptionsForFindMyWay = {} as FastifyRouterOptions +expectAssignable>>(routerOptionsForFindMyWay) + +fastify({ + routerOptions: { + defaultRoute: (req, res) => { + expectType>(req) + expectType>(res) + expectNotAssignable(res) + res.end('foo') + }, + onBadUrl: (path, req, res) => { + expectType(path) + expectType>(req) + expectType>(res) + expectNotAssignable(res) + res.end('foo') + } + } +}) + expectType>(server.defaultTextParser) expectType>(server.getDefaultJsonParser('ignore', 'error')) @@ -282,65 +367,210 @@ expectType(server.printRoutes({ includeHooks: true, commonPrefix: false, expectType(server.printRoutes({ includeMeta: ['key1', Symbol('key2')] })) +expectType(server.printRoutes({ method: 'GET' })) + expectType(server.printRoutes()) -server.decorate('nonexistent', () => {}) -server.decorateRequest('nonexistent', () => {}) -server.decorateReply('nonexistent', () => {}) +server.decorate<(x: string) => void>('test', function (x: string): void { + expectType(this) +}) +server.decorate('test', function (x: string): void { + expectType(this) +}) +server.decorate('test', { + getter () { + expectType(this) + return 'foo' + } +}) +server.decorate('test', { + getter () { + expectType(this) + return 'foo' + }, + setter (x) { + expectType(x) + expectType(this) + } +}) +server.decorate('test') +server.decorate('test', null, ['foo']) + +server.decorateRequest<(x: string, y: number) => void>('test', function (x: string, y: number): void { + expectType(this) +}) +server.decorateRequest('test', function (x: string, y: number): void { + expectType(this) +}) +server.decorateRequest('test') +server.decorateRequest('test', null, ['foo']) + +server.decorateReply<(x: string) => void>('test', function (x: string): void { + expectType(this) +}) +server.decorateReply('test', function (x: string): void { + expectType(this) +}) +server.decorateReply('test') +server.decorateReply('test', null, ['foo']) + +expectError(server.decorate('test', true)) +expectError(server.decorate<(myNumber: number) => number>('test', function (myNumber: number): string { + return '' +})) +expectError(server.decorate('test', { + getter () { + return true + } +})) +expectError(server.decorate('test', { + setter (x) {} +})) declare module '../../fastify' { interface FastifyInstance { - functionWithTypeDefinition: (foo: string, bar: number) => Promise + typedTestProperty: boolean + typedTestPropertyGetterSetter: string + typedTestMethod (x: string): string } + interface FastifyRequest { - numberWithTypeDefinition: number + typedTestRequestProperty: boolean + typedTestRequestPropertyGetterSetter: string + typedTestRequestMethod (x: string): string } + interface FastifyReply { - stringWithTypeDefinition: 'foo' | 'bar' + typedTestReplyProperty: boolean + typedTestReplyPropertyGetterSetter: string + typedTestReplyMethod (x: string): string } } -expectError(server.decorate('functionWithTypeDefinition', (foo: any, bar: any) => {})) // error because invalid return type -expectError(server.decorate('functionWithTypeDefinition', (foo: any, bar: any) => true)) // error because doesn't return a promise -expectError(server.decorate('functionWithTypeDefinition', async (foo: any, bar: any, qwe: any) => true)) // error because too many args -expectAssignable(server.decorate('functionWithTypeDefinition', async (foo, bar) => { - expectType(foo) - expectType(bar) - return true -})) - -expectError(server.decorateRequest('numberWithTypeDefinition', 'not a number')) // error because invalid type -expectAssignable(server.decorateRequest('numberWithTypeDefinition', 10)) - -expectError(server.decorateReply('stringWithTypeDefinition', 'not in enum')) // error because invalid type -expectAssignable(server.decorateReply('stringWithTypeDefinition', 'foo')) -server.decorate<'test', (x: string) => void>('test', function (x: string): void { - expectType(this) +server.decorate('typedTestProperty', false) +server.decorate('typedTestProperty', { + getter () { + return false + } }) -server.decorate('test', function (x: string): void { +server.decorate('typedTestProperty', { + getter (): boolean { + return true + }, + setter (x) { + expectType(x) + expectType(this) + } +}) +server.decorate('typedTestProperty') +server.decorate('typedTestProperty', null, ['foo']) +expectError(server.decorate('typedTestProperty', null)) +expectError(server.decorate('typedTestProperty', 'foo')) +expectError(server.decorate('typedTestProperty', { + getter () { + return 'foo' + } +})) +server.decorate('typedTestMethod', function (x) { + expectType(x) expectType(this) + return 'foo' }) +server.decorate('typedTestMethod', x => x) +expectError(server.decorate('typedTestMethod', function (x: boolean) { + return 'foo' +})) +expectError(server.decorate('typedTestMethod', function (x) { + return true +})) +expectError(server.decorate('typedTestMethod', async function (x) { + return 'foo' +})) -server.decorateRequest<'test', (x: string, y: number) => void>('test', function (x: string, y: number): void { - expectType(this) +server.decorateRequest('typedTestRequestProperty', false) +server.decorateRequest('typedTestRequestProperty', { + getter () { + return false + } }) -server.decorateRequest('test', function (x: string, y: number): void { +server.decorateRequest('typedTestRequestProperty', { + getter (): boolean { + return true + }, + setter (x) { + expectType(x) + expectType(this) + } +}) +server.decorateRequest('typedTestRequestProperty') +server.decorateRequest('typedTestRequestProperty', null, ['foo']) +expectError(server.decorateRequest('typedTestRequestProperty', null)) +expectError(server.decorateRequest('typedTestRequestProperty', 'foo')) +expectError(server.decorateRequest('typedTestRequestProperty', { + getter () { + return 'foo' + } +})) +server.decorateRequest('typedTestRequestMethod', function (x) { + expectType(x) expectType(this) + return 'foo' }) +server.decorateRequest('typedTestRequestMethod', x => x) +expectError(server.decorateRequest('typedTestRequestMethod', function (x: boolean) { + return 'foo' +})) +expectError(server.decorateRequest('typedTestRequestMethod', function (x) { + return true +})) +expectError(server.decorateRequest('typedTestRequestMethod', async function (x) { + return 'foo' +})) -server.decorateReply<'test', (x: string) => void>('test', function (x: string): void { - expectType(this) +server.decorateReply('typedTestReplyProperty', false) +server.decorateReply('typedTestReplyProperty', { + getter () { + return false + } }) -server.decorateReply('test', function (x: string): void { +server.decorateReply('typedTestReplyProperty', { + getter (): boolean { + return true + }, + setter (x) { + expectType(x) + expectType(this) + } +}) +server.decorateReply('typedTestReplyProperty') +server.decorateReply('typedTestReplyProperty', null, ['foo']) +expectError(server.decorateReply('typedTestReplyProperty', null)) +expectError(server.decorateReply('typedTestReplyProperty', 'foo')) +expectError(server.decorateReply('typedTestReplyProperty', { + getter () { + return 'foo' + } +})) +server.decorateReply('typedTestReplyMethod', function (x) { + expectType(x) expectType(this) + return 'foo' }) - -expectError(server.decorate<'test', string>('test', true)) -expectError(server.decorate<'test', (myNumber: number) => number>('test', function (myNumber: number): string { - return '' +server.decorateReply('typedTestReplyMethod', x => x) +expectError(server.decorateReply('typedTestReplyMethod', function (x: boolean) { + return 'foo' +})) +expectError(server.decorateReply('typedTestReplyMethod', function (x) { + return true +})) +expectError(server.decorateReply('typedTestReplyMethod', async function (x) { + return 'foo' })) -const versionConstraintStrategy = { +const foo = server.getDecorator('foo') +expectType(foo) + +const versionConstraintStrategy: ConstraintStrategy> = { name: 'version', storage: () => ({ get: () => () => {}, @@ -354,7 +584,5 @@ const versionConstraintStrategy = { expectType(server.addConstraintStrategy(versionConstraintStrategy)) expectType(server.hasConstraintStrategy(versionConstraintStrategy.name)) -expectAssignable>(server.getDefaultRoute()) - expectType | undefined>(server.validatorCompiler) expectType | undefined>(server.serializerCompiler) diff --git a/test/types/logger.test-d.ts b/test/types/logger.test-d.ts index daac4c5e9b3..847cf768ff8 100644 --- a/test/types/logger.test-d.ts +++ b/test/types/logger.test-d.ts @@ -1,36 +1,45 @@ -import { expectError, expectType } from 'tsd' +import * as fs from 'node:fs' +import { IncomingMessage, Server, ServerResponse } from 'node:http' +import P from 'pino' +import { expectAssignable, expectDeprecated, expectError, expectNotAssignable, expectType } from 'tsd' import fastify, { + FastifyBaseLogger, FastifyLogFn, - LogLevel, - FastifyLoggerInstance, - FastifyRequest, FastifyReply, - FastifyBaseLogger + FastifyRequest, + LogLevel } from '../../fastify' -import { Server, IncomingMessage, ServerResponse } from 'http' -import * as fs from 'fs' -import P from 'pino' +import { FastifyLoggerInstance, ResSerializerReply } from '../../types/logger' -expectType(fastify().log) +expectType(fastify().log) -class Foo {} +class Foo { } ['trace', 'debug', 'info', 'warn', 'error', 'fatal'].forEach(logLevel => { - expectType(fastify().log[logLevel as LogLevel]) - expectType(fastify().log[logLevel as LogLevel]('')) - expectType(fastify().log[logLevel as LogLevel]({})) - expectType(fastify().log[logLevel as LogLevel]({ foo: 'bar' })) - expectType(fastify().log[logLevel as LogLevel](new Error())) - expectType(fastify().log[logLevel as LogLevel](new Foo())) + expectType( + fastify().log[logLevel as LogLevel] + ) + expectType( + fastify().log[logLevel as LogLevel]('') + ) + expectType( + fastify().log[logLevel as LogLevel]({}) + ) + expectType( + fastify().log[logLevel as LogLevel]({ foo: 'bar' }) + ) + expectType( + fastify().log[logLevel as LogLevel](new Error()) + ) + expectType( + fastify().log[logLevel as LogLevel](new Foo()) + ) }) -/* -// TODO make pino export BaseLogger again interface CustomLogger extends FastifyBaseLogger { customMethod(msg: string, ...args: unknown[]): void; } -// // ToDo https://github.com/pinojs/pino/issues/1100 class CustomLoggerImpl implements CustomLogger { level = 'info' customMethod (msg: string, ...args: unknown[]) { console.log(msg, args) } @@ -53,20 +62,19 @@ class CustomLoggerImpl implements CustomLogger { const customLogger = new CustomLoggerImpl() const serverWithCustomLogger = fastify< -Server, -IncomingMessage, -ServerResponse, -CustomLoggerImpl + Server, + IncomingMessage, + ServerResponse, + CustomLoggerImpl >({ logger: customLogger }) expectType(serverWithCustomLogger.log) -*/ const serverWithPino = fastify< -Server, -IncomingMessage, -ServerResponse, -P.Logger + Server, + IncomingMessage, + ServerResponse, + P.Logger >({ logger: P({ level: 'info', @@ -76,22 +84,36 @@ P.Logger expectType(serverWithPino.log) +serverWithPino.route({ + method: 'GET', + url: '/', + handler (request) { + expectType(this.log) + expectType(request.log) + } +}) + +serverWithPino.get('/', function (request) { + expectType(this.log) + expectType(request.log) +}) + const serverWithLogOptions = fastify< -Server, -IncomingMessage, -ServerResponse + Server, + IncomingMessage, + ServerResponse >({ logger: { level: 'info' } }) -expectType(serverWithLogOptions.log) +expectType(serverWithLogOptions.log) const serverWithFileOption = fastify< -Server, -IncomingMessage, -ServerResponse + Server, + IncomingMessage, + ServerResponse >({ logger: { level: 'info', @@ -99,7 +121,7 @@ ServerResponse } }) -expectType(serverWithFileOption.log) +expectType(serverWithFileOption.log) const serverAutoInferringTypes = fastify({ logger: { @@ -109,52 +131,78 @@ const serverAutoInferringTypes = fastify({ expectType(serverAutoInferringTypes.log) -const serverWithAutoInferredPino = fastify({ - logger: P({ +const serverWithLoggerInstance = fastify({ + loggerInstance: P({ level: 'info', redact: ['x-userinfo'] }) }) -expectType(serverWithAutoInferredPino.log) +expectType(serverWithLoggerInstance.log) -const serverAutoInferredFileOption = fastify({ +const serverWithPinoConfig = fastify({ logger: { level: 'info', - file: '/path/to/file' + serializers: { + req (IncomingMessage) { + expectType(IncomingMessage) + return { + method: 'method', + url: 'url', + version: 'version', + host: 'fastify.test', + remoteAddress: 'remoteAddress', + remotePort: 80, + other: '' + } + }, + res (ServerResponse) { + expectType>(ServerResponse) + expectAssignable & Pick>(ServerResponse) + expectNotAssignable(ServerResponse) + return { + statusCode: 'statusCode' + } + }, + err (FastifyError) { + return { + other: '', + type: 'type', + message: 'msg', + stack: 'stack' + } + } + } } }) -expectType(serverAutoInferredFileOption.log) +expectType(serverWithPinoConfig.log) -const serverAutoInferredPinoPrettyBooleanOption = fastify({ +const serverAutoInferredFileOption = fastify({ logger: { - prettyPrint: true + level: 'info', + file: '/path/to/file' } }) -expectType(serverAutoInferredPinoPrettyBooleanOption.log) +expectType(serverAutoInferredFileOption.log) -const serverAutoInferredPinoPrettyObjectOption = fastify({ +const serverAutoInferredSerializerResponseObjectOption = fastify({ logger: { - prettyPrint: { - translateTime: true, - levelFirst: false, - messageKey: 'msg', - timestampKey: 'time', - messageFormat: false, - colorize: true, - crlf: false, - errorLikeObjectKeys: ['err', 'error'], - errorProps: '', - search: 'foo == `bar`', - ignore: 'pid,hostname', - suppressFlushSyncWarning: true + serializers: { + res (ServerResponse) { + expectType>(ServerResponse) + expectAssignable & Pick>(ServerResponse) + expectNotAssignable(ServerResponse) + return { + status: '200' + } + } } } }) -expectType(serverAutoInferredPinoPrettyObjectOption.log) +expectType(serverAutoInferredSerializerResponseObjectOption.log) const serverAutoInferredSerializerObjectOption = fastify({ logger: { @@ -165,14 +213,16 @@ const serverAutoInferredSerializerObjectOption = fastify({ method: 'method', url: 'url', version: 'version', - hostname: 'hostname', + host: 'fastify.test', remoteAddress: 'remoteAddress', remotePort: 80, other: '' } }, res (ServerResponse) { - expectType(ServerResponse) + expectType>(ServerResponse) + expectAssignable & Pick>(ServerResponse) + expectNotAssignable(ServerResponse) return { statusCode: 'statusCode' } @@ -204,21 +254,22 @@ const passPinoOption = fastify({ redact: ['custom'], messageKey: 'msg', nestedKey: 'nested', - prettyPrint: { - - }, enabled: true } }) expectType(passPinoOption.log) +// FastifyLoggerInstance is deprecated +expectDeprecated({} as FastifyLoggerInstance) + const childParent = fastify().log // we test different option variant here -expectType(childParent.child({}, { level: 'info' })) -expectType(childParent.child({}, { redact: ['pass', 'pin'] })) -expectType(childParent.child({}, { serializers: { key: () => {} } })) -expectType(childParent.child({}, { level: 'info', redact: ['pass', 'pin'], serializers: { key: () => {} } })) +expectType(childParent.child({}, { level: 'info' })) +expectType(childParent.child({}, { level: 'silent' })) +expectType(childParent.child({}, { redact: ['pass', 'pin'] })) +expectType(childParent.child({}, { serializers: { key: () => { } } })) +expectType(childParent.child({}, { level: 'info', redact: ['pass', 'pin'], serializers: { key: () => { } } })) // no option pass expectError(childParent.child()) diff --git a/test/types/plugin.test-d.ts b/test/types/plugin.test-d.ts index 566ec6682b3..f5e48217b3a 100644 --- a/test/types/plugin.test-d.ts +++ b/test/types/plugin.test-d.ts @@ -1,6 +1,6 @@ -import fastify, { FastifyInstance, FastifyPluginOptions } from '../../fastify' -import * as http from 'http' -import * as https from 'https' +import fastify, { FastifyInstance, FastifyPluginOptions, SafePromiseLike } from '../../fastify' +import * as http from 'node:http' +import * as https from 'node:https' import { expectType, expectError, expectAssignable } from 'tsd' import { FastifyPluginCallback, FastifyPluginAsync } from '../../types/plugin' import { FastifyError } from '@fastify/error' @@ -10,11 +10,26 @@ interface TestOptions extends FastifyPluginOptions { option1: string; option2: boolean; } -const testPluginOpts: FastifyPluginCallback = function (instance, opts, done) { } -const testPluginOptsAsync: FastifyPluginAsync = async function (instance, opts) { } +const testOptions: TestOptions = { + option1: 'a', + option2: false +} +const testPluginOpts: FastifyPluginCallback = function (instance, opts, done) { + expectType(opts) +} +const testPluginOptsAsync: FastifyPluginAsync = async function (instance, opts) { + expectType(opts) +} -const testPluginOptsWithType = (instance: FastifyInstance, opts: FastifyPluginOptions, done: (error?: FastifyError) => void) => { } -const testPluginOptsWithTypeAsync = async (instance: FastifyInstance, opts: FastifyPluginOptions) => { } +const testPluginOptsWithType = ( + instance: FastifyInstance, + opts: FastifyPluginOptions, + done: (error?: FastifyError) => void +) => { } +const testPluginOptsWithTypeAsync = async ( + instance: FastifyInstance, + opts: FastifyPluginOptions +) => { } expectError(fastify().register(testPluginOpts, {})) // error because missing required options from generic declaration expectError(fastify().register(testPluginOptsAsync, {})) // error because missing required options from generic declaration @@ -35,18 +50,31 @@ expectAssignable(fastify().register(testPluginCallback, {})) const testPluginAsync: FastifyPluginAsync = async function (instance, opts) { } expectAssignable(fastify().register(testPluginAsync, {})) -expectAssignable(fastify().register(function (instance, opts): Promise { return Promise.resolve() })) +expectAssignable( + fastify().register(function (instance, opts): Promise { return Promise.resolve() }) +) expectAssignable(fastify().register(async function (instance, opts) { }, () => { })) expectAssignable(fastify().register(async function (instance, opts) { }, { logLevel: 'info', prefix: 'foobar' })) -expectError(fastify().register(function (instance, opts, done) { }, { logLevel: '' })) // must use a valid logLevel +expectError(fastify().register(function (instance, opts, done) { }, { ...testOptions, logLevel: '' })) // must use a valid logLevel const httpsServer = fastify({ https: {} }) -expectType & PromiseLike>>(httpsServer) +expectError< + FastifyInstance & + Promise> +>(httpsServer) +expectAssignable< + FastifyInstance & + PromiseLike> +>(httpsServer) +expectType< + FastifyInstance & + SafePromiseLike> +>(httpsServer) // Chainable httpsServer - .register(testPluginOpts) + .register(testPluginOpts, testOptions) .after((_error) => { }) .ready((_error) => { }) .close(() => { }) @@ -55,14 +83,15 @@ httpsServer expectAssignable>(httpsServer.after()) expectAssignable>(httpsServer.close()) expectAssignable>(httpsServer.ready()) -expectAssignable>(httpsServer.register(testPluginOpts)) +expectAssignable>(httpsServer.register(testPluginOpts, testOptions)) expectAssignable>(httpsServer.register(testPluginOptsWithType)) expectAssignable>(httpsServer.register(testPluginOptsWithTypeAsync)) expectAssignable>(httpsServer.register(testPluginOptsWithType, { prefix: '/test' })) expectAssignable>(httpsServer.register(testPluginOptsWithTypeAsync, { prefix: '/test' })) +/* eslint-disable @typescript-eslint/no-unused-vars */ async function testAsync (): Promise { await httpsServer - .register(testPluginOpts) - .register(testPluginOpts) + .register(testPluginOpts, testOptions) + .register(testPluginOpts, testOptions) } diff --git a/test/types/register.test-d.ts b/test/types/register.test-d.ts index 8cfd6b07117..a763a679407 100644 --- a/test/types/register.test-d.ts +++ b/test/types/register.test-d.ts @@ -1,7 +1,7 @@ import { expectAssignable, expectError, expectType } from 'tsd' -import { IncomingMessage, Server, ServerResponse } from 'http' -import { Http2Server, Http2ServerRequest, Http2ServerResponse } from 'http2' -import fastify, { FastifyInstance, FastifyError, FastifyLoggerInstance, FastifyPluginAsync, FastifyPluginCallback, FastifyPluginOptions, RawServerDefault } from '../../fastify' +import { IncomingMessage, Server, ServerResponse } from 'node:http' +import { Http2Server, Http2ServerRequest, Http2ServerResponse } from 'node:http2' +import fastify, { FastifyInstance, FastifyError, FastifyBaseLogger, FastifyPluginAsync, FastifyPluginCallback, FastifyPluginOptions, RawServerDefault } from '../../fastify' const testPluginCallback: FastifyPluginCallback = function (instance, opts, done) { } const testPluginAsync: FastifyPluginAsync = async function (instance, opts) { } @@ -9,7 +9,11 @@ const testPluginAsync: FastifyPluginAsync = async function (instance, opts) { } const testPluginOpts: FastifyPluginCallback = function (instance, opts, done) { } const testPluginOptsAsync: FastifyPluginAsync = async function (instance, opts) { } -const testPluginOptsWithType = (instance: FastifyInstance, opts: FastifyPluginOptions, done: (error?: FastifyError) => void) => { } +const testPluginOptsWithType = ( + instance: FastifyInstance, + opts: FastifyPluginOptions, + done: (error?: FastifyError) => void +) => { } const testPluginOptsWithTypeAsync = async (instance: FastifyInstance, opts: FastifyPluginOptions) => { } interface TestOptions extends FastifyPluginOptions { @@ -46,16 +50,31 @@ const serverWithHttp2 = fastify({ http2: true }) type ServerWithHttp2 = FastifyInstance const testPluginWithHttp2: FastifyPluginCallback = function (instance, opts, done) { } const testPluginWithHttp2Async: FastifyPluginAsync = async function (instance, opts) { } -const testPluginWithHttp2WithType = (instance: ServerWithHttp2, opts: FastifyPluginOptions, done: (error?: FastifyError) => void) => { } -const testPluginWithHttp2WithTypeAsync = async (instance: ServerWithHttp2, opts: FastifyPluginOptions) => { } +const testPluginWithHttp2WithType = ( + instance: ServerWithHttp2, + opts: FastifyPluginOptions, + done: (error?: FastifyError) => void +) => { } +const testPluginWithHttp2WithTypeAsync = async ( + instance: ServerWithHttp2, + opts: FastifyPluginOptions +) => { } +const testOptions: TestOptions = { + option1: 'a', + option2: false +} expectAssignable(serverWithHttp2.register(testPluginCallback)) expectAssignable(serverWithHttp2.register(testPluginAsync)) expectAssignable(serverWithHttp2.register(testPluginOpts)) expectAssignable(serverWithHttp2.register(testPluginOptsAsync)) expectAssignable(serverWithHttp2.register(testPluginOptsWithType)) expectAssignable(serverWithHttp2.register(testPluginOptsWithTypeAsync)) -expectAssignable(serverWithHttp2.register(testPluginWithHttp2)) -expectAssignable(serverWithHttp2.register(testPluginWithHttp2Async)) +// @ts-expect-error +serverWithHttp2.register(testPluginWithHttp2) +expectAssignable(serverWithHttp2.register(testPluginWithHttp2, testOptions)) +// @ts-expect-error +serverWithHttp2.register(testPluginWithHttp2Async) +expectAssignable(serverWithHttp2.register(testPluginWithHttp2Async, testOptions)) expectAssignable(serverWithHttp2.register(testPluginWithHttp2WithType)) expectAssignable(serverWithHttp2.register(testPluginWithHttp2WithTypeAsync)) expectAssignable(serverWithHttp2.register((instance) => { @@ -72,21 +91,46 @@ expectAssignable(serverWithHttp2.register(async (instance: Serv })) // With Type Provider -type TestTypeProvider = { input: 'test', output: 'test' } +type TestTypeProvider = { schema: 'test', validator: 'test', serializer: 'test' } const serverWithTypeProvider = fastify().withTypeProvider() -type ServerWithTypeProvider = FastifyInstance -const testPluginWithTypeProvider: FastifyPluginCallback = function (instance, opts, done) { } -const testPluginWithTypeProviderAsync: FastifyPluginAsync = async function (instance, opts) { } -const testPluginWithTypeProviderWithType = (instance: ServerWithTypeProvider, opts: FastifyPluginOptions, done: (error?: FastifyError) => void) => { } -const testPluginWithTypeProviderWithTypeAsync = async (instance: ServerWithTypeProvider, opts: FastifyPluginOptions) => { } +type ServerWithTypeProvider = FastifyInstance< + Server, + IncomingMessage, + ServerResponse, + FastifyBaseLogger, + TestTypeProvider +> +const testPluginWithTypeProvider: FastifyPluginCallback< + TestOptions, + RawServerDefault, + TestTypeProvider +> = function (instance, opts, done) { } +const testPluginWithTypeProviderAsync: FastifyPluginAsync< + TestOptions, + RawServerDefault, + TestTypeProvider +> = async function (instance, opts) { } +const testPluginWithTypeProviderWithType = ( + instance: ServerWithTypeProvider, + opts: FastifyPluginOptions, + done: (error?: FastifyError) => void +) => { } +const testPluginWithTypeProviderWithTypeAsync = async ( + instance: ServerWithTypeProvider, + opts: FastifyPluginOptions +) => { } expectAssignable(serverWithTypeProvider.register(testPluginCallback)) expectAssignable(serverWithTypeProvider.register(testPluginAsync)) expectAssignable(serverWithTypeProvider.register(testPluginOpts)) expectAssignable(serverWithTypeProvider.register(testPluginOptsAsync)) expectAssignable(serverWithTypeProvider.register(testPluginOptsWithType)) expectAssignable(serverWithTypeProvider.register(testPluginOptsWithTypeAsync)) -expectAssignable(serverWithTypeProvider.register(testPluginWithTypeProvider)) -expectAssignable(serverWithTypeProvider.register(testPluginWithTypeProviderAsync)) +// @ts-expect-error +serverWithTypeProvider.register(testPluginWithTypeProvider) +expectAssignable(serverWithTypeProvider.register(testPluginWithTypeProvider, testOptions)) +// @ts-expect-error +serverWithTypeProvider.register(testPluginWithTypeProviderAsync) +expectAssignable(serverWithTypeProvider.register(testPluginWithTypeProviderAsync, testOptions)) expectAssignable(serverWithTypeProvider.register(testPluginWithTypeProviderWithType)) expectAssignable(serverWithTypeProvider.register(testPluginWithTypeProviderWithTypeAsync)) expectAssignable(serverWithTypeProvider.register((instance) => { @@ -101,3 +145,93 @@ expectAssignable(serverWithTypeProvider.register(async ( expectAssignable(serverWithTypeProvider.register(async (instance: ServerWithTypeProvider) => { expectAssignable(instance) })) + +// With Type Provider and logger +const customLogger = { + level: 'info', + info: () => { }, + warn: () => { }, + error: () => { }, + fatal: () => { }, + trace: () => { }, + debug: () => { }, + child: () => customLogger, + silent: () => { } +} +const serverWithTypeProviderAndLogger = fastify({ + loggerInstance: customLogger +}).withTypeProvider() +type ServerWithTypeProviderAndLogger = FastifyInstance< + Server, + IncomingMessage, + ServerResponse, + typeof customLogger, + TestTypeProvider +> +const testPluginWithTypeProviderAndLogger: FastifyPluginCallback< + TestOptions, + RawServerDefault, + TestTypeProvider, + typeof customLogger +> = function (instance, opts, done) { } +const testPluginWithTypeProviderAndLoggerAsync: FastifyPluginAsync< + TestOptions, + RawServerDefault, + TestTypeProvider, + typeof customLogger +> = async function (instance, opts) { } +const testPluginWithTypeProviderAndLoggerWithType = ( + instance: ServerWithTypeProviderAndLogger, + opts: FastifyPluginOptions, + done: (error?: FastifyError) => void +) => { } +const testPluginWithTypeProviderAndLoggerWithTypeAsync = async ( + instance: ServerWithTypeProviderAndLogger, + opts: FastifyPluginOptions +) => { } +expectAssignable(serverWithTypeProviderAndLogger.register(testPluginCallback)) +expectAssignable(serverWithTypeProviderAndLogger.register(testPluginAsync)) +expectAssignable(serverWithTypeProviderAndLogger.register(testPluginOpts)) +expectAssignable(serverWithTypeProviderAndLogger.register(testPluginOptsAsync)) +expectAssignable(serverWithTypeProviderAndLogger.register(testPluginOptsWithType)) +expectAssignable(serverWithTypeProviderAndLogger.register(testPluginOptsWithTypeAsync)) +expectAssignable( + // @ts-expect-error + serverWithTypeProviderAndLogger.register(testPluginWithTypeProviderAndLogger) +) +expectAssignable( + serverWithTypeProviderAndLogger.register(testPluginWithTypeProviderAndLogger, testOptions) +) +expectAssignable( + // @ts-expect-error + serverWithTypeProviderAndLogger.register(testPluginWithTypeProviderAndLoggerAsync) +) +expectAssignable( + serverWithTypeProviderAndLogger.register(testPluginWithTypeProviderAndLoggerAsync, testOptions) +) +expectAssignable( + serverWithTypeProviderAndLogger.register(testPluginWithTypeProviderAndLoggerWithType) +) +expectAssignable( + serverWithTypeProviderAndLogger.register(testPluginWithTypeProviderAndLoggerWithTypeAsync) +) +expectAssignable( + serverWithTypeProviderAndLogger.register((instance) => { + expectAssignable(instance) + }) +) +expectAssignable( + serverWithTypeProviderAndLogger.register((instance: ServerWithTypeProviderAndLogger) => { + expectAssignable(instance) + }) +) +expectAssignable( + serverWithTypeProviderAndLogger.register(async (instance) => { + expectAssignable(instance) + }) +) +expectAssignable( + serverWithTypeProviderAndLogger.register(async (instance: ServerWithTypeProviderAndLogger) => { + expectAssignable(instance) + }) +) diff --git a/test/types/reply.test-d.ts b/test/types/reply.test-d.ts index f988d604358..ebad0649581 100644 --- a/test/types/reply.test-d.ts +++ b/test/types/reply.test-d.ts @@ -1,37 +1,68 @@ -import { expectType, expectError } from 'tsd' -import fastify, { RouteHandlerMethod, RouteHandler, RawRequestDefaultExpression, FastifyContext, FastifyContextConfig, FastifyRequest, FastifyReply } from '../../fastify' -import { RawServerDefault, RawReplyDefaultExpression, ContextConfigDefault } from '../../types/utils' -import { FastifyLoggerInstance } from '../../types/logger' -import { RouteGenericInterface } from '../../types/route' +import { Buffer } from 'node:buffer' +import { expectAssignable, expectError, expectType } from 'tsd' +import fastify, { FastifyContextConfig, FastifyReply, FastifyRequest, FastifySchema, FastifyTypeProviderDefault, RawRequestDefaultExpression, RouteHandler, RouteHandlerMethod } from '../../fastify' import { FastifyInstance } from '../../types/instance' -import { Buffer } from 'buffer' +import { FastifyBaseLogger } from '../../types/logger' +import { ResolveReplyTypeWithRouteGeneric } from '../../types/reply' +import { FastifyRouteConfig, RouteGenericInterface } from '../../types/route' +import { ContextConfigDefault, RawReplyDefaultExpression, RawServerDefault } from '../../types/utils' + +type DefaultSerializationFunction = (payload: { [key: string]: unknown }) => string +type DefaultFastifyReplyWithCode = FastifyReply> const getHandler: RouteHandlerMethod = function (_request, reply) { expectType(reply.raw) - expectType>(reply.context) - expectType(reply.context.config) - expectType(reply.log) + expectType(reply.log) expectType>(reply.request) - expectType<(statusCode: number) => FastifyReply>(reply.code) - expectType<(statusCode: number) => FastifyReply>(reply.status) + expectType<(statusCode: Code) => DefaultFastifyReplyWithCode>(reply.code) + expectType<(statusCode: Code) => DefaultFastifyReplyWithCode>(reply.status) + expectType<(...args: [payload?: unknown]) => FastifyReply>(reply.code(100 as number).send) + expectType(reply.elapsedTime) expectType(reply.statusCode) expectType(reply.sent) - expectType<((payload?: unknown) => FastifyReply)>(reply.send) - expectType<(key: string, value: any) => FastifyReply>(reply.header) - expectType<(values: {[key: string]: any}) => FastifyReply>(reply.headers) - expectType<(key: string) => string | undefined>(reply.getHeader) - expectType<() => { [key: string]: number | string | string[] | undefined }>(reply.getHeaders) - expectType<(key: string) => void>(reply.removeHeader) - expectType<(key: string) => boolean>(reply.hasHeader) - expectType<{(statusCode: number, url: string): FastifyReply; (url: string): FastifyReply }>(reply.redirect) + expectType< + (hints: Record, callback?: (() => void) | undefined) => void + >(reply.writeEarlyHints) + expectType<((...args: [payload?: unknown]) => FastifyReply)>(reply.send) + expectAssignable<(key: string, value: any) => FastifyReply>(reply.header) + expectAssignable<(values: { [key: string]: any }) => FastifyReply>(reply.headers) + expectAssignable<(key: string) => number | string | string[] | undefined>(reply.getHeader) + expectAssignable<() => { [key: string]: number | string | string[] | undefined }>(reply.getHeaders) + expectAssignable<(key: string) => FastifyReply>(reply.removeHeader) + expectAssignable<(key: string) => boolean>(reply.hasHeader) + expectType<(url: string, statusCode?: number) => FastifyReply>(reply.redirect) expectType<() => FastifyReply>(reply.hijack) expectType<() => void>(reply.callNotFound) - expectType<() => number>(reply.getResponseTime) expectType<(contentType: string) => FastifyReply>(reply.type) expectType<(fn: (payload: any) => string) => FastifyReply>(reply.serializer) expectType<(payload: any) => string | ArrayBuffer | Buffer>(reply.serialize) expectType<(fulfilled: () => void, rejected: (err: Error) => void) => void>(reply.then) + expectType< + ( + key: string, + fn: ((reply: FastifyReply, payload: string | Buffer | null) => Promise) | + ((reply: FastifyReply, payload: string | Buffer | null, + done: (err: Error | null, value?: string) => void) => void) + ) => FastifyReply + >(reply.trailer) + expectType<(key: string) => boolean>(reply.hasTrailer) + expectType<(key: string) => FastifyReply>(reply.removeTrailer) expectType(reply.server) + expectAssignable< + ((httpStatus: string) => DefaultSerializationFunction | undefined) + >(reply.getSerializationFunction) + expectAssignable< + ((schema: { [key: string]: unknown }) => DefaultSerializationFunction | undefined) + >(reply.getSerializationFunction) + expectAssignable< + ((schema: { [key: string]: unknown }, httpStatus?: string) => DefaultSerializationFunction) + >(reply.compileSerializationSchema) + expectAssignable< + ((input: { [key: string]: unknown }, schema: { [key: string]: unknown }, httpStatus?: string) => unknown) + >(reply.serializeInput) + expectAssignable<((input: { [key: string]: unknown }, httpStatus: string) => unknown)>(reply.serializeInput) + expectType(reply.routeOptions.config) + expectType(reply.getDecorator('foo')) } interface ReplyPayload { @@ -40,8 +71,58 @@ interface ReplyPayload { }; } +interface ReplyArrayPayload { + Reply: string[] +} + +interface ReplyUnion { + Reply: { + success: boolean; + } | { + error: string; + } +} + +interface ReplyHttpCodes { + Reply: { + '1xx': number, + 200: 'abc', + 201: boolean, + 300: { foo: string }, + } +} + +interface InvalidReplyHttpCodes { + Reply: { + '1xx': number, + 200: string, + 999: boolean, + } +} + +interface ReplyVoid { + Reply: void; +} + +interface ReplyUndefined { + Reply: undefined; +} + +// Issue #5534 scenario: 204 No Content should allow empty send(), 201 Created should require payload +// Note: `204: undefined` gets converted to `unknown` via UndefinedToUnknown in type-provider.d.ts, +// meaning send() is optional but send({}) is also allowed. Use `void` instead of `undefined` +// if you want stricter "no payload allowed" semantics. +interface ReplyHttpCodesWithNoContent { + Reply: { + 201: { id: string }; + 204: undefined; + } +} + const typedHandler: RouteHandler = async (request, reply) => { - expectType<((payload?: ReplyPayload['Reply']) => FastifyReply, RawReplyDefaultExpression, ReplyPayload>)>(reply.send) + // When Reply type is specified, send() requires a payload argument + expectType<((...args: [payload: ReplyPayload['Reply']]) => FastifyReply, RawReplyDefaultExpression>)>(reply.send) + expectType<((...args: [payload: ReplyPayload['Reply']]) => FastifyReply, RawReplyDefaultExpression>)>(reply.code(100).send) } const server = fastify() @@ -50,12 +131,124 @@ server.get('/typed', typedHandler) server.get('/get-generic-send', async function handler (request, reply) { reply.send({ test: true }) }) +// When Reply type is specified, send() requires a payload - calling without arguments should error +expectError(server.get('/get-generic-send-missing-payload', async function handler (request, reply) { + reply.send() +})) server.get('/get-generic-return', async function handler (request, reply) { return { test: false } }) -expectError(server.get('/get-generic-return-error', async function handler (request, reply) { +expectError(server.get('/get-generic-send-error', async function handler (request, reply) { reply.send({ foo: 'bar' }) })) expectError(server.get('/get-generic-return-error', async function handler (request, reply) { return { foo: 'bar' } })) +server.get('/get-generic-union-send', async function handler (request, reply) { + if (0 as number === 0) { + reply.send({ success: true }) + } else { + reply.send({ error: 'error' }) + } +}) +server.get('/get-generic-union-return', async function handler (request, reply) { + if (0 as number === 0) { + return { success: true } + } else { + return { error: 'error' } + } +}) +expectError(server.get('/get-generic-union-send-error-1', async function handler (request, reply) { + reply.send({ successes: true }) +})) +expectError(server.get('/get-generic-union-send-error-2', async function handler (request, reply) { + reply.send({ error: 500 }) +})) +expectError(server.get('/get-generic-union-return-error-1', async function handler (request, reply) { + return { successes: true } +})) +expectError(server.get('/get-generic-union-return-error-2', async function handler (request, reply) { + return { error: 500 } +})) +server.get('/get-generic-http-codes-send', async function handler (request, reply) { + reply.code(200).send('abc') + reply.code(201).send(true) + reply.code(300).send({ foo: 'bar' }) + reply.code(101).send(123) +}) +expectError(server.get('/get-generic-http-codes-send-error-1', async function handler (request, reply) { + reply.code(200).send('def') +})) +expectError(server.get('/get-generic-http-codes-send-error-2', async function handler (request, reply) { + reply.code(201).send(0) +})) +expectError(server.get('/get-generic-http-codes-send-error-3', async function handler (request, reply) { + reply.code(300).send({ foo: 123 }) +})) +expectError(server.get('/get-generic-http-codes-send-error-4', async function handler (request, reply) { + reply.code(100).send('asdasd') +})) +expectError(server.get('/get-generic-http-codes-send-error-5', async function handler (request, reply) { + reply.code(401).send({ foo: 123 }) +})) +server.get('/get-generic-array-send', async function handler (request, reply) { + reply.code(200).send(['']) +}) +expectError(server.get('get-invalid-http-codes-reply-error', async function handler (request, reply) { + reply.code(200).send('') +})) +server.get('get-invalid-http-codes-reply-error', async function handler (request, reply) { + reply.code(200).send({ + '1xx': 0, + 200: '', + 999: false + }) +}) + +/* eslint-disable @typescript-eslint/no-unused-vars */ +const httpHeaderHandler: RouteHandlerMethod = function (_request, reply) { + // accept is a header provided by @types/node + reply.getHeader('accept') + /* eslint-disable @typescript-eslint/no-unused-expressions */ + reply.getHeaders().accept + reply.hasHeader('accept') + reply.header('accept', 'test') + reply.headers({ accept: 'test' }) + reply.removeHeader('accept') + + // x-fastify-test is not a header provided by @types/node + // and should not result in a typing error + reply.getHeader('x-fastify-test') + reply.getHeaders()['x-fastify-test'] + reply.hasHeader('x-fastify-test') + reply.header('x-fastify-test', 'test') + reply.headers({ 'x-fastify-test': 'test' }) + reply.removeHeader('x-fastify-test') +} + +// Test: send() without arguments is valid when no Reply type is specified (default unknown) +server.get('/get-no-type-send-empty', async function handler (request, reply) { + reply.send() +}) + +// Test: send() without arguments is valid when Reply type is void +server.get('/get-void-send-empty', async function handler (request, reply) { + reply.send() +}) + +// Test: send() without arguments is valid when Reply type is undefined +server.get('/get-undefined-send-empty', async function handler (request, reply) { + reply.send() +}) + +// Issue #5534 scenario: HTTP status codes with 204 No Content +server.get('/get-http-codes-no-content', async function handler (request, reply) { + // 204 No Content - send() without payload is valid because Reply is undefined + reply.code(204).send() + // 201 Created - send() requires payload + reply.code(201).send({ id: '123' }) +}) +// 201 Created without payload should error +expectError(server.get('/get-http-codes-201-missing-payload', async function handler (request, reply) { + reply.code(201).send() +})) diff --git a/test/types/request.test-d.ts b/test/types/request.test-d.ts index fbff88ee096..8e8267dc3ae 100644 --- a/test/types/request.test-d.ts +++ b/test/types/request.test-d.ts @@ -1,27 +1,25 @@ -import { expectType } from 'tsd' -import pino from 'pino' +import { expectAssignable, expectError, expectType } from 'tsd' import fastify, { - RouteHandler, - RawRequestDefaultExpression, - RequestBodyDefault, - RequestGenericInterface, - FastifyContext, ContextConfigDefault, FastifyContextConfig, FastifyLogFn, - RouteHandlerMethod, - RawServerDefault, - RawReplyDefaultExpression, FastifySchema, - FastifyTypeProviderDefault + FastifyTypeProviderDefault, + RawReplyDefaultExpression, + RawRequestDefaultExpression, + RawServerDefault, + RequestBodyDefault, + RequestGenericInterface, + RouteHandler, + RouteHandlerMethod, + SafePromiseLike } from '../../fastify' -import { RequestParamsDefault, RequestHeadersDefault, RequestQuerystringDefault } from '../../types/utils' -import { FastifyLoggerInstance } from '../../types/logger' -import { FastifyRequest } from '../../types/request' -import { FastifyReply } from '../../types/reply' import { FastifyInstance } from '../../types/instance' -import { RouteGenericInterface } from '../../types/route' -import { ResolveFastifyReplyReturnType, ResolveFastifyRequestType } from '../../types/type-provider' +import { FastifyBaseLogger } from '../../types/logger' +import { FastifyReply } from '../../types/reply' +import { FastifyRequest, RequestRouteOptions } from '../../types/request' +import { FastifyRouteConfig, RouteGenericInterface } from '../../types/route' +import { RequestHeadersDefault, RequestParamsDefault, RequestQuerystringDefault } from '../../types/utils' interface RequestBody { content: string; @@ -55,52 +53,81 @@ type CustomRequest = FastifyRequest<{ Headers: RequestHeaders; }> -interface CustomLoggerInterface extends FastifyLoggerInstance { +type HTTPRequestPart = 'body' | 'query' | 'querystring' | 'params' | 'headers' +type ExpectedGetValidationFunction = (input: { [key: string]: unknown }) => boolean + +interface CustomLoggerInterface extends FastifyBaseLogger { foo: FastifyLogFn; // custom severity logger method } const getHandler: RouteHandler = function (request, _reply) { expectType(request.url) + expectType(request.originalUrl) expectType(request.method) - expectType(request.routerPath) - expectType(request.routerMethod) + expectType>(request.routeOptions) expectType(request.is404) expectType(request.hostname) + expectType(request.host) + expectType(request.port) expectType(request.ip) expectType(request.ips) expectType(request.raw) expectType(request.body) expectType(request.params) - expectType>(request.context) - expectType(request.context.config) + expectType(request.routeOptions.config) + expectType(request.routeOptions.schema) + expectType(request.routeOptions.handler) + expectType(request.routeOptions.url) + expectType(request.routeOptions.version) expectType(request.headers) request.headers = {} expectType(request.query) - expectType(request.id) - expectType(request.log) + expectType(request.id) + expectType(request.log) expectType(request.socket) + expectType(request.signal) expectType(request.validationError) expectType(request.server) + expectAssignable<(httpPart: HTTPRequestPart) => ExpectedGetValidationFunction>(request.getValidationFunction) + expectAssignable<(schema: { [key: string]: unknown }) => ExpectedGetValidationFunction>(request.getValidationFunction) + expectAssignable< + (input: { [key: string]: unknown }, schema: { [key: string]: unknown }, httpPart?: HTTPRequestPart) => boolean + >(request.validateInput) + expectAssignable<(input: { [key: string]: unknown }, httpPart?: HTTPRequestPart) => boolean>(request.validateInput) + expectType(request.getDecorator('foo')) + expectType(request.setDecorator('foo', 'hello')) + expectType(request.setDecorator('foo', 'hello')) + expectError(request.setDecorator('foo', true)) } -const getHandlerWithCustomLogger: RouteHandlerMethod, CustomLoggerInterface> = function (request, _reply) { +const getHandlerWithCustomLogger: RouteHandlerMethod< + RawServerDefault, + RawRequestDefaultExpression, + RawReplyDefaultExpression, + RouteGenericInterface, + ContextConfigDefault, + FastifySchema, + FastifyTypeProviderDefault, + CustomLoggerInterface +> = function (request, _reply) { expectType(request.log) } const postHandler: Handler = function (request) { expectType(request.body) expectType(request.params) - expectType(request.headers) + expectType( + request.headers + ) expectType(request.query) expectType(request.body.content) expectType(request.query.from) expectType(request.params.id) expectType(request.headers['x-foobar']) expectType(request.server) - expectType>(request.context) - expectType(request.context.config) + expectType(request.routeOptions.config) } function putHandler (request: CustomRequest, reply: FastifyReply) { @@ -108,7 +135,7 @@ function putHandler (request: CustomRequest, reply: FastifyReply) { expectType(request.params) expectType(request.headers) expectType(request.query) - if (typeof request.body === 'undefined') { + if (request.body === undefined) { expectType(request.body) } else { expectType(request.body.content) @@ -117,8 +144,7 @@ function putHandler (request: CustomRequest, reply: FastifyReply) { expectType(request.params.id) expectType(request.headers['x-foobar']) expectType(request.server) - expectType>(request.context) - expectType(request.context.config) + expectType(request.routeOptions.config) } const server = fastify() @@ -128,17 +154,6 @@ server.put('/put', putHandler) const customLogger: CustomLoggerInterface = { level: 'info', - version: '5.0', - useOnlyCustomLevels: false, - useLevelLabels: false, - levels: { labels: [], values: {} }, - eventNames: () => [], - listenerCount: (eventName: string | symbol) => 0, - bindings: () => ({}), - flush: () => () => {}, - customLevels: { foo: 1 }, - isLevelEnabled: () => false, - levelVal: 0, silent: () => { }, info: () => { }, warn: () => { }, @@ -147,27 +162,27 @@ const customLogger: CustomLoggerInterface = { trace: () => { }, debug: () => { }, foo: () => { }, // custom severity logger method - on: (event, listener) => customLogger, - emit: (event, listener) => false, - off: (event, listener) => customLogger, - addListener: (event, listener) => customLogger, - prependListener: (event, listener) => customLogger, - prependOnceListener: (event, listener) => customLogger, - removeListener: (event, listener) => customLogger, - removeAllListeners: (event) => customLogger, - setMaxListeners: (n) => customLogger, - getMaxListeners: () => 0, - listeners: () => [], - rawListeners: () => [], - once: (event, listener) => customLogger, - child: () => customLogger as pino.Logger, - setBindings: (bindings) => { } + child: () => customLogger } -const serverWithCustomLogger = fastify({ logger: customLogger }) +const serverWithCustomLogger = fastify({ loggerInstance: customLogger }) +expectError< +FastifyInstance +& Promise< + FastifyInstance +> +>(serverWithCustomLogger) +expectAssignable< +FastifyInstance +& PromiseLike< + FastifyInstance +> +>(serverWithCustomLogger) expectType< FastifyInstance -& PromiseLike> +& SafePromiseLike< + FastifyInstance +> >(serverWithCustomLogger) serverWithCustomLogger.get('/get', getHandlerWithCustomLogger) diff --git a/test/types/route.test-d.ts b/test/types/route.test-d.ts index 0b10b9fa612..26e4ae4647f 100644 --- a/test/types/route.test-d.ts +++ b/test/types/route.test-d.ts @@ -1,9 +1,10 @@ -import fastify, { FastifyInstance, FastifyRequest, FastifyReply, RouteHandlerMethod } from '../../fastify' -import { expectType, expectError, expectAssignable, printType } from 'tsd' -import { HTTPMethods } from '../../types/utils' -import * as http from 'http' -import { RequestPayload } from '../../types/hooks' import { FastifyError } from '@fastify/error' +import * as http from 'node:http' +import { expectAssignable, expectError, expectType } from 'tsd' +import fastify, { FastifyInstance, FastifyReply, FastifyRequest, RouteHandlerMethod } from '../../fastify' +import { RequestPayload } from '../../types/hooks' +import { FindMyWayFindResult } from '../../types/instance' +import { HTTPMethods, RawServerDefault } from '../../types/utils' /* * Testing Fastify HTTP Routes and Route Shorthands. @@ -18,6 +19,23 @@ declare module '../../fastify' { interface FastifyContextConfig { foo: string; bar: number; + includeMessage?: boolean; + } + + /* eslint-disable @typescript-eslint/no-unused-vars */ + interface FastifyRequest< + RouteGeneric, + RawServer, + RawRequest, + SchemaCompiler, + TypeProvider, + ContextConfig, + Logger, + RequestType + > { + message: ContextConfig extends { includeMessage: true } + ? string + : null; } } @@ -35,9 +53,35 @@ const routeHandlerWithReturnValue: RouteHandlerMethod = function (request, reply return reply.send() } -type LowerCaseHTTPMethods = 'get' | 'post' | 'put' | 'patch' | 'head' | 'delete' | 'options' +const asyncPreHandler = async (request: FastifyRequest) => { + expectType(request) +} + +fastify().get('/', { preHandler: asyncPreHandler }, async () => 'this is an example') + +fastify().get( + '/', + { config: { foo: 'bar', bar: 100, includeMessage: true } }, + (req) => { + expectType(req.message) + } +) -;['GET', 'POST', 'PUT', 'PATCH', 'HEAD', 'DELETE', 'OPTIONS'].forEach(method => { +fastify().get( + '/', + { config: { foo: 'bar', bar: 100, includeMessage: false } }, + (req) => { + expectType(req.message) + } +) + +type LowerCaseHTTPMethods = 'delete' | 'get' | 'head' | 'patch' | 'post' | 'put' | + 'options' | 'propfind' | 'proppatch' | 'mkcol' | 'copy' | 'move' | 'lock' | + 'unlock' | 'trace' | 'search' | 'mkcalendar' | 'report' + + ;['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT', 'OPTIONS', 'PROPFIND', + 'PROPPATCH', 'MKCOL', 'COPY', 'MOVE', 'LOCK', 'UNLOCK', 'TRACE', 'SEARCH', 'MKCALENDAR', 'REPORT' +].forEach(method => { // route method expectType(fastify().route({ method: method as HTTPMethods, @@ -57,6 +101,9 @@ type LowerCaseHTTPMethods = 'get' | 'post' | 'put' | 'patch' | 'head' | 'delete' errorHandler: (error, request, reply) => { expectType(error) reply.send('error') + }, + childLoggerFactory: function (logger, bindings, opts) { + return logger.child(bindings, opts) } })) @@ -79,12 +126,16 @@ type LowerCaseHTTPMethods = 'get' | 'post' | 'put' | 'patch' | 'head' | 'delete' expectType(req.query) expectType(req.params) expectType(req.headers) - expectType(req.context.config.foo) - expectType(req.context.config.bar) - expectType(req.context.config.extra) - expectType(res.context.config.foo) - expectType(res.context.config.bar) - expectType(res.context.config.extra) + expectType(req.routeOptions.config.foo) + expectType(req.routeOptions.config.bar) + expectType(req.routeOptions.config.extra) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) + expectType(res.routeOptions.config.foo) + expectType(res.routeOptions.config.bar) + expectType(res.routeOptions.config.extra) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) }) fastify().route({ @@ -97,20 +148,28 @@ type LowerCaseHTTPMethods = 'get' | 'post' | 'put' | 'patch' | 'head' | 'delete' expectType(req.query) expectType(req.params) expectType(req.headers) - expectType(req.context.config.foo) - expectType(req.context.config.bar) - expectType(res.context.config.foo) - expectType(res.context.config.bar) + expectType(req.routeOptions.config.foo) + expectType(req.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) + expectType(res.routeOptions.config.foo) + expectType(res.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) }, preParsing: (req, res, payload, done) => { expectType(req.body) expectType(req.query) expectType(req.params) expectType(req.headers) - expectType(req.context.config.foo) - expectType(req.context.config.bar) - expectType(res.context.config.foo) - expectType(res.context.config.bar) + expectType(req.routeOptions.config.foo) + expectType(req.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) + expectType(res.routeOptions.config.foo) + expectType(res.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) expectType(payload) expectAssignable<(err?: FastifyError | null, res?: RequestPayload) => void>(done) expectAssignable<(err?: NodeJS.ErrnoException) => void>(done) @@ -120,78 +179,255 @@ type LowerCaseHTTPMethods = 'get' | 'post' | 'put' | 'patch' | 'head' | 'delete' expectType(req.query) expectType(req.params) expectType(req.headers) - expectType(req.context.config.foo) - expectType(req.context.config.bar) - expectType(res.context.config.foo) - expectType(res.context.config.bar) + expectType(req.routeOptions.config.foo) + expectType(req.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) + expectType(res.routeOptions.config.foo) + expectType(res.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) }, preHandler: (req, res, done) => { expectType(req.body) expectType(req.query) expectType(req.params) expectType(req.headers) - expectType(req.context.config.foo) - expectType(req.context.config.bar) - expectType(res.context.config.foo) - expectType(res.context.config.bar) + expectType(req.routeOptions.config.foo) + expectType(req.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) + expectType(res.routeOptions.config.foo) + expectType(res.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) }, onResponse: (req, res, done) => { expectType(req.body) expectType(req.query) expectType(req.params) expectType(req.headers) - expectType(req.context.config.foo) - expectType(req.context.config.bar) - expectType(res.context.config.foo) - expectType(res.context.config.bar) + expectType(req.routeOptions.config.foo) + expectType(req.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) + expectType(res.routeOptions.config.foo) + expectType(res.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) + expectType(res.statusCode) + }, + onError: (req, res, error, done) => { + expectType(req.body) + expectType(req.query) + expectType(req.params) + expectType(req.headers) + expectType(req.routeOptions.config.foo) + expectType(req.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) + expectType(res.routeOptions.config.foo) + expectType(res.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) + }, + preSerialization: (req, res, payload, done) => { + expectType(req.body) + expectType(req.query) + expectType(req.params) + expectType(req.headers) + expectType(req.routeOptions.config.foo) + expectType(req.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) + expectType(res.routeOptions.config.foo) + expectType(res.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) + }, + onSend: (req, res, payload, done) => { + expectType(req.body) + expectType(req.query) + expectType(req.params) + expectType(req.headers) + expectType(req.routeOptions.config.foo) + expectType(req.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) + expectType(res.routeOptions.config.foo) + expectType(res.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) + }, + handler: (req, res) => { + expectType(req.body) + expectType(req.query) + expectType(req.params) + expectType(req.headers) + expectType(req.routeOptions.config.foo) + expectType(req.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) + expectType(res.routeOptions.config.foo) + expectType(res.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) + } + }) + + fastify().route({ + url: '/', + method: method as HTTPMethods, + config: { foo: 'bar', bar: 100 }, + prefixTrailingSlash: 'slash', + onRequest: async (req, res, done) => { // these handlers are tested in `hooks.test-d.ts` + expectType(req.body) + expectType(req.query) + expectType(req.params) + expectType(req.headers) + expectType(req.routeOptions.config.foo) + expectType(req.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) + expectType(res.routeOptions.config.foo) + expectType(res.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) + }, + preParsing: async (req, res, payload, done) => { + expectType(req.body) + expectType(req.query) + expectType(req.params) + expectType(req.headers) + expectType(req.routeOptions.config.foo) + expectType(req.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) + expectType(res.routeOptions.config.foo) + expectType(res.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) + expectType(payload) + expectAssignable<(err?: FastifyError | null, res?: RequestPayload) => void>(done) + expectAssignable<(err?: NodeJS.ErrnoException) => void>(done) + }, + preValidation: async (req, res, done) => { + expectType(req.body) + expectType(req.query) + expectType(req.params) + expectType(req.headers) + expectType(req.routeOptions.config.foo) + expectType(req.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) + expectType(res.routeOptions.config.foo) + expectType(res.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) + }, + preHandler: async (req, res, done) => { + expectType(req.body) + expectType(req.query) + expectType(req.params) + expectType(req.headers) + expectType(req.routeOptions.config.foo) + expectType(req.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) + expectType(res.routeOptions.config.foo) + expectType(res.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) + }, + onResponse: async (req, res, done) => { + expectType(req.body) + expectType(req.query) + expectType(req.params) + expectType(req.headers) + expectType(req.routeOptions.config.foo) + expectType(req.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) + expectType(res.routeOptions.config.foo) + expectType(res.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) expectType(res.statusCode) }, - onError: (req, res, done) => { + onError: async (req, res, error, done) => { expectType(req.body) expectType(req.query) expectType(req.params) expectType(req.headers) - expectType(req.context.config.foo) - expectType(req.context.config.bar) - expectType(res.context.config.foo) - expectType(res.context.config.bar) + expectType(req.routeOptions.config.foo) + expectType(req.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) + expectType(res.routeOptions.config.foo) + expectType(res.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) }, - preSerialization: (req, res, done) => { + preSerialization: async (req, res, payload, done) => { expectType(req.body) expectType(req.query) expectType(req.params) expectType(req.headers) - expectType(req.context.config.foo) - expectType(req.context.config.bar) - expectType(res.context.config.foo) - expectType(res.context.config.bar) + expectType(req.routeOptions.config.foo) + expectType(req.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) + expectType(res.routeOptions.config.foo) + expectType(res.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) }, - onSend: (req, res, done) => { + onSend: async (req, res, payload, done) => { expectType(req.body) expectType(req.query) expectType(req.params) expectType(req.headers) - expectType(req.context.config.foo) - expectType(req.context.config.bar) - expectType(res.context.config.foo) - expectType(res.context.config.bar) + expectType(req.routeOptions.config.foo) + expectType(req.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) + expectType(res.routeOptions.config.foo) + expectType(res.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) }, handler: (req, res) => { expectType(req.body) expectType(req.query) expectType(req.params) expectType(req.headers) - expectType(req.context.config.foo) - expectType(req.context.config.bar) - expectType(res.context.config.foo) - expectType(res.context.config.bar) + expectType(req.routeOptions.config.foo) + expectType(req.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) + expectType(res.routeOptions.config.foo) + expectType(res.routeOptions.config.bar) + expectType(req.routeOptions.config.url) + expectType(req.routeOptions.config.method) } }) }) -expectError(fastify().route({ +expectType(fastify().route({ url: '/', - method: 'CONNECT', // not a valid method + method: 'CONNECT', // not a valid method but could be implemented by the user + handler: routeHandler +})) + +expectType(fastify().route({ + url: '/', + method: 'OPTIONS', + handler: routeHandler +})) + +expectType(fastify().route({ + url: '/', + method: 'OPTION', // OPTION is a typo for OPTIONS handler: routeHandler })) @@ -201,7 +437,7 @@ expectType(fastify().route({ handler: routeHandler })) -expectError(fastify().route({ +expectType(fastify().route({ url: '/', method: ['GET', 'POST', 'OPTION'], // OPTION is a typo for OPTIONS handler: routeHandler @@ -230,3 +466,88 @@ expectType(fastify().route({ method: 'GET', handler: routeHandlerWithReturnValue })) + +expectType(fastify().hasRoute({ + url: '/', + method: 'GET' +})) + +expectType(fastify().hasRoute({ + url: '/', + method: 'GET', + constraints: { version: '1.2.0' } +})) + +expectType(fastify().hasRoute({ + url: '/', + method: 'GET', + constraints: { host: 'auth.fastify.test' } +})) + +expectType(fastify().hasRoute({ + url: '/', + method: 'GET', + constraints: { host: /.*\.fastify\.test$/ } +})) + +expectType(fastify().hasRoute({ + url: '/', + method: 'GET', + constraints: { host: /.*\.fastify\.test$/, version: '1.2.3' } +})) + +expectType(fastify().hasRoute({ + url: '/', + method: 'GET', + constraints: { + // constraints value should accept any value + number: 12, + date: new Date(), + boolean: true, + function: () => { }, + object: { foo: 'bar' } + } +})) + +expectType, 'store'>>( + fastify().findRoute({ + url: '/', + method: 'get' + }) +) + +// we should not expose store +expectError(fastify().findRoute({ + url: '/', + method: 'get' +}).store) + +expectType(fastify().route({ + url: '/', + method: 'get', + handler: routeHandlerWithReturnValue +})) + +expectType(fastify().route({ + url: '/', + method: ['put', 'patch'], + handler: routeHandlerWithReturnValue +})) + +expectType(fastify().route({ + url: '/', + method: 'GET', + handler: (req) => { + expectType(req.routeOptions.method) + expectAssignable>(req.routeOptions.method) + } +})) + +expectType(fastify().route({ + url: '/', + method: ['HEAD', 'GET'], + handler: (req) => { + expectType(req.routeOptions.method) + expectAssignable>(req.routeOptions.method) + } +})) diff --git a/test/types/schema.test-d.ts b/test/types/schema.test-d.ts index ef871b1badf..5d3934d6fe2 100644 --- a/test/types/schema.test-d.ts +++ b/test/types/schema.test-d.ts @@ -1,9 +1,8 @@ -import { expectAssignable, expectError } from 'tsd' -import fastify, { FastifyInstance, FastifyRequest, FastifySchema } from '../../fastify' -import { RouteGenericInterface } from '../../types/route' -import { ContextConfigDefault } from '../../types/utils' -import { FastifyReply } from '../../types/reply' +import { StandaloneValidator } from '@fastify/ajv-compiler' +import { StandaloneSerializer } from '@fastify/fast-json-stringify-compiler' import Ajv from 'ajv' +import { expectAssignable } from 'tsd' +import fastify, { FastifyInstance, FastifySchema } from '../../fastify' const server = fastify() @@ -21,6 +20,25 @@ expectAssignable(server.get( () => { } )) +expectAssignable(server.post( + '/multiple-content-schema', + { + schema: { + body: { + content: { + 'application/json': { + schema: { type: 'object' } + }, + 'text/plain': { + schema: { type: 'string' } + } + } + } + } + }, + () => { } +)) + expectAssignable(server.get( '/empty-schema', { @@ -54,6 +72,27 @@ expectAssignable(server.post('/test', { } }, async req => req.body)) +expectAssignable(server.post('/test', { + validatorCompiler: ({ schema }) => { + return data => { + if (!data || data.constructor !== Object) { + return { + error: [ + { + keyword: 'type', + instancePath: '', + schemaPath: '#/type', + params: { type: 'object' }, + message: 'value is not an object' + } + ] + } + } + return { value: data } + } + } +}, async req => req.body)) + expectAssignable(server.setValidatorCompiler }>( function ({ schema }) { return new Ajv().compile(schema) @@ -63,3 +102,34 @@ expectAssignable(server.setValidatorCompiler(server.setSerializerCompiler( () => data => JSON.stringify(data) )) + +// https://github.com/fastify/ajv-compiler/issues/95 +{ + const factory = StandaloneValidator({ + readMode: false, + storeFunction (routeOpts, schemaValidationCode) { } + }) + + fastify({ + schemaController: { + compilersFactory: { + buildValidator: factory + } + } + }) +} + +{ + const factory = StandaloneSerializer({ + readMode: false, + storeFunction (routeOpts, schemaValidationCode) { } + }) + + fastify({ + schemaController: { + compilersFactory: { + buildSerializer: factory + } + } + }) +} diff --git a/test/types/serverFactory.test-d.ts b/test/types/serverFactory.test-d.ts index d8788bcf558..ecb04b5f61c 100644 --- a/test/types/serverFactory.test-d.ts +++ b/test/types/serverFactory.test-d.ts @@ -1,9 +1,9 @@ import fastify, { FastifyServerFactory } from '../../fastify' -import * as http from 'http' +import * as http from 'node:http' import { expectType } from 'tsd' // Custom Server -type CustomType = void; +type CustomType = void interface CustomIncomingMessage extends http.IncomingMessage { fakeMethod?: () => CustomType; } diff --git a/test/types/type-provider.test-d.ts b/test/types/type-provider.test-d.ts index 5e8a9d38c22..6cf04359225 100644 --- a/test/types/type-provider.test-d.ts +++ b/test/types/type-provider.test-d.ts @@ -1,15 +1,16 @@ import fastify, { - ContextConfigDefault, FastifySchema, - FastifyTypeProvider, RawReplyDefaultExpression, - RawRequestDefaultExpression, - RawServerDefault, - RouteHandlerMethod + FastifyTypeProvider, + HookHandlerDoneFunction, + FastifyRequest, + FastifyReply, + FastifyInstance, + FastifyError, + SafePromiseLike } from '../../fastify' import { expectAssignable, expectError, expectType } from 'tsd' -import { IncomingHttpHeaders } from 'http' -import { Type, TSchema, Static } from '@sinclair/typebox' +import { IncomingHttpHeaders } from 'node:http' +import { Type, TSchema, Static } from 'typebox' import { FromSchema, JSONSchema } from 'json-schema-to-ts' -import { RouteGenericInterface } from '../../types/route' const server = fastify() @@ -23,7 +24,10 @@ expectAssignable(server.get('/', (req) => expectType(req.body))) // Remapping // ------------------------------------------------------------------- -interface NumberProvider extends FastifyTypeProvider { output: number } // remap all schemas to numbers +interface NumberProvider extends FastifyTypeProvider { + validator: number + serializer: number +} // remap all schemas to numbers expectAssignable(server.withTypeProvider().get( '/', @@ -47,7 +51,7 @@ expectAssignable(server.withTypeProvider().get( // Override // ------------------------------------------------------------------- -interface OverriddenProvider extends FastifyTypeProvider { output: 'inferenced' } +interface OverriddenProvider extends FastifyTypeProvider { validator: 'inferenced' } expectAssignable(server.withTypeProvider().get<{ Body: 'override' }>( '/', @@ -69,7 +73,10 @@ expectAssignable(server.withTypeProvider().get<{ Body: 'over // TypeBox // ------------------------------------------------------------------- -interface TypeBoxProvider extends FastifyTypeProvider { output: this['input'] extends TSchema ? Static : never } +interface TypeBoxProvider extends FastifyTypeProvider { + validator: this['schema'] extends TSchema ? Static : unknown + serializer: this['schema'] extends TSchema ? Static : unknown +} expectAssignable(server.withTypeProvider().get( '/', @@ -80,6 +87,14 @@ expectAssignable(server.withTypeProvider().get( y: Type.Number(), z: Type.Number() }) + }, + errorHandler: (error, request, reply) => { + expectType(error) + expectAssignable(request) + expectType(request.body.x) + expectType(request.body.y) + expectType(request.body.z) + expectAssignable(reply) } }, (req) => { @@ -89,11 +104,18 @@ expectAssignable(server.withTypeProvider().get( } )) +expectAssignable(server.withTypeProvider()) + // ------------------------------------------------------------------- // JsonSchemaToTs // ------------------------------------------------------------------- -interface JsonSchemaToTsProvider extends FastifyTypeProvider { output: this['input'] extends JSONSchema ? FromSchema : never } +interface JsonSchemaToTsProvider extends FastifyTypeProvider { + validator: this['schema'] extends JSONSchema ? FromSchema : unknown + serializer: this['schema'] extends JSONSchema ? FromSchema : unknown +} + +// explicitly setting schema `as const` expectAssignable(server.withTypeProvider().get( '/', @@ -107,6 +129,14 @@ expectAssignable(server.withTypeProvider().get( z: { type: 'boolean' } } } as const + }, + errorHandler: (error, request, reply) => { + expectType(error) + expectAssignable(request) + expectType(request.body.x) + expectType(request.body.y) + expectType(request.body.z) + expectAssignable(reply) } }, (req) => { @@ -116,6 +146,95 @@ expectAssignable(server.withTypeProvider().get( } )) +expectAssignable(server.withTypeProvider().route({ + url: '/', + method: 'POST', + schema: { + body: { + type: 'object', + properties: { + x: { type: 'number' }, + y: { type: 'string' }, + z: { type: 'boolean' } + } + } + } as const, + errorHandler: (error, request, reply) => { + expectType(error) + expectAssignable(request) + expectType(request.body.x) + expectType(request.body.y) + expectType(request.body.z) + expectAssignable(reply) + }, + handler: (req) => { + expectType(req.body.x) + expectType(req.body.y) + expectType(req.body.z) + } +})) + +// inferring schema `as const` + +expectAssignable(server.withTypeProvider().get( + '/', + { + schema: { + body: { + type: 'object', + properties: { + x: { type: 'number' }, + y: { type: 'string' }, + z: { type: 'boolean' } + } + } + }, + errorHandler: (error, request, reply) => { + expectType(error) + expectAssignable(request) + expectType(request.body.x) + expectType(request.body.y) + expectType(request.body.z) + expectAssignable(reply) + } + }, + (req) => { + expectType(req.body.x) + expectType(req.body.y) + expectType(req.body.z) + } +)) + +expectAssignable(server.withTypeProvider().route({ + url: '/', + method: 'POST', + schema: { + body: { + type: 'object', + properties: { + x: { type: 'number' }, + y: { type: 'string' }, + z: { type: 'boolean' } + } + } + }, + errorHandler: (error, request, reply) => { + expectType(error) + expectAssignable(request) + expectType(request.body.x) + expectType(request.body.y) + expectType(request.body.z) + expectAssignable(reply) + }, + handler: (req) => { + expectType(req.body.x) + expectType(req.body.y) + expectType(req.body.z) + } +})) + +expectAssignable(server.withTypeProvider()) + // ------------------------------------------------------------------- // Instance Type Remappable // ------------------------------------------------------------------- @@ -132,6 +251,14 @@ expectAssignable(server.withTypeProvider().withTypeProvider { + expectType(error) + expectAssignable(request) + expectType(request.body.x) + expectType(request.body.y) + expectType(request.body.z) + expectAssignable(reply) } }, (req) => { @@ -145,6 +272,73 @@ expectAssignable(server.withTypeProvider().withTypeProvider().get( + '/', + { + schema: { + body: Type.Object({ + x: Type.Number(), + y: Type.String(), + z: Type.Boolean() + }) + }, + preHandler: (req, reply, done) => { + expectType(req.body.x) + expectType(req.body.y) + expectType(req.body.z) + }, + preParsing: (req, reply, payload, done) => { + expectType(req.body.x) + expectType(req.body.y) + expectType(req.body.z) + }, + preSerialization: (req, reply, payload, done) => { + expectType(req.body.x) + expectType(req.body.y) + expectType(req.body.z) + }, + preValidation: (req, reply, done) => { + expectType(req.body.x) + expectType(req.body.y) + expectType(req.body.z) + }, + onError: (req, reply, error, done) => { + expectType(req.body.x) + expectType(req.body.y) + expectType(req.body.z) + }, + onRequest: (req, reply, done) => { + expectType(req.body.x) + expectType(req.body.y) + expectType(req.body.z) + }, + onResponse: (req, reply, done) => { + expectType(req.body.x) + expectType(req.body.y) + expectType(req.body.z) + }, + onTimeout: (req, reply, done) => { + expectType(req.body.x) + expectType(req.body.y) + expectType(req.body.z) + }, + onSend: (req, reply, payload, done) => { + expectType(req.body.x) + expectType(req.body.y) + expectType(req.body.z) + } + }, + req => { + expectType(req.body.x) + expectType(req.body.y) + expectType(req.body.z) + } +)) + +// Async handlers + expectAssignable(server.withTypeProvider().get( '/', { @@ -155,47 +349,47 @@ expectAssignable(server.withTypeProvider().get( z: Type.Boolean() }) }, - preHandler: req => { + preHandler: async (req, reply, done) => { expectType(req.body.x) expectType(req.body.y) expectType(req.body.z) }, - preParsing: req => { + preParsing: async (req, reply, payload, done) => { expectType(req.body.x) expectType(req.body.y) expectType(req.body.z) }, - preSerialization: req => { + preSerialization: async (req, reply, payload, done) => { expectType(req.body.x) expectType(req.body.y) expectType(req.body.z) }, - preValidation: req => { + preValidation: async (req, reply, done) => { expectType(req.body.x) expectType(req.body.y) expectType(req.body.z) }, - onError: req => { + onError: async (req, reply, error, done) => { expectType(req.body.x) expectType(req.body.y) expectType(req.body.z) }, - onRequest: req => { + onRequest: async (req, reply, done) => { expectType(req.body.x) expectType(req.body.y) expectType(req.body.z) }, - onResponse: req => { + onResponse: async (req, reply, done) => { expectType(req.body.x) expectType(req.body.y) expectType(req.body.z) }, - onTimeout: req => { + onTimeout: async (req, reply, done) => { expectType(req.body.x) expectType(req.body.y) expectType(req.body.z) }, - onSend: req => { + onSend: async (req, reply, payload, done) => { expectType(req.body.x) expectType(req.body.y) expectType(req.body.z) @@ -208,6 +402,62 @@ expectAssignable(server.withTypeProvider().get( } )) +// ------------------------------------------------------------------- +// Request headers +// ------------------------------------------------------------------- + +// JsonSchemaToTsProvider +expectAssignable(server.withTypeProvider().get( + '/', + { + schema: { + headers: { + type: 'object', + properties: { + lowercase: { type: 'string' }, + UPPERCASE: { type: 'number' }, + camelCase: { type: 'boolean' }, + 'KEBAB-case': { type: 'boolean' }, + PRESERVE_OPTIONAL: { type: 'number' } + }, + required: ['lowercase', 'UPPERCASE', 'camelCase', 'KEBAB-case'] + } as const + } + }, + (req) => { + expectType(req.headers.lowercase) + expectType(req.headers.UPPERCASE) + expectType(req.headers.uppercase) + expectType(req.headers.camelcase) + expectType(req.headers['kebab-case']) + expectType(req.headers.preserve_optional) + } +)) + +// TypeBoxProvider +expectAssignable(server.withTypeProvider().get( + '/', + { + schema: { + headers: Type.Object({ + lowercase: Type.String(), + UPPERCASE: Type.Number(), + camelCase: Type.Boolean(), + 'KEBAB-case': Type.Boolean(), + PRESERVE_OPTIONAL: Type.Optional(Type.Number()) + }) + } + }, + (req) => { + expectType(req.headers.lowercase) + expectType(req.headers.UPPERCASE) + expectType(req.headers.uppercase) + expectType(req.headers.camelcase) + expectType(req.headers['kebab-case']) + expectType(req.headers.preserve_optional) + } +)) + // ------------------------------------------------------------------- // TypeBox Reply Type // ------------------------------------------------------------------- @@ -229,6 +479,44 @@ expectAssignable(server.withTypeProvider().get( res.send('hello') res.send(42) res.send({ error: 'error' }) + expectType<((...args: [payload: string]) => typeof res)>(res.code(200).send) + expectType<((...args: [payload: number]) => typeof res)>(res.code(400).send) + expectType<((...args: [payload: { error: string }]) => typeof res)>(res.code(500).send) + expectError<(payload?: unknown) => typeof res>(res.code(200).send) + } +)) + +// ------------------------------------------------------------------- +// TypeBox Reply Type (Different Content-types) +// ------------------------------------------------------------------- + +expectAssignable(server.withTypeProvider().get( + '/', + { + schema: { + response: { + 200: { + content: { + 'text/string': { + schema: Type.String() + }, + 'application/json': { + schema: Type.Object({ + msg: Type.String() + }) + } + } + }, + 500: Type.Object({ + error: Type.String() + }) + } + } + }, + async (_, res) => { + res.send('hello') + res.send({ msg: 'hello' }) + res.send({ error: 'error' }) } )) @@ -254,6 +542,38 @@ expectError(server.withTypeProvider().get( } )) +// ------------------------------------------------------------------- +// TypeBox Reply Type: Non Assignable (Different Content-types) +// ------------------------------------------------------------------- + +expectError(server.withTypeProvider().get( + '/', + { + schema: { + response: { + 200: { + content: { + 'text/string': { + schema: Type.String() + }, + 'application/json': { + schema: Type.Object({ + msg: Type.String() + }) + } + } + }, + 500: Type.Object({ + error: Type.String() + }) + } + } + }, + async (_, res) => { + res.send(false) + } +)) + // ------------------------------------------------------------------- // TypeBox Reply Return Type // ------------------------------------------------------------------- @@ -281,6 +601,43 @@ expectAssignable(server.withTypeProvider().get( } )) +// ------------------------------------------------------------------- +// TypeBox Reply Return Type (Different Content-types) +// ------------------------------------------------------------------- + +expectAssignable(server.withTypeProvider().get( + '/', + { + schema: { + response: { + 200: { + content: { + 'text/string': { + schema: Type.String() + }, + 'application/json': { + schema: Type.Object({ + msg: Type.String() + }) + } + } + }, + 500: Type.Object({ + error: Type.String() + }) + } + } + }, + async (_, res) => { + const option = 1 as 1 | 2 | 3 + switch (option) { + case 1: return 'hello' + case 2: return { msg: 'hello' } + case 3: return { error: 'error' } + } + } +)) + // ------------------------------------------------------------------- // TypeBox Reply Return Type: Non Assignable // ------------------------------------------------------------------- @@ -303,6 +660,38 @@ expectError(server.withTypeProvider().get( } )) +// ------------------------------------------------------------------- +// TypeBox Reply Return Type: Non Assignable (Different Content-types) +// ------------------------------------------------------------------- + +expectError(server.withTypeProvider().get( + '/', + { + schema: { + response: { + 200: { + content: { + 'text/string': { + schema: Type.String() + }, + 'application/json': { + schema: Type.Object({ + msg: Type.String() + }) + } + } + }, + 500: Type.Object({ + error: Type.String() + }) + } + } + }, + async (_, res) => { + return false + } +)) + // ------------------------------------------------------------------- // JsonSchemaToTs Reply Type // ------------------------------------------------------------------- @@ -322,6 +711,40 @@ expectAssignable(server.withTypeProvider().get( res.send('hello') res.send(42) res.send({ error: 'error' }) + expectType<((...args: [payload: string]) => typeof res)>(res.code(200).send) + expectType<((...args: [payload: number]) => typeof res)>(res.code(400).send) + expectType<((...args: [payload: { [x: string]: unknown; error?: string }]) => typeof res)>(res.code(500).send) + expectError<(payload?: unknown) => typeof res>(res.code(200).send) + } +)) + +// ------------------------------------------------------------------- +// JsonSchemaToTs Reply Type (Different Content-types) +// ------------------------------------------------------------------- + +expectAssignable(server.withTypeProvider().get( + '/', + { + schema: { + response: { + 200: { + content: { + 'text/string': { + schema: { type: 'string' } + }, + 'application/json': { + schema: { type: 'object', properties: { msg: { type: 'string' } } } + } + } + }, + 500: { type: 'object', properties: { error: { type: 'string' } } } + } as const + } + }, + (_, res) => { + res.send('hello') + res.send({ msg: 'hello' }) + res.send({ error: 'error' }) } )) @@ -345,6 +768,34 @@ expectError(server.withTypeProvider().get( } )) +// ------------------------------------------------------------------- +// JsonSchemaToTs Reply Type: Non Assignable (Different Content-types) +// ------------------------------------------------------------------- + +expectError(server.withTypeProvider().get( + '/', + { + schema: { + response: { + 200: { + content: { + 'text/string': { + schema: { type: 'string' } + }, + 'application/json': { + schema: { type: 'object', properties: { msg: { type: 'string' } } } + } + } + }, + 500: { type: 'object', properties: { error: { type: 'string' } } } + } as const + } + }, + async (_, res) => { + res.send(false) + } +)) + // ------------------------------------------------------------------- // JsonSchemaToTs Reply Type Return // ------------------------------------------------------------------- @@ -369,6 +820,40 @@ expectAssignable(server.withTypeProvider().get( } } )) + +// ------------------------------------------------------------------- +// JsonSchemaToTs Reply Type Return (Different Content-types) +// ------------------------------------------------------------------- + +expectAssignable(server.withTypeProvider().get( + '/', + { + schema: { + response: { + 200: { + content: { + 'text/string': { + schema: { type: 'string' } + }, + 'application/json': { + schema: { type: 'object', properties: { msg: { type: 'string' } } } + } + } + }, + 500: { type: 'object', properties: { error: { type: 'string' } } } + } as const + } + }, + async (_, res) => { + const option = 1 as 1 | 2 | 3 + switch (option) { + case 1: return 'hello' + case 2: return { msg: 'hello' } + case 3: return { error: 'error' } + } + } +)) + // ------------------------------------------------------------------- // JsonSchemaToTs Reply Type Return: Non Assignable // ------------------------------------------------------------------- @@ -392,17 +877,47 @@ expectError(server.withTypeProvider().get( // https://github.com/fastify/fastify/issues/4088 expectError(server.withTypeProvider().get('/', { schema: { - response: { type: 'string' } + response: { + 200: { type: 'string' } + } } as const }, (_, res) => { return { foo: 555 } })) +// ------------------------------------------------------------------- +// JsonSchemaToTs Reply Type Return: Non Assignable (Different Content-types) +// ------------------------------------------------------------------- + +expectError(server.withTypeProvider().get( + '/', + { + schema: { + response: { + 200: { + content: { + 'text/string': { + schema: { type: 'string' } + }, + 'application/json': { + schema: { type: 'object', properties: { msg: { type: 'string' } } } + } + } + }, + 500: { type: 'object', properties: { error: { type: 'string' } } } + } as const + } + }, + async (_, res) => { + return false + } +)) + // ------------------------------------------------------------------- // Reply Type Override // ------------------------------------------------------------------- -expectAssignable(server.withTypeProvider().get<{Reply: boolean}>( +expectAssignable(server.withTypeProvider().get<{ Reply: boolean }>( '/', { schema: { @@ -418,11 +933,39 @@ expectAssignable(server.withTypeProvider().get<{Reply: b } )) +// ------------------------------------------------------------------- +// Reply Type Override (Different Content-types) +// ------------------------------------------------------------------- + +expectAssignable(server.withTypeProvider().get<{ Reply: boolean }>( + '/', + { + schema: { + response: { + 200: { + content: { + 'text/string': { + schema: { type: 'string' } + }, + 'application/json': { + schema: { type: 'object', properties: { msg: { type: 'string' } } } + } + } + }, + 500: { type: 'object', properties: { error: { type: 'string' } } } + } as const + } + }, + async (_, res) => { + res.send(true) + } +)) + // ------------------------------------------------------------------- // Reply Type Return Override // ------------------------------------------------------------------- -expectAssignable(server.withTypeProvider().get<{Reply: boolean}>( +expectAssignable(server.withTypeProvider().get<{ Reply: boolean }>( '/', { schema: { @@ -437,3 +980,234 @@ expectAssignable(server.withTypeProvider().get<{Reply: b return true } )) + +// ------------------------------------------------------------------- +// Reply Type Return Override (Different Content-types) +// ------------------------------------------------------------------- + +expectAssignable(server.withTypeProvider().get<{ Reply: boolean }>( + '/', + { + schema: { + response: { + 200: { + content: { + 'text/string': { + schema: { type: 'string' } + }, + 'application/json': { + schema: { type: 'object', properties: { msg: { type: 'string' } } } + } + } + }, + 500: { type: 'object', properties: { error: { type: 'string' } } } + } as const + } + }, + async (_, res) => { + return true + } +)) + +// ------------------------------------------------------------------- +// Reply Status Code (Different Status Codes) +// ------------------------------------------------------------------- + +expectAssignable(server.withTypeProvider().get( + '/', + { + schema: { + response: { + 200: { + content: { + 'text/string': { + schema: { type: 'string' } + }, + 'application/json': { + schema: { type: 'object', properties: { msg: { type: 'string' } } } + } + } + }, + 500: { type: 'object', properties: { error: { type: 'string' } } } + } as const + } + }, + async (_, res) => { + res.code(200) + res.code(500) + expectError(() => res.code(201)) + expectError(() => res.code(400)) + } +)) + +// ------------------------------------------------------------------- +// RouteGeneric Reply Type Return (Different Status Codes) +// ------------------------------------------------------------------- + +expectAssignable(server.get<{ + Reply: { + 200: string | { msg: string } + 400: number + '5xx': { error: string } + } +}>( + '/', + async (_, res) => { + const option = 1 as 1 | 2 | 3 | 4 + switch (option) { + case 1: return 'hello' + case 2: return { msg: 'hello' } + case 3: return 400 + case 4: return { error: 'error' } + } + } +)) + +// ------------------------------------------------------------------- +// RouteGeneric Status Code (Different Status Codes) +// ------------------------------------------------------------------- + +expectAssignable(server.get<{ + Reply: { + 200: string | { msg: string } + 400: number + '5xx': { error: string } + } +}>( + '/', + async (_, res) => { + res.code(200) + res.code(400) + res.code(500) + res.code(502) + expectError(() => res.code(201)) + expectError(() => res.code(300)) + expectError(() => res.code(404)) + return 'hello' + } +)) + +// ------------------------------------------------------------------- +// RouteGeneric Reply Type Return: Non Assignable (Different Status Codes) +// ------------------------------------------------------------------- + +expectError(server.get<{ + Reply: { + 200: string | { msg: string } + 400: number + '5xx': { error: string } + } +}>( + '/', + async (_, res) => { + return true + } +)) + +// ------------------------------------------------------------------- +// FastifyPlugin: Auxiliary +// ------------------------------------------------------------------- + +interface AuxiliaryPluginProvider extends FastifyTypeProvider { validator: 'plugin-auxiliary' } + +// Auxiliary plugins may have varying server types per application. Recommendation would be to explicitly remap instance provider context within plugin if required. +function plugin (instance: T) { + expectAssignable(instance.withTypeProvider().get( + '/', + { + schema: { body: null } + }, + (req) => { + expectType<'plugin-auxiliary'>(req.body) + } + )) +} + +expectAssignable(server.withTypeProvider().register(plugin).get( + '/', + { + schema: { body: null } + }, + (req) => { + expectType<'plugin-auxiliary'>(req.body) + } +)) + +// ------------------------------------------------------------------- +// Handlers: Inline +// ------------------------------------------------------------------- + +interface InlineHandlerProvider extends FastifyTypeProvider { validator: 'handler-inline' } + +// Inline handlers should infer for the request parameters (non-shared) +expectAssignable(server.withTypeProvider().get( + '/', + { + onRequest: (req, res, done) => { + expectType<'handler-inline'>(req.body) + }, + schema: { body: null } + }, + (req) => { + expectType<'handler-inline'>(req.body) + } +)) + +// ------------------------------------------------------------------- +// Handlers: Auxiliary +// ------------------------------------------------------------------- + +interface AuxiliaryHandlerProvider extends FastifyTypeProvider { validator: 'handler-auxiliary' } + +// Auxiliary handlers are likely shared for multiple routes and thus should infer as unknown due to potential varying parameters +function auxiliaryHandler (request: FastifyRequest, reply: FastifyReply, done: HookHandlerDoneFunction): void { + expectType(request.body) +} + +expectAssignable(server.withTypeProvider().get( + '/', + { + onRequest: auxiliaryHandler, + schema: { body: 'handler-auxiliary' } + }, + (req) => { + expectType<'handler-auxiliary'>(req.body) + } +)) + +// ------------------------------------------------------------------- +// SafePromiseLike +// ------------------------------------------------------------------- +const safePromiseLike = { + then: new Promise(resolve => resolve('')).then, + __linterBrands: 'SafePromiseLike' as const +} +expectAssignable>(safePromiseLike) +expectAssignable>(safePromiseLike) +expectError>(safePromiseLike) + +// ------------------------------------------------------------------- +// Separate Providers +// ------------------------------------------------------------------- + +interface SeparateProvider extends FastifyTypeProvider { + validator: string + serializer: Date +} + +expectAssignable(server.withTypeProvider().get( + '/', + { + schema: { + body: null, + response: { + 200: { type: 'string' } + } + } + }, + (req, res) => { + expectType(req.body) + + res.send(new Date()) + } +)) diff --git a/test/types/using.test-d.ts b/test/types/using.test-d.ts new file mode 100644 index 00000000000..72252d47fe7 --- /dev/null +++ b/test/types/using.test-d.ts @@ -0,0 +1,17 @@ +import { expectAssignable } from 'tsd' +import fastify, { FastifyInstance } from '../../fastify' + +async function hasSymbolDisposeWithUsing () { + await using app = fastify() + expectAssignable(app) + expectAssignable(app.close) +} + +async function hasSymbolDispose () { + const app = fastify() + expectAssignable(app) + expectAssignable(app.close) +} + +hasSymbolDisposeWithUsing() +hasSymbolDispose() diff --git a/test/unsupported-httpversion.test.js b/test/unsupported-httpversion.test.js deleted file mode 100644 index 9a984c5133c..00000000000 --- a/test/unsupported-httpversion.test.js +++ /dev/null @@ -1,57 +0,0 @@ -'use strict' - -const net = require('net') -const t = require('tap') -const Fastify = require('../fastify') - -t.test('Will return 505 HTTP error if HTTP version (default) is not supported', t => { - const fastify = Fastify() - - t.teardown(fastify.close.bind(fastify)) - - fastify.get('/', (req, reply) => { - reply.send({ hello: 'world' }) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - const port = fastify.server.address().port - const client = net.createConnection({ port }, () => { - client.write('GET / HTTP/5.1\r\n\r\n') - - client.once('data', data => { - t.match(data.toString(), /505 HTTP Version Not Supported/i) - client.end(() => { - t.end() - }) - }) - }) - }) -}) - -t.test('Will return 505 HTTP error if HTTP version (2.0 when server is 1.1) is not supported', t => { - const fastify = Fastify() - - t.teardown(fastify.close.bind(fastify)) - - fastify.get('/', (req, reply) => { - reply.send({ hello: 'world' }) - }) - - fastify.listen({ port: 0 }, err => { - t.error(err) - - const port = fastify.server.address().port - const client = net.createConnection({ port }, () => { - client.write('GET / HTTP/2.0\r\n\r\n') - - client.once('data', data => { - t.match(data.toString(), /505 HTTP Version Not Supported/i) - client.end(() => { - t.end() - }) - }) - }) - }) -}) diff --git a/test/upgrade.test.js b/test/upgrade.test.js new file mode 100644 index 00000000000..8f1bf8c290d --- /dev/null +++ b/test/upgrade.test.js @@ -0,0 +1,52 @@ +'use strict' + +const { describe, test } = require('node:test') +const Fastify = require('..') +const { connect } = require('node:net') +const { once } = require('node:events') +const dns = require('node:dns').promises + +describe('upgrade to both servers', async () => { + const localAddresses = await dns.lookup('localhost', { all: true }) + const skip = localAddresses.length === 1 && 'requires both IPv4 and IPv6' + + await test('upgrade IPv4 and IPv6', { skip }, async t => { + t.plan(2) + + const fastify = Fastify() + fastify.server.on('upgrade', (req, socket, head) => { + t.assert.ok(`upgrade event ${JSON.stringify(socket.address())}`) + socket.end() + }) + + fastify.get('/', (req, res) => { + res.send() + }) + + await fastify.listen() + t.after(() => fastify.close()) + + { + const clientIPv4 = connect(fastify.server.address().port, '127.0.0.1') + clientIPv4.write('GET / HTTP/1.1\r\n') + clientIPv4.write('Upgrade: websocket\r\n') + clientIPv4.write('Connection: Upgrade\r\n') + clientIPv4.write('Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==\r\n') + clientIPv4.write('Sec-WebSocket-Protocol: com.xxx.service.v1\r\n') + clientIPv4.write('Sec-WebSocket-Version: 13\r\n\r\n') + clientIPv4.write('\r\n\r\n') + await once(clientIPv4, 'close') + } + + { + const clientIPv6 = connect(fastify.server.address().port, '::1') + clientIPv6.write('GET / HTTP/1.1\r\n') + clientIPv6.write('Upgrade: websocket\r\n') + clientIPv6.write('Connection: Upgrade\r\n') + clientIPv6.write('Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==\r\n') + clientIPv6.write('Sec-WebSocket-Protocol: com.xxx.service.v1\r\n') + clientIPv6.write('Sec-WebSocket-Version: 13\r\n\r\n') + await once(clientIPv6, 'close') + } + }) +}) diff --git a/test/url-rewriting.test.js b/test/url-rewriting.test.js index f786364d515..f76aefdd762 100644 --- a/test/url-rewriting.test.js +++ b/test/url-rewriting.test.js @@ -1,15 +1,14 @@ 'use strict' -const t = require('tap') -const test = t.test +const { test } = require('node:test') const Fastify = require('..') -const sget = require('simple-get').concat -test('Should rewrite url', t => { - t.plan(5) +test('Should rewrite url', async t => { + t.plan(4) const fastify = Fastify({ rewriteUrl (req) { - t.equal(req.url, '/this-would-404-without-url-rewrite') + t.assert.strictEqual(req.url, '/this-would-404-without-url-rewrite') + this.log.info('rewriting url') return '/' } }) @@ -22,27 +21,23 @@ test('Should rewrite url', t => { } }) - fastify.listen({ port: 0 }, function (err) { - t.error(err) - - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/this-would-404-without-url-rewrite' - }, (err, response, body) => { - t.error(err) - t.same(JSON.parse(body), { hello: 'world' }) - t.equal(response.statusCode, 200) - }) - }) + const fastifyServer = await fastify.listen({ port: 0 }) + + t.after(() => fastify.close()) + + const result = await fetch(`${fastifyServer}/this-would-404-without-url-rewrite`) - t.teardown(() => fastify.close()) + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.deepStrictEqual(await result.json(), { hello: 'world' }) }) -test('Should not rewrite if the url is the same', t => { - t.plan(4) +test('Should not rewrite if the url is the same', async t => { + t.plan(3) const fastify = Fastify({ rewriteUrl (req) { - t.equal(req.url, '/this-would-404-without-url-rewrite') + t.assert.strictEqual(req.url, '/this-would-404-without-url-rewrite') + this.log.info('rewriting url') return req.url } }) @@ -55,25 +50,22 @@ test('Should not rewrite if the url is the same', t => { } }) - fastify.listen({ port: 0 }, function (err) { - t.error(err) + const fastifyServer = await fastify.listen({ port: 0 }) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/this-would-404-without-url-rewrite' - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 404) - }) - }) + t.after(() => fastify.close()) + + const result = await fetch(`${fastifyServer}/this-would-404-without-url-rewrite`) - t.teardown(() => fastify.close()) + t.assert.ok(!result.ok) + t.assert.strictEqual(result.status, 404) }) -test('Should throw an error', t => { - t.plan(5) + +test('Should throw an error', async t => { + t.plan(2) const fastify = Fastify({ rewriteUrl (req) { - t.equal(req.url, '/this-would-404-without-url-rewrite') + t.assert.strictEqual(req.url, '/this-would-404-without-url-rewrite') + this.log.info('rewriting url') return undefined } }) @@ -86,18 +78,45 @@ test('Should throw an error', t => { } }) - fastify.listen({ port: 0 }, function (err) { - t.error(err) - - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port + '/this-would-404-without-url-rewrite' - }, (err, response, body) => { - t.equal(err.code, 'ECONNRESET') - t.equal(response, undefined) - t.equal(body, undefined) - }) + const fastifyServer = await fastify.listen({ port: 0 }) + + t.after(() => fastify.close()) + + try { + await fetch(`${fastifyServer}/this-would-404-without-url-rewrite`) + t.assert.fail('Expected fetch to throw an error') + } catch (err) { + t.assert.ok(err instanceof Error) + } +}) + +test('Should rewrite url but keep originalUrl unchanged', async t => { + t.plan(6) + const fastify = Fastify({ + rewriteUrl (req) { + t.assert.strictEqual(req.url, '/this-would-404-without-url-rewrite') + t.assert.strictEqual(req.originalUrl, '/this-would-404-without-url-rewrite') + return '/' + } + }) + + fastify.route({ + method: 'GET', + url: '/', + handler: (req, reply) => { + reply.send({ hello: 'world', hostname: req.hostname, port: req.port }) + t.assert.strictEqual(req.originalUrl, '/this-would-404-without-url-rewrite') + } }) - t.teardown(() => fastify.close()) + await fastify.listen({ port: 0 }) + const port = fastify.server.address().port + + t.after(() => fastify.close()) + + const result = await fetch(`http://localhost:${port}/this-would-404-without-url-rewrite`) + + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.deepStrictEqual(await result.json(), { hello: 'world', hostname: 'localhost', port }) }) diff --git a/test/use-semicolon-delimiter.test.js b/test/use-semicolon-delimiter.test.js new file mode 100644 index 00000000000..5d0506d61b1 --- /dev/null +++ b/test/use-semicolon-delimiter.test.js @@ -0,0 +1,168 @@ +'use strict' + +const { test } = require('node:test') +const Fastify = require('..') + +test('use semicolon delimiter default false', async (t) => { + t.plan(2) + + const fastify = Fastify() + + fastify.get('/1234;foo=bar', (req, reply) => { + reply.send(req.query) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) + + const result = await fetch(fastifyServer + '/1234;foo=bar', { + method: 'GET' + }) + t.assert.strictEqual(result.status, 200) + const body = await result.json() + t.assert.deepStrictEqual(body, {}) +}) + +test('use semicolon delimiter set to true', async (t) => { + t.plan(3) + const fastify = Fastify({ + useSemicolonDelimiter: true + }) + + fastify.get('/1234', (req, reply) => { + reply.send(req.query) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) + + const result = await fetch(fastifyServer + '/1234;foo=bar') + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.deepStrictEqual(await result.json(), { + foo: 'bar' + }) +}) + +test('use semicolon delimiter set to false', async (t) => { + t.plan(3) + + const fastify = Fastify({ + useSemicolonDelimiter: false + }) + + fastify.get('/1234;foo=bar', (req, reply) => { + reply.send(req.query) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) + + const result = await fetch(fastifyServer + '/1234;foo=bar') + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.deepStrictEqual(await result.json(), {}) +}) + +test('use semicolon delimiter set to false return 404', async (t) => { + t.plan(2) + + const fastify = Fastify({ + useSemicolonDelimiter: false + }) + + fastify.get('/1234', (req, reply) => { + reply.send(req.query) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) + + const result = await fetch(fastifyServer + '/1234;foo=bar') + t.assert.ok(!result.ok) + t.assert.strictEqual(result.status, 404) +}) + +test('use routerOptions semicolon delimiter default false', async t => { + t.plan(3) + + const fastify = Fastify() + + fastify.get('/1234;foo=bar', (req, reply) => { + reply.send(req.query) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) + + const result = await fetch(fastifyServer + '/1234;foo=bar') + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.deepStrictEqual(await result.json(), {}) +}) + +test('use routerOptions semicolon delimiter set to true', async t => { + t.plan(3) + const fastify = Fastify({ + routerOptions: { + useSemicolonDelimiter: true + } + }) + + fastify.get('/1234', async (req, reply) => { + reply.send(req.query) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) + + const result = await fetch(fastifyServer + '/1234;foo=bar') + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.deepStrictEqual(await result.json(), { + foo: 'bar' + }) +}) + +test('use routerOptions semicolon delimiter set to false', async t => { + t.plan(3) + + const fastify = Fastify({ + routerOptions: { + useSemicolonDelimiter: false + } + }) + + fastify.get('/1234;foo=bar', (req, reply) => { + reply.send(req.query) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) + + const result = await fetch(fastifyServer + '/1234;foo=bar') + t.assert.ok(result.ok) + t.assert.strictEqual(result.status, 200) + t.assert.deepStrictEqual(await result.json(), {}) +}) + +test('use routerOptions semicolon delimiter set to false return 404', async t => { + t.plan(2) + + const fastify = Fastify({ + routerOptions: { + useSemicolonDelimiter: false + } + }) + + fastify.get('/1234', (req, reply) => { + reply.send(req.query) + }) + + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) + + const result = await fetch(fastifyServer + '/1234;foo=bar') + t.assert.ok(!result.ok) + t.assert.strictEqual(result.status, 404) +}) diff --git a/test/validation-error-handling.test.js b/test/validation-error-handling.test.js index 534258af74f..f8933f3e4d4 100644 --- a/test/validation-error-handling.test.js +++ b/test/validation-error-handling.test.js @@ -1,6 +1,6 @@ 'use strict' -const { test } = require('tap') +const { describe, test } = require('node:test') const Joi = require('joi') const Fastify = require('..') @@ -19,58 +19,56 @@ function echoBody (req, reply) { reply.code(200).send(req.body.name) } -test('should work with valid payload', t => { - t.plan(3) +test('should work with valid payload', async (t) => { + t.plan(2) const fastify = Fastify() fastify.post('/', { schema }, echoBody) - fastify.inject({ + const response = await fastify.inject({ method: 'POST', payload: { name: 'michelangelo', work: 'sculptor, painter, architect and poet' }, url: '/' - }, (err, res) => { - t.error(err) - t.same(res.payload, 'michelangelo') - t.equal(res.statusCode, 200) }) + t.assert.deepStrictEqual(response.payload, 'michelangelo') + t.assert.strictEqual(response.statusCode, 200) }) -test('should fail immediately with invalid payload', t => { - t.plan(3) +test('should fail immediately with invalid payload', async (t) => { + t.plan(2) const fastify = Fastify() fastify.post('/', { schema }, echoBody) - fastify.inject({ + const response = await fastify.inject({ method: 'POST', payload: { hello: 'michelangelo' }, url: '/' - }, (err, res) => { - t.error(err) - t.same(res.json(), { - statusCode: 400, - error: 'Bad Request', - message: "body must have required property 'name'" - }) - t.equal(res.statusCode, 400) }) + + t.assert.deepStrictEqual(response.json(), { + statusCode: 400, + code: 'FST_ERR_VALIDATION', + error: 'Bad Request', + message: "body must have required property 'name'" + }) + t.assert.strictEqual(response.statusCode, 400) }) -test('should be able to use setErrorHandler specify custom validation error', t => { - t.plan(3) +test('should be able to use setErrorHandler specify custom validation error', async (t) => { + t.plan(2) const fastify = Fastify() fastify.post('/', { schema }, function (req, reply) { - t.fail('should not be here') + t.assert.fail('should not be here') reply.code(200).send(req.body.name) }) @@ -80,25 +78,24 @@ test('should be able to use setErrorHandler specify custom validation error', t } }) - fastify.inject({ + const response = await fastify.inject({ method: 'POST', payload: { hello: 'michelangelo' }, url: '/' - }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { - statusCode: 422, - error: 'Unprocessable Entity', - message: 'validation failed' - }) - t.equal(res.statusCode, 422) }) + + t.assert.deepStrictEqual(JSON.parse(response.payload), { + statusCode: 422, + error: 'Unprocessable Entity', + message: 'validation failed' + }) + t.assert.strictEqual(response.statusCode, 422) }) -test('validation error has 400 statusCode set', t => { - t.plan(3) +test('validation error has 400 statusCode set', async (t) => { + t.plan(2) const fastify = Fastify() @@ -113,23 +110,22 @@ test('validation error has 400 statusCode set', t => { fastify.post('/', { schema }, echoBody) - fastify.inject({ + const response = await fastify.inject({ method: 'POST', payload: { hello: 'michelangelo' }, url: '/' - }, (err, res) => { - t.error(err) - t.same(res.json(), { - statusCode: 400, - message: "body must have required property 'name'" - }) - t.equal(res.statusCode, 400) }) + + t.assert.deepStrictEqual(response.json(), { + statusCode: 400, + message: "body must have required property 'name'" + }) + t.assert.strictEqual(response.statusCode, 400) }) -test('error inside custom error handler should have validationContext', t => { +test('error inside custom error handler should have validationContext', async (t) => { t.plan(1) const fastify = Fastify() @@ -142,26 +138,26 @@ test('error inside custom error handler should have validationContext', t => { } } }, function (req, reply) { - t.fail('should not be here') + t.assert.fail('should not be here') reply.code(200).send(req.body.name) }) fastify.setErrorHandler(function (error, request, reply) { - t.equal(error.validationContext, 'body') + t.assert.strictEqual(error.validationContext, 'body') reply.status(500).send(error) }) - fastify.inject({ + await fastify.inject({ method: 'POST', payload: { name: 'michelangelo', work: 'artist' }, url: '/' - }, () => {}) + }) }) -test('error inside custom error handler should have validationContext if specified by custom error handler', t => { +test('error inside custom error handler should have validationContext if specified by custom error handler', async (t) => { t.plan(1) const fastify = Fastify() @@ -176,27 +172,27 @@ test('error inside custom error handler should have validationContext if specifi } } }, function (req, reply) { - t.fail('should not be here') + t.assert.fail('should not be here') reply.code(200).send(req.body.name) }) fastify.setErrorHandler(function (error, request, reply) { - t.equal(error.validationContext, 'customContext') + t.assert.strictEqual(error.validationContext, 'customContext') reply.status(500).send(error) }) - fastify.inject({ + await fastify.inject({ method: 'POST', payload: { name: 'michelangelo', work: 'artist' }, url: '/' - }, () => {}) + }) }) -test('should be able to attach validation to request', t => { - t.plan(3) +test('should be able to attach validation to request', async (t) => { + t.plan(2) const fastify = Fastify() @@ -204,55 +200,53 @@ test('should be able to attach validation to request', t => { reply.code(400).send(req.validationError.validation) }) - fastify.inject({ + const response = await fastify.inject({ method: 'POST', payload: { hello: 'michelangelo' }, url: '/' - }, (err, res) => { - t.error(err) - - t.same(res.json(), [{ - keyword: 'required', - instancePath: '', - schemaPath: '#/required', - params: { missingProperty: 'name' }, - message: 'must have required property \'name\'' - }]) - t.equal(res.statusCode, 400) }) + + t.assert.deepStrictEqual(response.json(), [{ + keyword: 'required', + instancePath: '', + schemaPath: '#/required', + params: { missingProperty: 'name' }, + message: 'must have required property \'name\'' + }]) + t.assert.strictEqual(response.statusCode, 400) }) -test('should respect when attachValidation is explicitly set to false', t => { - t.plan(3) +test('should respect when attachValidation is explicitly set to false', async (t) => { + t.plan(2) const fastify = Fastify() fastify.post('/', { schema, attachValidation: false }, function (req, reply) { - t.fail('should not be here') + t.assert.fail('should not be here') reply.code(200).send(req.validationError.validation) }) - fastify.inject({ + const response = await fastify.inject({ method: 'POST', payload: { hello: 'michelangelo' }, url: '/' - }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { - statusCode: 400, - error: 'Bad Request', - message: "body must have required property 'name'" - }) - t.equal(res.statusCode, 400) }) + + t.assert.deepStrictEqual(JSON.parse(response.payload), { + statusCode: 400, + code: 'FST_ERR_VALIDATION', + error: 'Bad Request', + message: "body must have required property 'name'" + }) + t.assert.strictEqual(response.statusCode, 400) }) -test('Attached validation error should take precedence over setErrorHandler', t => { - t.plan(3) +test('Attached validation error should take precedence over setErrorHandler', async (t) => { + t.plan(2) const fastify = Fastify() @@ -261,26 +255,25 @@ test('Attached validation error should take precedence over setErrorHandler', t }) fastify.setErrorHandler(function (error, request, reply) { - t.fail('should not be here') + t.assert.fail('should not be here') if (error.validation) { reply.status(422).send(new Error('validation failed')) } }) - fastify.inject({ + const response = await fastify.inject({ method: 'POST', payload: { hello: 'michelangelo' }, url: '/' - }, (err, res) => { - t.error(err) - t.same(res.payload, "Attached: Error: body must have required property 'name'") - t.equal(res.statusCode, 400) }) + + t.assert.deepStrictEqual(response.payload, "Attached: Error: body must have required property 'name'") + t.assert.strictEqual(response.statusCode, 400) }) -test('should handle response validation error', t => { +test('should handle response validation error', async (t) => { t.plan(2) const response = { @@ -304,17 +297,17 @@ test('should handle response validation error', t => { } }) - fastify.inject({ + const injectResponse = await fastify.inject({ method: 'GET', payload: { }, url: '/' - }, (err, res) => { - t.error(err) - t.equal(res.payload, '{"statusCode":500,"error":"Internal Server Error","message":"\\"name\\" is required!"}') }) + + t.assert.strictEqual(injectResponse.statusCode, 500) + t.assert.strictEqual(injectResponse.payload, '{"statusCode":500,"error":"Internal Server Error","message":"\\"name\\" is required!"}') }) -test('should handle response validation error with promises', t => { +test('should handle response validation error with promises', async (t) => { t.plan(2) const response = { @@ -334,17 +327,17 @@ test('should handle response validation error with promises', t => { return Promise.resolve({ work: 'actor' }) }) - fastify.inject({ + const injectResponse = await fastify.inject({ method: 'GET', payload: { }, url: '/' - }, (err, res) => { - t.error(err) - t.equal(res.payload, '{"statusCode":500,"error":"Internal Server Error","message":"\\"name\\" is required!"}') }) + + t.assert.strictEqual(injectResponse.statusCode, 500) + t.assert.strictEqual(injectResponse.payload, '{"statusCode":500,"error":"Internal Server Error","message":"\\"name\\" is required!"}') }) -test('should return a defined output message parsing AJV errors', t => { +test('should return a defined output message parsing AJV errors', async (t) => { t.plan(2) const body = { @@ -359,20 +352,20 @@ test('should return a defined output message parsing AJV errors', t => { const fastify = Fastify() fastify.post('/', { schema: { body } }, function (req, reply) { - t.fail() + t.assert.fail() }) - fastify.inject({ + const response = await fastify.inject({ method: 'POST', payload: { }, url: '/' - }, (err, res) => { - t.error(err) - t.equal(res.payload, '{"statusCode":400,"error":"Bad Request","message":"body must have required property \'name\'"}') }) + + t.assert.strictEqual(response.statusCode, 400) + t.assert.strictEqual(response.payload, '{"statusCode":400,"code":"FST_ERR_VALIDATION","error":"Bad Request","message":"body must have required property \'name\'"}') }) -test('should return a defined output message parsing JOI errors', t => { +test('should return a defined output message parsing JOI errors', async (t) => { t.plan(2) const body = Joi.object().keys({ @@ -389,20 +382,20 @@ test('should return a defined output message parsing JOI errors', t => { } }, function (req, reply) { - t.fail() + t.assert.fail() }) - fastify.inject({ + const response = await fastify.inject({ method: 'POST', payload: {}, url: '/' - }, (err, res) => { - t.error(err) - t.equal(res.payload, '{"statusCode":400,"error":"Bad Request","message":"\\"name\\" is required"}') }) + + t.assert.strictEqual(response.statusCode, 400) + t.assert.strictEqual(response.payload, '{"statusCode":400,"code":"FST_ERR_VALIDATION","error":"Bad Request","message":"\\"name\\" is required"}') }) -test('should return a defined output message parsing JOI error details', t => { +test('should return a defined output message parsing JOI error details', async (t) => { t.plan(2) const body = Joi.object().keys({ @@ -422,115 +415,115 @@ test('should return a defined output message parsing JOI error details', t => { } }, function (req, reply) { - t.fail() + t.assert.fail() }) - fastify.inject({ + const response = await fastify.inject({ method: 'POST', payload: {}, url: '/' - }, (err, res) => { - t.error(err) - t.equal(res.payload, '{"statusCode":400,"error":"Bad Request","message":"body \\"name\\" is required"}') }) + + t.assert.strictEqual(response.statusCode, 400) + t.assert.strictEqual(response.payload, '{"statusCode":400,"code":"FST_ERR_VALIDATION","error":"Bad Request","message":"body \\"name\\" is required"}') }) -test('the custom error formatter context must be the server instance', t => { - t.plan(4) +test('the custom error formatter context must be the server instance', async (t) => { + t.plan(3) const fastify = Fastify() fastify.setSchemaErrorFormatter(function (errors, dataVar) { - t.same(this, fastify) + t.assert.deepStrictEqual(this, fastify) return new Error('my error') }) fastify.post('/', { schema }, echoBody) - fastify.inject({ + const response = await fastify.inject({ method: 'POST', payload: { hello: 'michelangelo' }, url: '/' - }, (err, res) => { - t.error(err) - t.same(res.json(), { - statusCode: 400, - error: 'Bad Request', - message: 'my error' - }) - t.equal(res.statusCode, 400) }) + + t.assert.deepStrictEqual(response.json(), { + statusCode: 400, + code: 'FST_ERR_VALIDATION', + error: 'Bad Request', + message: 'my error' + }) + t.assert.strictEqual(response.statusCode, 400) }) -test('the custom error formatter context must be the server instance in options', t => { - t.plan(4) +test('the custom error formatter context must be the server instance in options', async (t) => { + t.plan(3) const fastify = Fastify({ schemaErrorFormatter: function (errors, dataVar) { - t.same(this, fastify) + t.assert.deepStrictEqual(this, fastify) return new Error('my error') } }) fastify.post('/', { schema }, echoBody) - fastify.inject({ + const response = await fastify.inject({ method: 'POST', payload: { hello: 'michelangelo' }, url: '/' - }, (err, res) => { - t.error(err) - t.same(res.json(), { - statusCode: 400, - error: 'Bad Request', - message: 'my error' - }) - t.equal(res.statusCode, 400) }) + + t.assert.deepStrictEqual(response.json(), { + statusCode: 400, + code: 'FST_ERR_VALIDATION', + error: 'Bad Request', + message: 'my error' + }) + t.assert.strictEqual(response.statusCode, 400) }) -test('should call custom error formatter', t => { - t.plan(9) +test('should call custom error formatter', async (t) => { + t.plan(8) const fastify = Fastify({ schemaErrorFormatter: (errors, dataVar) => { - t.equal(errors.length, 1) - t.equal(errors[0].message, "must have required property 'name'") - t.equal(errors[0].keyword, 'required') - t.equal(errors[0].schemaPath, '#/required') - t.same(errors[0].params, { + t.assert.strictEqual(errors.length, 1) + t.assert.strictEqual(errors[0].message, "must have required property 'name'") + t.assert.strictEqual(errors[0].keyword, 'required') + t.assert.strictEqual(errors[0].schemaPath, '#/required') + t.assert.deepStrictEqual(errors[0].params, { missingProperty: 'name' }) - t.equal(dataVar, 'body') + t.assert.strictEqual(dataVar, 'body') return new Error('my error') } }) fastify.post('/', { schema }, echoBody) - fastify.inject({ + const response = await fastify.inject({ method: 'POST', payload: { hello: 'michelangelo' }, url: '/' - }, (err, res) => { - t.error(err) - t.same(res.json(), { - statusCode: 400, - error: 'Bad Request', - message: 'my error' - }) - t.equal(res.statusCode, 400) }) + + t.assert.deepStrictEqual(response.json(), { + statusCode: 400, + code: 'FST_ERR_VALIDATION', + error: 'Bad Request', + message: 'my error' + }) + t.assert.strictEqual(response.statusCode, 400) }) -test('should catch error inside formatter and return message', t => { - t.plan(3) +test('should catch error inside formatter and return message', async (t) => { + t.plan(2) const fastify = Fastify({ schemaErrorFormatter: (errors, dataVar) => { @@ -540,25 +533,23 @@ test('should catch error inside formatter and return message', t => { fastify.post('/', { schema }, echoBody) - fastify.inject({ + const response = await fastify.inject({ method: 'POST', payload: { hello: 'michelangelo' }, url: '/' - }, (err, res) => { - t.error(err) - t.same(res.json(), { - statusCode: 500, - error: 'Internal Server Error', - message: 'abc' - }) - t.equal(res.statusCode, 500) - t.end() }) + + t.assert.deepStrictEqual(response.json(), { + statusCode: 500, + error: 'Internal Server Error', + message: 'abc' + }) + t.assert.strictEqual(response.statusCode, 500) }) -test('cannot create a fastify instance with wrong type of errorFormatter', t => { +test('cannot create a fastify instance with wrong type of errorFormatter', async (t) => { t.plan(3) try { @@ -568,7 +559,7 @@ test('cannot create a fastify instance with wrong type of errorFormatter', t => } }) } catch (err) { - t.equal(err.message, 'schemaErrorFormatter option should not be an async function') + t.assert.strictEqual(err.code, 'FST_ERR_SCHEMA_ERROR_FORMATTER_NOT_FN') } try { @@ -576,19 +567,19 @@ test('cannot create a fastify instance with wrong type of errorFormatter', t => schemaErrorFormatter: 500 }) } catch (err) { - t.equal(err.message, 'schemaErrorFormatter option should be a function, instead got number') + t.assert.strictEqual(err.code, 'FST_ERR_SCHEMA_ERROR_FORMATTER_NOT_FN') } try { const fastify = Fastify() fastify.setSchemaErrorFormatter(500) } catch (err) { - t.equal(err.message, 'schemaErrorFormatter option should be a function, instead got number') + t.assert.strictEqual(err.code, 'FST_ERR_SCHEMA_ERROR_FORMATTER_NOT_FN') } }) -test('should register a route based schema error formatter', t => { - t.plan(3) +test('should register a route based schema error formatter', async (t) => { + t.plan(2) const fastify = Fastify() @@ -599,26 +590,25 @@ test('should register a route based schema error formatter', t => { } }, echoBody) - fastify.inject({ + const response = await fastify.inject({ method: 'POST', payload: { hello: 'michelangelo' }, url: '/' - }, (err, res) => { - t.error(err) - t.same(res.json(), { - statusCode: 400, - error: 'Bad Request', - message: 'abc' - }) - t.equal(res.statusCode, 400) - t.end() }) + + t.assert.deepStrictEqual(response.json(), { + statusCode: 400, + code: 'FST_ERR_VALIDATION', + error: 'Bad Request', + message: 'abc' + }) + t.assert.strictEqual(response.statusCode, 400) }) -test('prefer route based error formatter over global one', t => { - t.plan(9) +test('prefer route based error formatter over global one', async (t) => { + t.plan(6) const fastify = Fastify({ schemaErrorFormatter: (errors, dataVar) => { @@ -642,57 +632,57 @@ test('prefer route based error formatter over global one', t => { fastify.post('/test', { schema }, echoBody) - fastify.inject({ + const response1 = await fastify.inject({ method: 'POST', payload: { hello: 'michelangelo' }, url: '/' - }, (err, res) => { - t.error(err) - t.same(res.json(), { - statusCode: 400, - error: 'Bad Request', - message: '123' - }) - t.equal(res.statusCode, 400) }) - fastify.inject({ + t.assert.deepStrictEqual(response1.json(), { + statusCode: 400, + code: 'FST_ERR_VALIDATION', + error: 'Bad Request', + message: '123' + }) + t.assert.strictEqual(response1.statusCode, 400) + + const response2 = await fastify.inject({ method: 'POST', payload: { hello: 'michelangelo' }, url: '/abc' - }, (err, res) => { - t.error(err) - t.same(res.json(), { - statusCode: 400, - error: 'Bad Request', - message: 'abc' - }) - t.equal(res.statusCode, 400) }) - fastify.inject({ + t.assert.deepStrictEqual(response2.json(), { + statusCode: 400, + code: 'FST_ERR_VALIDATION', + error: 'Bad Request', + message: 'abc' + }) + t.assert.strictEqual(response2.statusCode, 400) + + const response3 = await fastify.inject({ method: 'POST', payload: { hello: 'michelangelo' }, url: '/test' - }, (err, res) => { - t.error(err) - t.same(res.json(), { - statusCode: 400, - error: 'Bad Request', - message: 'abc123' - }) - t.equal(res.statusCode, 400) }) + + t.assert.deepStrictEqual(response3.json(), { + statusCode: 400, + code: 'FST_ERR_VALIDATION', + error: 'Bad Request', + message: 'abc123' + }) + t.assert.strictEqual(response3.statusCode, 400) }) -test('adding schemaErrorFormatter', t => { - t.plan(3) +test('adding schemaErrorFormatter', async (t) => { + t.plan(2) const fastify = Fastify() @@ -702,26 +692,25 @@ test('adding schemaErrorFormatter', t => { fastify.post('/', { schema }, echoBody) - fastify.inject({ + const response = await fastify.inject({ method: 'POST', payload: { hello: 'michelangelo' }, url: '/' - }, (err, res) => { - t.error(err) - t.same(res.json(), { - statusCode: 400, - error: 'Bad Request', - message: 'abc' - }) - t.equal(res.statusCode, 400) - t.end() }) + + t.assert.deepStrictEqual(response.json(), { + statusCode: 400, + code: 'FST_ERR_VALIDATION', + error: 'Bad Request', + message: 'abc' + }) + t.assert.strictEqual(response.statusCode, 400) }) -test('plugin override', t => { - t.plan(15) +test('plugin override', async (t) => { + t.plan(10) const fastify = Fastify({ schemaErrorFormatter: (errors, dataVar) => { @@ -762,83 +751,150 @@ test('plugin override', t => { } }, echoBody) - fastify.inject({ + const response1 = await fastify.inject({ method: 'POST', payload: { hello: 'michelangelo' }, url: '/' - }, (err, res) => { - t.error(err) - t.same(res.json(), { - statusCode: 400, - error: 'Bad Request', - message: 'A' - }) - t.equal(res.statusCode, 400) }) - fastify.inject({ + t.assert.deepStrictEqual(response1.json(), { + statusCode: 400, + code: 'FST_ERR_VALIDATION', + error: 'Bad Request', + message: 'A' + }) + t.assert.strictEqual(response1.statusCode, 400) + + const response2 = await fastify.inject({ method: 'POST', payload: { hello: 'michelangelo' }, url: '/b' - }, (err, res) => { - t.error(err) - t.same(res.json(), { - statusCode: 400, - error: 'Bad Request', - message: 'B' - }) - t.equal(res.statusCode, 400) }) - fastify.inject({ + t.assert.deepStrictEqual(response2.json(), { + statusCode: 400, + code: 'FST_ERR_VALIDATION', + error: 'Bad Request', + message: 'B' + }) + t.assert.strictEqual(response2.statusCode, 400) + + const response3 = await fastify.inject({ method: 'POST', payload: { hello: 'michelangelo' }, url: '/c' - }, (err, res) => { - t.error(err) - t.same(res.json(), { - statusCode: 400, - error: 'Bad Request', - message: 'C' - }) - t.equal(res.statusCode, 400) }) - fastify.inject({ + t.assert.deepStrictEqual(response3.json(), { + statusCode: 400, + code: 'FST_ERR_VALIDATION', + error: 'Bad Request', + message: 'C' + }) + t.assert.strictEqual(response3.statusCode, 400) + + const response4 = await fastify.inject({ method: 'POST', payload: { hello: 'michelangelo' }, url: '/d' - }, (err, res) => { - t.error(err) - t.same(res.json(), { - statusCode: 400, - error: 'Bad Request', - message: 'D' - }) - t.equal(res.statusCode, 400) }) - fastify.inject({ + t.assert.deepStrictEqual(response4.json(), { + statusCode: 400, + code: 'FST_ERR_VALIDATION', + error: 'Bad Request', + message: 'D' + }) + t.assert.strictEqual(response4.statusCode, 400) + + const response5 = await fastify.inject({ method: 'POST', payload: { hello: 'michelangelo' }, url: '/stillC' - }, (err, res) => { - t.error(err) - t.same(res.json(), { - statusCode: 400, - error: 'Bad Request', - message: 'C' + }) + + t.assert.deepStrictEqual(response5.json(), { + statusCode: 400, + code: 'FST_ERR_VALIDATION', + error: 'Bad Request', + message: 'C' + }) + t.assert.strictEqual(response5.statusCode, 400) +}) + +describe('sync and async must work in the same way', () => { + // Route with custom validator that throws + const throwingRouteValidator = { + schema: { + body: { + type: 'object', + properties: { name: { type: 'string' } } + } + }, + validatorCompiler: () => { + return function (inputData) { + // This custom validator throws a sync error instead of returning `{ error }` + throw new Error('Custom validation failed') + } + }, + handler (request, reply) { reply.send({ success: true }) } + } + + test('async preValidation with custom validator should trigger error handler when validator throws', async (t) => { + t.plan(4) + + const fastify = Fastify() + fastify.setErrorHandler((error, request, reply) => { + t.assert.ok(error instanceof Error, 'error should be an Error instance') + t.assert.strictEqual(error.message, 'Custom validation failed') + reply.status(500).send({ error: error.message }) + }) + + // Add async preValidation hook + fastify.addHook('preValidation', async (request, reply) => { + await Promise.resolve('ok') + }) + fastify.post('/async', throwingRouteValidator) + + const response = await fastify.inject({ + method: 'POST', + url: '/async', + payload: { name: 'test' } + }) + t.assert.strictEqual(response.statusCode, 500) + t.assert.deepStrictEqual(response.json(), { error: 'Custom validation failed' }) + }) + + test('sync preValidation with custom validator should trigger error handler when validator throws', async (t) => { + t.plan(4) + + const fastify = Fastify() + fastify.setErrorHandler((error, request, reply) => { + t.assert.ok(error instanceof Error, 'error should be an Error instance') + t.assert.strictEqual(error.message, 'Custom validation failed') + reply.status(500).send({ error: error.message }) + }) + + // Add sync preValidation hook + fastify.addHook('preValidation', (request, reply, next) => { next() }) + fastify.post('/sync', throwingRouteValidator) + + const response = await fastify.inject({ + method: 'POST', + url: '/sync', + payload: { name: 'test' } }) - t.equal(res.statusCode, 400) + t.assert.strictEqual(response.statusCode, 500) + t.assert.deepStrictEqual(response.json(), { error: 'Custom validation failed' }) }) }) diff --git a/test/versioned-routes.test.js b/test/versioned-routes.test.js index 00a3acb6e37..0ec03b1d9f3 100644 --- a/test/versioned-routes.test.js +++ b/test/versioned-routes.test.js @@ -1,13 +1,11 @@ 'use strict' -const { test, before } = require('tap') +const { test, before } = require('node:test') const helper = require('./helper') const Fastify = require('..') -const sget = require('simple-get').concat -const http = require('http') +const http = require('node:http') const split = require('split2') const append = require('vary').append -const proxyquire = require('proxyquire') process.removeAllListeners('warning') @@ -16,7 +14,7 @@ before(async function () { [localhost] = await helper.getLoopbackHost() }) -test('Should register a versioned route', t => { +test('Should register a versioned route (inject)', (t, done) => { t.plan(11) const fastify = Fastify() @@ -36,9 +34,9 @@ test('Should register a versioned route', t => { 'Accept-Version': '1.x' } }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + t.assert.strictEqual(res.statusCode, 200) }) fastify.inject({ @@ -48,9 +46,9 @@ test('Should register a versioned route', t => { 'Accept-Version': '1.2.x' } }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + t.assert.strictEqual(res.statusCode, 200) }) fastify.inject({ @@ -60,9 +58,9 @@ test('Should register a versioned route', t => { 'Accept-Version': '1.2.0' } }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + t.assert.strictEqual(res.statusCode, 200) }) fastify.inject({ @@ -72,12 +70,13 @@ test('Should register a versioned route', t => { 'Accept-Version': '1.2.1' } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 404) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 404) + done() }) }) -test('Should register a versioned route via route constraints', t => { +test('Should register a versioned route via route constraints', (t, done) => { t.plan(6) const fastify = Fastify() @@ -97,9 +96,9 @@ test('Should register a versioned route via route constraints', t => { 'Accept-Version': '1.x' } }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + t.assert.strictEqual(res.statusCode, 200) }) fastify.inject({ @@ -109,13 +108,14 @@ test('Should register a versioned route via route constraints', t => { 'Accept-Version': '1.2.x' } }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + t.assert.strictEqual(res.statusCode, 200) + done() }) }) -test('Should register the same route with different versions', t => { +test('Should register the same route with different versions', (t, done) => { t.plan(8) const fastify = Fastify() @@ -144,9 +144,9 @@ test('Should register the same route with different versions', t => { 'Accept-Version': '1.x' } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.equal(res.payload, '1.3.0') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload, '1.3.0') }) fastify.inject({ @@ -156,9 +156,9 @@ test('Should register the same route with different versions', t => { 'Accept-Version': '1.2.x' } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 200) - t.equal(res.payload, '1.2.0') + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.payload, '1.2.0') }) fastify.inject({ @@ -168,12 +168,13 @@ test('Should register the same route with different versions', t => { 'Accept-Version': '2.x' } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 404) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 404) + done() }) }) -test('The versioned route should take precedence', t => { +test('The versioned route should take precedence', (t, done) => { t.plan(3) const fastify = Fastify() @@ -201,13 +202,14 @@ test('The versioned route should take precedence', t => { 'Accept-Version': '1.x' } }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + t.assert.strictEqual(res.statusCode, 200) + done() }) }) -test('Versioned route but not version header should return a 404', t => { +test('Versioned route but not version header should return a 404', (t, done) => { t.plan(2) const fastify = Fastify() @@ -224,13 +226,14 @@ test('Versioned route but not version header should return a 404', t => { method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 404) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 404) + done() }) }) -test('Should register a versioned route', t => { - t.plan(6) +test('Should register a versioned route (server)', async t => { + t.plan(5) const fastify = Fastify() fastify.route({ @@ -242,36 +245,30 @@ test('Should register a versioned route', t => { } }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port, - headers: { - 'Accept-Version': '1.x' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 200) - t.same(JSON.parse(body), { hello: 'world' }) - }) + const result1 = await fetch(fastifyServer, { + headers: { + 'Accept-Version': '1.x' + } + }) + t.assert.ok(result1.ok) + t.assert.strictEqual(result1.status, 200) + const body1 = await result1.json() + t.assert.deepStrictEqual(body1, { hello: 'world' }) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port, - headers: { - 'Accept-Version': '2.x' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 404) - }) + const result2 = await fetch(fastifyServer, { + headers: { + 'Accept-Version': '2.x' + } }) + + t.assert.ok(!result2.ok) + t.assert.strictEqual(result2.status, 404) }) -test('Shorthand route declaration', t => { +test('Shorthand route declaration', (t, done) => { t.plan(5) const fastify = Fastify() @@ -286,9 +283,9 @@ test('Shorthand route declaration', t => { 'Accept-Version': '1.x' } }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + t.assert.strictEqual(res.statusCode, 200) }) fastify.inject({ @@ -298,27 +295,28 @@ test('Shorthand route declaration', t => { 'Accept-Version': '1.2.1' } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 404) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 404) + done() }) }) -test('The not found handler should not erase the Accept-Version header', t => { +test('The not found handler should not erase the Accept-Version header', (t, done) => { t.plan(13) const fastify = Fastify() fastify.addHook('onRequest', function (req, reply, done) { - t.same(req.headers['accept-version'], '2.x') + t.assert.deepStrictEqual(req.headers['accept-version'], '2.x') done() }) fastify.addHook('preValidation', function (req, reply, done) { - t.same(req.headers['accept-version'], '2.x') + t.assert.deepStrictEqual(req.headers['accept-version'], '2.x') done() }) fastify.addHook('preHandler', function (req, reply, done) { - t.same(req.headers['accept-version'], '2.x') + t.assert.deepStrictEqual(req.headers['accept-version'], '2.x') done() }) @@ -332,14 +330,14 @@ test('The not found handler should not erase the Accept-Version header', t => { }) fastify.setNotFoundHandler(function (req, reply) { - t.same(req.headers['accept-version'], '2.x') + t.assert.deepStrictEqual(req.headers['accept-version'], '2.x') // we check if the symbol is exposed on key or not for (const key in req.headers) { - t.same(typeof key, 'string') + t.assert.deepStrictEqual(typeof key, 'string') } for (const key of Object.keys(req.headers)) { - t.same(typeof key, 'string') + t.assert.deepStrictEqual(typeof key, 'string') } reply.code(404).send('not found handler') @@ -352,13 +350,14 @@ test('The not found handler should not erase the Accept-Version header', t => { 'Accept-Version': '2.x' } }, (err, res) => { - t.error(err) - t.same(res.payload, 'not found handler') - t.equal(res.statusCode, 404) + t.assert.ifError(err) + t.assert.deepStrictEqual(res.payload, 'not found handler') + t.assert.strictEqual(res.statusCode, 404) + done() }) }) -test('Bad accept version (inject)', t => { +test('Bad accept version (inject)', (t, done) => { t.plan(4) const fastify = Fastify() @@ -378,8 +377,8 @@ test('Bad accept version (inject)', t => { 'Accept-Version': 'a.b.c' } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 404) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 404) }) fastify.inject({ @@ -389,13 +388,14 @@ test('Bad accept version (inject)', t => { 'Accept-Version': 12 } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 404) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 404) + done() }) }) -test('Bas accept version (server)', t => { - t.plan(5) +test('Bad accept version (server)', async t => { + t.plan(4) const fastify = Fastify() fastify.route({ @@ -407,35 +407,27 @@ test('Bas accept version (server)', t => { } }) - fastify.listen({ port: 0 }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) + const fastifyServer = await fastify.listen({ port: 0 }) + t.after(() => { fastify.close() }) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port, - headers: { - 'Accept-Version': 'a.b.c' - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 404) - }) + const result1 = await fetch(fastifyServer, { + headers: { + 'Accept-Version': 'a.b.c' + } + }) + t.assert.ok(!result1.ok) + t.assert.strictEqual(result1.status, 404) - sget({ - method: 'GET', - url: 'http://localhost:' + fastify.server.address().port, - headers: { - 'Accept-Version': 12 - } - }, (err, response, body) => { - t.error(err) - t.equal(response.statusCode, 404) - }) + const result2 = await fetch(fastifyServer, { + headers: { + 'Accept-Version': '12' + } }) + t.assert.ok(!result2.ok) + t.assert.strictEqual(result2.status, 404) }) -test('test log stream', t => { +test('test log stream', (t, done) => { t.plan(3) const stream = split(JSON.parse) const fastify = Fastify({ @@ -450,11 +442,11 @@ test('test log stream', t => { }) fastify.listen({ port: 0, host: localhost }, err => { - t.error(err) - t.teardown(() => { fastify.close() }) + t.assert.ifError(err) + t.after(() => { fastify.close() }) http.get({ - hostname: fastify.server.address().hostname, + host: fastify.server.address().hostname, port: fastify.server.address().port, path: '/', method: 'GET', @@ -465,16 +457,17 @@ test('test log stream', t => { stream.once('data', listenAtLogLine => { stream.once('data', line => { - t.equal(line.req.version, '1.x') + t.assert.strictEqual(line.req.version, '1.x') stream.once('data', line => { - t.equal(line.req.version, '1.x') + t.assert.strictEqual(line.req.version, '1.x') + done() }) }) }) }) }) -test('Should register a versioned route with custom versioning strategy', t => { +test('Should register a versioned route with custom versioning strategy', (t, done) => { t.plan(8) const customVersioning = { @@ -524,9 +517,9 @@ test('Should register a versioned route with custom versioning strategy', t => { Accept: 'application/vnd.example.api+json;version=2' } }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'from route v2' }) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'from route v2' }) + t.assert.strictEqual(res.statusCode, 200) }) fastify.inject({ @@ -536,9 +529,9 @@ test('Should register a versioned route with custom versioning strategy', t => { Accept: 'application/vnd.example.api+json;version=3' } }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'from route v3' }) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'from route v3' }) + t.assert.strictEqual(res.statusCode, 200) }) fastify.inject({ @@ -548,65 +541,13 @@ test('Should register a versioned route with custom versioning strategy', t => { Accept: 'application/vnd.example.api+json;version=4' } }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 404) - }) -}) - -test('Should get error using an invalid a versioned route, using default validation (deprecated versioning option)', t => { - t.plan(3) - - const fastify = Fastify({ - versioning: { - storage: function () { - const versions = {} - return { - get: (version) => { return versions[version] || null }, - set: (version, store) => { versions[version] = store } - } - }, - deriveVersion: (req, ctx) => { - return req.headers.accept - } - } - }) - - fastify.route({ - method: 'GET', - url: '/', - constraints: { version: 'application/vnd.example.api+json;version=1' }, - handler: (req, reply) => { - reply.send({ hello: 'cant match route v1' }) - } - }) - - try { - fastify.route({ - method: 'GET', - url: '/', - // not a string version - constraints: { version: 2 }, - handler: (req, reply) => { - reply.send({ hello: 'cant match route v2' }) - } - }) - } catch (err) { - t.equal(err.message, 'Version constraint should be a string.') - } - - fastify.inject({ - method: 'GET', - url: '/', - headers: { - Accept: 'application/vnd.example.api+json;version=2' - } - }, (err, res) => { - t.error(err) - t.equal(res.statusCode, 404) + t.assert.ifError(err) + t.assert.strictEqual(res.statusCode, 404) + done() }) }) -test('Vary header check (for documentation example)', t => { +test('Vary header check (for documentation example)', (t, done) => { t.plan(8) const fastify = Fastify() fastify.addHook('onSend', async (req, reply) => { @@ -643,54 +584,20 @@ test('Vary header check (for documentation example)', t => { 'Accept-Version': '1.x' } }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) - t.equal(res.statusCode, 200) - t.equal(res.headers.vary, 'Accept-Version') + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers.vary, 'Accept-Version') }) fastify.inject({ method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) - t.equal(res.statusCode, 200) - t.equal(res.headers.vary, undefined) - }) -}) - -test('Should trigger a warning when a versioned route is registered via version option', t => { - t.plan(4) - - function onWarning (code) { - t.equal(code, 'FSTDEP008') - } - const warning = { - emit: onWarning - } - - const route = proxyquire('../lib/route', { './warnings': warning }) - const fastify = proxyquire('..', { './lib/route.js': route })({ exposeHeadRoutes: false }) - - fastify.route({ - method: 'GET', - url: '/', - version: '1.2.0', - handler: (req, reply) => { - reply.send({ hello: 'world' }) - } - }) - - fastify.inject({ - method: 'GET', - url: '/', - headers: { - 'Accept-Version': '1.x' - } - }, (err, res) => { - t.error(err) - t.same(JSON.parse(res.payload), { hello: 'world' }) - t.equal(res.statusCode, 200) + t.assert.ifError(err) + t.assert.deepStrictEqual(JSON.parse(res.payload), { hello: 'world' }) + t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.headers.vary, undefined) + done() }) }) diff --git a/test/web-api.test.js b/test/web-api.test.js new file mode 100644 index 00000000000..e4fd574ce70 --- /dev/null +++ b/test/web-api.test.js @@ -0,0 +1,616 @@ +'use strict' + +const { test } = require('node:test') +const Fastify = require('../fastify') +const fs = require('node:fs') +const { Readable } = require('node:stream') +const { fetch: undiciFetch } = require('undici') +const http = require('node:http') +const { setTimeout: sleep } = require('node:timers/promises') + +test('should response with a ReadableStream', async (t) => { + t.plan(2) + + const fastify = Fastify() + + fastify.get('/', function (request, reply) { + const stream = fs.createReadStream(__filename) + reply.code(200).send(Readable.toWeb(stream)) + }) + + const { + statusCode, + body + } = await fastify.inject({ method: 'GET', path: '/' }) + + const expected = await fs.promises.readFile(__filename) + + t.assert.strictEqual(statusCode, 200) + t.assert.strictEqual(expected.toString(), body.toString()) +}) + +test('should response with a Response', async (t) => { + t.plan(3) + + const fastify = Fastify() + + fastify.get('/', function (request, reply) { + const stream = fs.createReadStream(__filename) + reply.send(new Response(Readable.toWeb(stream), { + status: 200, + headers: { + hello: 'world' + } + })) + }) + + const { + statusCode, + headers, + body + } = await fastify.inject({ method: 'GET', path: '/' }) + + const expected = await fs.promises.readFile(__filename) + + t.assert.strictEqual(statusCode, 200) + t.assert.strictEqual(expected.toString(), body.toString()) + t.assert.strictEqual(headers.hello, 'world') +}) + +test('should response with a Response 204', async (t) => { + t.plan(3) + + const fastify = Fastify() + + fastify.get('/', function (request, reply) { + reply.send(new Response(null, { + status: 204, + headers: { + hello: 'world' + } + })) + }) + + const { + statusCode, + headers, + body + } = await fastify.inject({ method: 'GET', path: '/' }) + + t.assert.strictEqual(statusCode, 204) + t.assert.strictEqual(body, '') + t.assert.strictEqual(headers.hello, 'world') +}) + +test('should response with a Response 304', async (t) => { + t.plan(3) + + const fastify = Fastify() + + fastify.get('/', function (request, reply) { + reply.send(new Response(null, { + status: 304, + headers: { + hello: 'world' + } + })) + }) + + const { + statusCode, + headers, + body + } = await fastify.inject({ method: 'GET', path: '/' }) + + t.assert.strictEqual(statusCode, 304) + t.assert.strictEqual(body, '') + t.assert.strictEqual(headers.hello, 'world') +}) + +test('should response with a Response without body', async (t) => { + t.plan(3) + + const fastify = Fastify() + + fastify.get('/', function (request, reply) { + reply.send(new Response(null, { + status: 200, + headers: { + hello: 'world' + } + })) + }) + + const { + statusCode, + headers, + body + } = await fastify.inject({ method: 'GET', path: '/' }) + + t.assert.strictEqual(statusCode, 200) + t.assert.strictEqual(body, '') + t.assert.strictEqual(headers.hello, 'world') +}) + +test('able to use in onSend hook - ReadableStream', async (t) => { + t.plan(4) + + const fastify = Fastify() + + fastify.get('/', function (request, reply) { + const stream = fs.createReadStream(__filename) + reply.code(500).send(Readable.toWeb(stream)) + }) + + fastify.addHook('onSend', (request, reply, payload, done) => { + t.assert.strictEqual(Object.prototype.toString.call(payload), '[object ReadableStream]') + done(null, new Response(payload, { + status: 200, + headers: { + hello: 'world' + } + })) + }) + + const { + statusCode, + headers, + body + } = await fastify.inject({ method: 'GET', path: '/' }) + + const expected = await fs.promises.readFile(__filename) + + t.assert.strictEqual(statusCode, 200) + t.assert.strictEqual(expected.toString(), body.toString()) + t.assert.strictEqual(headers.hello, 'world') +}) + +test('able to use in onSend hook - Response', async (t) => { + t.plan(4) + + const fastify = Fastify() + + fastify.get('/', function (request, reply) { + const stream = fs.createReadStream(__filename) + reply.send(new Response(Readable.toWeb(stream), { + status: 500, + headers: { + hello: 'world' + } + })) + }) + + fastify.addHook('onSend', (request, reply, payload, done) => { + t.assert.strictEqual(Object.prototype.toString.call(payload), '[object Response]') + done(null, new Response(payload.body, { + status: 200, + headers: payload.headers + })) + }) + + const { + statusCode, + headers, + body + } = await fastify.inject({ method: 'GET', path: '/' }) + + const expected = await fs.promises.readFile(__filename) + + t.assert.strictEqual(statusCode, 200) + t.assert.strictEqual(expected.toString(), body.toString()) + t.assert.strictEqual(headers.hello, 'world') +}) + +test('Error when Response.bodyUsed', async (t) => { + t.plan(4) + + const expected = await fs.promises.readFile(__filename) + + const fastify = Fastify() + + fastify.get('/', async function (request, reply) { + const stream = fs.createReadStream(__filename) + const response = new Response(Readable.toWeb(stream), { + status: 200, + headers: { + hello: 'world' + } + }) + const file = await response.text() + t.assert.strictEqual(expected.toString(), file) + t.assert.strictEqual(response.bodyUsed, true) + return reply.send(response) + }) + + const response = await fastify.inject({ method: 'GET', path: '/' }) + + t.assert.strictEqual(response.statusCode, 500) + const body = response.json() + t.assert.strictEqual(body.code, 'FST_ERR_REP_RESPONSE_BODY_CONSUMED') +}) + +test('Error when Response.body.locked', async (t) => { + t.plan(3) + + const fastify = Fastify() + + fastify.get('/', async function (request, reply) { + const stream = Readable.toWeb(fs.createReadStream(__filename)) + const response = new Response(stream, { + status: 200, + headers: { + hello: 'world' + } + }) + stream.getReader() + t.assert.strictEqual(stream.locked, true) + return reply.send(response) + }) + + const response = await fastify.inject({ method: 'GET', path: '/' }) + + t.assert.strictEqual(response.statusCode, 500) + const body = response.json() + t.assert.strictEqual(body.code, 'FST_ERR_REP_READABLE_STREAM_LOCKED') +}) + +test('Error when ReadableStream.locked', async (t) => { + t.plan(3) + + const fastify = Fastify() + + fastify.get('/', async function (request, reply) { + const stream = Readable.toWeb(fs.createReadStream(__filename)) + stream.getReader() + t.assert.strictEqual(stream.locked, true) + return reply.send(stream) + }) + + const response = await fastify.inject({ method: 'GET', path: '/' }) + + t.assert.strictEqual(response.statusCode, 500) + const body = response.json() + t.assert.strictEqual(body.code, 'FST_ERR_REP_READABLE_STREAM_LOCKED') +}) + +test('allow to pipe with fetch', async (t) => { + t.plan(2) + const abortController = new AbortController() + const { signal } = abortController + + const fastify = Fastify() + t.after(() => { + fastify.close() + abortController.abort() + }) + + fastify.get('/', function (request, reply) { + return fetch(`${fastify.listeningOrigin}/fetch`, { + method: 'GET', + signal + }) + }) + + fastify.get('/fetch', function async (request, reply) { + reply.code(200).send({ ok: true }) + }) + + await fastify.listen() + + const response = await fastify.inject({ method: 'GET', path: '/' }) + + t.assert.strictEqual(response.statusCode, 200) + t.assert.deepStrictEqual(response.json(), { ok: true }) +}) + +test('allow to pipe with undici.fetch', async (t) => { + t.plan(2) + const abortController = new AbortController() + const { signal } = abortController + + const fastify = Fastify() + t.after(() => { + fastify.close() + abortController.abort() + }) + + fastify.get('/', function (request, reply) { + return undiciFetch(`${fastify.listeningOrigin}/fetch`, { + method: 'GET', + signal + }) + }) + + fastify.get('/fetch', function (request, reply) { + reply.code(200).send({ ok: true }) + }) + + await fastify.listen() + + const response = await fastify.inject({ method: 'GET', path: '/' }) + + t.assert.strictEqual(response.statusCode, 200) + t.assert.deepStrictEqual(response.json(), { ok: true }) +}) + +test('WebStream error before headers sent should trigger error handler', async (t) => { + t.plan(2) + + const fastify = Fastify() + + fastify.get('/', function (request, reply) { + const stream = new ReadableStream({ + start (controller) { + controller.error(new Error('stream error')) + } + }) + reply.send(stream) + }) + + const response = await fastify.inject({ method: 'GET', path: '/' }) + + t.assert.strictEqual(response.statusCode, 500) + t.assert.strictEqual(response.json().message, 'stream error') +}) + +test('WebStream error after headers sent should destroy response', (t, done) => { + t.plan(2) + + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.get('/', function (request, reply) { + const stream = new ReadableStream({ + start (controller) { + controller.enqueue('hello') + }, + pull (controller) { + setTimeout(() => { + controller.error(new Error('stream error')) + }, 10) + } + }) + reply.header('content-type', 'text/plain').send(stream) + }) + + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + + let finished = false + http.get(`http://localhost:${fastify.server.address().port}`, (res) => { + res.on('close', () => { + if (!finished) { + finished = true + t.assert.ok('response closed') + done() + } + }) + res.resume() + }) + }) +}) + +test('WebStream should cancel reader when response is destroyed', (t, done) => { + t.plan(2) + + const fastify = Fastify() + t.after(() => fastify.close()) + + let readerCancelled = false + + fastify.get('/', function (request, reply) { + const stream = new ReadableStream({ + start (controller) { + controller.enqueue('hello') + }, + pull (controller) { + return new Promise(() => {}) + }, + cancel () { + readerCancelled = true + } + }) + reply.header('content-type', 'text/plain').send(stream) + }) + + fastify.listen({ port: 0 }, err => { + t.assert.ifError(err) + + const req = http.get(`http://localhost:${fastify.server.address().port}`, (res) => { + res.once('data', () => { + req.destroy() + setTimeout(() => { + t.assert.strictEqual(readerCancelled, true) + done() + }, 50) + }) + }) + }) +}) + +test('WebStream should respect backpressure', async (t) => { + t.plan(3) + + const fastify = Fastify() + t.after(() => fastify.close()) + + let drainEmittedAt = 0 + let secondWriteAt = 0 + let resolveSecondWrite + const secondWrite = new Promise((resolve) => { + resolveSecondWrite = resolve + }) + + fastify.get('/', function (request, reply) { + const raw = reply.raw + const originalWrite = raw.write.bind(raw) + const bufferedChunks = [] + let wroteFirstChunk = false + + raw.once('drain', () => { + for (const bufferedChunk of bufferedChunks) { + originalWrite(bufferedChunk) + } + }) + + raw.write = function (chunk, encoding, cb) { + if (!wroteFirstChunk) { + wroteFirstChunk = true + bufferedChunks.push(Buffer.from(chunk)) + sleep(100).then(() => { + drainEmittedAt = Date.now() + raw.emit('drain') + }) + if (typeof cb === 'function') { + cb() + } + return false + } + if (!secondWriteAt) { + secondWriteAt = Date.now() + resolveSecondWrite() + } + return originalWrite(chunk, encoding, cb) + } + + const stream = new ReadableStream({ + start (controller) { + controller.enqueue(Buffer.from('chunk-1')) + }, + pull (controller) { + controller.enqueue(Buffer.from('chunk-2')) + controller.close() + } + }) + + reply.header('content-type', 'text/plain').send(stream) + }) + + await fastify.listen({ port: 0 }) + + const response = await undiciFetch(`http://localhost:${fastify.server.address().port}/`) + const bodyPromise = response.text() + + await secondWrite + await sleep(120) + const body = await bodyPromise + + t.assert.strictEqual(response.status, 200) + t.assert.strictEqual(body, 'chunk-1chunk-2') + t.assert.ok(secondWriteAt >= drainEmittedAt) +}) + +test('WebStream should stop reading on drain after response destroy', async (t) => { + t.plan(2) + + const fastify = Fastify() + t.after(() => fastify.close()) + + let cancelCalled = false + let resolveCancel + const cancelPromise = new Promise((resolve) => { + resolveCancel = resolve + }) + + fastify.get('/', function (request, reply) { + const raw = reply.raw + const originalWrite = raw.write.bind(raw) + let firstWrite = true + + raw.write = function (chunk, encoding, cb) { + if (firstWrite) { + firstWrite = false + if (typeof cb === 'function') { + cb() + } + queueMicrotask(() => { + raw.destroy() + raw.emit('drain') + }) + return false + } + return originalWrite(chunk, encoding, cb) + } + + const stream = new ReadableStream({ + start (controller) { + controller.enqueue(Buffer.from('chunk-1')) + }, + pull (controller) { + controller.enqueue(Buffer.from('chunk-2')) + controller.close() + }, + cancel () { + cancelCalled = true + resolveCancel() + } + }) + + reply.header('content-type', 'text/plain').send(stream) + }) + + await new Promise((resolve, reject) => { + fastify.listen({ port: 0 }, err => { + if (err) return reject(err) + resolve() + }) + }) + + await new Promise((resolve, reject) => { + const req = http.get(`http://localhost:${fastify.server.address().port}/`, (res) => { + res.once('close', resolve) + res.resume() + }) + req.once('error', (err) => { + if (err.code === 'ECONNRESET') { + resolve() + } else { + reject(err) + } + }) + }) + + await cancelPromise + t.assert.ok(true, 'response interrupted as expected') + t.assert.strictEqual(cancelCalled, true) +}) + +test('WebStream should warn when headers already sent', async (t) => { + t.plan(2) + + let warnCalled = false + const spyLogger = { + level: 'warn', + fatal: () => { }, + error: () => { }, + warn: (msg) => { + if (typeof msg === 'string' && msg.includes('use res.writeHead in stream mode')) { + warnCalled = true + } + }, + info: () => { }, + debug: () => { }, + trace: () => { }, + child: () => spyLogger + } + + const fastify = Fastify({ loggerInstance: spyLogger }) + t.after(() => fastify.close()) + + fastify.get('/', function (request, reply) { + reply.raw.writeHead(200, { 'content-type': 'text/plain' }) + const stream = new ReadableStream({ + start (controller) { + controller.enqueue('hello') + controller.close() + } + }) + reply.send(stream) + }) + + await fastify.listen({ port: 0 }) + + const response = await fetch(`http://localhost:${fastify.server.address().port}/`) + t.assert.strictEqual(response.status, 200) + t.assert.strictEqual(warnCalled, true) +}) diff --git a/test/wrapThenable.test.js b/test/wrap-thenable.test.js similarity index 58% rename from test/wrapThenable.test.js rename to test/wrap-thenable.test.js index e953d29c16c..b906acea369 100644 --- a/test/wrapThenable.test.js +++ b/test/wrap-thenable.test.js @@ -1,17 +1,18 @@ 'use strict' -const t = require('tap') -const test = t.test +const { test } = require('node:test') const { kReplyHijacked } = require('../lib/symbols') -const wrapThenable = require('../lib/wrapThenable') +const wrapThenable = require('../lib/wrap-thenable') const Reply = require('../lib/reply') -test('should resolve immediately when reply[kReplyHijacked] is true', t => { - const reply = {} - reply[kReplyHijacked] = true - const thenable = Promise.resolve() - wrapThenable(thenable, reply) - t.end() +test('should resolve immediately when reply[kReplyHijacked] is true', async t => { + await new Promise(resolve => { + const reply = {} + reply[kReplyHijacked] = true + const thenable = Promise.resolve() + wrapThenable(thenable, reply) + resolve() + }) }) test('should reject immediately when reply[kReplyHijacked] is true', t => { @@ -20,7 +21,7 @@ test('should reject immediately when reply[kReplyHijacked] is true', t => { reply[kReplyHijacked] = true reply.log = { error: ({ err }) => { - t.equal(err.message, 'Reply sent already') + t.assert.strictEqual(err.message, 'Reply sent already') } } diff --git a/types/.eslintrc.json b/types/.eslintrc.json deleted file mode 100644 index 8389d4cf0da..00000000000 --- a/types/.eslintrc.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/eslint-recommended", - "plugin:@typescript-eslint/recommended", - "standard" - ], - "parser": "@typescript-eslint/parser", - "plugins": ["@typescript-eslint"], - "env": { "node": true }, - "parserOptions": { - "ecmaVersion": 6, - "sourceType": "module", - "project": "./types/tsconfig.json", - "createDefaultProgram": true - }, - "rules": { - "no-console": "off", - "@typescript-eslint/indent": ["error", 2], - "func-call-spacing": "off", - "@typescript-eslint/func-call-spacing": ["error"], - "semi": ["error", "never"], - "import/export": "off" // this errors on multiple exports (overload interfaces) - }, - "overrides": [ - { - "files": ["*.d.ts","*.test-d.ts"], - "rules": { - "@typescript-eslint/no-explicit-any": "off" - } - }, - { - "files": ["*.test-d.ts"], - "rules": { - "no-unused-vars": "off", - "n/handle-callback-err": "off", - "@typescript-eslint/no-empty-function": "off", - "@typescript-eslint/explicit-function-return-type": "off", - "@typescript-eslint/no-unused-vars": "off", - "@typescript-eslint/no-non-null-assertion": "off", - "@typescript-eslint/no-misused-promises": ["error", { - "checksVoidReturn": false - }] - }, - "globals": { - "NodeJS": "readonly" - } - } - ] -} diff --git a/types/content-type-parser.d.ts b/types/content-type-parser.d.ts index 631093fd31b..c4c7839bcee 100644 --- a/types/content-type-parser.d.ts +++ b/types/content-type-parser.d.ts @@ -15,7 +15,7 @@ export type FastifyBodyParser< RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, RouteGeneric extends RouteGenericInterface = RouteGenericInterface, SchemaCompiler extends FastifySchema = FastifySchema, - TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, + TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault > = ((request: FastifyRequest, rawBody: RawBody, done: ContentTypeParserDoneFunction) => void) | ((request: FastifyRequest, rawBody: RawBody) => Promise) @@ -27,7 +27,7 @@ export type FastifyContentTypeParser< RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, RouteGeneric extends RouteGenericInterface = RouteGenericInterface, SchemaCompiler extends FastifySchema = FastifySchema, - TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, + TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault > = ((request: FastifyRequest, payload: RawRequest) => Promise) | ((request: FastifyRequest, payload: RawRequest, done: ContentTypeParserDoneFunction) => void) @@ -39,7 +39,7 @@ export interface AddContentTypeParser< RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, RouteGeneric extends RouteGenericInterface = RouteGenericInterface, SchemaCompiler extends FastifySchema = FastifySchema, - TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, + TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault > { ( contentType: string | string[] | RegExp, diff --git a/types/context.d.ts b/types/context.d.ts index e64a024228c..01f66d807e0 100644 --- a/types/context.d.ts +++ b/types/context.d.ts @@ -1,12 +1,22 @@ +import { FastifyRouteConfig } from './route' import { ContextConfigDefault } from './utils' -// eslint-disable-next-line @typescript-eslint/no-empty-interface export interface FastifyContextConfig { } /** * Route context object. Properties defined here will be available in the route's handler */ -export interface FastifyContext { - config: FastifyContextConfig & ContextConfig; +export interface FastifyRequestContext { + /** + * @deprecated Use Request#routeOptions#config or Request#routeOptions#schema instead + */ + config: FastifyContextConfig & FastifyRouteConfig & ContextConfig; +} + +export interface FastifyReplyContext { + /** + * @deprecated Use Reply#routeOptions#config or Reply#routeOptions#schema instead + */ + config: FastifyContextConfig & FastifyRouteConfig & ContextConfig; } diff --git a/types/errors.d.ts b/types/errors.d.ts new file mode 100644 index 00000000000..13c5cf406b6 --- /dev/null +++ b/types/errors.d.ts @@ -0,0 +1,92 @@ +import { FastifyErrorConstructor } from '@fastify/error' + +export type FastifyErrorCodes = Record< + 'FST_ERR_NOT_FOUND' | + 'FST_ERR_OPTIONS_NOT_OBJ' | + 'FST_ERR_QSP_NOT_FN' | + 'FST_ERR_SCHEMA_CONTROLLER_BUCKET_OPT_NOT_FN' | + 'FST_ERR_SCHEMA_ERROR_FORMATTER_NOT_FN' | + 'FST_ERR_AJV_CUSTOM_OPTIONS_OPT_NOT_OBJ' | + 'FST_ERR_AJV_CUSTOM_OPTIONS_OPT_NOT_ARR' | + 'FST_ERR_VALIDATION' | + 'FST_ERR_LISTEN_OPTIONS_INVALID' | + 'FST_ERR_ERROR_HANDLER_NOT_FN' | + 'FST_ERR_ERROR_HANDLER_ALREADY_SET' | + 'FST_ERR_CTP_ALREADY_PRESENT' | + 'FST_ERR_CTP_INVALID_TYPE' | + 'FST_ERR_CTP_EMPTY_TYPE' | + 'FST_ERR_CTP_INVALID_HANDLER' | + 'FST_ERR_CTP_INVALID_PARSE_TYPE' | + 'FST_ERR_CTP_BODY_TOO_LARGE' | + 'FST_ERR_CTP_INVALID_MEDIA_TYPE' | + 'FST_ERR_CTP_INVALID_CONTENT_LENGTH' | + 'FST_ERR_CTP_EMPTY_JSON_BODY' | + 'FST_ERR_CTP_INVALID_JSON_BODY' | + 'FST_ERR_CTP_INSTANCE_ALREADY_STARTED' | + 'FST_ERR_DEC_ALREADY_PRESENT' | + 'FST_ERR_DEC_DEPENDENCY_INVALID_TYPE' | + 'FST_ERR_DEC_MISSING_DEPENDENCY' | + 'FST_ERR_DEC_AFTER_START' | + 'FST_ERR_DEC_REFERENCE_TYPE' | + 'FST_ERR_DEC_UNDECLARED' | + 'FST_ERR_HOOK_INVALID_TYPE' | + 'FST_ERR_HOOK_INVALID_HANDLER' | + 'FST_ERR_HOOK_INVALID_ASYNC_HANDLER' | + 'FST_ERR_HOOK_NOT_SUPPORTED' | + 'FST_ERR_MISSING_MIDDLEWARE' | + 'FST_ERR_HOOK_TIMEOUT' | + 'FST_ERR_LOG_INVALID_DESTINATION' | + 'FST_ERR_LOG_INVALID_LOGGER' | + 'FST_ERR_LOG_INVALID_LOGGER_INSTANCE' | + 'FST_ERR_LOG_INVALID_LOGGER_CONFIG' | + 'FST_ERR_LOG_LOGGER_AND_LOGGER_INSTANCE_PROVIDED' | + 'FST_ERR_REP_INVALID_PAYLOAD_TYPE' | + 'FST_ERR_REP_RESPONSE_BODY_CONSUMED' | + 'FST_ERR_REP_READABLE_STREAM_LOCKED' | + 'FST_ERR_REP_ALREADY_SENT' | + 'FST_ERR_REP_SENT_VALUE' | + 'FST_ERR_SEND_INSIDE_ONERR' | + 'FST_ERR_SEND_UNDEFINED_ERR' | + 'FST_ERR_BAD_STATUS_CODE' | + 'FST_ERR_BAD_TRAILER_NAME' | + 'FST_ERR_BAD_TRAILER_VALUE' | + 'FST_ERR_FAILED_ERROR_SERIALIZATION' | + 'FST_ERR_MISSING_SERIALIZATION_FN' | + 'FST_ERR_MISSING_CONTENTTYPE_SERIALIZATION_FN' | + 'FST_ERR_REQ_INVALID_VALIDATION_INVOCATION' | + 'FST_ERR_SCH_MISSING_ID' | + 'FST_ERR_SCH_ALREADY_PRESENT' | + 'FST_ERR_SCH_CONTENT_MISSING_SCHEMA' | + 'FST_ERR_SCH_DUPLICATE' | + 'FST_ERR_SCH_VALIDATION_BUILD' | + 'FST_ERR_SCH_SERIALIZATION_BUILD' | + 'FST_ERR_SCH_RESPONSE_SCHEMA_NOT_NESTED_2XX' | + 'FST_ERR_INIT_OPTS_INVALID' | + 'FST_ERR_FORCE_CLOSE_CONNECTIONS_IDLE_NOT_AVAILABLE' | + 'FST_ERR_DUPLICATED_ROUTE' | + 'FST_ERR_BAD_URL' | + 'FST_ERR_ASYNC_CONSTRAINT' | + 'FST_ERR_INVALID_URL' | + 'FST_ERR_ROUTE_OPTIONS_NOT_OBJ' | + 'FST_ERR_ROUTE_DUPLICATED_HANDLER' | + 'FST_ERR_ROUTE_HANDLER_NOT_FN' | + 'FST_ERR_ROUTE_MISSING_HANDLER' | + 'FST_ERR_ROUTE_METHOD_INVALID' | + 'FST_ERR_ROUTE_METHOD_NOT_SUPPORTED' | + 'FST_ERR_ROUTE_BODY_VALIDATION_SCHEMA_NOT_SUPPORTED' | + 'FST_ERR_ROUTE_BODY_LIMIT_OPTION_NOT_INT' | + 'FST_ERR_HANDLER_TIMEOUT' | + 'FST_ERR_ROUTE_HANDLER_TIMEOUT_OPTION_NOT_INT' | + 'FST_ERR_ROUTE_REWRITE_NOT_STR' | + 'FST_ERR_REOPENED_CLOSE_SERVER' | + 'FST_ERR_REOPENED_SERVER' | + 'FST_ERR_INSTANCE_ALREADY_LISTENING' | + 'FST_ERR_PLUGIN_VERSION_MISMATCH' | + 'FST_ERR_PLUGIN_NOT_PRESENT_IN_INSTANCE' | + 'FST_ERR_PLUGIN_INVALID_ASYNC_HANDLER' | + 'FST_ERR_PLUGIN_CALLBACK_NOT_FN' | + 'FST_ERR_PLUGIN_NOT_VALID' | + 'FST_ERR_ROOT_PLG_BOOTED' | + 'FST_ERR_PARENT_PLUGIN_BOOTED' | + 'FST_ERR_PLUGIN_TIMEOUT' + , FastifyErrorConstructor> diff --git a/types/hooks.d.ts b/types/hooks.d.ts index 95025e20c67..ed045f10b1f 100644 --- a/types/hooks.d.ts +++ b/types/hooks.d.ts @@ -1,16 +1,14 @@ -import { Readable } from 'stream' +import { Readable } from 'node:stream' import { FastifyInstance } from './instance' import { RouteOptions, RouteGenericInterface } from './route' import { RawServerBase, RawServerDefault, RawRequestDefaultExpression, RawReplyDefaultExpression, ContextConfigDefault } from './utils' import { FastifyRequest } from './request' import { FastifyReply } from './reply' import { FastifyError } from '@fastify/error' -import { FastifyLoggerInstance } from './logger' +import { FastifyBaseLogger } from './logger' import { - FastifyRequestType, FastifyTypeProvider, - FastifyTypeProviderDefault, - ResolveFastifyRequestType + FastifyTypeProviderDefault } from './type-provider' import { RegisterOptions } from './register' import { FastifySchema } from './schema' @@ -36,13 +34,12 @@ export interface onRequestHookHandler< ContextConfig = ContextConfigDefault, SchemaCompiler extends FastifySchema = FastifySchema, TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance + Logger extends FastifyBaseLogger = FastifyBaseLogger > { ( this: FastifyInstance, - request: FastifyRequest, - reply: FastifyReply, + request: FastifyRequest, + reply: FastifyReply, done: HookHandlerDoneFunction ): void; } @@ -55,16 +52,33 @@ export interface onRequestAsyncHookHandler< ContextConfig = ContextConfigDefault, SchemaCompiler extends FastifySchema = FastifySchema, TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance + Logger extends FastifyBaseLogger = FastifyBaseLogger > { ( this: FastifyInstance, - request: FastifyRequest, - reply: FastifyReply, + request: FastifyRequest, + reply: FastifyReply, ): Promise; } +// helper type which infers whether onRequestHookHandler or onRequestAsyncHookHandler are +// applicable based on the specified return type. +export type onRequestMetaHookHandler< + RawServer extends RawServerBase = RawServerDefault, + RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, + RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, + RouteGeneric extends RouteGenericInterface = RouteGenericInterface, + ContextConfig = ContextConfigDefault, + SchemaCompiler extends FastifySchema = FastifySchema, + TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, + Logger extends FastifyBaseLogger = FastifyBaseLogger, + Return extends ReturnType> + | ReturnType> + = ReturnType> +> = Return extends ReturnType> + ? onRequestHookHandler + : onRequestAsyncHookHandler + /** * `preParsing` is the second hook to be executed in the request lifecycle. The previous hook was `onRequest`, the next hook will be `preValidation`. * Notice: in the `preParsing` hook, request.body will always be null, because the body parsing happens before the `preHandler` hook. @@ -77,13 +91,12 @@ export interface preParsingHookHandler< ContextConfig = ContextConfigDefault, SchemaCompiler extends FastifySchema = FastifySchema, TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance + Logger extends FastifyBaseLogger = FastifyBaseLogger > { ( this: FastifyInstance, - request: FastifyRequest, - reply: FastifyReply, + request: FastifyRequest, + reply: FastifyReply, payload: RequestPayload, done: (err?: TError | null, res?: RequestPayload) => void ): void; @@ -97,17 +110,34 @@ export interface preParsingAsyncHookHandler< ContextConfig = ContextConfigDefault, SchemaCompiler extends FastifySchema = FastifySchema, TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance + Logger extends FastifyBaseLogger = FastifyBaseLogger > { ( this: FastifyInstance, - request: FastifyRequest, - reply: FastifyReply, + request: FastifyRequest, + reply: FastifyReply, payload: RequestPayload, ): Promise; } +// helper type which infers whether preParsingHookHandler or preParsingAsyncHookHandler are +// applicable based on the specified return type. +export type preParsingMetaHookHandler< + RawServer extends RawServerBase = RawServerDefault, + RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, + RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, + RouteGeneric extends RouteGenericInterface = RouteGenericInterface, + ContextConfig = ContextConfigDefault, + SchemaCompiler extends FastifySchema = FastifySchema, + TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, + Logger extends FastifyBaseLogger = FastifyBaseLogger, + Return extends ReturnType> + | ReturnType> + = ReturnType> +> = Return extends ReturnType> + ? preParsingHookHandler + : preParsingAsyncHookHandler + /** * `preValidation` is the third hook to be executed in the request lifecycle. The previous hook was `preParsing`, the next hook will be `preHandler`. */ @@ -119,13 +149,12 @@ export interface preValidationHookHandler< ContextConfig = ContextConfigDefault, SchemaCompiler extends FastifySchema = FastifySchema, TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance + Logger extends FastifyBaseLogger = FastifyBaseLogger > { ( this: FastifyInstance, - request: FastifyRequest, - reply: FastifyReply, + request: FastifyRequest, + reply: FastifyReply, done: HookHandlerDoneFunction ): void; } @@ -138,16 +167,33 @@ export interface preValidationAsyncHookHandler< ContextConfig = ContextConfigDefault, SchemaCompiler extends FastifySchema = FastifySchema, TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance + Logger extends FastifyBaseLogger = FastifyBaseLogger > { ( this: FastifyInstance, - request: FastifyRequest, - reply: FastifyReply, + request: FastifyRequest, + reply: FastifyReply, ): Promise; } +// helper type which infers whether preValidationHookHandler or preValidationAsyncHookHandler are +// applicable based on the specified return type. +export type preValidationMetaHookHandler< + RawServer extends RawServerBase = RawServerDefault, + RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, + RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, + RouteGeneric extends RouteGenericInterface = RouteGenericInterface, + ContextConfig = ContextConfigDefault, + SchemaCompiler extends FastifySchema = FastifySchema, + TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, + Logger extends FastifyBaseLogger = FastifyBaseLogger, + Return extends ReturnType> + | ReturnType> + = ReturnType> +> = Return extends ReturnType> + ? preValidationHookHandler + : preValidationAsyncHookHandler + /** * `preHandler` is the fourth hook to be executed in the request lifecycle. The previous hook was `preValidation`, the next hook will be `preSerialization`. */ @@ -159,13 +205,12 @@ export interface preHandlerHookHandler< ContextConfig = ContextConfigDefault, SchemaCompiler extends FastifySchema = FastifySchema, TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance + Logger extends FastifyBaseLogger = FastifyBaseLogger > { ( this: FastifyInstance, - request: FastifyRequest, - reply: FastifyReply, + request: FastifyRequest, + reply: FastifyReply, done: HookHandlerDoneFunction ): void; } @@ -178,16 +223,33 @@ export interface preHandlerAsyncHookHandler< ContextConfig = ContextConfigDefault, SchemaCompiler extends FastifySchema = FastifySchema, TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance + Logger extends FastifyBaseLogger = FastifyBaseLogger > { ( this: FastifyInstance, - request: FastifyRequest, - reply: FastifyReply, + request: FastifyRequest, + reply: FastifyReply, ): Promise; } +// helper type which infers whether preHandlerHookHandler or preHandlerAsyncHookHandler are +// applicable based on the specified return type. +export type preHandlerMetaHookHandler< + RawServer extends RawServerBase = RawServerDefault, + RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, + RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, + RouteGeneric extends RouteGenericInterface = RouteGenericInterface, + ContextConfig = ContextConfigDefault, + SchemaCompiler extends FastifySchema = FastifySchema, + TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, + Logger extends FastifyBaseLogger = FastifyBaseLogger, + Return extends ReturnType> + | ReturnType> + = ReturnType> +> = Return extends ReturnType> + ? preHandlerHookHandler + : preHandlerAsyncHookHandler + // This is used within the `preSerialization` and `onSend` hook handlers interface DoneFuncWithErrOrRes { (): void; @@ -200,7 +262,7 @@ interface DoneFuncWithErrOrRes { * Note: the hook is NOT called if the payload is a string, a Buffer, a stream or null. */ export interface preSerializationHookHandler< - PreSerializationPayload, + PreSerializationPayload = unknown, RawServer extends RawServerBase = RawServerDefault, RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, @@ -208,20 +270,19 @@ export interface preSerializationHookHandler< ContextConfig = ContextConfigDefault, SchemaCompiler extends FastifySchema = FastifySchema, TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance + Logger extends FastifyBaseLogger = FastifyBaseLogger > { ( this: FastifyInstance, - request: FastifyRequest, - reply: FastifyReply, + request: FastifyRequest, + reply: FastifyReply, payload: PreSerializationPayload, done: DoneFuncWithErrOrRes ): void; } export interface preSerializationAsyncHookHandler< - PreSerializationPayload, + PreSerializationPayload = unknown, RawServer extends RawServerBase = RawServerDefault, RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, @@ -229,23 +290,41 @@ export interface preSerializationAsyncHookHandler< ContextConfig = ContextConfigDefault, SchemaCompiler extends FastifySchema = FastifySchema, TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance + Logger extends FastifyBaseLogger = FastifyBaseLogger > { ( this: FastifyInstance, - request: FastifyRequest, - reply: FastifyReply, + request: FastifyRequest, + reply: FastifyReply, payload: PreSerializationPayload ): Promise; } +// helper type which infers whether preSerializationHookHandler or preSerializationAsyncHookHandler are +// applicable based on the specified return type. +export type preSerializationMetaHookHandler< + PreSerializationPayload = unknown, + RawServer extends RawServerBase = RawServerDefault, + RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, + RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, + RouteGeneric extends RouteGenericInterface = RouteGenericInterface, + ContextConfig = ContextConfigDefault, + SchemaCompiler extends FastifySchema = FastifySchema, + TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, + Logger extends FastifyBaseLogger = FastifyBaseLogger, + Return extends ReturnType> + | ReturnType> + = ReturnType> +> = Return extends ReturnType> + ? preSerializationHookHandler + : preSerializationAsyncHookHandler + /** * You can change the payload with the `onSend` hook. It is the sixth hook to be executed in the request lifecycle. The previous hook was `preSerialization`, the next hook will be `onResponse`. * Note: If you change the payload, you may only change it to a string, a Buffer, a stream, or null. */ export interface onSendHookHandler< - OnSendPayload, + OnSendPayload = unknown, RawServer extends RawServerBase = RawServerDefault, RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, @@ -253,20 +332,19 @@ export interface onSendHookHandler< ContextConfig = ContextConfigDefault, SchemaCompiler extends FastifySchema = FastifySchema, TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance + Logger extends FastifyBaseLogger = FastifyBaseLogger > { ( this: FastifyInstance, - request: FastifyRequest, - reply: FastifyReply, + request: FastifyRequest, + reply: FastifyReply, payload: OnSendPayload, done: DoneFuncWithErrOrRes ): void; } export interface onSendAsyncHookHandler< - OnSendPayload, + OnSendPayload = unknown, RawServer extends RawServerBase = RawServerDefault, RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, @@ -274,17 +352,35 @@ export interface onSendAsyncHookHandler< ContextConfig = ContextConfigDefault, SchemaCompiler extends FastifySchema = FastifySchema, TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance + Logger extends FastifyBaseLogger = FastifyBaseLogger > { ( this: FastifyInstance, - request: FastifyRequest, - reply: FastifyReply, + request: FastifyRequest, + reply: FastifyReply, payload: OnSendPayload, ): Promise; } +// helper type which infers whether onSendHookHandler or onSendAsyncHookHandler are +// applicable based on the specified return type. +export type onSendMetaHookHandler< + OnSendPayload = unknown, + RawServer extends RawServerBase = RawServerDefault, + RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, + RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, + RouteGeneric extends RouteGenericInterface = RouteGenericInterface, + ContextConfig = ContextConfigDefault, + SchemaCompiler extends FastifySchema = FastifySchema, + TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, + Logger extends FastifyBaseLogger = FastifyBaseLogger, + Return extends ReturnType> + | ReturnType> + = ReturnType> +> = Return extends ReturnType> + ? onSendHookHandler + : onSendAsyncHookHandler + /** * `onResponse` is the seventh and last hook in the request hook lifecycle. The previous hook was `onSend`, there is no next hook. * The onResponse hook is executed when a response has been sent, so you will not be able to send more data to the client. It can however be useful for sending data to external services, for example to gather statistics. @@ -297,13 +393,12 @@ export interface onResponseHookHandler< ContextConfig = ContextConfigDefault, SchemaCompiler extends FastifySchema = FastifySchema, TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance + Logger extends FastifyBaseLogger = FastifyBaseLogger > { ( this: FastifyInstance, - request: FastifyRequest, - reply: FastifyReply, + request: FastifyRequest, + reply: FastifyReply, done: HookHandlerDoneFunction ): void; } @@ -316,16 +411,33 @@ export interface onResponseAsyncHookHandler< ContextConfig = ContextConfigDefault, SchemaCompiler extends FastifySchema = FastifySchema, TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance + Logger extends FastifyBaseLogger = FastifyBaseLogger > { ( this: FastifyInstance, - request: FastifyRequest, - reply: FastifyReply + request: FastifyRequest, + reply: FastifyReply ): Promise; } +// helper type which infers whether onResponseHookHandler or onResponseAsyncHookHandler are +// applicable based on the specified return type. +export type onResponseMetaHookHandler< + RawServer extends RawServerBase = RawServerDefault, + RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, + RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, + RouteGeneric extends RouteGenericInterface = RouteGenericInterface, + ContextConfig = ContextConfigDefault, + SchemaCompiler extends FastifySchema = FastifySchema, + TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, + Logger extends FastifyBaseLogger = FastifyBaseLogger, + Return extends ReturnType> + | ReturnType> + = ReturnType> +> = Return extends ReturnType> + ? onResponseHookHandler + : onResponseAsyncHookHandler + /** * `onTimeout` is useful if you need to monitor the request timed out in your service. (if the `connectionTimeout` property is set on the fastify instance) * The onTimeout hook is executed when a request is timed out and the http socket has been hanged up. Therefore you will not be able to send data to the client. @@ -338,13 +450,12 @@ export interface onTimeoutHookHandler< ContextConfig = ContextConfigDefault, SchemaCompiler extends FastifySchema = FastifySchema, TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance + Logger extends FastifyBaseLogger = FastifyBaseLogger > { ( this: FastifyInstance, - request: FastifyRequest, - reply: FastifyReply, + request: FastifyRequest, + reply: FastifyReply, done: HookHandlerDoneFunction ): void; } @@ -357,20 +468,37 @@ export interface onTimeoutAsyncHookHandler< ContextConfig = ContextConfigDefault, SchemaCompiler extends FastifySchema = FastifySchema, TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance + Logger extends FastifyBaseLogger = FastifyBaseLogger > { ( this: FastifyInstance, - request: FastifyRequest, - reply: FastifyReply + request: FastifyRequest, + reply: FastifyReply ): Promise; } +// helper type which infers whether onTimeoutHookHandler or onTimeoutAsyncHookHandler are +// applicable based on the specified return type. +export type onTimeoutMetaHookHandler< + RawServer extends RawServerBase = RawServerDefault, + RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, + RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, + RouteGeneric extends RouteGenericInterface = RouteGenericInterface, + ContextConfig = ContextConfigDefault, + SchemaCompiler extends FastifySchema = FastifySchema, + TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, + Logger extends FastifyBaseLogger = FastifyBaseLogger, + Return extends ReturnType> + | ReturnType> + = ReturnType> +> = Return extends ReturnType> + ? onTimeoutHookHandler + : onTimeoutAsyncHookHandler + /** * This hook is useful if you need to do some custom error logging or add some specific header in case of error. * It is not intended for changing the error, and calling reply.send will throw an exception. - * This hook will be executed only after the customErrorHandler has been executed, and only if the customErrorHandler sends an error back to the user (Note that the default customErrorHandler always sends the error back to the user). + * This hook will be executed before the customErrorHandler. * Notice: unlike the other hooks, pass an error to the done function is not supported. */ export interface onErrorHookHandler< @@ -382,13 +510,12 @@ export interface onErrorHookHandler< TError extends Error = FastifyError, SchemaCompiler extends FastifySchema = FastifySchema, TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance + Logger extends FastifyBaseLogger = FastifyBaseLogger > { ( this: FastifyInstance, - request: FastifyRequest, - reply: FastifyReply, + request: FastifyRequest, + reply: FastifyReply, error: TError, done: () => void ): void; @@ -403,17 +530,151 @@ export interface onErrorAsyncHookHandler< TError extends Error = FastifyError, SchemaCompiler extends FastifySchema = FastifySchema, TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance + Logger extends FastifyBaseLogger = FastifyBaseLogger > { ( this: FastifyInstance, - request: FastifyRequest, - reply: FastifyReply, + request: FastifyRequest, + reply: FastifyReply, error: TError ): Promise; } +// helper type which infers whether onErrorHookHandler or onErrorAsyncHookHandler are +// applicable based on the specified return type. +export type onErrorMetaHookHandler< + RawServer extends RawServerBase = RawServerDefault, + RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, + RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, + RouteGeneric extends RouteGenericInterface = RouteGenericInterface, + ContextConfig = ContextConfigDefault, + TError extends Error = FastifyError, + SchemaCompiler extends FastifySchema = FastifySchema, + TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, + Logger extends FastifyBaseLogger = FastifyBaseLogger, + Return extends ReturnType> + | ReturnType> + = ReturnType> +> = Return extends ReturnType> + ? onErrorHookHandler + : onErrorAsyncHookHandler + +/** + * `onRequestAbort` is useful if you need to monitor the if the client aborts the request (if the `request.raw.aborted` property is set to `true`). + * The `onRequestAbort` hook is executed when a client closes the connection before the entire request has been received. Therefore, you will not be able to send data to the client. + * Notice: client abort detection is not completely reliable. See: https://github.com/fastify/fastify/blob/main/docs/Guides/Detecting-When-Clients-Abort.md + */ +export interface onRequestAbortHookHandler< + RawServer extends RawServerBase = RawServerDefault, + RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, + RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, + RouteGeneric extends RouteGenericInterface = RouteGenericInterface, + ContextConfig = ContextConfigDefault, + SchemaCompiler extends FastifySchema = FastifySchema, + TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, + Logger extends FastifyBaseLogger = FastifyBaseLogger +> { + ( + this: FastifyInstance, + request: FastifyRequest, + done: HookHandlerDoneFunction + ): void; +} + +export interface onRequestAbortAsyncHookHandler< + RawServer extends RawServerBase = RawServerDefault, + RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, + RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, + RouteGeneric extends RouteGenericInterface = RouteGenericInterface, + ContextConfig = ContextConfigDefault, + SchemaCompiler extends FastifySchema = FastifySchema, + TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, + Logger extends FastifyBaseLogger = FastifyBaseLogger +> { + ( + this: FastifyInstance, + request: FastifyRequest, + ): Promise; +} + +// helper type which infers whether onRequestAbortHookHandler or onRequestAbortHookHandler are +// applicable based on the specified return type. +export type onRequestAbortMetaHookHandler< + RawServer extends RawServerBase = RawServerDefault, + RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, + RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, + RouteGeneric extends RouteGenericInterface = RouteGenericInterface, + ContextConfig = ContextConfigDefault, + SchemaCompiler extends FastifySchema = FastifySchema, + TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, + Logger extends FastifyBaseLogger = FastifyBaseLogger, + Return extends ReturnType> + | ReturnType> + = ReturnType> +> = Return extends ReturnType> + ? onRequestAbortHookHandler + : onRequestAbortAsyncHookHandler + +export type LifecycleHook = 'onRequest' +| 'preParsing' +| 'preValidation' +| 'preHandler' +| 'preSerialization' +| 'onSend' +| 'onResponse' +| 'onRequest' +| 'onError' +| 'onTimeout' +| 'onRequestAbort' + +export type LifecycleHookLookup = K extends 'onRequest' + ? onRequestHookHandler + : K extends 'preParsing' + ? preParsingHookHandler + : K extends 'preValidation' + ? preValidationHookHandler + : K extends 'preHandler' + ? preHandlerHookHandler + : K extends 'preSerialization' + ? preSerializationHookHandler + : K extends 'onSend' + ? onSendHookHandler + : K extends 'onResponse' + ? onResponseHookHandler + : K extends 'onRequest' + ? onRequestHookHandler + : K extends 'onError' + ? onErrorHookHandler + : K extends 'onTimeout' + ? onTimeoutHookHandler + : K extends 'onRequestAbort' + ? onRequestAbortHookHandler + : never + +export type LifecycleHookAsyncLookup = K extends 'onRequest' + ? onRequestAsyncHookHandler + : K extends 'preParsing' + ? preParsingAsyncHookHandler + : K extends 'preValidation' + ? preValidationAsyncHookHandler + : K extends 'preHandler' + ? preHandlerAsyncHookHandler + : K extends 'preSerialization' + ? preSerializationAsyncHookHandler + : K extends 'onSend' + ? onSendAsyncHookHandler + : K extends 'onResponse' + ? onResponseAsyncHookHandler + : K extends 'onRequest' + ? onRequestAsyncHookHandler + : K extends 'onError' + ? onErrorAsyncHookHandler + : K extends 'onTimeout' + ? onTimeoutAsyncHookHandler + : K extends 'onRequestAbort' + ? onRequestAbortAsyncHookHandler + : never + // Application Hooks /** @@ -427,7 +688,7 @@ export interface onRouteHookHandler< ContextConfig = ContextConfigDefault, SchemaCompiler extends FastifySchema = FastifySchema, TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance + Logger extends FastifyBaseLogger = FastifyBaseLogger > { ( this: FastifyInstance, @@ -444,15 +705,15 @@ export interface onRegisterHookHandler< RawServer extends RawServerBase = RawServerDefault, RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance, + Logger extends FastifyBaseLogger = FastifyBaseLogger, TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, Options extends FastifyPluginOptions = FastifyPluginOptions > { ( + this: FastifyInstance, instance: FastifyInstance, - opts: RegisterOptions & Options, - done: HookHandlerDoneFunction - ): Promise | void; // documentation is missing the `done` method + opts: RegisterOptions & Options + ): Promise | void; } /** @@ -462,8 +723,8 @@ export interface onReadyHookHandler< RawServer extends RawServerBase = RawServerDefault, RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance, - TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, + Logger extends FastifyBaseLogger = FastifyBaseLogger, + TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault > { ( this: FastifyInstance, @@ -475,8 +736,36 @@ export interface onReadyAsyncHookHandler< RawServer extends RawServerBase = RawServerDefault, RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance, - TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, + Logger extends FastifyBaseLogger = FastifyBaseLogger, + TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault +> { + ( + this: FastifyInstance, + ): Promise; +} + +/** + * Triggered when fastify.listen() is invoked to start the server. It is useful when plugins need a "onListen" event, for example to run logics after the server start listening for requests. + */ +export interface onListenHookHandler< + RawServer extends RawServerBase = RawServerDefault, + RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, + RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, + Logger extends FastifyBaseLogger = FastifyBaseLogger, + TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault +> { + ( + this: FastifyInstance, + done: HookHandlerDoneFunction + ): void; +} + +export interface onListenAsyncHookHandler< + RawServer extends RawServerBase = RawServerDefault, + RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, + RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, + Logger extends FastifyBaseLogger = FastifyBaseLogger, + TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault > { ( this: FastifyInstance, @@ -489,10 +778,11 @@ export interface onCloseHookHandler< RawServer extends RawServerBase = RawServerDefault, RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance, - TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, + Logger extends FastifyBaseLogger = FastifyBaseLogger, + TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault > { ( + this: FastifyInstance, instance: FastifyInstance, done: HookHandlerDoneFunction ): void; @@ -502,10 +792,84 @@ export interface onCloseAsyncHookHandler< RawServer extends RawServerBase = RawServerDefault, RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance, + Logger extends FastifyBaseLogger = FastifyBaseLogger, TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault > { ( + this: FastifyInstance, instance: FastifyInstance ): Promise; } + +/** + * Triggered when fastify.close() is invoked to stop the server. It is useful when plugins need to cancel some state to allow the server to close successfully. + */ +export interface preCloseHookHandler< + RawServer extends RawServerBase = RawServerDefault, + RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, + RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, + Logger extends FastifyBaseLogger = FastifyBaseLogger, + TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault +> { + ( + this: FastifyInstance, + done: HookHandlerDoneFunction + ): void; +} + +export interface preCloseAsyncHookHandler< + RawServer extends RawServerBase = RawServerDefault, + RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, + RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, + Logger extends FastifyBaseLogger = FastifyBaseLogger, + TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault +> { + ( + this: FastifyInstance, + ): Promise; +} + +export type ApplicationHook = 'onRoute' +| 'onRegister' +| 'onReady' +| 'onListen' +| 'onClose' +| 'preClose' + +export type ApplicationHookLookup = K extends 'onRegister' + ? onRegisterHookHandler + : K extends 'onReady' + ? onReadyHookHandler + : K extends 'onListen' + ? onListenHookHandler + : K extends 'onClose' + ? onCloseHookHandler + : K extends 'preClose' + ? preCloseHookHandler + : K extends 'onRoute' + ? onRouteHookHandler + : never + +export type ApplicationHookAsyncLookup = K extends 'onRegister' + ? onRegisterHookHandler + : K extends 'onReady' + ? onReadyAsyncHookHandler + : K extends 'onListen' + ? onListenAsyncHookHandler + : K extends 'onClose' + ? onCloseAsyncHookHandler + : K extends 'preClose' + ? preCloseAsyncHookHandler + : never + +export type HookLookup = K extends ApplicationHook + ? ApplicationHookLookup + : K extends LifecycleHook + ? LifecycleHookLookup + : never + +export type HookAsyncLookup = K extends ApplicationHook + ? ApplicationHookAsyncLookup + : K extends LifecycleHook + ? LifecycleHookAsyncLookup + : never diff --git a/types/instance.d.ts b/types/instance.d.ts index 078abf57394..0fd1baafe13 100644 --- a/types/instance.d.ts +++ b/types/instance.d.ts @@ -1,35 +1,39 @@ import { FastifyError } from '@fastify/error' -import { ConstraintStrategy, HTTPVersion } from 'find-my-way' -import * as http from 'http' -import { CallbackFunc as LightMyRequestCallback, Chain as LightMyRequestChain, InjectOptions, Response as LightMyRequestResponse } from 'light-my-request' -import { AddContentTypeParser, ConstructorAction, FastifyBodyParser, getDefaultJsonParser, hasContentTypeParser, ProtoAction, removeAllContentTypeParsers, removeContentTypeParser } from './content-type-parser' -import { onCloseAsyncHookHandler, onCloseHookHandler, onErrorAsyncHookHandler, onErrorHookHandler, onReadyAsyncHookHandler, onReadyHookHandler, onRegisterHookHandler, onRequestAsyncHookHandler, onRequestHookHandler, onResponseAsyncHookHandler, onResponseHookHandler, onRouteHookHandler, onSendAsyncHookHandler, onSendHookHandler, onTimeoutAsyncHookHandler, onTimeoutHookHandler, preHandlerAsyncHookHandler, preHandlerHookHandler, preParsingAsyncHookHandler, preParsingHookHandler, preSerializationAsyncHookHandler, preSerializationHookHandler, preValidationAsyncHookHandler, preValidationHookHandler } from './hooks' -import { FastifyLoggerInstance } from './logger' +import { ConstraintStrategy, FindResult, HTTPVersion } from 'find-my-way' +import * as http from 'node:http' +import { InjectOptions, CallbackFunc as LightMyRequestCallback, Chain as LightMyRequestChain, Response as LightMyRequestResponse } from 'light-my-request' +import { AddressInfo } from 'node:net' +import { AddContentTypeParser, ConstructorAction, FastifyBodyParser, ProtoAction, getDefaultJsonParser, hasContentTypeParser, removeAllContentTypeParsers, removeContentTypeParser } from './content-type-parser' +import { ApplicationHook, HookAsyncLookup, HookLookup, LifecycleHook, onCloseAsyncHookHandler, onCloseHookHandler, onErrorAsyncHookHandler, onErrorHookHandler, onListenAsyncHookHandler, onListenHookHandler, onReadyAsyncHookHandler, onReadyHookHandler, onRegisterHookHandler, onRequestAbortAsyncHookHandler, onRequestAbortHookHandler, onRequestAsyncHookHandler, onRequestHookHandler, onResponseAsyncHookHandler, onResponseHookHandler, onRouteHookHandler, onSendAsyncHookHandler, onSendHookHandler, onTimeoutAsyncHookHandler, onTimeoutHookHandler, preCloseAsyncHookHandler, preCloseHookHandler, preHandlerAsyncHookHandler, preHandlerHookHandler, preParsingAsyncHookHandler, preParsingHookHandler, preSerializationAsyncHookHandler, preSerializationHookHandler, preValidationAsyncHookHandler, preValidationHookHandler } from './hooks' +import { FastifyBaseLogger, FastifyChildLoggerFactory } from './logger' import { FastifyRegister } from './register' import { FastifyReply } from './reply' import { FastifyRequest } from './request' -import { DefaultRoute, RouteGenericInterface, RouteOptions, RouteShorthandMethod } from './route' +import { RouteGenericInterface, RouteHandlerMethod, RouteOptions, RouteShorthandMethod } from './route' import { FastifySchema, FastifySchemaCompiler, FastifySchemaControllerOptions, - FastifySchemaValidationError, - FastifySerializerCompiler + FastifySerializerCompiler, + SchemaErrorFormatter } from './schema' import { - FastifyRequestType, FastifyTypeProvider, FastifyTypeProviderDefault, - ResolveFastifyRequestType + SafePromiseLike } from './type-provider' -import { ContextConfigDefault, RawReplyDefaultExpression, RawRequestDefaultExpression, RawServerBase, RawServerDefault } from './utils' +import { ContextConfigDefault, HTTPMethods, RawReplyDefaultExpression, RawRequestDefaultExpression, RawServerBase, RawServerDefault } from './utils' +import { FastifyRouterOptions } from '../fastify' export interface PrintRoutesOptions { + method?: HTTPMethods; includeMeta?: boolean | (string | symbol)[] commonPrefix?: boolean includeHooks?: boolean } +type AsyncFunction = (...args: any) => Promise + export interface FastifyListenOptions { /** * Default to `0` (picks the first available open port). @@ -74,10 +78,41 @@ export interface FastifyListenOptions { * @since This option is available only in Node.js v15.6.0 and greater */ signal?: AbortSignal; + + /** + * Function that resolves text to log after server has been successfully started + * @param address + */ + listenTextResolver?: (address: string) => string; } type NotInInterface = Key extends keyof _Interface ? never : Key type FindMyWayVersion = RawServer extends http.Server ? HTTPVersion.V1 : HTTPVersion.V2 +type FindMyWayFindResult = FindResult> + +type GetterSetter = T | { + getter: (this: This) => T, + setter?: (this: This, value: T) => void +} + +type DecorationMethod = { + < + // Need to disable "no-use-before-define" to maintain backwards compatibility, as else decorate would suddenly mean something new + + T extends (P extends keyof This ? This[P] : unknown), + P extends string | symbol = string | symbol + >(property: P, + value: GetterSetter any + ? (this: This, ...args: Parameters) => ReturnType + : T + >, + dependencies?: string[] + ): Return; + + (property: string | symbol): Return; + + (property: string | symbol, value: null | undefined, dependencies: string[]): Return; +} /** * Fastify server instance. Returned by the core `fastify()` method. @@ -86,75 +121,44 @@ export interface FastifyInstance< RawServer extends RawServerBase = RawServerDefault, RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance, - TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, + Logger extends FastifyBaseLogger = FastifyBaseLogger, + TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault > { server: RawServer; + pluginName: string; prefix: string; version: string; log: Logger; - + listeningOrigin: string; + addresses(): AddressInfo[] withTypeProvider(): FastifyInstance; addSchema(schema: unknown): FastifyInstance; getSchema(schemaId: string): unknown; getSchemas(): Record; - after(): FastifyInstance & PromiseLike; - after(afterListener: (err: Error) => void): FastifyInstance; + after(): FastifyInstance & SafePromiseLike; + after(afterListener: (err: Error | null) => void): FastifyInstance; close(): Promise; close(closeListener: () => void): undefined; - // should be able to define something useful with the decorator getter/setter pattern using Generics to enforce the users function returns what they expect it to - decorate( - property: K, - value: FastifyInstance[K] extends (...args: any[]) => any - ? (this: FastifyInstance, ...args: Parameters) => ReturnType - : FastifyInstance[K], - dependencies?: string[] - ): FastifyInstance; - decorate( - property: NotInInterface, - value: T extends (...args: any[]) => any - ? (this: FastifyInstance, ...args: Parameters) => ReturnType - : T, - dependencies?: string[] - ): FastifyInstance; + /** Alias for {@linkcode FastifyInstance.close()} */ - decorateRequest( - property: K, - value: FastifyRequest[K] extends (...args: any[]) => any - ? (this: FastifyRequest, ...args: Parameters) => ReturnType - : FastifyRequest[K], - dependencies?: string[] - ): FastifyInstance; - decorateRequest( - property: NotInInterface, - value: T extends (...args: any[]) => any - ? (this: FastifyRequest, ...args: Parameters) => ReturnType - : T, - dependencies?: string[] - ): FastifyInstance; + // @ts-ignore - type only available for @types/node >=17 or typescript >= 5.2 + [Symbol.asyncDispose](): Promise; - decorateReply( - property: K, - value: FastifyReply[K] extends (...args: any[]) => any - ? (this: FastifyReply, ...args: Parameters) => ReturnType - : FastifyReply[K], - dependencies?: string[] - ): FastifyInstance; - decorateReply( - property: NotInInterface, - value: T extends (...args: any[]) => any - ? (this: FastifyReply, ...args: Parameters) => ReturnType - : T, - dependencies?: string[] - ): FastifyInstance; + // should be able to define something useful with the decorator getter/setter pattern using Generics to enforce the users function returns what they expect it to + decorate: DecorationMethod>; + decorateRequest: DecorationMethod>; + decorateReply: DecorationMethod>; + + getDecorator(name: string | symbol): T; hasDecorator(decorator: string | symbol): boolean; hasRequestDecorator(decorator: string | symbol): boolean; hasReplyDecorator(decorator: string | symbol): boolean; + hasPlugin(name: string): boolean; addConstraintStrategy(strategy: ConstraintStrategy, unknown>): void; hasConstraintStrategy(strategyName: string): boolean; @@ -167,50 +171,50 @@ export interface FastifyInstance< listen(opts?: FastifyListenOptions): Promise; listen(callback: (err: Error | null, address: string) => void): void; - /** - * @deprecated Variadic listen method is deprecated. Please use `.listen(optionsObject, callback)` instead. The variadic signature will be removed in `fastify@5` - * @see https://github.com/fastify/fastify/pull/3712 - */ - listen(port: number | string, address: string, backlog: number, callback: (err: Error | null, address: string) => void): void; - /** - * @deprecated Variadic listen method is deprecated. Please use `.listen(optionsObject, callback)` instead. The variadic signature will be removed in `fastify@5` - * @see https://github.com/fastify/fastify/pull/3712 - */ - listen(port: number | string, address: string, callback: (err: Error | null, address: string) => void): void; - /** - * @deprecated Variadic listen method is deprecated. Please use `.listen(optionsObject, callback)` instead. The variadic signature will be removed in `fastify@5` - * @see https://github.com/fastify/fastify/pull/3712 - */ - listen(port: number | string, callback: (err: Error | null, address: string) => void): void; - /** - * @deprecated Variadic listen method is deprecated. Please use `.listen(optionsObject)` instead. The variadic signature will be removed in `fastify@5` - * @see https://github.com/fastify/fastify/pull/3712 - */ - listen(port: number | string, address?: string, backlog?: number): Promise; - - ready(): FastifyInstance & PromiseLike; - ready(readyListener: (err: Error) => void): FastifyInstance; + ready(): FastifyInstance & SafePromiseLike; + ready(readyListener: (err: Error | null) => void | Promise): FastifyInstance; - register: FastifyRegister & PromiseLike>; + register: FastifyRegister & SafePromiseLike>; routing(req: RawRequest, res: RawReply): void; - getDefaultRoute(): DefaultRoute; - setDefaultRoute(defaultRoute: DefaultRoute): void; route< RouteGeneric extends RouteGenericInterface = RouteGenericInterface, ContextConfig = ContextConfigDefault, - SchemaCompiler = FastifySchema, - >(opts: RouteOptions): FastifyInstance; - - get: RouteShorthandMethod; - head: RouteShorthandMethod; - post: RouteShorthandMethod; - put: RouteShorthandMethod; - delete: RouteShorthandMethod; - options: RouteShorthandMethod; - patch: RouteShorthandMethod; - all: RouteShorthandMethod; + const SchemaCompiler extends FastifySchema = FastifySchema + >(opts: RouteOptions): FastifyInstance; + + delete: RouteShorthandMethod; + get: RouteShorthandMethod; + head: RouteShorthandMethod; + patch: RouteShorthandMethod; + post: RouteShorthandMethod; + put: RouteShorthandMethod; + options: RouteShorthandMethod; + propfind: RouteShorthandMethod; + proppatch: RouteShorthandMethod; + mkcalendar: RouteShorthandMethod; + mkcol: RouteShorthandMethod; + copy: RouteShorthandMethod; + move: RouteShorthandMethod; + lock: RouteShorthandMethod; + unlock: RouteShorthandMethod; + trace: RouteShorthandMethod; + report: RouteShorthandMethod; + search: RouteShorthandMethod; + all: RouteShorthandMethod; + + hasRoute< + RouteGeneric extends RouteGenericInterface = RouteGenericInterface, + ContextConfig = ContextConfigDefault, + SchemaCompiler extends FastifySchema = FastifySchema + >(opts: Pick, 'method' | 'url' | 'constraints'>): boolean; + + findRoute< + RouteGeneric extends RouteGenericInterface = RouteGenericInterface, + ContextConfig = ContextConfigDefault, + SchemaCompiler extends FastifySchema = FastifySchema + >(opts: Pick, 'method' | 'url' | 'constraints'>): Omit, 'store'>; // addHook: overloads @@ -224,22 +228,11 @@ export interface FastifyInstance< RouteGeneric extends RouteGenericInterface = RouteGenericInterface, ContextConfig = ContextConfigDefault, SchemaCompiler extends FastifySchema = FastifySchema, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance - >( - name: 'onRequest', - hook: onRequestHookHandler - ): FastifyInstance; - - addHook< - RouteGeneric extends RouteGenericInterface = RouteGenericInterface, - ContextConfig = ContextConfigDefault, - SchemaCompiler extends FastifySchema = FastifySchema, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance + Logger extends FastifyBaseLogger = FastifyBaseLogger, + Fn extends onRequestHookHandler | onRequestAsyncHookHandler = onRequestHookHandler >( name: 'onRequest', - hook: onRequestAsyncHookHandler + hook: Fn extends unknown ? Fn extends AsyncFunction ? onRequestAsyncHookHandler : onRequestHookHandler : Fn, ): FastifyInstance; /** @@ -250,22 +243,11 @@ export interface FastifyInstance< RouteGeneric extends RouteGenericInterface = RouteGenericInterface, ContextConfig = ContextConfigDefault, SchemaCompiler extends FastifySchema = FastifySchema, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance - >( - name: 'preParsing', - hook: preParsingHookHandler - ): FastifyInstance; - - addHook< - RouteGeneric extends RouteGenericInterface = RouteGenericInterface, - ContextConfig = ContextConfigDefault, - SchemaCompiler extends FastifySchema = FastifySchema, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance + Logger extends FastifyBaseLogger = FastifyBaseLogger, + Fn extends preParsingHookHandler | preParsingAsyncHookHandler = preParsingHookHandler >( name: 'preParsing', - hook: preParsingAsyncHookHandler + hook: Fn extends unknown ? Fn extends AsyncFunction ? preParsingAsyncHookHandler : preParsingHookHandler : Fn, ): FastifyInstance; /** @@ -275,22 +257,11 @@ export interface FastifyInstance< RouteGeneric extends RouteGenericInterface = RouteGenericInterface, ContextConfig = ContextConfigDefault, SchemaCompiler extends FastifySchema = FastifySchema, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance - >( - name: 'preValidation', - hook: preValidationHookHandler - ): FastifyInstance; - - addHook< - RouteGeneric extends RouteGenericInterface = RouteGenericInterface, - ContextConfig = ContextConfigDefault, - SchemaCompiler extends FastifySchema = FastifySchema, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance + Logger extends FastifyBaseLogger = FastifyBaseLogger, + Fn extends preValidationHookHandler | preValidationAsyncHookHandler = preValidationHookHandler >( name: 'preValidation', - hook: preValidationAsyncHookHandler + hook: Fn extends unknown ? Fn extends AsyncFunction ? preValidationAsyncHookHandler : preValidationHookHandler : Fn, ): FastifyInstance; /** @@ -300,22 +271,11 @@ export interface FastifyInstance< RouteGeneric extends RouteGenericInterface = RouteGenericInterface, ContextConfig = ContextConfigDefault, SchemaCompiler extends FastifySchema = FastifySchema, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance - >( - name: 'preHandler', - hook: preHandlerHookHandler - ): FastifyInstance; - - addHook< - RouteGeneric extends RouteGenericInterface = RouteGenericInterface, - ContextConfig = ContextConfigDefault, - SchemaCompiler extends FastifySchema = FastifySchema, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance + Logger extends FastifyBaseLogger = FastifyBaseLogger, + Fn extends preHandlerHookHandler | preHandlerAsyncHookHandler = preHandlerHookHandler >( name: 'preHandler', - hook: preHandlerAsyncHookHandler + hook: Fn extends unknown ? Fn extends AsyncFunction ? preHandlerAsyncHookHandler : preHandlerHookHandler : Fn, ): FastifyInstance; /** @@ -327,23 +287,11 @@ export interface FastifyInstance< RouteGeneric extends RouteGenericInterface = RouteGenericInterface, ContextConfig = ContextConfigDefault, SchemaCompiler extends FastifySchema = FastifySchema, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance + Logger extends FastifyBaseLogger = FastifyBaseLogger, + Fn extends preSerializationHookHandler | preSerializationAsyncHookHandler = preSerializationHookHandler >( name: 'preSerialization', - hook: preSerializationHookHandler - ): FastifyInstance; - - addHook< - PreSerializationPayload = unknown, - RouteGeneric extends RouteGenericInterface = RouteGenericInterface, - ContextConfig = ContextConfigDefault, - SchemaCompiler extends FastifySchema = FastifySchema, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance - >( - name: 'preSerialization', - hook: preSerializationAsyncHookHandler + hook: Fn extends unknown ? Fn extends AsyncFunction ? preSerializationAsyncHookHandler : preSerializationHookHandler : Fn, ): FastifyInstance; /** @@ -355,23 +303,11 @@ export interface FastifyInstance< RouteGeneric extends RouteGenericInterface = RouteGenericInterface, ContextConfig = ContextConfigDefault, SchemaCompiler extends FastifySchema = FastifySchema, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance + Logger extends FastifyBaseLogger = FastifyBaseLogger, + Fn extends onSendHookHandler | onSendAsyncHookHandler = onSendHookHandler >( name: 'onSend', - hook: onSendHookHandler - ): FastifyInstance; - - addHook< - OnSendPayload = unknown, - RouteGeneric extends RouteGenericInterface = RouteGenericInterface, - ContextConfig = ContextConfigDefault, - SchemaCompiler extends FastifySchema = FastifySchema, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance - >( - name: 'onSend', - hook: onSendAsyncHookHandler + hook: Fn extends unknown ? Fn extends AsyncFunction ? onSendAsyncHookHandler : onSendHookHandler : Fn, ): FastifyInstance; /** @@ -382,22 +318,11 @@ export interface FastifyInstance< RouteGeneric extends RouteGenericInterface = RouteGenericInterface, ContextConfig = ContextConfigDefault, SchemaCompiler extends FastifySchema = FastifySchema, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance + Logger extends FastifyBaseLogger = FastifyBaseLogger, + Fn extends onResponseHookHandler | onResponseAsyncHookHandler = onResponseHookHandler >( name: 'onResponse', - hook: onResponseHookHandler - ): FastifyInstance; - - addHook< - RouteGeneric extends RouteGenericInterface = RouteGenericInterface, - ContextConfig = ContextConfigDefault, - SchemaCompiler extends FastifySchema = FastifySchema, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance - >( - name: 'onResponse', - hook: onResponseAsyncHookHandler + hook: Fn extends unknown ? Fn extends AsyncFunction ? onResponseAsyncHookHandler : onResponseHookHandler : Fn, ): FastifyInstance; /** @@ -408,22 +333,27 @@ export interface FastifyInstance< RouteGeneric extends RouteGenericInterface = RouteGenericInterface, ContextConfig = ContextConfigDefault, SchemaCompiler extends FastifySchema = FastifySchema, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance + Logger extends FastifyBaseLogger = FastifyBaseLogger, + Fn extends onTimeoutHookHandler | onTimeoutAsyncHookHandler = onTimeoutHookHandler >( name: 'onTimeout', - hook: onTimeoutHookHandler + hook: Fn extends unknown ? Fn extends AsyncFunction ? onTimeoutAsyncHookHandler : onTimeoutHookHandler : Fn, ): FastifyInstance; + /** + * `onRequestAbort` is useful if you need to monitor the if the client aborts the request (if the `request.raw.aborted` property is set to `true`). + * The `onRequestAbort` hook is executed when a client closes the connection before the entire request has been received. Therefore, you will not be able to send data to the client. + * Notice: client abort detection is not completely reliable. See: https://github.com/fastify/fastify/blob/main/docs/Guides/Detecting-When-Clients-Abort.md + */ addHook< RouteGeneric extends RouteGenericInterface = RouteGenericInterface, ContextConfig = ContextConfigDefault, SchemaCompiler extends FastifySchema = FastifySchema, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance + Logger extends FastifyBaseLogger = FastifyBaseLogger, + Fn extends onRequestAbortHookHandler | onRequestAbortAsyncHookHandler = onRequestAbortHookHandler >( - name: 'onTimeout', - hook: onTimeoutAsyncHookHandler + name: 'onRequestAbort', + hook: Fn extends unknown ? Fn extends AsyncFunction ? onRequestAbortAsyncHookHandler : onRequestAbortHookHandler : Fn, ): FastifyInstance; /** @@ -436,22 +366,11 @@ export interface FastifyInstance< RouteGeneric extends RouteGenericInterface = RouteGenericInterface, ContextConfig = ContextConfigDefault, SchemaCompiler extends FastifySchema = FastifySchema, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance - >( - name: 'onError', - hook: onErrorHookHandler - ): FastifyInstance; - - addHook< - RouteGeneric extends RouteGenericInterface = RouteGenericInterface, - ContextConfig = ContextConfigDefault, - SchemaCompiler extends FastifySchema = FastifySchema, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance + Logger extends FastifyBaseLogger = FastifyBaseLogger, + Fn extends onErrorHookHandler | onErrorAsyncHookHandler = onErrorHookHandler >( name: 'onError', - hook: onErrorAsyncHookHandler + hook: Fn extends unknown ? Fn extends AsyncFunction ? onErrorAsyncHookHandler : onErrorHookHandler : Fn, ): FastifyInstance; // Application addHooks @@ -463,7 +382,7 @@ export interface FastifyInstance< RouteGeneric extends RouteGenericInterface = RouteGenericInterface, ContextConfig = ContextConfigDefault, SchemaCompiler extends FastifySchema = FastifySchema, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance + Logger extends FastifyBaseLogger = FastifyBaseLogger >( name: 'onRoute', hook: onRouteHookHandler @@ -482,56 +401,109 @@ export interface FastifyInstance< /** * Triggered when fastify.listen() or fastify.ready() is invoked to start the server. It is useful when plugins need a "ready" event, for example to load data before the server start listening for requests. */ - addHook( + addHook< + Fn extends onReadyHookHandler | onReadyAsyncHookHandler = onReadyHookHandler + >( name: 'onReady', - hook: onReadyHookHandler + hook: Fn extends unknown ? Fn extends AsyncFunction ? onReadyAsyncHookHandler : onReadyHookHandler : Fn, ): FastifyInstance; - addHook( - name: 'onReady', - hook: onReadyAsyncHookHandler, + /** + * Triggered when fastify.listen() is invoked to start the server. It is useful when plugins need a "onListen" event, for example to run logics after the server start listening for requests. + */ + addHook< + Fn extends onListenHookHandler | onListenAsyncHookHandler = onListenHookHandler + >( + name: 'onListen', + hook: Fn extends unknown ? Fn extends AsyncFunction ? onListenAsyncHookHandler : onListenHookHandler : Fn, ): FastifyInstance; /** * Triggered when fastify.close() is invoked to stop the server. It is useful when plugins need a "shutdown" event, for example to close an open connection to a database. */ - addHook( + addHook< + Fn extends onCloseHookHandler | onCloseAsyncHookHandler = onCloseHookHandler + >( name: 'onClose', - hook: onCloseHookHandler + hook: Fn extends unknown ? Fn extends AsyncFunction ? onCloseAsyncHookHandler : onCloseHookHandler : Fn, ): FastifyInstance; - addHook( - name: 'onClose', - hook: onCloseAsyncHookHandler - ): FastifyInstance; + /** + * Triggered when fastify.close() is invoked to stop the server. It is useful when plugins need to cancel some state to allow the server to close successfully. + */ + addHook< + Fn extends preCloseHookHandler | preCloseAsyncHookHandler = preCloseHookHandler + >( + name: 'preClose', + hook: Fn extends unknown ? Fn extends AsyncFunction ? preCloseAsyncHookHandler : preCloseHookHandler : Fn, + ): FastifyInstance; + + addHook< + K extends ApplicationHook | LifecycleHook, + Fn extends (...args: any) => Promise | any + > ( + name: K, + hook: Fn extends unknown ? Fn extends AsyncFunction ? HookAsyncLookup : HookLookup : Fn + ): FastifyInstance; /** - * Set the 404 handler - */ - setNotFoundHandler( - handler: (request: FastifyRequest, reply: FastifyReply) => void | Promise + * Set the 404 handler + */ + setNotFoundHandler ( + handler: RouteHandlerMethod ): FastifyInstance; - setNotFoundHandler( + setNotFoundHandler ( opts: { - preValidation?: preValidationHookHandler | preValidationHookHandler[]; - preHandler?: preHandlerHookHandler | preHandlerHookHandler[]; + preValidation?: preValidationHookHandler | preValidationAsyncHookHandler | preValidationHookHandler[] | preValidationAsyncHookHandler[]; + preHandler?: preHandlerHookHandler | preHandlerAsyncHookHandler | preHandlerHookHandler[] | preHandlerAsyncHookHandler[]; }, - handler: (request: FastifyRequest, reply: FastifyReply) => void | Promise + handler: RouteHandlerMethod ): FastifyInstance /** * Fastify default error handler */ - errorHandler: (error: FastifyError, request: FastifyRequest, reply: FastifyReply) => void; + errorHandler: (error: TError, request: FastifyRequest, reply: FastifyReply) => void; /** - * Set a function that will be called whenever an error happens + * Set a function that will be invoked whenever an exception is thrown during the request lifecycle. */ - setErrorHandler( - handler: (this: FastifyInstance, error: TError, request: FastifyRequest, reply: FastifyReply) => any | Promise + setErrorHandler( + handler: (this: FastifyInstance, error: TError, request: FastifyRequest, reply: FastifyReply) => any | Promise ): FastifyInstance; + /** + * Set a function that will generate a request-ids + */ + setGenReqId(fn: (req: RawRequestDefaultExpression) => string): FastifyInstance; + + /** + * Hook function that is called when creating a child logger instance for each request + * which allows for modifying or adding child logger bindings and logger options, or + * returning a completely custom child logger implementation. + */ + childLoggerFactory: FastifyChildLoggerFactory; + + /** + * Hook function that is called when creating a child logger instance for each request + * which allows for modifying or adding child logger bindings and logger options, or + * returning a completely custom child logger implementation. + * + * Child logger bindings have a performance advantage over per-log bindings, because + * they are pre-serialised by Pino when the child logger is created. + * + * For example: + * ``` + * function childLoggerFactory(logger, bindings, opts, rawReq) { + * // Calculate additional bindings from the request + * bindings.traceContext = rawReq.headers['x-cloud-trace-context'] + * return logger.child(bindings, opts); + * } + * ``` + */ + setChildLoggerFactory(factory: FastifyChildLoggerFactory): FastifyInstance; + /** * Fastify schema validator for all routes. */ @@ -565,7 +537,7 @@ export interface FastifyInstance< /* * Set the schema error formatter for all routes. */ - setSchemaErrorFormatter(errorFormatter: (errors: FastifySchemaValidationError[], dataVar: string) => Error): FastifyInstance; + setSchemaErrorFormatter(errorFormatter: SchemaErrorFormatter): FastifyInstance; /** * Add a content type parser */ @@ -579,6 +551,17 @@ export interface FastifyInstance< * Remove all content type parsers, including the default ones */ removeAllContentTypeParsers: removeAllContentTypeParsers + /** + * Returns an array of strings containing the list of supported HTTP methods + */ + supportedMethods: string[] + /** + * Add a non-standard HTTP method + * + * Methods defined by default include `GET`, `HEAD`, `TRACE`, `DELETE`, + * `OPTIONS`, `PATCH`, `PUT` and `POST` + */ + addHttpMethod(method: string, methodOptions?: { hasBody: boolean }): FastifyInstance; /** * Fastify default JSON parser */ @@ -612,13 +595,15 @@ export interface FastifyInstance< https?: boolean | Readonly<{ allowHTTP1: boolean }>, ignoreTrailingSlash?: boolean, ignoreDuplicateSlashes?: boolean, - disableRequestLogging?: boolean, + disableRequestLogging?: boolean | ((req: FastifyRequest) => boolean), maxParamLength?: number, onProtoPoisoning?: ProtoAction, onConstructorPoisoning?: ConstructorAction, pluginTimeout?: number, - requestIdHeader?: string, + requestIdHeader?: string | false, requestIdLogLabel?: string, - http2SessionTimeout?: number + http2SessionTimeout?: number, + useSemicolonDelimiter?: boolean, + routerOptions?: FastifyRouterOptions }> } diff --git a/types/logger.d.ts b/types/logger.d.ts index 4483f0b372b..7ed40fef2cf 100644 --- a/types/logger.d.ts +++ b/types/logger.d.ts @@ -1,36 +1,51 @@ import { FastifyError } from '@fastify/error' -import { RouteGenericInterface } from './route' -import { FastifyRequest } from './request' +import { FastifyInstance } from './instance' import { FastifyReply } from './reply' -import { RawServerBase, RawServerDefault, RawRequestDefaultExpression, RawReplyDefaultExpression, ContextConfigDefault } from './utils' -import { FastifyTypeProvider, FastifyTypeProviderDefault } from './type-provider' +import { FastifyRequest } from './request' +import { RouteGenericInterface } from './route' import { FastifySchema } from './schema' +import { FastifyTypeProvider, FastifyTypeProviderDefault } from './type-provider' +import { ContextConfigDefault, RawReplyDefaultExpression, RawRequestDefaultExpression, RawServerBase, RawServerDefault } from './utils' -import pino from 'pino' - -/** - * Standard Fastify logging function - */ -export type FastifyLogFn = pino.LogFn - -export type LogLevel = pino.Level - -export type Bindings = pino.Bindings +import type { + BaseLogger, + LogFn as FastifyLogFn, + LevelWithSilent as LogLevel, + Bindings, + ChildLoggerOptions, + LoggerOptions as PinoLoggerOptions +} from 'pino' -export type ChildLoggerOptions = pino.ChildLoggerOptions +export type { + FastifyLogFn, + LogLevel, + Bindings, + ChildLoggerOptions, + PinoLoggerOptions +} -export type FastifyLoggerInstance = pino.Logger -// TODO make pino export BaseLogger again -// export type FastifyBaseLogger = pino.BaseLogger & { -export type FastifyBaseLogger = pino.Logger & { +export interface FastifyBaseLogger extends Pick { child(bindings: Bindings, options?: ChildLoggerOptions): FastifyBaseLogger } +// TODO delete FastifyLoggerInstance in the next major release. It seems that it is enough to have only FastifyBaseLogger. +/** + * @deprecated Use FastifyBaseLogger instead + */ +export type FastifyLoggerInstance = FastifyBaseLogger + export interface FastifyLoggerStreamDestination { write(msg: string): void; } -export type PinoLoggerOptions = pino.LoggerOptions +// TODO: once node 18 is EOL, this type can be replaced with plain FastifyReply. +/** + * Specialized reply type used for the `res` log serializer, since only `statusCode` is passed in certain cases. + */ +export type ResSerializerReply< + RawServer extends RawServerBase, + RawReply extends FastifyReply +> = Partial & Pick /** * Fastify Custom Logger options. @@ -38,14 +53,14 @@ export type PinoLoggerOptions = pino.LoggerOptions export interface FastifyLoggerOptions< RawServer extends RawServerBase = RawServerDefault, RawRequest extends FastifyRequest, FastifySchema, FastifyTypeProvider> = FastifyRequest, FastifySchema, FastifyTypeProviderDefault>, - RawReply extends FastifyReply, RawReplyDefaultExpression, RouteGenericInterface, ContextConfigDefault, FastifySchema, FastifyTypeProvider> = FastifyReply, RawReplyDefaultExpression, RouteGenericInterface, ContextConfigDefault, FastifySchema, FastifyTypeProviderDefault>, + RawReply extends FastifyReply, RawReplyDefaultExpression, ContextConfigDefault, FastifySchema, FastifyTypeProvider> = FastifyReply, RawReplyDefaultExpression, ContextConfigDefault, FastifySchema, FastifyTypeProviderDefault> > { serializers?: { req?: (req: RawRequest) => { method?: string; url?: string; version?: string; - hostname?: string; + host?: string; remoteAddress?: string; remotePort?: number; [key: string]: unknown; @@ -56,8 +71,8 @@ export interface FastifyLoggerOptions< stack: string; [key: string]: unknown; }; - res?: (res: RawReply) => { - statusCode: string | number; + res?: (res: ResSerializerReply) => { + statusCode?: string | number; [key: string]: unknown; }; }; @@ -66,3 +81,27 @@ export interface FastifyLoggerOptions< genReqId?: (req: RawRequest) => string; stream?: FastifyLoggerStreamDestination; } + +export interface FastifyChildLoggerFactory< + RawServer extends RawServerBase = RawServerDefault, + RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, + RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, + Logger extends FastifyBaseLogger = FastifyBaseLogger, + TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault +> { + /** + * @param logger The parent logger + * @param bindings The bindings object that will be passed to the child logger + * @param childLoggerOpts The logger options that will be passed to the child logger + * @param rawReq The raw request + * @this The fastify instance + * @returns The child logger instance + */ + ( + this: FastifyInstance, + logger: Logger, + bindings: Bindings, + childLoggerOpts: ChildLoggerOptions, + rawReq: RawRequest + ): Logger +} diff --git a/types/plugin.d.ts b/types/plugin.d.ts index 302422b57f6..797b9cac156 100644 --- a/types/plugin.d.ts +++ b/types/plugin.d.ts @@ -1,7 +1,7 @@ import { FastifyInstance } from './instance' import { RawServerBase, RawRequestDefaultExpression, RawReplyDefaultExpression, RawServerDefault } from './utils' import { FastifyTypeProvider, FastifyTypeProviderDefault } from './type-provider' -import { FastifyLoggerInstance } from './logger' +import { FastifyBaseLogger } from './logger' export type FastifyPluginOptions = Record @@ -10,8 +10,13 @@ export type FastifyPluginOptions = Record * * Fastify allows the user to extend its functionalities with plugins. A plugin can be a set of routes, a server decorator or whatever. To activate plugins, use the `fastify.register()` method. */ -export type FastifyPluginCallback, Server extends RawServerBase = RawServerDefault, TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault> = ( - instance: FastifyInstance, RawReplyDefaultExpression, FastifyLoggerInstance, TypeProvider>, +export type FastifyPluginCallback< + Options extends FastifyPluginOptions = Record, + Server extends RawServerBase = RawServerDefault, + TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, + Logger extends FastifyBaseLogger = FastifyBaseLogger +> = ( + instance: FastifyInstance, RawReplyDefaultExpression, Logger, TypeProvider>, opts: Options, done: (err?: Error) => void ) => void @@ -25,13 +30,15 @@ export type FastifyPluginAsync< Options extends FastifyPluginOptions = Record, Server extends RawServerBase = RawServerDefault, TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, + Logger extends FastifyBaseLogger = FastifyBaseLogger > = ( - instance: FastifyInstance, RawReplyDefaultExpression, FastifyLoggerInstance, TypeProvider>, + instance: FastifyInstance, RawReplyDefaultExpression, Logger, TypeProvider>, opts: Options -) => Promise; +) => Promise /** * Generic plugin type. - * @deprecated union type doesn't work well with type inference in TS and is therefore deprecated in favor of explicit types. See FastifyRegister. + * @deprecated union type doesn't work well with type inference in TS and is therefore deprecated in favor of explicit types. Use `FastifyPluginCallback` or `FastifyPluginAsync` instead. To activate + * plugins use `FastifyRegister`. https://fastify.dev/docs/latest/Reference/TypeScript/#register */ export type FastifyPlugin> = FastifyPluginCallback | FastifyPluginAsync diff --git a/types/register.d.ts b/types/register.d.ts index 5cca479df16..b6a7625f75f 100644 --- a/types/register.d.ts +++ b/types/register.d.ts @@ -2,7 +2,7 @@ import { FastifyPluginOptions, FastifyPluginCallback, FastifyPluginAsync } from import { LogLevel } from './logger' import { FastifyInstance } from './instance' import { RawServerBase } from './utils' -import { FastifyTypeProvider, RawServerDefault } from '../fastify' +import { FastifyBaseLogger, FastifyTypeProvider, RawServerDefault } from '../fastify' export interface RegisterOptions { prefix?: string; @@ -17,17 +17,26 @@ export type FastifyRegisterOptions = (RegisterOptions & Options) | ((in * * Function for adding a plugin to fastify. The options are inferred from the passed in FastifyPlugin parameter. */ -export interface FastifyRegister { - ( - plugin: FastifyPluginCallback, - opts?: FastifyRegisterOptions +export interface FastifyRegister { + ( + plugin: FastifyPluginCallback ): T; - ( - plugin: FastifyPluginAsync, - opts?: FastifyRegisterOptions + ( + plugin: FastifyPluginCallback, + opts: FastifyRegisterOptions ): T; - ( - plugin: FastifyPluginCallback | FastifyPluginAsync | Promise<{ default: FastifyPluginCallback }> | Promise<{ default: FastifyPluginAsync }>, - opts?: FastifyRegisterOptions + ( + plugin: FastifyPluginAsync + ): T; + ( + plugin: FastifyPluginAsync, + opts: FastifyRegisterOptions + ): T; + ( + plugin: FastifyPluginCallback | FastifyPluginAsync | Promise<{ default: FastifyPluginCallback }> | Promise<{ default: FastifyPluginAsync }>, + ): T; + ( + plugin: FastifyPluginCallback | FastifyPluginAsync | Promise<{ default: FastifyPluginCallback }> | Promise<{ default: FastifyPluginAsync }>, + opts: FastifyRegisterOptions ): T; } diff --git a/types/reply.d.ts b/types/reply.d.ts index 778032b6ca9..ff405c366bf 100644 --- a/types/reply.d.ts +++ b/types/reply.d.ts @@ -1,58 +1,81 @@ -import { RawReplyDefaultExpression, RawServerBase, RawServerDefault, ContextConfigDefault, RawRequestDefaultExpression, ReplyDefault } from './utils' -import { FastifyReplyType, ResolveFastifyReplyType, FastifyTypeProvider, FastifyTypeProviderDefault } from './type-provider' -import { FastifyContext } from './context' -import { FastifyLoggerInstance } from './logger' -import { FastifyRequest } from './request' -import { RouteGenericInterface } from './route' +import { Buffer } from 'node:buffer' import { FastifyInstance } from './instance' +import { FastifyBaseLogger } from './logger' +import { FastifyRequest, RequestRouteOptions } from './request' +import { RouteGenericInterface } from './route' import { FastifySchema } from './schema' -import { Buffer } from 'buffer' +import { CallSerializerTypeProvider, FastifyReplyType, FastifyTypeProvider, FastifyTypeProviderDefault, ResolveFastifyReplyType, SendArgs } from './type-provider' +import { CodeToReplyKey, ContextConfigDefault, HttpHeader, HttpKeys, RawReplyDefaultExpression, RawRequestDefaultExpression, RawServerBase, RawServerDefault, ReplyDefault, ReplyKeysToCodes } from './utils' export interface ReplyGenericInterface { Reply?: ReplyDefault; } +type HttpCodesReplyType = Partial> + +type ReplyTypeConstrainer> = + RouteGenericReply extends HttpCodesReplyType & Record, never> ? + Code extends keyof RouteGenericReply ? RouteGenericReply[Code] : + CodeToReplyKey extends keyof RouteGenericReply ? RouteGenericReply[CodeToReplyKey] : unknown : + RouteGenericReply + +export type ResolveReplyTypeWithRouteGeneric, + SchemaCompiler extends FastifySchema = FastifySchema, + TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault> = + Code extends keyof SchemaCompiler['response'] ? + CallSerializerTypeProvider : + ResolveFastifyReplyType }> /** * FastifyReply is an instance of the standard http or http2 reply types. * It defaults to http.ServerResponse, and it also extends the relative reply object. */ export interface FastifyReply< + RouteGeneric extends RouteGenericInterface = RouteGenericInterface, RawServer extends RawServerBase = RawServerDefault, RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, - RouteGeneric extends RouteGenericInterface = RouteGenericInterface, ContextConfig = ContextConfigDefault, SchemaCompiler extends FastifySchema = FastifySchema, TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, ReplyType extends FastifyReplyType = ResolveFastifyReplyType > { + readonly routeOptions: Readonly> + raw: RawReply; - context: FastifyContext; - log: FastifyLoggerInstance; + elapsedTime: number; + log: FastifyBaseLogger; request: FastifyRequest; server: FastifyInstance; - code(statusCode: number): FastifyReply; - status(statusCode: number): FastifyReply; + code : keyof SchemaCompiler['response'] extends ReplyKeysToCodes ? keyof SchemaCompiler['response'] : ReplyKeysToCodes>(statusCode: Code): FastifyReply>; + status : keyof SchemaCompiler['response'] extends ReplyKeysToCodes ? keyof SchemaCompiler['response'] : ReplyKeysToCodes>(statusCode: Code): FastifyReply>; statusCode: number; sent: boolean; - send(payload?: ReplyType): FastifyReply; - header(key: string, value: any): FastifyReply; - headers(values: {[key: string]: any}): FastifyReply; - getHeader(key: string): string | undefined; - getHeaders(): { - // Node's `getHeaders()` can return numbers and arrays, so they're included here as possible types. - [key: string]: number | string | string[] | undefined; - }; - removeHeader(key: string): void; - hasHeader(key: string): boolean; - // Note: should consider refactoring the argument order for redirect. statusCode is optional so it should be after the required url param - redirect(statusCode: number, url: string): FastifyReply; - redirect(url: string): FastifyReply; - hijack(): FastifyReply; + send(...args: SendArgs): FastifyReply; + header(key: HttpHeader, value: any): FastifyReply; + headers(values: Partial>): FastifyReply; + getHeader(key: HttpHeader): number | string | string[] | undefined; + getHeaders(): Record; + removeHeader(key: HttpHeader): FastifyReply; + hasHeader(key: HttpHeader): boolean; + redirect(url: string, statusCode?: number): FastifyReply; + writeEarlyHints(hints: Record, callback?: () => void): void; + hijack(): FastifyReply; callNotFound(): void; - getResponseTime(): number; - type(contentType: string): FastifyReply; - serializer(fn: (payload: any) => string): FastifyReply; + type(contentType: string): FastifyReply; + serializer(fn: (payload: any) => string): FastifyReply; serialize(payload: any): string | ArrayBuffer | Buffer; + // Serialization Methods + getSerializationFunction(httpStatus: string, contentType?: string): ((payload: { [key: string]: unknown }) => string) | undefined; + getSerializationFunction(schema: { [key: string]: unknown }): ((payload: { [key: string]: unknown }) => string) | undefined; + compileSerializationSchema(schema: { [key: string]: unknown }, httpStatus?: string, contentType?: string): (payload: { [key: string]: unknown }) => string; + serializeInput(input: { [key: string]: unknown }, schema: { [key: string]: unknown }, httpStatus?: string, contentType?: string): string; + serializeInput(input: { [key: string]: unknown }, httpStatus: string, contentType?: string): unknown; then(fulfilled: () => void, rejected: (err: Error) => void): void; + trailer: ( + key: string, + fn: ((reply: FastifyReply, payload: string | Buffer | null) => Promise) | ((reply: FastifyReply, payload: string | Buffer | null, done: (err: Error | null, value?: string) => void) => void) + ) => FastifyReply; + hasTrailer(key: string): boolean; + removeTrailer(key: string): FastifyReply; + getDecorator(name: string | symbol): T; } diff --git a/types/request.d.ts b/types/request.d.ts index ba89f4b956d..e7229b5909c 100644 --- a/types/request.d.ts +++ b/types/request.d.ts @@ -1,11 +1,13 @@ -import { FastifyLoggerInstance } from './logger' -import { ContextConfigDefault, RawServerBase, RawServerDefault, RawRequestDefaultExpression, RequestBodyDefault, RequestQuerystringDefault, RequestParamsDefault, RequestHeadersDefault } from './utils' -import { RouteGenericInterface } from './route' +import { ErrorObject } from '@fastify/ajv-compiler' +import { FastifyContextConfig } from './context' import { FastifyInstance } from './instance' -import { FastifyTypeProvider, FastifyTypeProviderDefault, FastifyRequestType, ResolveFastifyRequestType } from './type-provider' +import { FastifyBaseLogger } from './logger' +import { FastifyRouteConfig, RouteGenericInterface, RouteHandlerMethod } from './route' import { FastifySchema } from './schema' -import { FastifyContext } from './context' +import { FastifyRequestType, FastifyTypeProvider, FastifyTypeProviderDefault, ResolveFastifyRequestType } from './type-provider' +import { ContextConfigDefault, HTTPMethods, RawRequestDefaultExpression, RawServerBase, RawServerDefault, RequestBodyDefault, RequestHeadersDefault, RequestParamsDefault, RequestQuerystringDefault } from './utils' +type HTTPRequestPart = 'body' | 'query' | 'querystring' | 'params' | 'headers' export interface RequestGenericInterface { Body?: RequestBodyDefault; Querystring?: RequestQuerystringDefault; @@ -13,6 +15,27 @@ export interface RequestGenericInterface { Headers?: RequestHeadersDefault; } +export interface ValidationFunction { + (input: any): boolean + errors?: null | ErrorObject[]; +} + +export interface RequestRouteOptions { + method: HTTPMethods | HTTPMethods[]; + // `url` can be `undefined` for instance when `request.is404` is true + url: string | undefined; + bodyLimit: number; + handlerTimeout: number; + attachValidation: boolean; + logLevel: string; + exposeHeadRoute: boolean; + prefixTrailingSlash: string; + config: FastifyContextConfig & FastifyRouteConfig & ContextConfig; + schema?: SchemaCompiler; // it is empty for 404 requests + handler: RouteHandlerMethod; + version?: string; +} + /** * FastifyRequest is an instance of the standard http or http2 request objects. * It defaults to http.IncomingMessage, and it also extends the relative request object. @@ -23,18 +46,23 @@ export interface FastifyRequest, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance + Logger extends FastifyBaseLogger = FastifyBaseLogger, + RequestType extends FastifyRequestType = ResolveFastifyRequestType +// ^ Temporary Note: RequestType has been re-ordered to be the last argument in +// generic list. This generic argument is now considered optional as it can be +// automatically inferred from the SchemaCompiler, RouteGeneric and TypeProvider +// arguments. Implementations that already pass this argument can either omit +// the RequestType (preferred) or swap Logger and RequestType arguments when +// creating custom types of FastifyRequest. Related issue #4123 > { - id: any; - params: RequestType['params']; + id: string; + params: RequestType['params']; // deferred inference raw: RawRequest; query: RequestType['query']; headers: RawRequest['headers'] & RequestType['headers']; // this enables the developer to extend the existing http(s|2) headers list log: Logger; server: FastifyInstance; body: RequestType['body']; - context: FastifyContext; /** in order for this to be used the user should ensure they have set the attachValidation option. */ validationError?: Error & { validation: any; validationContext: string }; @@ -45,16 +73,23 @@ export interface FastifyRequest> readonly is404: boolean; readonly socket: RawRequest['socket']; + readonly signal: AbortSignal; - // Prefer `socket` over deprecated `connection` property in node 13.0.0 or higher - // @deprecated - readonly connection: RawRequest['socket']; + getValidationFunction(httpPart: HTTPRequestPart): ValidationFunction + getValidationFunction(schema: { [key: string]: any }): ValidationFunction + compileValidationSchema(schema: { [key: string]: any }, httpPart?: HTTPRequestPart): ValidationFunction + validateInput(input: any, schema: { [key: string]: any }, httpPart?: HTTPRequestPart): boolean + validateInput(input: any, httpPart?: HTTPRequestPart): boolean + getDecorator(name: string | symbol): T; + setDecorator(name: string | symbol, value: T): void; } diff --git a/types/route.d.ts b/types/route.d.ts index 7aa2f00cf11..e12fa007fe9 100644 --- a/types/route.d.ts +++ b/types/route.d.ts @@ -1,63 +1,105 @@ +import { FastifyError } from '@fastify/error' +import { ConstraintStrategy } from 'find-my-way' +import { FastifyContextConfig } from './context' +import { + onErrorHookHandler, + onRequestAbortHookHandler, + onRequestHookHandler, + onResponseHookHandler, + onSendHookHandler, + onTimeoutHookHandler, + preHandlerHookHandler, + preParsingHookHandler, + preSerializationHookHandler, + preValidationHookHandler +} from './hooks' import { FastifyInstance } from './instance' -import { FastifyRequest, RequestGenericInterface } from './request' +import { FastifyBaseLogger, FastifyChildLoggerFactory, LogLevel } from './logger' import { FastifyReply, ReplyGenericInterface } from './reply' -import { FastifySchema, FastifySchemaCompiler, FastifySchemaValidationError, FastifySerializerCompiler } from './schema' -import { HTTPMethods, RawServerBase, RawServerDefault, RawRequestDefaultExpression, RawReplyDefaultExpression, ContextConfigDefault } from './utils' -import { preValidationHookHandler, preHandlerHookHandler, preSerializationHookHandler, onRequestHookHandler, preParsingHookHandler, onResponseHookHandler, onSendHookHandler, onErrorHookHandler, onTimeoutHookHandler } from './hooks' -import { FastifyError } from '@fastify/error' -import { FastifyContext } from './context' +import { FastifyRequest, RequestGenericInterface } from './request' +import { FastifySchema, FastifySchemaCompiler, FastifySerializerCompiler, SchemaErrorFormatter } from './schema' import { - FastifyRequestType, FastifyTypeProvider, FastifyTypeProviderDefault, - ResolveFastifyReplyReturnType, ResolveFastifyRequestType + ResolveFastifyReplyReturnType } from './type-provider' -import { FastifyLoggerInstance, LogLevel } from './logger' +import { ContextConfigDefault, HTTPMethods, RawReplyDefaultExpression, RawRequestDefaultExpression, RawServerBase, RawServerDefault } from './utils' + +export interface FastifyRouteConfig { + url: string; + method: HTTPMethods | HTTPMethods[]; +} -export interface RouteGenericInterface extends RequestGenericInterface, ReplyGenericInterface {} +export interface RouteGenericInterface extends RequestGenericInterface, ReplyGenericInterface { } + +export type RouteConstraintType = Omit, 'deriveConstraint'> & { + deriveConstraint(req: RawRequestDefaultExpression, ctx?: Context, done?: (err: Error, ...args: any) => any): any, +} + +export interface RouteConstraint { + version?: string + host?: RegExp | string + [name: string]: unknown +} /** * Route shorthand options for the various shorthand methods */ +type RouteShorthandHook any> = (...args: Parameters) => void | Promise + export interface RouteShorthandOptions< RawServer extends RawServerBase = RawServerDefault, RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, RouteGeneric extends RouteGenericInterface = RouteGenericInterface, ContextConfig = ContextConfigDefault, - SchemaCompiler = FastifySchema, + SchemaCompiler extends FastifySchema = FastifySchema, TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance + Logger extends FastifyBaseLogger = FastifyBaseLogger > { schema?: SchemaCompiler, // originally FastifySchema attachValidation?: boolean; exposeHeadRoute?: boolean; - validatorCompiler?: FastifySchemaCompiler; - serializerCompiler?: FastifySerializerCompiler; + validatorCompiler?: FastifySchemaCompiler>; + serializerCompiler?: FastifySerializerCompiler>; bodyLimit?: number; + handlerTimeout?: number; logLevel?: LogLevel; - config?: FastifyContext['config']; - version?: string; - constraints?: { [name: string]: any }, - prefixTrailingSlash?: 'slash'|'no-slash'|'both'; - errorHandler?: (this: FastifyInstance, error: FastifyError, request: FastifyRequest, reply: FastifyReply) => void; - // TODO: Change to actual type. - schemaErrorFormatter?: (errors: FastifySchemaValidationError[], dataVar: string) => Error; + config?: FastifyContextConfig & ContextConfig; + constraints?: RouteConstraint, + prefixTrailingSlash?: 'slash' | 'no-slash' | 'both'; + errorHandler?: ( + this: FastifyInstance, + error: FastifyError, + request: FastifyRequest, TypeProvider, ContextConfig, Logger>, + reply: FastifyReply, TypeProvider> + ) => void; + childLoggerFactory?: FastifyChildLoggerFactory; + schemaErrorFormatter?: SchemaErrorFormatter; // hooks - onRequest?: onRequestHookHandler | onRequestHookHandler[]; - preParsing?: preParsingHookHandler | preParsingHookHandler[]; - preValidation?: preValidationHookHandler | preValidationHookHandler[]; - preHandler?: preHandlerHookHandler | preHandlerHookHandler[]; - preSerialization?: preSerializationHookHandler | preSerializationHookHandler[]; - onSend?: onSendHookHandler | onSendHookHandler[]; - onResponse?: onResponseHookHandler | onResponseHookHandler[]; - onTimeout?: onTimeoutHookHandler | onTimeoutHookHandler[]; - onError?: onErrorHookHandler | onErrorHookHandler[]; + onRequest?: RouteShorthandHook, TypeProvider, Logger>> + | RouteShorthandHook, TypeProvider, Logger>>[]; + preParsing?: RouteShorthandHook, TypeProvider, Logger>> + | RouteShorthandHook, TypeProvider, Logger>>[]; + preValidation?: RouteShorthandHook, TypeProvider, Logger>> + | RouteShorthandHook, TypeProvider, Logger>>[]; + preHandler?: RouteShorthandHook, TypeProvider, Logger>> + | RouteShorthandHook, TypeProvider, Logger>>[]; + preSerialization?: RouteShorthandHook, TypeProvider, Logger>> + | RouteShorthandHook, TypeProvider, Logger>>[]; + onSend?: RouteShorthandHook, TypeProvider, Logger>> + | RouteShorthandHook, TypeProvider, Logger>>[]; + onResponse?: RouteShorthandHook, TypeProvider, Logger>> + | RouteShorthandHook, TypeProvider, Logger>>[]; + onTimeout?: RouteShorthandHook, TypeProvider, Logger>> + | RouteShorthandHook, TypeProvider, Logger>>[]; + onError?: RouteShorthandHook, TypeProvider, Logger>> + | RouteShorthandHook, TypeProvider, Logger>>[]; + onRequestAbort?: RouteShorthandHook, TypeProvider, Logger>> + | RouteShorthandHook, TypeProvider, Logger>>[]; } - /** * Route handler method declaration. */ @@ -69,13 +111,12 @@ export type RouteHandlerMethod< ContextConfig = ContextConfigDefault, SchemaCompiler extends FastifySchema = FastifySchema, TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance + Logger extends FastifyBaseLogger = FastifyBaseLogger > = ( this: FastifyInstance, - request: FastifyRequest, - reply: FastifyReply -// This return type used to be a generic type argument. Due to TypeScript's inference of return types, this rendered returns unchecked. + request: FastifyRequest, + reply: FastifyReply + // This return type used to be a generic type argument. Due to TypeScript's inference of return types, this rendered returns unchecked. ) => ResolveFastifyReplyReturnType /** @@ -87,12 +128,11 @@ export interface RouteShorthandOptionsWithHandler< RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, RouteGeneric extends RouteGenericInterface = RouteGenericInterface, ContextConfig = ContextConfigDefault, - SchemaCompiler = FastifySchema, + SchemaCompiler extends FastifySchema = FastifySchema, TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance -> extends RouteShorthandOptions { - handler: RouteHandlerMethod; + Logger extends FastifyBaseLogger = FastifyBaseLogger +> extends RouteShorthandOptions { + handler: RouteHandlerMethod, TypeProvider, Logger>; } /** @@ -103,19 +143,20 @@ export interface RouteShorthandMethod< RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, + Logger extends FastifyBaseLogger = FastifyBaseLogger > { - , Logger extends FastifyLoggerInstance = FastifyLoggerInstance>( + ( path: string, - opts: RouteShorthandOptions, - handler: RouteHandlerMethod + opts: RouteShorthandOptions, + handler: RouteHandlerMethod ): FastifyInstance; - , Logger extends FastifyLoggerInstance = FastifyLoggerInstance>( + ( path: string, - handler: RouteHandlerMethod + handler: RouteHandlerMethod ): FastifyInstance; - , Logger extends FastifyLoggerInstance = FastifyLoggerInstance>( + ( path: string, - opts: RouteShorthandOptionsWithHandler + opts: RouteShorthandOptionsWithHandler ): FastifyInstance; } @@ -128,14 +169,13 @@ export interface RouteOptions< RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression, RouteGeneric extends RouteGenericInterface = RouteGenericInterface, ContextConfig = ContextConfigDefault, - SchemaCompiler = FastifySchema, + SchemaCompiler extends FastifySchema = FastifySchema, TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance -> extends RouteShorthandOptions { + Logger extends FastifyBaseLogger = FastifyBaseLogger +> extends RouteShorthandOptions { method: HTTPMethods | HTTPMethods[]; url: string; - handler: RouteHandlerMethod; + handler: RouteHandlerMethod; } export type RouteHandler< @@ -146,15 +186,14 @@ export type RouteHandler< ContextConfig = ContextConfigDefault, SchemaCompiler extends FastifySchema = FastifySchema, TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault, - RequestType extends FastifyRequestType = ResolveFastifyRequestType, - Logger extends FastifyLoggerInstance = FastifyLoggerInstance + Logger extends FastifyBaseLogger = FastifyBaseLogger > = ( this: FastifyInstance, - request: FastifyRequest, - reply: FastifyReply + request: FastifyRequest, + reply: FastifyReply ) => RouteGeneric['Reply'] | void | Promise export type DefaultRoute = ( req: Request, res: Reply, -) => void; +) => void diff --git a/types/schema.d.ts b/types/schema.d.ts index bb0fd709168..40a8dd1cb06 100644 --- a/types/schema.d.ts +++ b/types/schema.d.ts @@ -1,5 +1,6 @@ -import { ValidatorCompiler } from '@fastify/ajv-compiler' -import { FastifyInstance, FastifyServerOptions } from '../fastify' +import { ValidatorFactory } from '@fastify/ajv-compiler' +import { SerializerFactory } from '@fastify/fast-json-stringify-compiler' +import { FastifyInstance, SafePromiseLike } from '../fastify' /** * Schemas in Fastify follow the JSON-Schema standard. For this reason * we have opted to not ship strict schema based types. Instead we provide @@ -20,18 +21,19 @@ export interface FastifyRouteSchemaDef { url: string; httpPart?: string; httpStatus?: string; + contentType?: string; } export interface FastifySchemaValidationError { keyword: string; instancePath: string; schemaPath: string; - params: Record; + params: Record; message?: string; } export interface FastifyValidationResult { - (data: any): boolean | PromiseLike | { error?: Error, value?: any } + (data: any): boolean | SafePromiseLike | { error?: Error | FastifySchemaValidationError[], value?: any } errors?: FastifySchemaValidationError[] | null; } @@ -42,14 +44,18 @@ export type FastifySchemaCompiler = (routeSchema: FastifyRouteSchemaDef) = export type FastifySerializerCompiler = (routeSchema: FastifyRouteSchemaDef) => (data: any) => string -export interface FastifySchemaControllerOptions{ +export interface FastifySchemaControllerOptions { bucket?: (parentSchemas?: unknown) => { add(schema: unknown): FastifyInstance; getSchema(schemaId: string): unknown; getSchemas(): Record; }; compilersFactory?: { - buildValidator?: ValidatorCompiler; - buildSerializer?: (externalSchemas: unknown, serializerOptsServerOption: FastifyServerOptions['serializerOpts']) => FastifySerializerCompiler; + buildValidator?: ValidatorFactory; + buildSerializer?: SerializerFactory; }; } + +export type SchemaErrorDataVar = 'body' | 'headers' | 'params' | 'querystring' + +export type SchemaErrorFormatter = (errors: FastifySchemaValidationError[], dataVar: SchemaErrorDataVar) => Error diff --git a/types/serverFactory.d.ts b/types/server-factory.d.ts similarity index 67% rename from types/serverFactory.d.ts rename to types/server-factory.d.ts index 569bfc10760..05e743a0566 100644 --- a/types/serverFactory.d.ts +++ b/types/server-factory.d.ts @@ -1,7 +1,7 @@ import { RawServerBase, RawServerDefault, RawReplyDefaultExpression, RawRequestDefaultExpression } from './utils' -import * as http from 'http' -import * as https from 'https' -import * as http2 from 'http2' +import * as http from 'node:http' +import * as https from 'node:https' +import * as http2 from 'node:http2' export type FastifyServerFactoryHandler< RawServer extends RawServerBase = RawServerDefault, @@ -9,8 +9,8 @@ export type FastifyServerFactoryHandler< RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression > = RawServer extends http.Server | https.Server ? - (request: http.IncomingMessage & RawRequest, response: http.ServerResponse & RawReply) => void : - (request: http2.Http2ServerRequest & RawRequest, response: http2.Http2ServerResponse & RawReply) => void + (request: http.IncomingMessage & RawRequest, response: http.ServerResponse & RawReply) => void : + (request: http2.Http2ServerRequest & RawRequest, response: http2.Http2ServerResponse & RawReply) => void export interface FastifyServerFactory< RawServer extends RawServerBase = RawServerDefault diff --git a/types/tsconfig.json b/types/tsconfig.json deleted file mode 100644 index a6dea4a89e9..00000000000 --- a/types/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "es6", - "lib": [ "es2015" ], - "module": "commonjs", - "noEmit": true, - "strict": true - }, - "include": [ - "/test/types/*.test-d.ts", - "/types/*.d.ts" - ] -} diff --git a/types/type-provider.d.ts b/types/type-provider.d.ts index 017587e509e..002b08248ce 100644 --- a/types/type-provider.d.ts +++ b/types/type-provider.d.ts @@ -1,20 +1,21 @@ - import { RouteGenericInterface } from './route' import { FastifySchema } from './schema' +import { HttpKeys, RecordKeysToLowercase } from './utils' // ----------------------------------------------------------------------------------------------- // TypeProvider // ----------------------------------------------------------------------------------------------- export interface FastifyTypeProvider { - readonly input: unknown, - readonly output: unknown, + readonly schema: unknown, + readonly validator: unknown, + readonly serializer: unknown, } -// eslint-disable-next-line @typescript-eslint/no-empty-interface export interface FastifyTypeProviderDefault extends FastifyTypeProvider {} -export type CallTypeProvider = (F & { input: I })['output'] +export type CallValidatorTypeProvider = (F & { schema: S })['validator'] +export type CallSerializerTypeProvider = (F & { schema: S })['serializer'] // ----------------------------------------------------------------------------------------------- // FastifyRequestType @@ -32,13 +33,13 @@ type KeysOf = T extends any ? keyof T : never // Resolves Request types either from generic argument or Type Provider. type ResolveRequestParams = - UndefinedToUnknown extends never ? CallTypeProvider : RouteGeneric['Params']> + UndefinedToUnknown extends never ? CallValidatorTypeProvider : RouteGeneric['Params']> type ResolveRequestQuerystring = - UndefinedToUnknown extends never ? CallTypeProvider : RouteGeneric['Querystring']> + UndefinedToUnknown extends never ? CallValidatorTypeProvider : RouteGeneric['Querystring']> type ResolveRequestHeaders = - UndefinedToUnknown extends never ? CallTypeProvider : RouteGeneric['Headers']> + UndefinedToUnknown extends never ? CallValidatorTypeProvider : RouteGeneric['Headers']> type ResolveRequestBody = - UndefinedToUnknown extends never ? CallTypeProvider : RouteGeneric['Body']> + UndefinedToUnknown extends never ? CallValidatorTypeProvider : RouteGeneric['Body']> // The target request type. This type is inferenced on fastify 'requests' via generic argument assignment export interface FastifyRequestType { @@ -48,29 +49,23 @@ export interface FastifyRequestType = FastifyRequestType< -ResolveRequestParams, -ResolveRequestQuerystring, -ResolveRequestHeaders, -ResolveRequestBody -> +// Resolves the FastifyRequest generic parameters +export interface ResolveFastifyRequestType extends FastifyRequestType { + params: ResolveRequestParams, + query: ResolveRequestQuerystring, + headers: RecordKeysToLowercase>, + body: ResolveRequestBody +} // ----------------------------------------------------------------------------------------------- // FastifyReplyType // ----------------------------------------------------------------------------------------------- -// Tests if the user has specified a generic argument for Reply -type UseReplyFromRouteGeneric = keyof RouteGeneric['Reply'] extends never ? false : true - -// Tests if the user has specified a response schema. -type UseReplyFromSchemaCompiler = keyof SchemaCompiler['response'] extends never ? false : true - -// Resolves the Reply type from the generic argument -type ResolveReplyFromRouteGeneric = RouteGeneric['Reply'] - -// Resolves the Reply type by taking a union of response status codes +// Resolves the Reply type by taking a union of response status codes and content-types type ResolveReplyFromSchemaCompiler = { - [K in keyof SchemaCompiler['response']]: CallTypeProvider + [K1 in keyof SchemaCompiler['response']]: SchemaCompiler['response'][K1] extends { content: { [keyof: string]: { schema: unknown } } } ? ({ + [K2 in keyof SchemaCompiler['response'][K1]['content']]: CallSerializerTypeProvider + } extends infer Result ? Result[keyof Result] : unknown) : CallSerializerTypeProvider } extends infer Result ? Result[keyof Result] : unknown // The target reply type. This type is inferenced on fastify 'replies' via generic argument assignment @@ -78,28 +73,58 @@ export type FastifyReplyType = Reply // Resolves the Reply type either via generic argument or from response schema. This type uses a different // resolution strategy to Requests where the Reply will infer a union of each status code type specified -// by the user. The Reply can be explicitly overriden by users providing a generic Reply type on the route. -export type ResolveFastifyReplyType = FastifyReplyType< -UseReplyFromRouteGeneric extends true ? ResolveReplyFromRouteGeneric : - UseReplyFromSchemaCompiler extends true ? ResolveReplyFromSchemaCompiler : - unknown -> +// by the user. The Reply can be explicitly overridden by users providing a generic Reply type on the route. +export type ResolveFastifyReplyType = UndefinedToUnknown extends never ? ResolveReplyFromSchemaCompiler : RouteGeneric['Reply']> // ----------------------------------------------------------------------------------------------- // FastifyReplyReturnType // ----------------------------------------------------------------------------------------------- +// Resolves the Reply return type by taking a union of response status codes in the generic argument +type ResolveReplyReturnTypeFromRouteGeneric = RouteGeneric extends { Reply: infer Return } + ? keyof Return extends HttpKeys ? Return[keyof Return] | Return : Return + : unknown + // The target reply return type. This type is inferenced on fastify 'routes' via generic argument assignment export type ResolveFastifyReplyReturnType< TypeProvider extends FastifyTypeProvider, SchemaCompiler extends FastifySchema, - RouteGeneric extends RouteGenericInterface, + RouteGeneric extends RouteGenericInterface > = ResolveFastifyReplyType< TypeProvider, SchemaCompiler, RouteGeneric -> extends infer Return ? - (Return | void | Promise) +> extends infer ReplyType + ? RouteGeneric['Reply'] extends ReplyType + ? ResolveReplyReturnTypeFromRouteGeneric extends infer Return + ? Return | void | Promise + : unknown + : ReplyType | void | Promise // review: support both async and sync return types // (Promise | Return | Promise | void) : unknown + +/** + * This branded type is needed to indicate APIs that return Promise-likes which can + * safely "float" (not have rejections handled by calling code). + * + * Please refer to the following Github issue for more info: + * https://github.com/fastify/fastify/issues/5498 + */ +export type SafePromiseLike = PromiseLike & { __linterBrands: 'SafePromiseLike' } + +// ----------------------------------------------------------------------------------------------- +// SendArgs +// ----------------------------------------------------------------------------------------------- + +/** + * Determines whether the send() payload parameter should be required or optional. + * - When ReplyType is unknown (default/unspecified), payload is optional + * - When ReplyType is undefined or void, payload is optional (returning undefined is valid) + * - Otherwise, payload is required + */ +export type SendArgs = unknown extends ReplyType + ? [payload?: ReplyType] + : [ReplyType] extends [undefined | void] + ? [payload?: ReplyType] + : [payload: ReplyType] diff --git a/types/utils.d.ts b/types/utils.d.ts index 995e2b0f9a8..c569e15526d 100644 --- a/types/utils.d.ts +++ b/types/utils.d.ts @@ -1,11 +1,23 @@ -import * as http from 'http' -import * as http2 from 'http2' -import * as https from 'https' +import * as http from 'node:http' +import * as http2 from 'node:http2' +import * as https from 'node:https' + +type AutocompletePrimitiveBaseType = + T extends string ? string : + T extends number ? number : + T extends boolean ? boolean : + never + +export type Autocomplete = T | (AutocompletePrimitiveBaseType & Record) /** * Standard HTTP method strings + * for internal use */ -export type HTTPMethods = 'DELETE' | 'GET' | 'HEAD' | 'PATCH' | 'POST' | 'PUT' | 'OPTIONS' +type _HTTPMethods = 'DELETE' | 'GET' | 'HEAD' | 'PATCH' | 'POST' | 'PUT' | 'OPTIONS' | +'PROPFIND' | 'PROPPATCH' | 'MKCOL' | 'COPY' | 'MOVE' | 'LOCK' | 'UNLOCK' | 'TRACE' | 'SEARCH' | 'REPORT' | 'MKCALENDAR' + +export type HTTPMethods = Autocomplete<_HTTPMethods | Lowercase<_HTTPMethods>> /** * A union type of the Node.js server types from the http, https, and http2 modules. @@ -21,7 +33,7 @@ export type RawServerDefault = http.Server * The default request type based on the server type. Utilizes generic constraining. */ export type RawRequestDefaultExpression< - RawServer extends RawServerBase = RawServerDefault, + RawServer extends RawServerBase = RawServerDefault > = RawServer extends http.Server | https.Server ? http.IncomingMessage : RawServer extends http2.Http2Server | http2.Http2SecureServer ? http2.Http2ServerRequest : never @@ -42,3 +54,45 @@ export type RequestHeadersDefault = unknown export type ContextConfigDefault = unknown export type ReplyDefault = unknown + +/** + * Helpers for determining the type of the response payload based on the code + */ + +type StringAsNumber = T extends `${infer N extends number}` ? N : never +type CodeClasses = 1 | 2 | 3 | 4 | 5 +type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 +type HttpCodes = StringAsNumber<`${CodeClasses}${Digit}${Digit}`> +type HttpKeys = HttpCodes | `${Digit}xx` +export type StatusCodeReply = { + [Key in HttpKeys]?: unknown; +} + +// weird TS quirk: https://stackoverflow.com/questions/58977876/generic-conditional-type-resolves-to-never-when-the-generic-type-is-set-to-never +export type ReplyKeysToCodes = [Key] extends [never] ? number : + Key extends HttpCodes ? Key : + Key extends `${infer X extends CodeClasses}xx` ? + StringAsNumber<`${X}${Digit}${Digit}`> : number + +export type CodeToReplyKey = `${Code}` extends `${infer FirstDigit extends CodeClasses}${number}` + ? `${FirstDigit}xx` + : never + +export type RecordKeysToLowercase = Input extends Record + ? { + [Key in keyof Input as Key extends string + ? Lowercase + : Key + ]: Input[Key]; + } + : Input + +type OmitIndexSignature = { + [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K]; +} + +/** + * HTTP header strings + * Use this type only for input values, not for output values. + */ +export type HttpHeader = keyof OmitIndexSignature | (string & Record)