├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── question.md ├── dependabot.yml └── workflows │ ├── beta.yml │ ├── codeql-analysis.yml │ ├── dependencies.yml │ ├── headers.yml │ ├── minor.yml │ ├── node.js.yml │ ├── npm-publish.yml │ ├── patch.yml │ └── validations.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── LICENSE-3RD-PARTY.md ├── README.md ├── SECURITY.md ├── cjs-test ├── package.json ├── quick-start.spec.ts └── tsconfig.json ├── compat-test ├── eslint.config.js ├── migration.spec.ts ├── package.json ├── quick-start.spec.ts └── sample.ts ├── dataflow.svg ├── eslint.config.js ├── esm-test ├── package.json ├── quick-start.spec.ts └── tsconfig.json ├── example ├── __snapshots__ │ └── index.spec.ts.snap ├── assets │ ├── dataflow.svg │ ├── docs.yaml │ └── logo.svg ├── config.ts ├── endpoints │ ├── accept-raw.ts │ ├── create-user.ts │ ├── delete-user.ts │ ├── list-users.ts │ ├── retrieve-user.ts │ ├── send-avatar.ts │ ├── stream-avatar.ts │ ├── submit-feedback.ts │ ├── time-subscription.ts │ ├── update-user.ts │ └── upload-avatar.ts ├── example.client.ts ├── example.documentation.yaml ├── factories.ts ├── generate-client.ts ├── generate-documentation.ts ├── index.spec.ts ├── index.ts ├── middlewares.ts ├── package.json ├── routing.ts ├── tsconfig.json └── validate.spec.ts ├── express-zod-api ├── bench │ └── experiment.bench.ts ├── package.json ├── src │ ├── api-response.ts │ ├── buffer-schema.ts │ ├── builtin-logger.ts │ ├── common-helpers.ts │ ├── config-type.ts │ ├── content-type.ts │ ├── date-in-schema.ts │ ├── date-out-schema.ts │ ├── deep-checks.ts │ ├── depends-on-method.ts │ ├── diagnostics.ts │ ├── documentation-helpers.ts │ ├── documentation.ts │ ├── endpoint.ts │ ├── endpoints-factory.ts │ ├── errors.ts │ ├── form-schema.ts │ ├── graceful-helpers.ts │ ├── graceful-shutdown.ts │ ├── index.ts │ ├── integration-base.ts │ ├── integration.ts │ ├── io-schema.ts │ ├── json-schema-helpers.ts │ ├── last-resort.ts │ ├── logger-helpers.ts │ ├── logical-container.ts │ ├── mapping-helpers.ts │ ├── metadata.ts │ ├── method.ts │ ├── middleware.ts │ ├── migration.ts │ ├── peer-helpers.ts │ ├── proprietary-schemas.ts │ ├── raw-schema.ts │ ├── result-handler.ts │ ├── result-helpers.ts │ ├── routable.ts │ ├── routing-walker.ts │ ├── routing.ts │ ├── schema-walker.ts │ ├── security.ts │ ├── serve-static.ts │ ├── server-helpers.ts │ ├── server.ts │ ├── sse.ts │ ├── startup-logo.ts │ ├── testing.ts │ ├── typescript-api.ts │ ├── upload-schema.ts │ ├── well-known-headers.json │ ├── zod-plugin.ts │ ├── zts-helpers.ts │ └── zts.ts ├── tests │ ├── __snapshots__ │ │ ├── api-response.spec.ts.snap │ │ ├── builtin-logger.spec.ts.snap │ │ ├── common-helpers.spec.ts.snap │ │ ├── date-in-schema.spec.ts.snap │ │ ├── documentation-helpers.spec.ts.snap │ │ ├── documentation.spec.ts.snap │ │ ├── endpoint.spec.ts.snap │ │ ├── endpoints-factory.spec.ts.snap │ │ ├── env.spec.ts.snap │ │ ├── form-schema.spec.ts.snap │ │ ├── index.spec.ts.snap │ │ ├── integration.spec.ts.snap │ │ ├── io-schema.spec.ts.snap │ │ ├── json-schema-helpers.spec.ts.snap │ │ ├── logger-helpers.spec.ts.snap │ │ ├── metadata.spec.ts.snap │ │ ├── migration.spec.ts.snap │ │ ├── result-handler.spec.ts.snap │ │ ├── result-helpers.spec.ts.snap │ │ ├── routing.spec.ts.snap │ │ ├── server-helpers.spec.ts.snap │ │ ├── sse.spec.ts.snap │ │ ├── system.spec.ts.snap │ │ ├── upload-schema.spec.ts.snap │ │ └── zts.spec.ts.snap │ ├── api-response.spec.ts │ ├── buffer-schema.spec.ts │ ├── builtin-logger.spec.ts │ ├── common-helpers.spec.ts │ ├── config-type.spec.ts │ ├── content-type.spec.ts │ ├── date-in-schema.spec.ts │ ├── date-out-schema.spec.ts │ ├── deep-checks.spec.ts │ ├── depends-on-method.spec.ts │ ├── documentation-helpers.spec.ts │ ├── documentation.spec.ts │ ├── endpoint.spec.ts │ ├── endpoints-factory.spec.ts │ ├── env.spec.ts │ ├── errors.spec.ts │ ├── express-mock.ts │ ├── form-schema.spec.ts │ ├── graceful-helpers.spec.ts │ ├── graceful-shutdown.spec.ts │ ├── http-mock.ts │ ├── index.spec.ts │ ├── integration.spec.ts │ ├── io-schema.spec.ts │ ├── json-schema-helpers.spec.ts │ ├── last-resort.spec.ts │ ├── logger-helpers.spec.ts │ ├── logical-container.spec.ts │ ├── metadata.spec.ts │ ├── method.spec.ts │ ├── middleware.spec.ts │ ├── migration.spec.ts │ ├── peer-helpers.spec.ts │ ├── raw-schema.spec.ts │ ├── result-handler.spec.ts │ ├── result-helpers.spec.ts │ ├── routable.spec.ts │ ├── routing.spec.ts │ ├── serve-static.spec.ts │ ├── server-helpers.spec.ts │ ├── server.spec.ts │ ├── sse.spec.ts │ ├── ssl-helpers.ts │ ├── startup-logo.spec.ts │ ├── system.spec.ts │ ├── testing.spec.ts │ ├── upload-schema.spec.ts │ ├── zod-plugin.spec.ts │ └── zts.spec.ts ├── tsconfig.json ├── tsup.config.ts ├── vitest.config.ts └── vitest.setup.ts ├── issue952-test ├── package.json ├── symbols.ts ├── tags.ts └── tsconfig.json ├── logo.svg ├── package.json ├── tools ├── contributors.ts ├── headers.ts ├── license.ts ├── make-tests.ts ├── ports.ts └── tsconfig.json ├── tsconfig.base.json └── yarn.lock /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: RobinTail # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | # patreon: # Replace with a single Patreon username 5 | # open_collective: # Replace with a single Open Collective username 6 | # ko_fi: # Replace with a single Ko-fi username 7 | # tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | # community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | # liberapay: # Replace with a single Liberapay username 10 | # issuehunt: # Replace with a single IssueHunt username 11 | # lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | # polar: # Replace with a single Polar username 13 | buy_me_a_coffee: robintail # Replace with a single Buy Me a Coffee username 14 | # thanks_dev: # Replace with a single thanks.dev username 15 | # custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Something is not working right 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | # Description 11 | 12 | A clear and concise description of what the bug is. 13 | Accompany by screenshots or generated code samples if needed. 14 | 15 | ## Expected 16 | 17 | A clear and concise description of what you expected to happen. 18 | 19 | ## Reproduction 20 | 21 | Please provide the code samples, cURL requests and other assets required to reproduce the bug. 22 | 23 | ## Context 24 | 25 | - Node.js version: #.#.# 26 | - `express-zod-api` version: #.#.# 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Something is missing 4 | title: '' 5 | labels: enhancement, not an issue 6 | assignees: '' 7 | 8 | --- 9 | 10 | Please create a discussion in the Ideas section instead: 11 | https://github.com/RobinTail/express-zod-api/discussions/new?category=ideas 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Something is unclear 4 | title: '' 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | Please create a discussion in the Q&A section instead: 11 | https://github.com/RobinTail/express-zod-api/discussions/categories/q-a 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | open-pull-requests-limit: 10 10 | directory: "/" # Location of package manifests 11 | schedule: 12 | interval: "weekly" 13 | groups: 14 | vitest: 15 | patterns: 16 | - "vitest" 17 | - "@vitest/coverage-v8" 18 | typescript-eslint: 19 | patterns: 20 | - "typescript-eslint" 21 | - "@typescript-eslint/rule-tester" 22 | -------------------------------------------------------------------------------- /.github/workflows/beta.yml: -------------------------------------------------------------------------------- 1 | name: Beta 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | kind: 7 | description: 'Making pre- what?' 8 | required: true 9 | default: 'release' 10 | type: choice 11 | options: 12 | - major 13 | - minor 14 | - patch 15 | - release 16 | 17 | permissions: 18 | contents: write 19 | 20 | jobs: 21 | bumpVersion: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v4 25 | - uses: actions/setup-node@v4 26 | with: 27 | node-version: 20 28 | - uses: fregante/setup-git-user@v2 29 | - run: | 30 | yarn install 31 | yarn install_hooks 32 | - run: yarn workspace express-zod-api version --pre${{ inputs.kind }} --preid beta 33 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master, v20, v21, v22, v23 ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master, v20, v21, v22, v23 ] 20 | schedule: 21 | - cron: '26 8 * * 1' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v4 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v3 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Auto-build attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Auto-build 57 | uses: github/codeql-action/autobuild@v3 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Auto-build fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v3 72 | -------------------------------------------------------------------------------- /.github/workflows/dependencies.yml: -------------------------------------------------------------------------------- 1 | name: Dependencies upgrade 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 0 1 * *" # Runs every 1st day of month at midnight UTC 7 | 8 | permissions: 9 | contents: write # Grants write access to push changes 10 | pull-requests: write # and create PRs 11 | 12 | jobs: 13 | run-bash-and-pr: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v4 19 | 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version: 20 23 | 24 | - name: Install dependencies 25 | run: yarn install 26 | 27 | - name: Upgrade dependencies 28 | run: | 29 | yarn workspace express-zod-api upgrade 30 | git checkout -- express-zod-api/package.json 31 | yarn install 32 | 33 | - name: Branch name 34 | id: create-branch 35 | run: | 36 | BRANCH_NAME="deps-$(date +%Y-%m-%d)" 37 | echo "branch-name=$BRANCH_NAME" >> $GITHUB_OUTPUT 38 | 39 | - name: Create a pull request 40 | uses: peter-evans/create-pull-request@v5 41 | with: 42 | branch: ${{ steps.create-branch.outputs.branch-name }} 43 | commit-message: "Upgrading all dependencies" 44 | title: "Upgrading all dependencies" 45 | body: "This PR contains automated updates generated by the monthly workflow." 46 | labels: "dependencies" 47 | assignees: "@robintail" 48 | reviewers: "@robintail" 49 | -------------------------------------------------------------------------------- /.github/workflows/headers.yml: -------------------------------------------------------------------------------- 1 | name: Headers update 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 0 * * 0" # Runs every Sunday at midnight UTC 7 | 8 | permissions: 9 | contents: write # Grants write access to push changes 10 | pull-requests: write # and create PRs 11 | 12 | jobs: 13 | run-bash-and-pr: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 # to figure out last change to the json file 21 | 22 | - uses: fregante/setup-git-user@v2 23 | 24 | - uses: actions/setup-node@v4 25 | with: 26 | node-version: 20 27 | 28 | - name: Install dependencies 29 | run: yarn install 30 | 31 | - name: Check for new headers on IANA.ORG 32 | run: yarn tsx tools/headers.ts 33 | 34 | - name: Check for changes 35 | id: git-state 36 | run: | 37 | if [ -n "$(git status --porcelain)" ]; then 38 | echo "changes=true" >> $GITHUB_OUTPUT 39 | else 40 | echo "changes=false" >> $GITHUB_OUTPUT 41 | fi 42 | 43 | - name: Create branch, commit, and push changes 44 | id: create-branch 45 | if: steps.git-state.outputs.changes == 'true' 46 | run: | 47 | BRANCH_NAME="headers-update-$(date +%Y%m%d)" 48 | echo "branch-name=$BRANCH_NAME" >> $GITHUB_OUTPUT 49 | 50 | - name: Create a pull request 51 | if: steps.git-state.outputs.changes == 'true' 52 | uses: peter-evans/create-pull-request@v5 53 | with: 54 | base: master 55 | branch: ${{ steps.create-branch.outputs.branch-name }} 56 | commit-message: "Changed well-known headers" 57 | title: "Well-known headers update" 58 | body: "This PR contains automated updates generated by the weekly workflow." 59 | delete-branch: false 60 | -------------------------------------------------------------------------------- /.github/workflows/minor.yml: -------------------------------------------------------------------------------- 1 | name: Minor 2 | 3 | on: workflow_dispatch 4 | 5 | permissions: 6 | contents: write 7 | 8 | jobs: 9 | bumpVersion: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 20 16 | - uses: fregante/setup-git-user@v2 17 | - run: | 18 | yarn install 19 | yarn install_hooks 20 | - run: yarn workspace express-zod-api version --minor 21 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master, v20, v21, v22, v23 ] 9 | pull_request: 10 | branches: [ master, v20, v21, v22, v23 ] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | node-version: [20.9.0, 20.x, 22.0.0, 22.x, 24.0.0, 24.x] 19 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 20 | steps: 21 | - name: Get yarn cache dir 22 | id: yarnCache 23 | run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT 24 | - name: Checkout 25 | uses: actions/checkout@v4 26 | - name: Install Node.js ${{ matrix.node-version }} 27 | id: setup-node 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: ${{ matrix.node-version }} 31 | - name: Cache node modules 32 | uses: actions/cache@v4 33 | env: 34 | cache-name: cache-yarn 35 | with: 36 | path: ${{ steps.yarnCache.outputs.dir }} 37 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }} 38 | - name: Install dependencies 39 | run: yarn install 40 | - name: Lint 41 | run: yarn lint 42 | - name: Unit tests 43 | run: yarn test 44 | - name: Coveralls 45 | uses: coverallsapp/github-action@v2 46 | continue-on-error: true 47 | with: 48 | github-token: ${{ secrets.github_token }} 49 | flag-name: run-${{ matrix.node-version }} 50 | parallel: true 51 | - name: Build 52 | run: yarn build 53 | - name: Example test 54 | run: yarn test:example 55 | - name: CJS test 56 | run: yarn test:cjs 57 | - name: ESM test 58 | run: yarn test:esm 59 | - name: Issue 952 # see https://github.com/RobinTail/express-zod-api/issues/952 60 | run: yarn test:952 61 | report: 62 | needs: build 63 | runs-on: ubuntu-latest 64 | steps: 65 | - name: Coveralls Finished 66 | continue-on-error: true 67 | uses: coverallsapp/github-action@v2 68 | with: 69 | github-token: ${{ secrets.github_token }} 70 | parallel-finished: true 71 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 2 | 3 | name: Publish to NPM 4 | 5 | on: 6 | workflow_dispatch: 7 | inputs: 8 | tag: 9 | description: 'Release channel' 10 | required: true 11 | default: 'latest' 12 | type: choice 13 | options: 14 | - latest 15 | - beta 16 | - prev 17 | jobs: 18 | publish: 19 | runs-on: ubuntu-latest 20 | permissions: 21 | id-token: write 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: actions/setup-node@v4 25 | with: 26 | node-version: 20 27 | registry-url: https://registry.npmjs.org/ 28 | - name: Install dependencies 29 | run: yarn install 30 | - name: Publish with ${{ inputs.tag }} 31 | run: npm publish -w express-zod-api --provenance --tag ${{ inputs.tag }} 32 | env: 33 | NODE_AUTH_TOKEN: ${{secrets.NPM_KEY}} 34 | -------------------------------------------------------------------------------- /.github/workflows/patch.yml: -------------------------------------------------------------------------------- 1 | name: Patch 2 | 3 | on: workflow_dispatch 4 | 5 | permissions: 6 | contents: write 7 | 8 | jobs: 9 | bumpVersion: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 20 16 | - uses: fregante/setup-git-user@v2 17 | - run: | 18 | yarn install 19 | yarn install_hooks 20 | - run: yarn workspace express-zod-api version --patch 21 | -------------------------------------------------------------------------------- /.github/workflows/validations.yml: -------------------------------------------------------------------------------- 1 | name: Validations 2 | 3 | on: 4 | push: 5 | branches: [ master, v20, v21, v22, v23 ] 6 | pull_request: 7 | branches: [ master, v20, v21, v22, v23 ] 8 | 9 | 10 | jobs: 11 | OpenAPI: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | # - name: Validate against OpenAPI 3.1 16 | # uses: char0n/apidom-validate@v1 17 | # with: 18 | # definition-file: example/example.documentation.yaml 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version: 20 22 | - run: yarn install 23 | - run: yarn test:oas 24 | - name: Build distribution 25 | run: yarn workspace express-zod-api build 26 | - name: Build tests 27 | run: yarn postbuild # builds the tests 28 | - name: Pack artifact 29 | run: yarn workspace express-zod-api pack --filename ../compat-test/dist.tgz 30 | - name: Upload artifact 31 | uses: actions/upload-artifact@v4 32 | with: 33 | name: dist 34 | path: compat-test 35 | Compatibility: 36 | name: express@${{matrix.express-version}} 37 | needs: OpenAPI 38 | runs-on: ubuntu-latest 39 | strategy: 40 | matrix: 41 | express-version: [ 5 ] 42 | steps: 43 | - uses: actions/setup-node@v4 44 | with: 45 | node-version: 20 46 | - name: Download artifact 47 | uses: actions/download-artifact@v4 48 | with: 49 | name: dist 50 | - name: Add dependencies 51 | run: | 52 | yarn add express@${{matrix.express-version}} typescript@5.1 http-errors zod 53 | yarn add -D eslint@9.0 typescript-eslint@8.0 vitest tsx 54 | yarn add express-zod-api@./dist.tgz 55 | - name: Run tests 56 | run: | 57 | yarn eslint --fix 58 | yarn vitest 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | *.tgz 4 | migration 5 | yarn-error.log 6 | .idea 7 | .npmrc 8 | coverage 9 | *-test/quick-start.ts 10 | issue952-test/*.d.ts 11 | express-zod-api/*.md 12 | express-zod-api/LICENSE 13 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | yarn precommit 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing guidelines 2 | 3 | Please be aware of the [Code of conduct](CODE_OF_CONDUCT.md). 4 | 5 | ### You have a question or idea 6 | 7 | Your feedback is highly appreciated in [Discussions section](https://github.com/RobinTail/express-zod-api/discussions). 8 | 9 | ### You found a bug 10 | 11 | Please [create a bug issue](https://github.com/RobinTail/express-zod-api/issues/new/choose). 12 | 13 | ### You found a vulnerability or other security issue 14 | 15 | Please refer to [Security policy](SECURITY.md). 16 | 17 | ### You wanna make it yourself 18 | 19 | Which is highly appreciated as well. Consider these steps: 20 | 21 | - Fork the repo, 22 | - Create a new branch in your fork of the repo (don't change `master`), 23 | - Install the dependencies using `yarn`, 24 | - Install the pre-commit hooks using `yarn install_hooks`, 25 | - Make changes, 26 | - Run the tests using `yarn test`, 27 | - In case you wanna run tests from `example` and `*-test` workspaces, run `yarn build` first. 28 | - Commit everything, 29 | - Push your branch into your fork, 30 | - Create a PR between the forks: 31 | - Make sure to allow edits by maintainer, 32 | - Describe the changes (why those changes are required?): 33 | - If you're fixing something, please create the bug issue first and make a reference "Fixes #..."; 34 | - If you're improving something, please make sure your solution is generic. 35 | - If I didn't notice your PR in a week, please mention me in a comment to your PR. 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Anna Bocharova 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /LICENSE-3RD-PARTY.md: -------------------------------------------------------------------------------- 1 | # Third Party Licenses 2 | 3 | Code fragments, ideas and assets created by third parties under the following licenses were used in the production 4 | of this software. 5 | 6 | ## Colossal.flf 7 | 8 | Jonathon - jon@mq.edu.au 9 | 10 | 8 June 1994 11 | 12 | ## zod-to-ts 13 | 14 | https://github.com/sachinraja/zod-to-ts 15 | 16 | MIT License 17 | 18 | Copyright (c) 2021 Sachin Raja 19 | 20 | Permission is hereby granted, free of charge, to any person obtaining a copy 21 | of this software and associated documentation files (the "Software"), to deal 22 | in the Software without restriction, including without limitation the rights 23 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 24 | copies of the Software, and to permit persons to whom the Software is 25 | furnished to do so, subject to the following conditions: 26 | 27 | The above copyright notice and this permission notice shall be included in all 28 | copies or substantial portions of the Software. 29 | 30 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 31 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 32 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 33 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 34 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 35 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 36 | SOFTWARE. 37 | 38 | ## http-terminator 39 | 40 | https://github.com/gajus/http-terminator 41 | 42 | Copyright (c) 2020, Gajus Kuizinas (http://gajus.com/) 43 | All rights reserved. 44 | 45 | Redistribution and use in source and binary forms, with or without 46 | modification, are permitted provided that the following conditions are met: 47 | 48 | - Redistributions of source code must retain the above copyright 49 | notice, this list of conditions and the following disclaimer. 50 | - Redistributions in binary form must reproduce the above copyright 51 | notice, this list of conditions and the following disclaimer in the 52 | documentation and/or other materials provided with the distribution. 53 | - Neither the name of the Gajus Kuizinas (http://gajus.com/) nor the 54 | names of its contributors may be used to endorse or promote products 55 | derived from this software without specific prior written permission. 56 | 57 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 58 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 59 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 60 | DISCLAIMED. IN NO EVENT SHALL ANUARY BE LIABLE FOR ANY 61 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 62 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 63 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 64 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 65 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 66 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 67 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Code name | Release | Supported | 6 | | ------: | :------------ | :------ | :----------------: | 7 | | 24.x.x | Ashley | 06.2025 | :white_check_mark: | 8 | | 23.x.x | Sonia | 04.2025 | :white_check_mark: | 9 | | 22.x.x | Tai | 01.2025 | :white_check_mark: | 10 | | 21.x.x | Kesaria | 11.2024 | :white_check_mark: | 11 | | 20.x.x | Zoey | 06.2024 | :white_check_mark: | 12 | | 19.x.x | Dime | 05.2024 | :white_check_mark: | 13 | | 18.x.x | Victoria | 04.2024 | :x: | 14 | | 17.x.x | Tonya | 02.2024 | :x: | 15 | | 16.x.x | Nina | 12.2023 | :x: | 16 | | 15.x.x | Vika | 12.2023 | :x: | 17 | | 14.x.x | Katie | 10.2023 | :x: | 18 | | 12.x.x | Adriana | 09.2023 | :x: | 19 | | 11.x.x | trans sisters | 06.2023 | :x: | 20 | | 10.x.x | Gisberta | 03.2023 | :x: | 21 | | 9.x.x | Brianna | 03.2023 | :x: | 22 | | 8.x.x | Ariyanna | 09.2022 | :x: | 23 | | 7.x.x | Dmitry | 05.2022 | :x: | 24 | | 6.x.x | Alice | 03.2022 | :x: | 25 | | 5.x.x | Ella | 12.2021 | :x: | 26 | | 4.x.x | Sharlie | 11.2021 | :x: | 27 | | 3.x.x | Xiona | 11.2021 | :x: | 28 | | 2.x.x | | 07.2021 | :x: | 29 | | 1.x.x | | 05.2021 | :x: | 30 | | 0.x.x | | 03.2021 | :x: | 31 | 32 | ## Reporting a Vulnerability 33 | 34 | Found a vulnerability or other security issue? 35 | 36 | Please urgently inform me privately by 37 | [email](https://github.com/RobinTail/express-zod-api/blob/master/express-zod-api/package.json#L14). 38 | 39 | I will try to fix it as soon as possible. 40 | -------------------------------------------------------------------------------- /cjs-test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cjs-test", 3 | "private": true, 4 | "version": "0.0.0", 5 | "scripts": { 6 | "test": "vitest run --globals" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /cjs-test/quick-start.spec.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from "node:child_process"; 2 | 3 | describe("CJS Test", async () => { 4 | const { givePort } = await import("../tools/ports.js"); 5 | let out = ""; 6 | const listener = (chunk: Buffer) => { 7 | out += chunk.toString(); 8 | }; 9 | const quickStart = spawn("tsx", ["quick-start.ts"]); 10 | quickStart.stdout.on("data", listener); 11 | quickStart.stderr.on("data", listener); 12 | const port = givePort("example"); 13 | await vi.waitFor(() => assert(out.includes(`Listening`)), { timeout: 1e4 }); 14 | 15 | afterAll(async () => { 16 | quickStart.stdout.removeListener("data", listener); 17 | quickStart.stderr.removeListener("data", listener); 18 | quickStart.kill(); 19 | await vi.waitFor(() => assert(quickStart.killed), { timeout: 1e4 }); 20 | }); 21 | 22 | afterEach(() => { 23 | console.log(out); 24 | out = ""; 25 | }); 26 | 27 | describe("Quick Start from Readme", () => { 28 | test("Should handle valid GET request", async () => { 29 | const response = await fetch( 30 | `http://localhost:${port}/v1/hello?name=Rick`, 31 | ); 32 | expect(response.status).toBe(200); 33 | const json = await response.json(); 34 | expect(json).toEqual({ 35 | status: "success", 36 | data: { 37 | greetings: "Hello, Rick. Happy coding!", 38 | }, 39 | }); 40 | }); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /cjs-test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json" 3 | } 4 | -------------------------------------------------------------------------------- /compat-test/eslint.config.js: -------------------------------------------------------------------------------- 1 | import parser from "@typescript-eslint/parser"; 2 | import migration from "express-zod-api/migration"; 3 | 4 | export default [ 5 | { languageOptions: { parser }, plugins: { migration } }, 6 | { files: ["**/*.ts"], rules: { "migration/v24": "error" } }, 7 | ]; 8 | -------------------------------------------------------------------------------- /compat-test/migration.spec.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from "node:fs/promises"; 2 | import { describe, test, expect } from "vitest"; 3 | 4 | describe("Migration", () => { 5 | test("should fix the import", async () => { 6 | const fixed = await readFile("./sample.ts", "utf-8"); 7 | expect(fixed).toBe("new Documentation({ });\n"); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /compat-test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "private": true, 4 | "description": "This file is a subject for populating by a CI workflow" 5 | } 6 | -------------------------------------------------------------------------------- /compat-test/quick-start.spec.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from "node:child_process"; 2 | import { 3 | describe, 4 | vi, 5 | assert, 6 | afterAll, 7 | afterEach, 8 | expect, 9 | test, 10 | } from "vitest"; 11 | 12 | describe("ESM Test", async () => { 13 | let out = ""; 14 | const listener = (chunk: Buffer) => { 15 | out += chunk.toString(); 16 | }; 17 | const quickStart = spawn("tsx", ["quick-start.ts"]); 18 | quickStart.stdout.on("data", listener); 19 | quickStart.stderr.on("data", listener); 20 | await vi.waitFor(() => assert(out.includes(`Listening`)), { timeout: 1e4 }); 21 | 22 | afterAll(async () => { 23 | quickStart.stdout.removeListener("data", listener); 24 | quickStart.stderr.removeListener("data", listener); 25 | quickStart.kill(); 26 | await vi.waitFor(() => assert(quickStart.killed), { timeout: 1e4 }); 27 | }); 28 | 29 | afterEach(() => { 30 | console.log(out); 31 | out = ""; 32 | }); 33 | 34 | describe("Quick Start from Readme", () => { 35 | test("Should handle valid GET request", async () => { 36 | const response = await fetch(`http://localhost:8090/v1/hello?name=Rick`); 37 | expect(response.status).toBe(200); 38 | const json = await response.json(); 39 | expect(json).toEqual({ 40 | status: "success", 41 | data: { 42 | greetings: "Hello, Rick. Happy coding!", 43 | }, 44 | }); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /compat-test/sample.ts: -------------------------------------------------------------------------------- 1 | new Documentation({ numericRange: {}, }); 2 | -------------------------------------------------------------------------------- /esm-test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "esm-test", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "test": "vitest run --globals" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /esm-test/quick-start.spec.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from "node:child_process"; 2 | import { givePort } from "../tools/ports"; 3 | 4 | describe("ESM Test", async () => { 5 | let out = ""; 6 | const listener = (chunk: Buffer) => { 7 | out += chunk.toString(); 8 | }; 9 | const quickStart = spawn("tsx", ["quick-start.ts"]); 10 | quickStart.stdout.on("data", listener); 11 | quickStart.stderr.on("data", listener); 12 | const port = givePort("example"); 13 | await vi.waitFor(() => assert(out.includes(`Listening`)), { timeout: 1e4 }); 14 | 15 | afterAll(async () => { 16 | quickStart.stdout.removeListener("data", listener); 17 | quickStart.stderr.removeListener("data", listener); 18 | quickStart.kill(); 19 | await vi.waitFor(() => assert(quickStart.killed), { timeout: 1e4 }); 20 | }); 21 | 22 | afterEach(() => { 23 | console.log(out); 24 | out = ""; 25 | }); 26 | 27 | describe("Quick Start from Readme", () => { 28 | test("Should handle valid GET request", async () => { 29 | const response = await fetch( 30 | `http://localhost:${port}/v1/hello?name=Rick`, 31 | ); 32 | expect(response.status).toBe(200); 33 | const json = await response.json(); 34 | expect(json).toEqual({ 35 | status: "success", 36 | data: { 37 | greetings: "Hello, Rick. Happy coding!", 38 | }, 39 | }); 40 | }); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /esm-test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "module": "ES2022", 5 | "moduleResolution": "Bundler" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /example/__snapshots__/index.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`Example > Client > Issue #2177: should handle path params correctly 1`] = ` 4 | { 5 | "data": { 6 | "createdAt": "2022-01-22T00:00:00.000Z", 7 | "name": "Alan Turing", 8 | }, 9 | "status": "success", 10 | } 11 | `; 12 | 13 | exports[`Example > Client > Should perform the request with a positive response 1`] = ` 14 | { 15 | "data": { 16 | "features": [ 17 | { 18 | "features": [ 19 | { 20 | "title": "Above 180cm", 21 | }, 22 | ], 23 | "title": "Tall", 24 | }, 25 | { 26 | "title": "Young", 27 | }, 28 | { 29 | "features": [ 30 | { 31 | "features": [ 32 | { 33 | "title": "About Typescript", 34 | }, 35 | ], 36 | "title": "Tells funny jokes", 37 | }, 38 | ], 39 | "title": "Cute", 40 | }, 41 | ], 42 | "id": 10, 43 | "name": "John Doe", 44 | }, 45 | "status": "success", 46 | } 47 | `; 48 | 49 | exports[`Example > Negative > GET request should fail on missing input param 1`] = ` 50 | { 51 | "error": { 52 | "message": "id: Invalid input: expected string, received undefined", 53 | }, 54 | "status": "error", 55 | } 56 | `; 57 | 58 | exports[`Example > Negative > PATCH request should fail on schema validation 1`] = ` 59 | { 60 | "error": { 61 | "message": "id: should be greater than or equal to 0", 62 | }, 63 | "status": "error", 64 | } 65 | `; 66 | 67 | exports[`Example > Negative > Should fail to upload if the file is too large 1`] = ` 68 | { 69 | "error": { 70 | "message": "The file is too large", 71 | }, 72 | "status": "error", 73 | } 74 | `; 75 | 76 | exports[`Example > Negative > Should respond with error to the missing static file request 1`] = ` 77 | { 78 | "error": { 79 | "message": "Can not GET /public/missing.svg", 80 | }, 81 | "status": "error", 82 | } 83 | `; 84 | 85 | exports[`Example > Positive > Should accept raw data 0 1`] = ` 86 | { 87 | "data": { 88 | "length": 48687, 89 | }, 90 | "status": "success", 91 | } 92 | `; 93 | 94 | exports[`Example > Positive > Should accept raw data 1 1`] = ` 95 | { 96 | "data": { 97 | "length": 48687, 98 | }, 99 | "status": "success", 100 | } 101 | `; 102 | 103 | exports[`Example > Positive > Should send an image with a correct header 1`] = `"f39beeff92379dc935586d726211c2620be6f879"`; 104 | 105 | exports[`Example > Positive > Should serve static files 1`] = `"f39beeff92379dc935586d726211c2620be6f879"`; 106 | 107 | exports[`Example > Positive > Should stream an image with a correct header 1`] = `"f39beeff92379dc935586d726211c2620be6f879"`; 108 | -------------------------------------------------------------------------------- /example/assets/dataflow.svg: -------------------------------------------------------------------------------- 1 | ../../dataflow.svg -------------------------------------------------------------------------------- /example/assets/docs.yaml: -------------------------------------------------------------------------------- 1 | ../example.documentation.yaml -------------------------------------------------------------------------------- /example/assets/logo.svg: -------------------------------------------------------------------------------- 1 | ../../logo.svg -------------------------------------------------------------------------------- /example/config.ts: -------------------------------------------------------------------------------- 1 | import { BuiltinLogger, createConfig } from "express-zod-api"; 2 | import ui from "swagger-ui-express"; 3 | import createHttpError from "http-errors"; 4 | 5 | export const config = createConfig({ 6 | http: { listen: 8090 }, 7 | upload: { 8 | limits: { fileSize: 51200 }, 9 | limitError: createHttpError(413, "The file is too large"), // affects uploadAvatarEndpoint 10 | }, 11 | compression: true, // affects sendAvatarEndpoint 12 | // third-party middlewares serving their own routes or establishing their own routing besides the API 13 | beforeRouting: ({ app }) => { 14 | app.use( 15 | "/docs", 16 | ui.serve, 17 | ui.setup(null, { swaggerUrl: "/public/docs.yaml" }), 18 | ); 19 | }, 20 | inputSources: { 21 | patch: ["headers", "body", "params"], // affects authMiddleware used by updateUserEndpoint 22 | }, 23 | cors: true, 24 | }); 25 | 26 | // These lines enable .child() and .profile() methods of built-in logger: 27 | declare module "express-zod-api" { 28 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- augmentation 29 | interface LoggerOverrides extends BuiltinLogger {} 30 | } 31 | 32 | // Uncomment these lines when using a custom logger, for example winston: 33 | /* 34 | declare module "express-zod-api" { 35 | interface LoggerOverrides extends winston.Logger {} 36 | } 37 | */ 38 | 39 | // These lines enable constraints on tags 40 | declare module "express-zod-api" { 41 | interface TagOverrides { 42 | users: unknown; 43 | files: unknown; 44 | subscriptions: unknown; 45 | forms: unknown; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /example/endpoints/accept-raw.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v4"; 2 | import { defaultEndpointsFactory, ez } from "express-zod-api"; 3 | 4 | export const rawAcceptingEndpoint = defaultEndpointsFactory.build({ 5 | method: "post", 6 | tag: "files", 7 | input: ez.raw({ 8 | /* the place for additional inputs, like route params, if needed */ 9 | }), 10 | output: z.object({ length: z.int().nonnegative() }), 11 | handler: async ({ input: { raw } }) => ({ 12 | length: raw.length, // input.raw is populated automatically by the corresponding parser 13 | }), 14 | }); 15 | -------------------------------------------------------------------------------- /example/endpoints/create-user.ts: -------------------------------------------------------------------------------- 1 | import createHttpError from "http-errors"; 2 | import assert from "node:assert/strict"; 3 | import { z } from "zod/v4"; 4 | import { statusDependingFactory } from "../factories"; 5 | 6 | const namePart = z.string().regex(/^\w+$/); 7 | 8 | /** @desc depending on the thrown error, the custom result handler of the factory responds slightly differently */ 9 | export const createUserEndpoint = statusDependingFactory.build({ 10 | method: "post", 11 | tag: "users", 12 | input: z.object({ 13 | name: z 14 | .templateLiteral([namePart, " ", namePart]) 15 | .describe("first name and last name") 16 | .example("John Doe"), 17 | }), 18 | output: z.object({ 19 | id: z.int().positive(), 20 | }), 21 | handler: async ({ input: { name } }) => { 22 | assert(name !== "Gimme Jimmy", createHttpError(500, "That went wrong")); 23 | assert( 24 | name !== "James McGill", 25 | createHttpError(409, "That one already exists", { id: 16 }), 26 | ); 27 | return { id: 16 }; 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /example/endpoints/delete-user.ts: -------------------------------------------------------------------------------- 1 | import createHttpError from "http-errors"; 2 | import assert from "node:assert/strict"; 3 | import { z } from "zod/v4"; 4 | import { noContentFactory } from "../factories"; 5 | 6 | /** @desc The endpoint demonstrates no content response established by its factory */ 7 | export const deleteUserEndpoint = noContentFactory.buildVoid({ 8 | method: "delete", 9 | tag: "users", 10 | input: z.object({ 11 | id: z 12 | .string() 13 | .regex(/\d+/) 14 | .transform((id) => parseInt(id, 10)) 15 | .describe("numeric string"), 16 | }), 17 | handler: async ({ input: { id } }) => { 18 | assert(id <= 100, createHttpError(404, "User not found")); 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /example/endpoints/list-users.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v4"; 2 | import { arrayRespondingFactory } from "../factories"; 3 | 4 | /** 5 | * This endpoint demonstrates the ability to respond with array. 6 | * Avoid doing this in new projects. This feature is only for easier migration of legacy APIs. 7 | * */ 8 | export const listUsersEndpoint = arrayRespondingFactory.build({ 9 | tag: "users", 10 | output: z.object({ 11 | // the arrayResultHandler will take the "items" prop as the response 12 | items: z 13 | .array(z.object({ name: z.string() })) 14 | .example([ 15 | { name: "Hunter Schafer" }, 16 | { name: "Laverne Cox" }, 17 | { name: "Patti Harrison" }, 18 | ]), 19 | }), 20 | handler: async () => ({ 21 | items: [ 22 | { name: "Maria Merian" }, 23 | { name: "Mary Anning" }, 24 | { name: "Marie Skłodowska Curie" }, 25 | { name: "Henrietta Leavitt" }, 26 | { name: "Lise Meitner" }, 27 | { name: "Alice Ball" }, 28 | { name: "Gerty Cori" }, 29 | { name: "Helen Taussig" }, 30 | ], 31 | }), 32 | }); 33 | -------------------------------------------------------------------------------- /example/endpoints/retrieve-user.ts: -------------------------------------------------------------------------------- 1 | import createHttpError from "http-errors"; 2 | import assert from "node:assert/strict"; 3 | import { z } from "zod/v4"; 4 | import { defaultEndpointsFactory } from "express-zod-api"; 5 | import { methodProviderMiddleware } from "../middlewares"; 6 | 7 | // Demonstrating circular schemas using z.object() 8 | const feature = z.object({ 9 | title: z.string(), 10 | get features() { 11 | return z.array(feature).optional(); 12 | }, 13 | }); 14 | 15 | export const retrieveUserEndpoint = defaultEndpointsFactory 16 | .addMiddleware(methodProviderMiddleware) 17 | .build({ 18 | tag: "users", 19 | shortDescription: "Retrieves the user.", 20 | description: "Example user retrieval endpoint.", 21 | input: z.object({ 22 | id: z 23 | .string() 24 | .trim() 25 | .regex(/\d+/) 26 | .transform((id) => parseInt(id, 10)) 27 | .describe("a numeric string containing the id of the user"), 28 | }), 29 | output: z.object({ 30 | id: z.int().nonnegative(), 31 | name: z.string(), 32 | features: feature.array(), // @link https://github.com/colinhacks/zod/issues/4592 33 | }), 34 | handler: async ({ input: { id }, options: { method }, logger }) => { 35 | logger.debug(`Requested id: ${id}, method ${method}`); 36 | const name = "John Doe"; 37 | assert(id <= 100, createHttpError(404, "User not found")); 38 | return { 39 | id, 40 | name, 41 | features: [ 42 | { title: "Tall", features: [{ title: "Above 180cm" }] }, 43 | { title: "Young" }, 44 | { 45 | title: "Cute", 46 | features: [ 47 | { 48 | title: "Tells funny jokes", 49 | features: [{ title: "About Typescript" }], 50 | }, 51 | ], 52 | }, 53 | ], 54 | }; 55 | }, 56 | }); 57 | -------------------------------------------------------------------------------- /example/endpoints/send-avatar.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v4"; 2 | import { fileSendingEndpointsFactory } from "../factories"; 3 | import { readFile } from "node:fs/promises"; 4 | 5 | export const sendAvatarEndpoint = fileSendingEndpointsFactory.build({ 6 | shortDescription: "Sends a file content.", 7 | tag: ["files", "users"], 8 | input: z.object({ 9 | userId: z 10 | .string() 11 | .regex(/\d+/) 12 | .transform((str) => parseInt(str, 10)), 13 | }), 14 | output: z.object({ 15 | data: z.string(), 16 | }), 17 | handler: async () => { 18 | const data = await readFile("assets/logo.svg", "utf-8"); 19 | return { data }; 20 | }, 21 | }); 22 | -------------------------------------------------------------------------------- /example/endpoints/stream-avatar.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v4"; 2 | import { fileStreamingEndpointsFactory } from "../factories"; 3 | 4 | export const streamAvatarEndpoint = fileStreamingEndpointsFactory.build({ 5 | shortDescription: "Streams a file content.", 6 | tag: ["users", "files"], 7 | input: z.object({ 8 | userId: z 9 | .string() 10 | .regex(/\d+/) 11 | .transform((str) => parseInt(str, 10)), 12 | }), 13 | output: z.object({ 14 | filename: z.string(), 15 | }), 16 | handler: async () => ({ filename: "assets/logo.svg" }), 17 | }); 18 | -------------------------------------------------------------------------------- /example/endpoints/submit-feedback.ts: -------------------------------------------------------------------------------- 1 | import { defaultEndpointsFactory, ez } from "express-zod-api"; 2 | import { z } from "zod/v4"; 3 | 4 | export const submitFeedbackEndpoint = defaultEndpointsFactory.build({ 5 | method: "post", 6 | tag: "forms", 7 | input: ez.form({ 8 | name: z.string().min(1), 9 | email: z.email(), 10 | message: z.string().min(1), 11 | }), 12 | output: z.object({ 13 | crc: z.int().positive(), 14 | }), 15 | handler: async ({ input: { name, email, message } }) => ({ 16 | crc: [name, email, message].reduce((acc, { length }) => acc + length, 0), 17 | }), 18 | }); 19 | -------------------------------------------------------------------------------- /example/endpoints/time-subscription.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v4"; 2 | import { setTimeout } from "node:timers/promises"; 3 | import { eventsFactory } from "../factories"; 4 | 5 | /** @desc The endpoint demonstrates emitting server-sent events (SSE) */ 6 | export const subscriptionEndpoint = eventsFactory.buildVoid({ 7 | tag: "subscriptions", 8 | input: z.object({ 9 | trigger: z 10 | .string() 11 | .optional() 12 | .deprecated() 13 | .describe("for testing error response"), 14 | }), 15 | handler: async ({ 16 | input: { trigger }, 17 | options: { emit, isClosed }, 18 | logger, 19 | }) => { 20 | if (trigger === "failure") throw new Error("Intentional failure"); 21 | while (!isClosed()) { 22 | logger.debug("emitting"); 23 | emit("time", Date.now()); 24 | await setTimeout(1000); 25 | } 26 | logger.debug("closed"); 27 | }, 28 | }); 29 | -------------------------------------------------------------------------------- /example/endpoints/update-user.ts: -------------------------------------------------------------------------------- 1 | import createHttpError from "http-errors"; 2 | import assert from "node:assert/strict"; 3 | import { z } from "zod/v4"; 4 | import { ez } from "express-zod-api"; 5 | import { keyAndTokenAuthenticatedEndpointsFactory } from "../factories"; 6 | 7 | export const updateUserEndpoint = 8 | keyAndTokenAuthenticatedEndpointsFactory.build({ 9 | tag: "users", 10 | description: "Changes the user record. Example user update endpoint.", 11 | input: z.object({ 12 | // id is the route path param of /v1/user/:id 13 | id: z 14 | .string() 15 | .example("12") // before transformation 16 | .transform((value) => parseInt(value, 10)) 17 | .refine((value) => value >= 0, "should be greater than or equal to 0"), 18 | name: z.string().nonempty().example("John Doe"), 19 | birthday: ez.dateIn({ 20 | description: "the day of birth", 21 | examples: ["1963-04-21"], 22 | }), 23 | }), 24 | output: z.object({ 25 | name: z.string().example("John Doe"), 26 | createdAt: ez.dateOut({ 27 | description: "account creation date", 28 | examples: ["2021-12-31T00:00:00.000Z"], 29 | }), 30 | }), 31 | handler: async ({ 32 | input: { id, name }, 33 | options: { authorized }, // comes from authMiddleware 34 | logger, 35 | }) => { 36 | logger.debug(`${authorized} is changing user #${id}`); 37 | assert(id <= 100, createHttpError(404, "User not found")); 38 | return { 39 | createdAt: new Date("2022-01-22"), 40 | name, 41 | }; 42 | }, 43 | }); 44 | -------------------------------------------------------------------------------- /example/endpoints/upload-avatar.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v4"; 2 | import { defaultEndpointsFactory, ez } from "express-zod-api"; 3 | import { createHash } from "node:crypto"; 4 | 5 | export const uploadAvatarEndpoint = defaultEndpointsFactory.build({ 6 | method: "post", 7 | tag: "files", 8 | description: "Handles a file upload.", 9 | input: z.looseObject({ 10 | avatar: ez.upload(), 11 | }), 12 | output: z.object({ 13 | name: z.string(), 14 | size: z.int().nonnegative(), 15 | mime: z.string(), 16 | hash: z.string(), 17 | otherInputs: z.record(z.string(), z.any()), 18 | }), 19 | handler: async ({ input: { avatar, ...rest } }) => ({ 20 | name: avatar.name, 21 | size: avatar.size, 22 | mime: avatar.mimetype, 23 | hash: createHash("sha1").update(avatar.data).digest("hex"), 24 | otherInputs: rest, 25 | }), 26 | }); 27 | -------------------------------------------------------------------------------- /example/generate-client.ts: -------------------------------------------------------------------------------- 1 | import { writeFile } from "node:fs/promises"; 2 | import { Integration } from "express-zod-api"; 3 | import { routing } from "./routing"; 4 | import { config } from "./config"; 5 | 6 | await writeFile( 7 | "example.client.ts", 8 | await new Integration({ 9 | routing, 10 | serverUrl: `http://localhost:${config.http!.listen}`, 11 | }).printFormatted(), // or just .print(), 12 | "utf-8", 13 | ); 14 | -------------------------------------------------------------------------------- /example/generate-documentation.ts: -------------------------------------------------------------------------------- 1 | import { writeFile } from "node:fs/promises"; 2 | import { Documentation } from "express-zod-api"; 3 | import { config } from "./config"; 4 | import { routing } from "./routing"; 5 | import manifest from "./package.json"; 6 | 7 | await writeFile( 8 | "example.documentation.yaml", 9 | new Documentation({ 10 | routing, 11 | config, 12 | version: manifest.version, 13 | title: "Example API", 14 | serverUrl: "https://example.com", 15 | tags: { 16 | users: "Everything about the users", 17 | files: "Everything about the files processing", 18 | subscriptions: "Everything about the subscriptions", 19 | }, 20 | }).getSpecAsYaml(), 21 | "utf-8", 22 | ); 23 | -------------------------------------------------------------------------------- /example/index.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from "express-zod-api"; 2 | import { config } from "./config"; 3 | import { routing } from "./routing"; 4 | 5 | /** 6 | * "await" is only needed for using entities returned from this method. 7 | * If you can not use await (on the top level of CJS), use IIFE wrapper: 8 | * @example (async () => { await ... })() 9 | * */ 10 | await createServer(config, routing); 11 | -------------------------------------------------------------------------------- /example/middlewares.ts: -------------------------------------------------------------------------------- 1 | import createHttpError from "http-errors"; 2 | import assert from "node:assert/strict"; 3 | import { z } from "zod/v4"; 4 | import { Method, Middleware } from "express-zod-api"; 5 | 6 | export const authMiddleware = new Middleware({ 7 | security: { 8 | and: [ 9 | { type: "input", name: "key" }, 10 | { type: "header", name: "token" }, 11 | ], 12 | }, 13 | input: z.object({ 14 | key: z.string().nonempty().example("1234-5678-90"), 15 | token: z.string().nonempty().example("1234567890"), 16 | }), 17 | handler: async ({ input: { key, token }, logger }) => { 18 | logger.debug(`Key and token: ${key}, ${token}`); 19 | assert.equal(key, "123", createHttpError(401, "Invalid key")); 20 | assert.equal(token, "456", createHttpError(401, "Invalid token")); 21 | return { authorized: "Jane Doe" }; 22 | }, 23 | }); 24 | 25 | export const methodProviderMiddleware = new Middleware({ 26 | handler: async ({ request }) => ({ 27 | method: request.method.toLowerCase() as Method, 28 | }), 29 | }); 30 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "example", 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "start": "tsx index.ts", 8 | "build": "yarn build:docs && yarn build:client", 9 | "build:docs": "tsx generate-documentation.ts", 10 | "build:client": "tsx generate-client.ts", 11 | "pretest": "tsc --noEmit", 12 | "test": "vitest run --globals index.spec.ts", 13 | "validate": "vitest run --globals validate.spec.ts" 14 | }, 15 | "dependencies": { 16 | "swagger-ui-express": "^5.0.0" 17 | }, 18 | "devDependencies": { 19 | "@types/swagger-ui-express": "^4.1.8" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /example/routing.ts: -------------------------------------------------------------------------------- 1 | import { DependsOnMethod, Routing, ServeStatic } from "express-zod-api"; 2 | import { rawAcceptingEndpoint } from "./endpoints/accept-raw"; 3 | import { createUserEndpoint } from "./endpoints/create-user"; 4 | import { deleteUserEndpoint } from "./endpoints/delete-user"; 5 | import { listUsersEndpoint } from "./endpoints/list-users"; 6 | import { submitFeedbackEndpoint } from "./endpoints/submit-feedback"; 7 | import { subscriptionEndpoint } from "./endpoints/time-subscription"; 8 | import { uploadAvatarEndpoint } from "./endpoints/upload-avatar"; 9 | import { retrieveUserEndpoint } from "./endpoints/retrieve-user"; 10 | import { sendAvatarEndpoint } from "./endpoints/send-avatar"; 11 | import { updateUserEndpoint } from "./endpoints/update-user"; 12 | import { streamAvatarEndpoint } from "./endpoints/stream-avatar"; 13 | 14 | export const routing: Routing = { 15 | v1: { 16 | user: { 17 | // syntax 1: methods are defined within the endpoint 18 | retrieve: retrieveUserEndpoint, // path: /v1/user/retrieve 19 | // syntax 2: methods are defined within the route (id is the route path param by the way) 20 | ":id": new DependsOnMethod({ 21 | patch: updateUserEndpoint, // demonstrates authentication 22 | }).nest({ 23 | remove: deleteUserEndpoint, // nested path: /v1/user/:id/remove 24 | }), 25 | // demonstrates different response schemas depending on status code 26 | create: createUserEndpoint, 27 | // this one demonstrates the legacy array based response 28 | list: listUsersEndpoint, 29 | }, 30 | avatar: { 31 | // custom result handler examples with a file serving 32 | send: sendAvatarEndpoint.deprecated(), // demo for deprecated route 33 | stream: streamAvatarEndpoint, 34 | // file upload example 35 | upload: uploadAvatarEndpoint, 36 | // raw body acceptance example 37 | raw: rawAcceptingEndpoint, 38 | }, 39 | // nested flat syntax: 40 | "events/stream": subscriptionEndpoint, 41 | }, 42 | // flat syntax with explicitly specified method: 43 | "post /v1/forms/feedback": submitFeedbackEndpoint, 44 | // path /public serves static files from /assets 45 | public: new ServeStatic("assets", { 46 | dotfiles: "deny", 47 | index: false, 48 | redirect: false, 49 | }), 50 | }; 51 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "module": "ES2022", 5 | "moduleResolution": "Bundler" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /example/validate.spec.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from "node:fs/promises"; 2 | 3 | describe("OpenAPI schema validation", () => { 4 | test("should be valid", async () => { 5 | const data = await readFile("example.documentation.yaml", "utf-8"); 6 | const response = await fetch( 7 | "https://validator.swagger.io/validator/debug", 8 | { 9 | method: "POST", 10 | headers: { "Content-Type": "application/yaml" }, 11 | body: data, 12 | }, 13 | ); 14 | expect(response.status).toBe(200); 15 | const json = await response.json(); 16 | if ( 17 | typeof json === "object" && 18 | json !== null && 19 | "schemaValidationMessages" in json && 20 | Array.isArray(json.schemaValidationMessages) && 21 | json.schemaValidationMessages.length 22 | ) { 23 | console.debug(json); 24 | json.schemaValidationMessages.every(({ level }) => 25 | expect(level).not.toBe("error"), 26 | ); 27 | } 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /express-zod-api/bench/experiment.bench.ts: -------------------------------------------------------------------------------- 1 | import * as R from "ramda"; 2 | import { bench } from "vitest"; 3 | 4 | describe("Experiment for unique elements", () => { 5 | const current = ["one", "two"]; 6 | 7 | bench("set", () => { 8 | return void [...new Set(current).add("null")]; 9 | }); 10 | 11 | bench("R.union", () => { 12 | return void R.union(current, ["null"]); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /express-zod-api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "express-zod-api", 3 | "version": "24.2.2", 4 | "description": "A Typescript framework to help you get an API server up and running with I/O schema validation and custom middlewares in minutes.", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/RobinTail/express-zod-api.git" 9 | }, 10 | "homepage": "https://ez.robintail.cz", 11 | "author": { 12 | "name": "Anna Bocharova", 13 | "url": "https://robintail.cz", 14 | "email": "me@robintail.cz" 15 | }, 16 | "bugs": "https://github.com/RobinTail/express-zod-api/issues", 17 | "funding": "https://github.com/sponsors/RobinTail", 18 | "scripts": { 19 | "build": "tsup", 20 | "postbuild": "yarn pack --filename release.tgz && attw release.tgz && rm release.tgz", 21 | "pretest": "tsc --noEmit", 22 | "test": "vitest run --coverage", 23 | "bench": "vitest bench --run ./bench", 24 | "prepublishOnly": "eslint && yarn test && yarn build", 25 | "prepack": "cp ../*.md ../LICENSE ./", 26 | "postversion": "git push && git push --tags" 27 | }, 28 | "type": "module", 29 | "sideEffects": true, 30 | "main": "dist/index.cjs", 31 | "types": "dist/index.d.ts", 32 | "module": "dist/index.js", 33 | "exports": { 34 | ".": { 35 | "import": { 36 | "types": "./dist/index.d.ts", 37 | "default": "./dist/index.js" 38 | }, 39 | "require": { 40 | "types": "./dist/index.d.cts", 41 | "default": "./dist/index.cjs" 42 | } 43 | }, 44 | "./migration": { 45 | "import": { 46 | "types": "./migration/index.d.ts", 47 | "default": "./migration/index.js" 48 | }, 49 | "require": { 50 | "types": "./migration/index.d.cts", 51 | "default": "./migration/index.cjs" 52 | } 53 | } 54 | }, 55 | "files": [ 56 | "dist", 57 | "migration", 58 | "*.md" 59 | ], 60 | "engines": { 61 | "node": "^20.9.0 || ^22.0.0 || ^24.0.0" 62 | }, 63 | "dependencies": { 64 | "ansis": "^4.1.0", 65 | "node-mocks-http": "^1.17.2", 66 | "openapi3-ts": "^4.4.0", 67 | "ramda": "^0.30.1" 68 | }, 69 | "peerDependencies": { 70 | "@types/compression": "^1.7.5", 71 | "@types/express": "^5.0.0", 72 | "@types/express-fileupload": "^1.5.0", 73 | "@types/http-errors": "^2.0.2", 74 | "compression": "^1.8.0", 75 | "express": "^5.1.0", 76 | "express-fileupload": "^1.5.0", 77 | "http-errors": "^2.0.0", 78 | "typescript": "^5.1.3", 79 | "zod": "^3.25.35" 80 | }, 81 | "peerDependenciesMeta": { 82 | "@types/compression": { 83 | "optional": true 84 | }, 85 | "@types/express": { 86 | "optional": true 87 | }, 88 | "@types/express-fileupload": { 89 | "optional": true 90 | }, 91 | "@types/http-errors": { 92 | "optional": true 93 | }, 94 | "compression": { 95 | "optional": true 96 | }, 97 | "express-fileupload": { 98 | "optional": true 99 | } 100 | }, 101 | "devDependencies": { 102 | "@arethetypeswrong/cli": "^0.18.1", 103 | "@types/cors": "^2.8.18", 104 | "@types/depd": "^1.1.36", 105 | "@types/node-forge": "^1.3.11", 106 | "@types/ramda": "^0.30.0", 107 | "@types/semver": "^7.7.0", 108 | "camelize-ts": "^3.0.0", 109 | "cors": "^2.8.5", 110 | "depd": "^2.0.0", 111 | "node-forge": "^1.3.1", 112 | "semver": "^7.7.2", 113 | "snakify-ts": "^2.3.0", 114 | "tsup": "^8.5.0", 115 | "undici": "^6.19.8" 116 | }, 117 | "keywords": [ 118 | "nodejs", 119 | "api", 120 | "http", 121 | "middleware", 122 | "documentation", 123 | "json", 124 | "express", 125 | "typescript", 126 | "schema", 127 | "server", 128 | "handler", 129 | "swagger", 130 | "documentation-tool", 131 | "openapi", 132 | "schema-validation", 133 | "endpoint", 134 | "openapi-specification", 135 | "swagger-documentation", 136 | "zod", 137 | "validation" 138 | ] 139 | } 140 | -------------------------------------------------------------------------------- /express-zod-api/src/api-response.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v4"; 2 | 3 | export const defaultStatusCodes = { 4 | positive: 200, 5 | negative: 400, 6 | } satisfies Record; 7 | 8 | export type ResponseVariant = keyof typeof defaultStatusCodes; 9 | export const responseVariants = Object.keys( 10 | defaultStatusCodes, 11 | ) as ResponseVariant[]; 12 | 13 | /** @public this is the user facing configuration */ 14 | export interface ApiResponse { 15 | schema: S; 16 | /** @default 200 for a positive and 400 for a negative response */ 17 | statusCode?: number | [number, ...number[]]; 18 | /** 19 | * @example null is for no content, such as 204 and 302 20 | * @default "application/json" 21 | * */ 22 | mimeType?: string | [string, ...string[]] | null; 23 | /** @deprecated use statusCode */ 24 | statusCodes?: never; 25 | /** @deprecated use mimeType */ 26 | mimeTypes?: never; 27 | } 28 | 29 | /** 30 | * @private This is what the framework entities operate 31 | * @see normalize 32 | * */ 33 | export interface NormalizedResponse { 34 | schema: z.ZodType; 35 | statusCodes: [number, ...number[]]; 36 | mimeTypes: [string, ...string[]] | null; 37 | } 38 | -------------------------------------------------------------------------------- /express-zod-api/src/buffer-schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v4"; 2 | 3 | export const ezBufferBrand = Symbol("Buffer"); 4 | 5 | export const buffer = () => 6 | z 7 | .custom((subject) => Buffer.isBuffer(subject), { 8 | error: "Expected Buffer", 9 | }) 10 | .brand(ezBufferBrand as symbol); 11 | -------------------------------------------------------------------------------- /express-zod-api/src/content-type.ts: -------------------------------------------------------------------------------- 1 | export const contentTypes = { 2 | json: "application/json", 3 | upload: "multipart/form-data", 4 | raw: "application/octet-stream", 5 | sse: "text/event-stream", 6 | form: "application/x-www-form-urlencoded", 7 | }; 8 | 9 | export type ContentType = keyof typeof contentTypes; 10 | -------------------------------------------------------------------------------- /express-zod-api/src/date-in-schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v4"; 2 | 3 | export const ezDateInBrand = Symbol("DateIn"); 4 | 5 | export const dateIn = ({ 6 | examples, 7 | ...rest 8 | }: Parameters[0] = {}) => { 9 | const schema = z.union([ 10 | z.iso.date(), 11 | z.iso.datetime(), 12 | z.iso.datetime({ local: true }), 13 | ]); 14 | 15 | return schema 16 | .meta({ examples }) 17 | .transform((str) => new Date(str)) 18 | .pipe(z.date()) 19 | .brand(ezDateInBrand as symbol) 20 | .meta(rest); 21 | }; 22 | -------------------------------------------------------------------------------- /express-zod-api/src/date-out-schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v4"; 2 | 3 | export const ezDateOutBrand = Symbol("DateOut"); 4 | 5 | export const dateOut = (meta: Parameters[0] = {}) => 6 | z 7 | .date() 8 | .transform((date) => date.toISOString()) 9 | .brand(ezDateOutBrand as symbol) 10 | .meta(meta); 11 | -------------------------------------------------------------------------------- /express-zod-api/src/deep-checks.ts: -------------------------------------------------------------------------------- 1 | import type { $ZodType, JSONSchema } from "zod/v4/core"; 2 | import * as R from "ramda"; 3 | import { z } from "zod/v4"; 4 | import { ezBufferBrand } from "./buffer-schema"; 5 | import { ezDateInBrand } from "./date-in-schema"; 6 | import { ezDateOutBrand } from "./date-out-schema"; 7 | import { DeepCheckError } from "./errors"; 8 | import { ezFormBrand } from "./form-schema"; 9 | import { IOSchema } from "./io-schema"; 10 | import { getBrand } from "./metadata"; 11 | import { FirstPartyKind } from "./schema-walker"; 12 | import { ezUploadBrand } from "./upload-schema"; 13 | import { ezRawBrand } from "./raw-schema"; 14 | 15 | interface NestedSchemaLookupProps { 16 | io: "input" | "output"; 17 | condition: (zodSchema: $ZodType) => boolean; 18 | } 19 | 20 | export const findNestedSchema = ( 21 | subject: $ZodType, 22 | { io, condition }: NestedSchemaLookupProps, 23 | ) => 24 | R.tryCatch( 25 | () => { 26 | z.toJSONSchema(subject, { 27 | io, 28 | unrepresentable: "any", 29 | override: ({ zodSchema }) => { 30 | if (condition(zodSchema)) throw new DeepCheckError(zodSchema); // exits early 31 | }, 32 | }); 33 | return undefined; 34 | }, 35 | (err: DeepCheckError) => err.cause, 36 | )(); 37 | 38 | /** not using cycle:"throw" because it also affects parenting objects */ 39 | export const hasCycle = ( 40 | subject: $ZodType, 41 | { io }: Pick, 42 | ) => { 43 | const json = z.toJSONSchema(subject, { 44 | io, 45 | unrepresentable: "any", 46 | override: ({ jsonSchema }) => { 47 | if (typeof jsonSchema.default === "bigint") delete jsonSchema.default; 48 | }, 49 | }); 50 | const stack: unknown[] = [json]; 51 | while (stack.length) { 52 | const entry = stack.shift()!; 53 | if (R.is(Object, entry)) { 54 | if ((entry as JSONSchema.BaseSchema).$ref === "#") return true; 55 | stack.push(...R.values(entry)); 56 | } 57 | if (R.is(Array, entry)) stack.push(...R.values(entry)); 58 | } 59 | return false; 60 | }; 61 | 62 | export const findRequestTypeDefiningSchema = (subject: IOSchema) => 63 | findNestedSchema(subject, { 64 | condition: (schema) => { 65 | const brand = getBrand(schema); 66 | return ( 67 | typeof brand === "symbol" && 68 | [ezUploadBrand, ezRawBrand, ezFormBrand].includes(brand) 69 | ); 70 | }, 71 | io: "input", 72 | }); 73 | 74 | const unsupported: FirstPartyKind[] = [ 75 | "nan", 76 | "symbol", 77 | "map", 78 | "set", 79 | "bigint", 80 | "void", 81 | "promise", 82 | "never", 83 | ]; 84 | 85 | export const findJsonIncompatible = ( 86 | subject: $ZodType, 87 | io: "input" | "output", 88 | ) => 89 | findNestedSchema(subject, { 90 | io, 91 | condition: (zodSchema) => { 92 | const brand = getBrand(zodSchema); 93 | const { type } = zodSchema._zod.def; 94 | if (unsupported.includes(type)) return true; 95 | if (brand === ezBufferBrand) return true; 96 | if (io === "input") { 97 | if (type === "date") return true; 98 | if (brand === ezDateOutBrand) return true; 99 | } 100 | if (io === "output") { 101 | if (brand === ezDateInBrand) return true; 102 | if (brand === ezRawBrand) return true; 103 | if (brand === ezUploadBrand) return true; 104 | } 105 | return false; 106 | }, 107 | }); 108 | -------------------------------------------------------------------------------- /express-zod-api/src/depends-on-method.ts: -------------------------------------------------------------------------------- 1 | import * as R from "ramda"; 2 | import { AbstractEndpoint } from "./endpoint"; 3 | import { Method } from "./method"; 4 | import { Routable } from "./routable"; 5 | 6 | export class DependsOnMethod extends Routable { 7 | readonly #endpoints: ConstructorParameters[0]; 8 | 9 | constructor(endpoints: Partial>) { 10 | super(); 11 | this.#endpoints = endpoints; 12 | } 13 | 14 | /** 15 | * @desc [method, endpoint] 16 | * @internal 17 | * */ 18 | public get entries() { 19 | const nonempty = R.filter( 20 | (pair): pair is [Method, AbstractEndpoint] => Boolean(pair[1]), 21 | Object.entries(this.#endpoints), 22 | ); 23 | return Object.freeze(nonempty); 24 | } 25 | 26 | public override deprecated() { 27 | const deprecatedEndpoints = Object.entries(this.#endpoints).reduce( 28 | (agg, [method, endpoint]) => 29 | Object.assign(agg, { [method]: endpoint.deprecated() }), 30 | {} as ConstructorParameters[0], 31 | ); 32 | return new DependsOnMethod(deprecatedEndpoints) as this; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /express-zod-api/src/diagnostics.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v4"; 2 | import { responseVariants } from "./api-response"; 3 | import { FlatObject, getRoutePathParams } from "./common-helpers"; 4 | import { contentTypes } from "./content-type"; 5 | import { findJsonIncompatible } from "./deep-checks"; 6 | import { AbstractEndpoint } from "./endpoint"; 7 | import { flattenIO } from "./json-schema-helpers"; 8 | import { ActualLogger } from "./logger-helpers"; 9 | 10 | export class Diagnostics { 11 | #verifiedEndpoints = new WeakSet(); 12 | #verifiedPaths = new WeakMap< 13 | AbstractEndpoint, 14 | { flat: ReturnType; paths: string[] } 15 | >(); 16 | 17 | constructor(protected logger: ActualLogger) {} 18 | 19 | public checkSchema(endpoint: AbstractEndpoint, ctx: FlatObject): void { 20 | if (this.#verifiedEndpoints.has(endpoint)) return; 21 | for (const dir of ["input", "output"] as const) { 22 | const stack = [ 23 | z.toJSONSchema(endpoint[`${dir}Schema`], { unrepresentable: "any" }), 24 | ]; 25 | while (stack.length > 0) { 26 | const entry = stack.shift()!; 27 | if (entry.type && entry.type !== "object") 28 | this.logger.warn(`Endpoint ${dir} schema is not object-based`, ctx); 29 | for (const prop of ["allOf", "oneOf", "anyOf"] as const) 30 | if (entry[prop]) stack.push(...entry[prop]); 31 | } 32 | } 33 | if (endpoint.requestType === "json") { 34 | const reason = findJsonIncompatible(endpoint.inputSchema, "input"); 35 | if (reason) { 36 | this.logger.warn( 37 | "The final input schema of the endpoint contains an unsupported JSON payload type.", 38 | Object.assign(ctx, { reason }), 39 | ); 40 | } 41 | } 42 | for (const variant of responseVariants) { 43 | for (const { mimeTypes, schema } of endpoint.getResponses(variant)) { 44 | if (!mimeTypes?.includes(contentTypes.json)) continue; 45 | const reason = findJsonIncompatible(schema, "output"); 46 | if (reason) { 47 | this.logger.warn( 48 | `The final ${variant} response schema of the endpoint contains an unsupported JSON payload type.`, 49 | Object.assign(ctx, { reason }), 50 | ); 51 | } 52 | } 53 | } 54 | this.#verifiedEndpoints.add(endpoint); 55 | } 56 | 57 | public checkPathParams( 58 | path: string, 59 | endpoint: AbstractEndpoint, 60 | ctx: FlatObject, 61 | ): void { 62 | const ref = this.#verifiedPaths.get(endpoint); 63 | if (ref?.paths.includes(path)) return; 64 | const params = getRoutePathParams(path); 65 | if (params.length === 0) return; // next statement can be expensive 66 | const flat = 67 | ref?.flat || 68 | flattenIO( 69 | z.toJSONSchema(endpoint.inputSchema, { 70 | unrepresentable: "any", 71 | io: "input", 72 | }), 73 | ); 74 | for (const param of params) { 75 | if (param in flat.properties) continue; 76 | this.logger.warn( 77 | "The input schema of the endpoint is most likely missing the parameter of the path it's assigned to.", 78 | Object.assign(ctx, { path, param }), 79 | ); 80 | } 81 | if (ref) ref.paths.push(path); 82 | else this.#verifiedPaths.set(endpoint, { flat, paths: [path] }); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /express-zod-api/src/errors.ts: -------------------------------------------------------------------------------- 1 | import type { $ZodType } from "zod/v4/core"; 2 | import { z } from "zod/v4"; 3 | import { getMessageFromError } from "./common-helpers"; 4 | import { OpenAPIContext } from "./documentation-helpers"; 5 | import type { Method } from "./method"; 6 | 7 | /** @desc An error related to the wrong Routing declaration */ 8 | export class RoutingError extends Error { 9 | public override name = "RoutingError"; 10 | public override readonly cause: { method: Method; path: string }; 11 | 12 | constructor(message: string, method: Method, path: string) { 13 | super(message); 14 | this.cause = { method, path }; 15 | } 16 | } 17 | 18 | /** 19 | * @desc An error related to the generating of the documentation 20 | * */ 21 | export class DocumentationError extends Error { 22 | public override name = "DocumentationError"; 23 | public override readonly cause: string; 24 | 25 | constructor( 26 | message: string, 27 | { 28 | method, 29 | path, 30 | isResponse, 31 | }: Pick, 32 | ) { 33 | super(message); 34 | this.cause = `${ 35 | isResponse ? "Response" : "Input" 36 | } schema of an Endpoint assigned to ${method.toUpperCase()} method of ${path} path.`; 37 | } 38 | } 39 | 40 | /** @desc An error related to the input and output schemas declaration */ 41 | export class IOSchemaError extends Error { 42 | public override name = "IOSchemaError"; 43 | } 44 | 45 | export class DeepCheckError extends IOSchemaError { 46 | public override name = "DeepCheckError"; 47 | 48 | constructor(public override readonly cause: $ZodType) { 49 | super("Found", { cause }); 50 | } 51 | } 52 | 53 | /** @desc An error of validating the Endpoint handler's returns against the Endpoint output schema */ 54 | export class OutputValidationError extends IOSchemaError { 55 | public override name = "OutputValidationError"; 56 | 57 | constructor(public override readonly cause: z.ZodError) { 58 | const prefixedPath = new z.ZodError( 59 | cause.issues.map(({ path, ...rest }) => ({ 60 | ...rest, 61 | path: ["output", ...path], 62 | })), 63 | ); 64 | super(getMessageFromError(prefixedPath), { cause }); 65 | } 66 | } 67 | 68 | /** @desc An error of validating the input sources against the Middleware or Endpoint input schema */ 69 | export class InputValidationError extends IOSchemaError { 70 | public override name = "InputValidationError"; 71 | 72 | constructor(public override readonly cause: z.ZodError) { 73 | super(getMessageFromError(cause), { cause }); 74 | } 75 | } 76 | 77 | /** @desc An error related to the execution or incorrect configuration of ResultHandler */ 78 | export class ResultHandlerError extends Error { 79 | public override name = "ResultHandlerError"; 80 | 81 | constructor( 82 | /** @desc The error thrown from ResultHandler */ 83 | public override readonly cause: Error, 84 | /** @desc The error being processed by ResultHandler when it failed */ 85 | public readonly handled?: Error, 86 | ) { 87 | super(getMessageFromError(cause), { cause }); 88 | } 89 | } 90 | 91 | export class MissingPeerError extends Error { 92 | public override name = "MissingPeerError"; 93 | constructor(module: string) { 94 | super( 95 | `Missing peer dependency: ${module}. Please install it to use the feature.`, 96 | ); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /express-zod-api/src/form-schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v4"; 2 | import type { $ZodShape } from "zod/v4/core"; 3 | 4 | export const ezFormBrand = Symbol("Form"); 5 | 6 | /** @desc Accepts an object shape or a custom object schema */ 7 | export const form = (base: S | z.ZodObject) => 8 | (base instanceof z.ZodObject ? base : z.object(base)).brand( 9 | ezFormBrand as symbol, 10 | ); 11 | -------------------------------------------------------------------------------- /express-zod-api/src/graceful-helpers.ts: -------------------------------------------------------------------------------- 1 | import http from "node:http"; 2 | import type { Socket, Server } from "node:net"; 3 | 4 | /** faster implementation than instanceof for using only the checked methods */ 5 | export const hasResponse = ( 6 | socket: Socket, 7 | ): socket is typeof socket & { 8 | _httpMessage: Pick; 9 | } => 10 | "_httpMessage" in socket && 11 | typeof socket._httpMessage === "object" && 12 | socket._httpMessage !== null && 13 | "headersSent" in socket._httpMessage && 14 | typeof socket._httpMessage.headersSent === "boolean" && 15 | "setHeader" in socket._httpMessage && 16 | typeof socket._httpMessage.setHeader === "function"; 17 | 18 | /** 6.88x faster than instanceof */ 19 | export const hasHttpServer = (socket: Socket): boolean => 20 | "server" in socket && 21 | typeof socket.server === "object" && 22 | socket.server !== null && 23 | "close" in socket.server && 24 | typeof socket.server.close === "function"; 25 | 26 | /** 6.30x faster than instanceof TLSSocket */ 27 | export const isEncrypted = (socket: Socket): boolean => 28 | "encrypted" in socket && 29 | typeof socket.encrypted === "boolean" && 30 | socket.encrypted; 31 | 32 | export const weAreClosed: http.RequestListener = ({}, res) => 33 | void (!res.headersSent && res.setHeader("connection", "close")); 34 | 35 | export const closeAsync = (server: Server) => 36 | new Promise( 37 | (resolve, reject) => 38 | void server.close((error) => (error ? reject(error) : resolve())), 39 | ); 40 | -------------------------------------------------------------------------------- /express-zod-api/src/graceful-shutdown.ts: -------------------------------------------------------------------------------- 1 | import http from "node:http"; 2 | import https from "node:https"; 3 | import { setInterval } from "node:timers/promises"; 4 | import type { Socket } from "node:net"; 5 | import type { ActualLogger } from "./logger-helpers"; 6 | import { 7 | closeAsync, 8 | hasHttpServer, 9 | hasResponse, 10 | isEncrypted, 11 | weAreClosed, 12 | } from "./graceful-helpers"; 13 | 14 | export const monitor = ( 15 | servers: Array, 16 | { timeout = 1e3, logger }: { timeout?: number; logger?: ActualLogger } = {}, 17 | ) => { 18 | let pending: Promise[]> | undefined; 19 | const sockets = new Set(); 20 | const destroy = (socket: Socket) => void sockets.delete(socket.destroy()); 21 | 22 | const disconnect = (socket: Socket) => 23 | void (hasResponse(socket) 24 | ? !socket._httpMessage.headersSent && 25 | socket._httpMessage.setHeader("connection", "close") 26 | : /* v8 ignore next -- unreachable */ destroy(socket)); 27 | 28 | const watch = (socket: Socket) => 29 | void (pending 30 | ? /* v8 ignore next -- unstable */ socket.destroy() 31 | : sockets.add(socket.once("close", () => void sockets.delete(socket)))); 32 | 33 | for (const server of servers) // eslint-disable-next-line curly 34 | for (const event of ["connection", "secureConnection"]) 35 | server.on(event, watch); 36 | 37 | const workflow = async () => { 38 | for (const server of servers) server.on("request", weAreClosed); 39 | logger?.info("Graceful shutdown", { sockets: sockets.size, timeout }); 40 | for (const socket of sockets) 41 | if (isEncrypted(socket) || hasHttpServer(socket)) disconnect(socket); 42 | for await (const started of setInterval(10, Date.now())) 43 | if (sockets.size === 0 || Date.now() - started >= timeout) break; 44 | for (const socket of sockets) destroy(socket); 45 | return Promise.allSettled(servers.map(closeAsync)); 46 | }; 47 | 48 | return { sockets, shutdown: () => (pending ??= workflow()) }; 49 | }; 50 | -------------------------------------------------------------------------------- /express-zod-api/src/index.ts: -------------------------------------------------------------------------------- 1 | import "./zod-plugin"; 2 | 3 | export { createConfig } from "./config-type"; 4 | export { 5 | EndpointsFactory, 6 | defaultEndpointsFactory, 7 | arrayEndpointsFactory, 8 | } from "./endpoints-factory"; 9 | export { getMessageFromError } from "./common-helpers"; 10 | export { ensureHttpError } from "./result-helpers"; 11 | export { BuiltinLogger } from "./builtin-logger"; 12 | export { Middleware } from "./middleware"; 13 | export { 14 | ResultHandler, 15 | defaultResultHandler, 16 | arrayResultHandler, 17 | } from "./result-handler"; 18 | export { DependsOnMethod } from "./depends-on-method"; 19 | export { ServeStatic } from "./serve-static"; 20 | export { createServer, attachRouting } from "./server"; 21 | export { Documentation } from "./documentation"; 22 | export { 23 | DocumentationError, 24 | RoutingError, 25 | OutputValidationError, 26 | InputValidationError, 27 | MissingPeerError, 28 | } from "./errors"; 29 | export { testEndpoint, testMiddleware } from "./testing"; 30 | export { Integration } from "./integration"; 31 | export { EventStreamFactory } from "./sse"; 32 | export { getExamples } from "./metadata"; 33 | 34 | export { ez } from "./proprietary-schemas"; 35 | 36 | // Convenience types 37 | export type { Depicter } from "./documentation-helpers"; 38 | export type { Producer } from "./zts-helpers"; 39 | 40 | // Interfaces exposed for augmentation 41 | export type { LoggerOverrides } from "./logger-helpers"; 42 | export type { TagOverrides } from "./common-helpers"; 43 | 44 | // Issues 952, 1182, 1269: Insufficient exports for consumer's declaration 45 | export type { Routing } from "./routing"; 46 | export type { FlatObject } from "./common-helpers"; 47 | export type { Method } from "./method"; 48 | export type { IOSchema } from "./io-schema"; 49 | export type { CommonConfig, AppConfig, ServerConfig } from "./config-type"; 50 | export type { ApiResponse } from "./api-response"; 51 | export type { 52 | BasicSecurity, 53 | BearerSecurity, 54 | CookieSecurity, 55 | HeaderSecurity, 56 | InputSecurity, 57 | OAuth2Security, 58 | OpenIdSecurity, 59 | } from "./security"; 60 | -------------------------------------------------------------------------------- /express-zod-api/src/io-schema.ts: -------------------------------------------------------------------------------- 1 | import * as R from "ramda"; 2 | import { z } from "zod/v4"; 3 | import { AbstractMiddleware } from "./middleware"; 4 | 5 | type Base = object & { [Symbol.iterator]?: never }; 6 | 7 | /** @desc The type allowed on the top level of Middlewares and Endpoints */ 8 | export type IOSchema = z.ZodType; 9 | 10 | /** 11 | * @description intersects input schemas of middlewares and the endpoint 12 | * @since 07.03.2022 former combineEndpointAndMiddlewareInputSchemas() 13 | * @since 05.03.2023 is immutable to metadata 14 | * @since 26.05.2024 uses the regular ZodIntersection 15 | * @since 22.05.2025 does not mix examples in after switching to Zod 4 16 | */ 17 | export const getFinalEndpointInputSchema = < 18 | MIN extends IOSchema, 19 | IN extends IOSchema, 20 | >( 21 | middlewares: AbstractMiddleware[], 22 | input: IN, 23 | ) => 24 | R.pluck("schema", middlewares) 25 | .concat(input) 26 | .reduce((acc, schema) => acc.and(schema)) as z.ZodIntersection; 27 | -------------------------------------------------------------------------------- /express-zod-api/src/json-schema-helpers.ts: -------------------------------------------------------------------------------- 1 | import type { JSONSchema } from "zod/v4/core"; 2 | import * as R from "ramda"; 3 | import { combinations, FlatObject, isObject } from "./common-helpers"; 4 | 5 | const isJsonObjectSchema = ( 6 | subject: JSONSchema.BaseSchema, 7 | ): subject is JSONSchema.ObjectSchema => subject.type === "object"; 8 | 9 | const propsMerger = R.mergeDeepWith((a: unknown, b: unknown) => { 10 | if (Array.isArray(a) && Array.isArray(b)) return R.concat(a, b); 11 | if (a === b) return b; 12 | throw new Error("Can not flatten properties", { cause: { a, b } }); 13 | }); 14 | 15 | const canMerge = R.pipe( 16 | Object.keys, 17 | R.without([ 18 | "type", 19 | "properties", 20 | "required", 21 | "examples", 22 | "description", 23 | "additionalProperties", 24 | ] satisfies Array), 25 | R.isEmpty, 26 | ); 27 | 28 | const nestOptional = R.pair(true); 29 | 30 | export const flattenIO = ( 31 | jsonSchema: JSONSchema.BaseSchema, 32 | mode: "coerce" | "throw" = "coerce", 33 | ) => { 34 | const stack = [R.pair(false, jsonSchema)]; // [isOptional, JSON Schema] 35 | const flat: JSONSchema.ObjectSchema & 36 | Required> = { 37 | type: "object", 38 | properties: {}, 39 | }; 40 | const flatRequired: string[] = []; 41 | while (stack.length) { 42 | const [isOptional, entry] = stack.shift()!; 43 | if (entry.description) flat.description ??= entry.description; 44 | if (entry.allOf) { 45 | stack.push( 46 | ...entry.allOf.map((one) => { 47 | if (mode === "throw" && !(one.type === "object" && canMerge(one))) 48 | throw new Error("Can not merge"); 49 | return R.pair(isOptional, one); 50 | }), 51 | ); 52 | } 53 | if (entry.anyOf) stack.push(...R.map(nestOptional, entry.anyOf)); 54 | if (entry.oneOf) stack.push(...R.map(nestOptional, entry.oneOf)); 55 | if (entry.examples?.length) { 56 | if (isOptional) { 57 | flat.examples = R.concat(flat.examples || [], entry.examples); 58 | } else { 59 | flat.examples = combinations( 60 | flat.examples?.filter(isObject) || [], 61 | entry.examples.filter(isObject), 62 | ([a, b]) => R.mergeDeepRight(a, b), 63 | ); 64 | } 65 | } 66 | if (!isJsonObjectSchema(entry)) continue; 67 | stack.push([isOptional, { examples: pullRequestExamples(entry) }]); 68 | if (entry.properties) { 69 | flat.properties = (mode === "throw" ? propsMerger : R.mergeDeepRight)( 70 | flat.properties, 71 | entry.properties, 72 | ); 73 | if (!isOptional && entry.required) flatRequired.push(...entry.required); 74 | } 75 | if (entry.propertyNames) { 76 | const keys: string[] = []; 77 | if (typeof entry.propertyNames.const === "string") 78 | keys.push(entry.propertyNames.const); 79 | if (entry.propertyNames.enum) { 80 | keys.push( 81 | ...entry.propertyNames.enum.filter((one) => typeof one === "string"), 82 | ); 83 | } 84 | const value = { ...Object(entry.additionalProperties) }; // it can be bool 85 | for (const key of keys) flat.properties[key] ??= value; 86 | if (!isOptional) flatRequired.push(...keys); 87 | } 88 | } 89 | if (flatRequired.length) flat.required = [...new Set(flatRequired)]; 90 | return flat; 91 | }; 92 | 93 | /** @see pullResponseExamples */ 94 | export const pullRequestExamples = (subject: JSONSchema.ObjectSchema) => 95 | Object.entries(subject.properties || {}).reduce( 96 | (acc, [key, { examples = [] }]) => 97 | combinations(acc, examples.map(R.objOf(key)), ([left, right]) => ({ 98 | ...left, 99 | ...right, 100 | })), 101 | [], 102 | ); 103 | -------------------------------------------------------------------------------- /express-zod-api/src/last-resort.ts: -------------------------------------------------------------------------------- 1 | import { Response } from "express"; 2 | import createHttpError, { isHttpError } from "http-errors"; 3 | import { ResultHandlerError } from "./errors"; 4 | import { ActualLogger } from "./logger-helpers"; 5 | import { getPublicErrorMessage } from "./result-helpers"; 6 | 7 | interface LastResortHandlerParams { 8 | error: ResultHandlerError; 9 | logger: ActualLogger; 10 | response: Response; 11 | } 12 | 13 | export const lastResortHandler = ({ 14 | error, 15 | logger, 16 | response, 17 | }: LastResortHandlerParams) => { 18 | logger.error("Result handler failure", error); 19 | const message = getPublicErrorMessage( 20 | createHttpError( 21 | 500, 22 | `An error occurred while serving the result: ${error.message}.` + 23 | (error.handled ? `\nOriginal error: ${error.handled.message}.` : ""), 24 | { expose: isHttpError(error.cause) ? error.cause.expose : false }, // retain the cause exposition setting 25 | ), 26 | ); 27 | response.status(500).type("text/plain").end(message); 28 | }; 29 | -------------------------------------------------------------------------------- /express-zod-api/src/logger-helpers.ts: -------------------------------------------------------------------------------- 1 | import { Ansis, blue, green, hex, red, cyanBright } from "ansis"; 2 | import * as R from "ramda"; 3 | import { isObject } from "./common-helpers"; 4 | 5 | export const styles = { 6 | debug: blue, 7 | info: green, 8 | warn: hex("#FFA500"), 9 | error: red, 10 | ctx: cyanBright, 11 | } satisfies Record; 12 | 13 | const severity = { 14 | debug: 10, 15 | info: 20, 16 | warn: 30, 17 | error: 40, 18 | } satisfies Record; 19 | 20 | export type Severity = keyof typeof severity; 21 | 22 | /** @desc You can use any logger compatible with this type. */ 23 | export type AbstractLogger = Record< 24 | Severity, 25 | (message: string, meta?: any) => any // eslint-disable-line @typescript-eslint/no-explicit-any -- for compatibility 26 | >; 27 | 28 | /** 29 | * @desc Using module augmentation approach you can set the type of the actual logger used 30 | * @example declare module "express-zod-api" { interface LoggerOverrides extends winston.Logger {} } 31 | * @link https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation 32 | * */ 33 | export interface LoggerOverrides {} // eslint-disable-line @typescript-eslint/no-empty-object-type -- for augmentation 34 | 35 | export type ActualLogger = AbstractLogger & LoggerOverrides; 36 | 37 | export const isLoggerInstance = (subject: unknown): subject is AbstractLogger => 38 | isObject(subject) && 39 | Object.keys(severity).some((method) => method in subject); 40 | 41 | export const isSeverity = (subject: PropertyKey): subject is Severity => 42 | subject in severity; 43 | 44 | export const isHidden = (subject: Severity, gate: Severity) => 45 | severity[subject] < severity[gate]; 46 | 47 | /** @link https://tc39.es/ecma402/#table-sanctioned-single-unit-identifiers */ 48 | type TimeUnit = 49 | | "nanosecond" 50 | | "microsecond" 51 | | "millisecond" 52 | | "second" 53 | | "minute"; 54 | 55 | const _makeNumberFormat = (unit: TimeUnit, fraction = 0) => 56 | Intl.NumberFormat(undefined, { 57 | useGrouping: false, 58 | minimumFractionDigits: 0, 59 | maximumFractionDigits: fraction, 60 | style: "unit", 61 | unitDisplay: "long", 62 | unit, 63 | }); 64 | export const makeNumberFormat = R.memoizeWith( 65 | (unit, fraction) => `${unit}${fraction}`, 66 | _makeNumberFormat, 67 | ); 68 | 69 | export const formatDuration = (ms: number) => { 70 | if (ms < 1e-6) return makeNumberFormat("nanosecond", 3).format(ms / 1e-6); 71 | if (ms < 1e-3) return makeNumberFormat("nanosecond").format(ms / 1e-6); 72 | if (ms < 1) return makeNumberFormat("microsecond").format(ms / 1e-3); 73 | if (ms < 1e3) return makeNumberFormat("millisecond").format(ms); 74 | if (ms < 6e4) return makeNumberFormat("second", 2).format(ms / 1e3); 75 | return makeNumberFormat("minute", 2).format(ms / 6e4); 76 | }; 77 | -------------------------------------------------------------------------------- /express-zod-api/src/logical-container.ts: -------------------------------------------------------------------------------- 1 | import * as R from "ramda"; 2 | import { combinations, isObject } from "./common-helpers"; 3 | 4 | type LogicalOr = { or: T[] }; 5 | type LogicalAnd = { and: T[] }; 6 | 7 | export type LogicalContainer = 8 | | LogicalOr> 9 | | LogicalAnd> 10 | | T; 11 | 12 | const isLogicalOr = (subject: LogicalContainer) => 13 | isObject(subject) && "or" in subject; 14 | 15 | const isLogicalAnd = (subject: LogicalContainer) => 16 | isObject(subject) && "and" in subject; 17 | 18 | const isSimple = (entry: LogicalContainer): entry is T => 19 | !isLogicalAnd(entry) && !isLogicalOr(entry); 20 | 21 | type Combination = T[]; 22 | /** @desc OR[ AND[a,b] , AND[b,c] ] */ 23 | export type Alternatives = Array>; 24 | 25 | export const processContainers = ( 26 | containers: LogicalContainer[], 27 | ): Alternatives => { 28 | const simples = R.filter(isSimple, containers); 29 | const ands = R.chain(R.prop("and"), R.filter(isLogicalAnd, containers)); 30 | const [simpleAnds, orsInAnds] = R.partition(isSimple, ands); 31 | const persistent: Combination = R.concat(simples, simpleAnds); 32 | const ors = R.filter(isLogicalOr, containers); 33 | const alternators = R.map(R.prop("or"), R.concat(ors, orsInAnds)); // no chain! 34 | return alternators.reduce( 35 | (acc, entry) => 36 | combinations( 37 | acc, 38 | R.map((opt) => (isSimple(opt) ? [opt] : opt.and), entry), 39 | ([a, b]) => R.concat(a, b), 40 | ), 41 | R.reject(R.isEmpty, [persistent]), 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /express-zod-api/src/mapping-helpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Mapping utils for Zod Runtime Plugin (remap) 3 | * @link https://stackoverflow.com/questions/55454125/typescript-remapping-object-properties-in-typesafe 4 | */ 5 | type TuplesFromObject = { 6 | [P in keyof T]: [P, T[P]]; 7 | }[keyof T]; 8 | 9 | type GetKeyByValue = 10 | TuplesFromObject extends infer TT 11 | ? TT extends [infer P, V] 12 | ? P 13 | : never 14 | : never; 15 | 16 | export type Remap = { 17 | [P in NonNullable]: T[GetKeyByValue]; 18 | }; 19 | 20 | export type Intact = { [K in Exclude]: T[K] }; 21 | -------------------------------------------------------------------------------- /express-zod-api/src/metadata.ts: -------------------------------------------------------------------------------- 1 | import { globalRegistry } from "zod/v4"; 2 | import type { $ZodType } from "zod/v4/core"; 3 | 4 | export const metaSymbol = Symbol.for("express-zod-api"); 5 | 6 | export const getBrand = (subject: $ZodType) => { 7 | const { brand } = subject._zod.bag || {}; 8 | if ( 9 | typeof brand === "symbol" || 10 | typeof brand === "string" || 11 | typeof brand === "number" 12 | ) 13 | return brand; 14 | return undefined; 15 | }; 16 | 17 | /** 18 | * @since zod 3.25.44 19 | * @link https://github.com/colinhacks/zod/pull/4586 20 | * */ 21 | export const getExamples = (subject: $ZodType): ReadonlyArray => { 22 | const { examples, example } = globalRegistry.get(subject) || {}; 23 | if (examples) { 24 | return Array.isArray(examples) 25 | ? examples 26 | : Object.values(examples).map(({ value }) => value); 27 | } 28 | return example === undefined ? [] : [example]; 29 | }; 30 | -------------------------------------------------------------------------------- /express-zod-api/src/method.ts: -------------------------------------------------------------------------------- 1 | import type { IRouter } from "express"; 2 | 3 | export const methods = [ 4 | "get", 5 | "post", 6 | "put", 7 | "delete", 8 | "patch", 9 | ] satisfies Array; 10 | 11 | export type Method = (typeof methods)[number]; 12 | 13 | export type AuxMethod = Extract; 14 | 15 | export const isMethod = (subject: string): subject is Method => 16 | (methods as string[]).includes(subject); 17 | -------------------------------------------------------------------------------- /express-zod-api/src/peer-helpers.ts: -------------------------------------------------------------------------------- 1 | import { MissingPeerError } from "./errors"; 2 | 3 | export const loadPeer = async ( 4 | moduleName: string, 5 | moduleExport: string = "default", 6 | ): Promise => { 7 | try { 8 | return (await import(moduleName))[moduleExport]; 9 | } catch {} 10 | throw new MissingPeerError(moduleName); 11 | }; 12 | -------------------------------------------------------------------------------- /express-zod-api/src/proprietary-schemas.ts: -------------------------------------------------------------------------------- 1 | import { buffer, type ezBufferBrand } from "./buffer-schema"; 2 | import { dateIn, type ezDateInBrand } from "./date-in-schema"; 3 | import { dateOut, type ezDateOutBrand } from "./date-out-schema"; 4 | import { form, type ezFormBrand } from "./form-schema"; 5 | import { raw, type ezRawBrand } from "./raw-schema"; 6 | import { upload, type ezUploadBrand } from "./upload-schema"; 7 | 8 | export const ez = { dateIn, dateOut, form, upload, raw, buffer }; 9 | 10 | export type ProprietaryBrand = 11 | | typeof ezFormBrand 12 | | typeof ezDateInBrand 13 | | typeof ezDateOutBrand 14 | | typeof ezUploadBrand 15 | | typeof ezRawBrand 16 | | typeof ezBufferBrand; 17 | -------------------------------------------------------------------------------- /express-zod-api/src/raw-schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v4"; 2 | import type { $ZodShape } from "zod/v4/core"; 3 | import { buffer } from "./buffer-schema"; 4 | 5 | export const ezRawBrand = Symbol("Raw"); 6 | 7 | const base = z.object({ raw: buffer() }); 8 | type Base = ReturnType>; 9 | 10 | const extended = (extra: S) => 11 | base.extend(extra).brand(ezRawBrand as symbol); 12 | 13 | export function raw(): Base; 14 | export function raw( 15 | extra: S, 16 | ): ReturnType>; 17 | export function raw(extra?: $ZodShape) { 18 | return extra ? extended(extra) : base.brand(ezRawBrand as symbol); 19 | } 20 | 21 | export type RawSchema = Base; 22 | -------------------------------------------------------------------------------- /express-zod-api/src/result-helpers.ts: -------------------------------------------------------------------------------- 1 | import { Request } from "express"; 2 | import createHttpError, { HttpError, isHttpError } from "http-errors"; 3 | import * as R from "ramda"; 4 | import { z } from "zod/v4"; 5 | import type { $ZodObject } from "zod/v4/core"; 6 | import { NormalizedResponse, ResponseVariant } from "./api-response"; 7 | import { 8 | combinations, 9 | FlatObject, 10 | getMessageFromError, 11 | isProduction, 12 | } from "./common-helpers"; 13 | import { InputValidationError, ResultHandlerError } from "./errors"; 14 | import { ActualLogger } from "./logger-helpers"; 15 | import { getExamples } from "./metadata"; 16 | import type { LazyResult, Result } from "./result-handler"; 17 | 18 | export type ResultSchema = 19 | R extends Result ? S : never; 20 | 21 | export type DiscriminatedResult = 22 | | { 23 | output: FlatObject; 24 | error: null; 25 | } 26 | | { 27 | output: null; 28 | error: Error; 29 | }; 30 | 31 | /** @throws ResultHandlerError when Result is an empty array */ 32 | export const normalize = ( 33 | subject: Result | LazyResult, 34 | { 35 | variant, 36 | args, 37 | ...fallback 38 | }: Omit & { 39 | variant: ResponseVariant; 40 | args: A; 41 | }, 42 | ): NormalizedResponse[] => { 43 | if (typeof subject === "function") subject = subject(...args); 44 | if (subject instanceof z.ZodType) return [{ schema: subject, ...fallback }]; 45 | if (Array.isArray(subject) && !subject.length) { 46 | const err = new Error(`At least one ${variant} response schema required.`); 47 | throw new ResultHandlerError(err); 48 | } 49 | return (Array.isArray(subject) ? subject : [subject]).map( 50 | ({ schema, statusCode, mimeType }) => ({ 51 | schema, 52 | statusCodes: 53 | typeof statusCode === "number" 54 | ? [statusCode] 55 | : statusCode || fallback.statusCodes, 56 | mimeTypes: 57 | typeof mimeType === "string" 58 | ? [mimeType] 59 | : mimeType === undefined 60 | ? fallback.mimeTypes 61 | : mimeType, 62 | }), 63 | ); 64 | }; 65 | 66 | export const logServerError = ( 67 | error: HttpError, 68 | logger: ActualLogger, 69 | { url }: Request, 70 | payload: FlatObject | null, 71 | ) => 72 | !error.expose && logger.error("Server side error", { error, url, payload }); 73 | 74 | /** 75 | * @example InputValidationError —> BadRequest(400) 76 | * @example Error —> InternalServerError(500) 77 | * */ 78 | export const ensureHttpError = (error: Error): HttpError => { 79 | if (isHttpError(error)) return error; 80 | return createHttpError( 81 | error instanceof InputValidationError ? 400 : 500, 82 | getMessageFromError(error), 83 | { cause: error.cause || error }, 84 | ); 85 | }; 86 | 87 | export const getPublicErrorMessage = (error: HttpError): string => 88 | isProduction() && !error.expose 89 | ? createHttpError(error.statusCode).message // default message for that code 90 | : error.message; 91 | 92 | /** @see pullRequestExamples */ 93 | export const pullResponseExamples = (subject: T) => 94 | Object.entries(subject._zod.def.shape).reduce( 95 | (acc, [key, schema]) => 96 | combinations( 97 | acc, 98 | getExamples(schema).map(R.objOf(key)), 99 | ([left, right]) => ({ 100 | ...left, 101 | ...right, 102 | }), 103 | ), 104 | [], 105 | ); 106 | -------------------------------------------------------------------------------- /express-zod-api/src/routable.ts: -------------------------------------------------------------------------------- 1 | import { Routing } from "./routing"; 2 | 3 | export abstract class Routable { 4 | /** @desc Marks the route as deprecated (makes a copy of the endpoint) */ 5 | public abstract deprecated(): this; 6 | 7 | /** @desc Enables nested routes within the path assigned to the subject */ 8 | public nest(routing: Routing): Routing { 9 | return Object.assign(routing, { "": this }); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /express-zod-api/src/routing-walker.ts: -------------------------------------------------------------------------------- 1 | import { DependsOnMethod } from "./depends-on-method"; 2 | import { AbstractEndpoint } from "./endpoint"; 3 | import { RoutingError } from "./errors"; 4 | import { isMethod, Method } from "./method"; 5 | import { Routing } from "./routing"; 6 | import { ServeStatic, StaticHandler } from "./serve-static"; 7 | 8 | export type OnEndpoint = ( 9 | endpoint: AbstractEndpoint, 10 | path: string, 11 | method: Method, 12 | ) => void; 13 | 14 | interface RoutingWalkerParams { 15 | routing: Routing; 16 | onEndpoint: OnEndpoint; 17 | onStatic?: (path: string, handler: StaticHandler) => void; 18 | } 19 | 20 | /** @example delete /v1/user/retrieve —> [/v1/user/retrieve, delete] */ 21 | const detachMethod = (subject: string): [string, Method?] => { 22 | const [method, rest] = subject.trim().split(/ (.+)/, 2); 23 | if (rest && isMethod(method)) return [rest, method]; 24 | return [subject]; 25 | }; 26 | 27 | /** Removes whitespace and slashes from the edges of the string */ 28 | const trimPath = (path: string) => 29 | path.trim().split("/").filter(Boolean).join("/"); 30 | 31 | const processEntries = (subject: Routing, parent?: string) => 32 | Object.entries(subject).map<[string, Routing[string], Method?]>( 33 | ([_key, item]) => { 34 | const [segment, method] = detachMethod(_key); 35 | const path = [parent || ""].concat(trimPath(segment) || []).join("/"); 36 | return [path, item, method]; 37 | }, 38 | ); 39 | 40 | const prohibit = (method: Method, path: string) => { 41 | throw new RoutingError( 42 | "Route with explicit method can only be assigned with Endpoint", 43 | method, 44 | path, 45 | ); 46 | }; 47 | 48 | const checkMethodSupported = ( 49 | method: Method, 50 | path: string, 51 | methods?: ReadonlyArray, 52 | ) => { 53 | if (!methods || methods.includes(method)) return; 54 | throw new RoutingError( 55 | `Method ${method} is not supported by the assigned Endpoint.`, 56 | method, 57 | path, 58 | ); 59 | }; 60 | 61 | const checkDuplicate = (method: Method, path: string, visited: Set) => { 62 | const key = `${method} ${path}`; 63 | if (visited.has(key)) 64 | throw new RoutingError("Route has a duplicate", method, path); 65 | visited.add(key); 66 | }; 67 | 68 | export const walkRouting = ({ 69 | routing, 70 | onEndpoint, 71 | onStatic, 72 | }: RoutingWalkerParams) => { 73 | const stack = processEntries(routing); 74 | const visited = new Set(); 75 | while (stack.length) { 76 | const [path, element, explicitMethod] = stack.shift()!; 77 | if (element instanceof AbstractEndpoint) { 78 | if (explicitMethod) { 79 | checkDuplicate(explicitMethod, path, visited); 80 | checkMethodSupported(explicitMethod, path, element.methods); 81 | onEndpoint(element, path, explicitMethod); 82 | } else { 83 | const { methods = ["get"] } = element; 84 | for (const method of methods) { 85 | checkDuplicate(method, path, visited); 86 | onEndpoint(element, path, method); 87 | } 88 | } 89 | } else { 90 | if (explicitMethod) prohibit(explicitMethod, path); 91 | if (element instanceof ServeStatic) { 92 | if (onStatic) element.apply(path, onStatic); 93 | } else if (element instanceof DependsOnMethod) { 94 | for (const [method, endpoint] of element.entries) { 95 | const { methods } = endpoint; 96 | checkDuplicate(method, path, visited); 97 | checkMethodSupported(method, path, methods); 98 | onEndpoint(endpoint, path, method); 99 | } 100 | } else { 101 | stack.unshift(...processEntries(element, path)); 102 | } 103 | } 104 | } 105 | }; 106 | -------------------------------------------------------------------------------- /express-zod-api/src/schema-walker.ts: -------------------------------------------------------------------------------- 1 | import type { $ZodType, $ZodTypeDef } from "zod/v4/core"; 2 | import type { EmptyObject, FlatObject } from "./common-helpers"; 3 | import { getBrand } from "./metadata"; 4 | 5 | export type FirstPartyKind = $ZodTypeDef["type"]; 6 | 7 | export interface NextHandlerInc { 8 | next: (schema: $ZodType) => U; 9 | } 10 | 11 | interface PrevInc { 12 | prev: U; 13 | } 14 | 15 | export type SchemaHandler< 16 | U, 17 | Context extends FlatObject = EmptyObject, 18 | Variant extends "regular" | "each" | "last" = "regular", 19 | > = ( 20 | schema: any, // eslint-disable-line @typescript-eslint/no-explicit-any -- for assignment compatibility 21 | ctx: Context & 22 | (Variant extends "regular" 23 | ? NextHandlerInc 24 | : Variant extends "each" 25 | ? PrevInc 26 | : Context), 27 | ) => U; 28 | 29 | export type HandlingRules< 30 | U, 31 | Context extends FlatObject = EmptyObject, 32 | K extends string | symbol = string | symbol, 33 | > = Partial>>; 34 | 35 | /** @since 10.1.1 calling onEach _after_ handler and giving it the previously achieved result */ 36 | export const walkSchema = < 37 | U extends object, 38 | Context extends FlatObject = EmptyObject, 39 | >( 40 | schema: $ZodType, 41 | { 42 | onEach, 43 | rules, 44 | onMissing, 45 | ctx = {} as Context, 46 | }: { 47 | ctx?: Context; 48 | onEach?: SchemaHandler; 49 | rules: HandlingRules; 50 | onMissing: SchemaHandler; 51 | }, 52 | ): U => { 53 | const brand = getBrand(schema); 54 | const handler = 55 | brand && brand in rules 56 | ? rules[brand as keyof typeof rules] 57 | : rules[schema._zod.def.type]; 58 | const next = (subject: $ZodType) => 59 | walkSchema(subject, { ctx, onEach, rules, onMissing }); 60 | const result = handler 61 | ? handler(schema, { ...ctx, next }) 62 | : onMissing(schema, ctx); 63 | const overrides = onEach && onEach(schema, { prev: result, ...ctx }); 64 | return overrides ? { ...result, ...overrides } : result; 65 | }; 66 | -------------------------------------------------------------------------------- /express-zod-api/src/security.ts: -------------------------------------------------------------------------------- 1 | export interface BasicSecurity { 2 | type: "basic"; 3 | } 4 | 5 | export interface BearerSecurity { 6 | type: "bearer"; 7 | format?: "JWT" | string; 8 | } 9 | 10 | export interface InputSecurity { 11 | type: "input"; 12 | name: K; 13 | } 14 | 15 | export interface HeaderSecurity { 16 | type: "header"; 17 | name: string; 18 | } 19 | 20 | export interface CookieSecurity { 21 | type: "cookie"; 22 | name: string; 23 | } 24 | 25 | /** 26 | * @see https://swagger.io/docs/specification/authentication/openid-connect-discovery/ 27 | * @desc available scopes has to be provided via the specified URL 28 | */ 29 | export interface OpenIdSecurity { 30 | type: "openid"; 31 | url: string; 32 | } 33 | 34 | interface AuthUrl { 35 | /** 36 | * @desc The authorization URL to use for this flow. Can be relative to the API server URL. 37 | * @see https://swagger.io/docs/specification/api-host-and-base-path/ 38 | */ 39 | authorizationUrl: string; 40 | } 41 | 42 | interface TokenUrl { 43 | /** @desc The token URL to use for this flow. Can be relative to the API server URL. */ 44 | tokenUrl: string; 45 | } 46 | 47 | interface RefreshUrl { 48 | /** @desc The URL to be used for obtaining refresh tokens. Can be relative to the API server URL. */ 49 | refreshUrl?: string; 50 | } 51 | 52 | interface Scopes { 53 | /** @desc The available scopes for the OAuth2 security and their short descriptions. Optional. */ 54 | scopes?: Record; 55 | } 56 | 57 | type AuthCodeFlow = AuthUrl & 58 | TokenUrl & 59 | RefreshUrl & 60 | Scopes; 61 | 62 | type ImplicitFlow = AuthUrl & RefreshUrl & Scopes; 63 | type PasswordFlow = TokenUrl & RefreshUrl & Scopes; 64 | type ClientCredFlow = TokenUrl & RefreshUrl & Scopes; 65 | 66 | /** 67 | * @see https://swagger.io/docs/specification/authentication/oauth2/ 68 | */ 69 | export interface OAuth2Security { 70 | type: "oauth2"; 71 | flows?: { 72 | /** @desc Authorization Code flow (previously called accessCode in OpenAPI 2.0) */ 73 | authorizationCode?: AuthCodeFlow; 74 | /** @desc Implicit flow */ 75 | implicit?: ImplicitFlow; 76 | /** @desc Resource Owner Password flow */ 77 | password?: PasswordFlow; 78 | /** @desc Client Credentials flow (previously called application in OpenAPI 2.0) */ 79 | clientCredentials?: ClientCredFlow; 80 | }; 81 | } 82 | 83 | /** 84 | * @desc Middleware security schema descriptor 85 | * @param K is an optional input field used by InputSecurity 86 | * @param S is an optional union of scopes used by OAuth2Security 87 | * */ 88 | export type Security = 89 | | BasicSecurity 90 | | BearerSecurity 91 | | InputSecurity 92 | | HeaderSecurity 93 | | CookieSecurity 94 | | OpenIdSecurity 95 | | OAuth2Security; 96 | -------------------------------------------------------------------------------- /express-zod-api/src/serve-static.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | 3 | type OriginalStatic = typeof express.static; 4 | export type StaticHandler = ReturnType; 5 | 6 | export class ServeStatic { 7 | readonly #params: Parameters; 8 | 9 | constructor(...params: Parameters) { 10 | this.#params = params; 11 | } 12 | 13 | /** @internal */ 14 | public apply( 15 | path: string, 16 | cb: (path: string, handler: StaticHandler) => void, 17 | ): void { 18 | return cb(path, express.static(...this.#params)); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /express-zod-api/src/sse.ts: -------------------------------------------------------------------------------- 1 | import { Response } from "express"; 2 | import { z } from "zod/v4"; 3 | import { EmptySchema, FlatObject } from "./common-helpers"; 4 | import { contentTypes } from "./content-type"; 5 | import { EndpointsFactory } from "./endpoints-factory"; 6 | import { Middleware } from "./middleware"; 7 | import { ResultHandler } from "./result-handler"; 8 | import { 9 | ensureHttpError, 10 | getPublicErrorMessage, 11 | logServerError, 12 | } from "./result-helpers"; 13 | 14 | type EventsMap = Record; 15 | 16 | export interface Emitter extends FlatObject { 17 | /** @desc Returns true when the connection was closed or terminated */ 18 | isClosed: () => boolean; 19 | /** @desc Sends an event to the stream according to the declared schema */ 20 | emit: (event: K, data: z.input) => void; 21 | } 22 | 23 | export const makeEventSchema = (event: string, data: z.ZodType) => 24 | z.object({ 25 | data, 26 | event: z.literal(event), 27 | id: z.string().optional(), 28 | retry: z.int().positive().optional(), 29 | }); 30 | 31 | export const formatEvent = ( 32 | events: E, 33 | event: keyof E, 34 | data: unknown, 35 | ) => 36 | makeEventSchema(String(event), events[event]) 37 | .transform((props) => 38 | [ 39 | `event: ${props.event}`, 40 | `data: ${JSON.stringify(props.data)}`, 41 | "", 42 | "", // empty line: events separator 43 | ].join("\n"), 44 | ) 45 | .parse({ event, data }); 46 | 47 | const headersTimeout = 1e4; // 10s to respond with a status code other than 200 48 | export const ensureStream = (response: Response) => 49 | response.headersSent || 50 | response.writeHead(200, { 51 | connection: "keep-alive", 52 | "content-type": contentTypes.sse, 53 | "cache-control": "no-cache", 54 | }); 55 | 56 | export const makeMiddleware = (events: E) => 57 | new Middleware({ 58 | handler: async ({ response }): Promise> => 59 | setTimeout(() => ensureStream(response), headersTimeout) && { 60 | isClosed: () => response.writableEnded || response.closed, 61 | emit: (event, data) => { 62 | ensureStream(response); 63 | response.write(formatEvent(events, event, data), "utf-8"); 64 | /** 65 | * Issue 2347: flush is the method of compression, it must be called only when compression is enabled 66 | * @link https://github.com/RobinTail/express-zod-api/issues/2347 67 | * */ 68 | response.flush?.(); 69 | }, 70 | }, 71 | }); 72 | 73 | export const makeResultHandler = (events: E) => 74 | new ResultHandler({ 75 | positive: () => { 76 | const [first, ...rest] = Object.entries(events).map(([event, schema]) => 77 | makeEventSchema(event, schema), 78 | ); 79 | return { 80 | mimeType: contentTypes.sse, 81 | schema: rest.length 82 | ? z.discriminatedUnion("event", [first, ...rest]) 83 | : first, 84 | }; 85 | }, 86 | negative: { mimeType: "text/plain", schema: z.string() }, 87 | handler: async ({ response, error, logger, request, input }) => { 88 | if (error) { 89 | const httpError = ensureHttpError(error); 90 | logServerError(httpError, logger, request, input); 91 | if (!response.headersSent) { 92 | response 93 | .status(httpError.statusCode) 94 | .type("text/plain") 95 | .write(getPublicErrorMessage(httpError), "utf-8"); 96 | } 97 | } 98 | response.end(); 99 | }, 100 | }); 101 | 102 | export class EventStreamFactory extends EndpointsFactory< 103 | EmptySchema, 104 | Emitter 105 | > { 106 | constructor(events: E) { 107 | super(makeResultHandler(events)); 108 | this.middlewares = [makeMiddleware(events)]; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /express-zod-api/src/startup-logo.ts: -------------------------------------------------------------------------------- 1 | import { Ansis, gray, hex, italic, whiteBright } from "ansis"; 2 | import { WriteStream } from "node:tty"; 3 | 4 | export const printStartupLogo = (stream: WriteStream) => { 5 | if (stream.columns < 132) return; 6 | const proud = italic("Proudly supports transgender community.".padStart(109)); 7 | const slogan = italic( 8 | "Start your API server with I/O schema validation and custom middlewares in minutes.".padStart( 9 | 109, 10 | ), 11 | ); 12 | const thanks = italic( 13 | "Thank you for choosing Express Zod API for your project.".padStart(132), 14 | ); 15 | const dedicationMessage = italic("for Ashley".padEnd(20)); 16 | 17 | const pink = hex("#F5A9B8"); 18 | const blue = hex("#5BCEFA"); 19 | 20 | const colors = new Array(14) 21 | .fill(blue, 1, 3) 22 | .fill(pink, 3, 5) 23 | .fill(whiteBright, 5, 7) 24 | .fill(pink, 7, 9) 25 | .fill(blue, 9, 12) 26 | .fill(gray, 12, 13); 27 | 28 | const logo = ` 29 | 8888888888 8888888888P 888 d8888 8888888b. 8888888 30 | 888 d88P 888 d88888 888 Y88b 888 31 | 888 d88P 888 d88P888 888 888 888 32 | 8888888 888 888 88888b. 888d888 .d88b. .d8888b .d8888b d88P .d88b. .d88888 d88P 888 888 d88P 888 33 | 888 `Y8bd8P' 888 "88b 888P" d8P Y8b 88K 88K d88P d88""88b d88" 888 d88P 888 8888888P" 888 34 | 888 X88K 888 888 888 88888888 "Y8888b. "Y8888b. d88P 888 888 888 888 d88P 888 888 888 35 | 888 .d8""8b. 888 d88P 888 Y8b. X88 X88 d88P Y88..88P Y88b 888 d8888888888 888 888 36 | 8888888888 888 888 88888P" 888 "Y8888 88888P' 88888P' d8888888888 "Y88P" "Y88888 d88P 888 888 8888888 37 | 888 38 | 888${proud} 39 | ${dedicationMessage}888${slogan} 40 | ${thanks} 41 | `; 42 | 43 | stream.write( 44 | logo 45 | .split("\n") 46 | .map((line, index) => (colors[index] ? colors[index](line) : line)) 47 | .join("\n"), 48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /express-zod-api/src/upload-schema.ts: -------------------------------------------------------------------------------- 1 | import type { UploadedFile } from "express-fileupload"; 2 | import { z } from "zod/v4"; 3 | 4 | export const ezUploadBrand = Symbol("Upload"); 5 | 6 | export const upload = () => 7 | z 8 | .custom( 9 | (subject) => 10 | typeof subject === "object" && 11 | subject !== null && 12 | "name" in subject && 13 | "encoding" in subject && 14 | "mimetype" in subject && 15 | "data" in subject && 16 | "tempFilePath" in subject && 17 | "truncated" in subject && 18 | "size" in subject && 19 | "md5" in subject && 20 | "mv" in subject && 21 | typeof subject.name === "string" && 22 | typeof subject.encoding === "string" && 23 | typeof subject.mimetype === "string" && 24 | Buffer.isBuffer(subject.data) && 25 | typeof subject.tempFilePath === "string" && 26 | typeof subject.truncated === "boolean" && 27 | typeof subject.size === "number" && 28 | typeof subject.md5 === "string" && 29 | typeof subject.mv === "function", 30 | { 31 | error: ({ input }) => ({ 32 | message: `Expected file upload, received ${typeof input}`, 33 | }), 34 | }, 35 | ) 36 | .brand(ezUploadBrand as symbol); 37 | -------------------------------------------------------------------------------- /express-zod-api/src/zts-helpers.ts: -------------------------------------------------------------------------------- 1 | import type ts from "typescript"; 2 | import { FlatObject } from "./common-helpers"; 3 | import { SchemaHandler } from "./schema-walker"; 4 | 5 | export interface ZTSContext extends FlatObject { 6 | isResponse: boolean; 7 | makeAlias: (key: object, produce: () => ts.TypeNode) => ts.TypeNode; 8 | } 9 | 10 | export type Producer = SchemaHandler; 11 | -------------------------------------------------------------------------------- /express-zod-api/tests/__snapshots__/api-response.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`ApiResponse > defaultStatusCodes > should be 200 and 400 1`] = ` 4 | { 5 | "negative": 400, 6 | "positive": 200, 7 | } 8 | `; 9 | 10 | exports[`ApiResponse > responseVariants > should consist of positive and negative 1`] = ` 11 | [ 12 | "positive", 13 | "negative", 14 | ] 15 | `; 16 | -------------------------------------------------------------------------------- /express-zod-api/tests/__snapshots__/builtin-logger.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`BuiltinLogger > .child() > should create a child logger 0 1`] = ` 4 | [ 5 | [ 6 | "2022-01-01T00:00:00.000Z some id info: Here is some message { more: 'information' } { extra: 'data' }", 7 | ], 8 | ] 9 | `; 10 | 11 | exports[`BuiltinLogger > .child() > should create a child logger 0 2`] = ` 12 | [ 13 | [ 14 | "2022-01-01T00:00:00.000Z some id info: Here is some message { more: 'information' } { extra: 'data' }", 15 | ], 16 | ] 17 | `; 18 | 19 | exports[`BuiltinLogger > .child() > should create a child logger 1 1`] = ` 20 | [ 21 | [ 22 | "2022-01-01T00:00:00.000Z simple info: Here is some message { more: 'information' }", 23 | ], 24 | ] 25 | `; 26 | 27 | exports[`BuiltinLogger > .child() > should create a child logger 1 2`] = ` 28 | [ 29 | [ 30 | "2022-01-01T00:00:00.000Z simple info: Here is some message { more: 'information' }", 31 | ], 32 | ] 33 | `; 34 | 35 | exports[`BuiltinLogger > constructor() > Should create debug logger 0 1`] = ` 36 | [ 37 | [ 38 | "2022-01-01T00:00:00.000Z debug: testing debug message { withColorful: 'output' }", 39 | ], 40 | ] 41 | `; 42 | 43 | exports[`BuiltinLogger > constructor() > Should create debug logger 1 1`] = ` 44 | [ 45 | [ 46 | "2022-01-01T00:00:00.000Z info: testing debug message { withColorful: 'output' }", 47 | ], 48 | ] 49 | `; 50 | 51 | exports[`BuiltinLogger > constructor() > Should create debug logger 2 1`] = ` 52 | [ 53 | [ 54 | "2022-01-01T00:00:00.000Z warn: testing debug message { withColorful: 'output' }", 55 | ], 56 | ] 57 | `; 58 | 59 | exports[`BuiltinLogger > constructor() > Should create debug logger 3 1`] = ` 60 | [ 61 | [ 62 | "2022-01-01T00:00:00.000Z error: testing debug message { withColorful: 'output' }", 63 | ], 64 | ] 65 | `; 66 | 67 | exports[`BuiltinLogger > constructor() > Should create warn logger 1`] = ` 68 | [ 69 | [ 70 | "2022-01-01T00:00:00.000Z warn: testing warn message { withMeta: true }", 71 | ], 72 | ] 73 | `; 74 | 75 | exports[`BuiltinLogger > constructor() > Should handle array 0 1`] = ` 76 | [ 77 | [ 78 | "2022-01-01T00:00:00.000Z error: Array [ 'test' ]", 79 | ], 80 | ] 81 | `; 82 | 83 | exports[`BuiltinLogger > constructor() > Should handle array 1 1`] = ` 84 | [ 85 | [ 86 | "2022-01-01T00:00:00.000Z error: Array [ 'test' ]", 87 | ], 88 | ] 89 | `; 90 | 91 | exports[`BuiltinLogger > constructor() > Should handle circular references within subject 0 1`] = ` 92 | [ 93 | [ 94 | "2022-01-01T00:00:00.000Z error: Recursive { 95 | a: [ [Circular *1] ], 96 | b: { inner: [Circular *2], obj: [Circular *1] } 97 | }", 98 | ], 99 | ] 100 | `; 101 | 102 | exports[`BuiltinLogger > constructor() > Should handle circular references within subject 1 1`] = ` 103 | [ 104 | [ 105 | "2022-01-01T00:00:00.000Z error: Recursive { a: [ [Circular *1] ], b: { inner: [Circular *2], obj: [Circular *1] } }", 106 | ], 107 | ] 108 | `; 109 | 110 | exports[`BuiltinLogger > constructor() > Should handle empty object meta 0 1`] = ` 111 | [ 112 | [ 113 | "2022-01-01T00:00:00.000Z error: Payload {}", 114 | ], 115 | ] 116 | `; 117 | 118 | exports[`BuiltinLogger > constructor() > Should handle empty object meta 1 1`] = ` 119 | [ 120 | [ 121 | "2022-01-01T00:00:00.000Z error: Payload {}", 122 | ], 123 | ] 124 | `; 125 | 126 | exports[`BuiltinLogger > constructor() > Should handle non-object meta 0 1`] = ` 127 | [ 128 | [ 129 | "2022-01-01T00:00:00.000Z error: Code 8090", 130 | ], 131 | ] 132 | `; 133 | 134 | exports[`BuiltinLogger > constructor() > Should handle non-object meta 1 1`] = ` 135 | [ 136 | [ 137 | "2022-01-01T00:00:00.000Z error: Code 8090", 138 | ], 139 | ] 140 | `; 141 | -------------------------------------------------------------------------------- /express-zod-api/tests/__snapshots__/common-helpers.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`Common Helpers > defaultInputSources > should be declared in a certain way 1`] = ` 4 | { 5 | "delete": [ 6 | "query", 7 | "params", 8 | ], 9 | "get": [ 10 | "query", 11 | "params", 12 | ], 13 | "patch": [ 14 | "body", 15 | "params", 16 | ], 17 | "post": [ 18 | "body", 19 | "params", 20 | "files", 21 | ], 22 | "put": [ 23 | "body", 24 | "params", 25 | ], 26 | } 27 | `; 28 | 29 | exports[`Common Helpers > getMessageFromError() > should compile a string from ZodError 1`] = `"user.id: expected number, got string; user.name: expected string, got number"`; 30 | 31 | exports[`Common Helpers > getMessageFromError() > should handle empty path in ZodIssue 1`] = `"Top level refinement issue"`; 32 | 33 | exports[`Common Helpers > getMessageFromError() > should pass message from other error types 1`] = `"something went wrong"`; 34 | 35 | exports[`Common Helpers > getMessageFromError() > should pass message from other error types 2`] = `"something went wrong"`; 36 | 37 | exports[`Common Helpers > makeCleanId() > should generate valid identifier from the supplied strings 0 1`] = `"Get"`; 38 | 39 | exports[`Common Helpers > makeCleanId() > should generate valid identifier from the supplied strings 1 1`] = `"PostSomething"`; 40 | 41 | exports[`Common Helpers > makeCleanId() > should generate valid identifier from the supplied strings 2 1`] = `"DeleteUserPermanently"`; 42 | 43 | exports[`Common Helpers > makeCleanId() > should generate valid identifier from the supplied strings 3 1`] = `"PatchUserAffiliatedAccount"`; 44 | 45 | exports[`Common Helpers > makeCleanId() > should generate valid identifier from the supplied strings 4 1`] = `"PutAssetsIntoStorageIdentifier"`; 46 | 47 | exports[`Common Helpers > makeCleanId() > should generate valid identifier from the supplied strings 5 1`] = `"GetFlightDetailsFromToSeatId"`; 48 | 49 | exports[`Common Helpers > makeCleanId() > should generate valid identifier from the supplied strings 6 1`] = `"GetCompanysCompanyIdUsersUserId"`; 50 | -------------------------------------------------------------------------------- /express-zod-api/tests/__snapshots__/endpoint.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`Endpoint > #handleResult > Should handle errors within ResultHandler 1`] = ` 4 | [ 5 | [ 6 | "Result handler failure", 7 | ResultHandlerError({ 8 | "cause": AssertionError({ 9 | "message": "Something unexpected happened", 10 | }), 11 | "message": "Something unexpected happened", 12 | }), 13 | ], 14 | ] 15 | `; 16 | 17 | exports[`Endpoint > #parseOutput > Should throw on output validation failure 1`] = ` 18 | { 19 | "error": { 20 | "message": "output.email: Invalid email address", 21 | }, 22 | "status": "error", 23 | } 24 | `; 25 | 26 | exports[`Endpoint > .getResponses() > should return the negative responses (readonly) 1`] = ` 27 | [ 28 | { 29 | "mimeTypes": [ 30 | "application/json", 31 | ], 32 | "schema": { 33 | "$schema": "https://json-schema.org/draft/2020-12/schema", 34 | "additionalProperties": false, 35 | "examples": [ 36 | { 37 | "error": { 38 | "message": "Sample error message", 39 | }, 40 | "status": "error", 41 | }, 42 | ], 43 | "properties": { 44 | "error": { 45 | "additionalProperties": false, 46 | "properties": { 47 | "message": { 48 | "type": "string", 49 | }, 50 | }, 51 | "required": [ 52 | "message", 53 | ], 54 | "type": "object", 55 | }, 56 | "status": { 57 | "const": "error", 58 | "type": "string", 59 | }, 60 | }, 61 | "required": [ 62 | "status", 63 | "error", 64 | ], 65 | "type": "object", 66 | }, 67 | "statusCodes": [ 68 | 400, 69 | ], 70 | }, 71 | ] 72 | `; 73 | 74 | exports[`Endpoint > .getResponses() > should return the positive responses (readonly) 1`] = ` 75 | [ 76 | { 77 | "mimeTypes": [ 78 | "application/json", 79 | ], 80 | "schema": { 81 | "$schema": "https://json-schema.org/draft/2020-12/schema", 82 | "additionalProperties": false, 83 | "properties": { 84 | "data": { 85 | "additionalProperties": false, 86 | "properties": { 87 | "something": { 88 | "type": "number", 89 | }, 90 | }, 91 | "required": [ 92 | "something", 93 | ], 94 | "type": "object", 95 | }, 96 | "status": { 97 | "const": "success", 98 | "type": "string", 99 | }, 100 | }, 101 | "required": [ 102 | "status", 103 | "data", 104 | ], 105 | "type": "object", 106 | }, 107 | "statusCodes": [ 108 | 200, 109 | ], 110 | }, 111 | ] 112 | `; 113 | 114 | exports[`Endpoint > Issue #585: Handling non-Error exceptions > thrown in #handleResult() 1`] = ` 115 | [ 116 | [ 117 | "Result handler failure", 118 | ResultHandlerError({ 119 | "cause": AssertionError({ 120 | "message": "Something unexpected happened", 121 | }), 122 | "message": "Something unexpected happened", 123 | }), 124 | ], 125 | ] 126 | `; 127 | -------------------------------------------------------------------------------- /express-zod-api/tests/__snapshots__/form-schema.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`ez.form() > parsing > should throw for missing props as a regular object schema 1`] = ` 4 | ZodError({ 5 | "issues": [ 6 | { 7 | "code": "invalid_type", 8 | "expected": "string", 9 | "message": "Invalid input: expected string, received undefined", 10 | "path": [ 11 | "name", 12 | ], 13 | }, 14 | ], 15 | }) 16 | `; 17 | -------------------------------------------------------------------------------- /express-zod-api/tests/__snapshots__/index.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`Index Entrypoint > exports > BuiltinLogger should have certain value 1`] = `[Function]`; 4 | 5 | exports[`Index Entrypoint > exports > DependsOnMethod should have certain value 1`] = `[Function]`; 6 | 7 | exports[`Index Entrypoint > exports > Documentation should have certain value 1`] = `[Function]`; 8 | 9 | exports[`Index Entrypoint > exports > DocumentationError should have certain value 1`] = `[Function]`; 10 | 11 | exports[`Index Entrypoint > exports > EndpointsFactory should have certain value 1`] = `[Function]`; 12 | 13 | exports[`Index Entrypoint > exports > EventStreamFactory should have certain value 1`] = `[Function]`; 14 | 15 | exports[`Index Entrypoint > exports > InputValidationError should have certain value 1`] = `[Function]`; 16 | 17 | exports[`Index Entrypoint > exports > Integration should have certain value 1`] = `[Function]`; 18 | 19 | exports[`Index Entrypoint > exports > Middleware should have certain value 1`] = `[Function]`; 20 | 21 | exports[`Index Entrypoint > exports > MissingPeerError should have certain value 1`] = `[Function]`; 22 | 23 | exports[`Index Entrypoint > exports > OutputValidationError should have certain value 1`] = `[Function]`; 24 | 25 | exports[`Index Entrypoint > exports > ResultHandler should have certain value 1`] = `[Function]`; 26 | 27 | exports[`Index Entrypoint > exports > RoutingError should have certain value 1`] = `[Function]`; 28 | 29 | exports[`Index Entrypoint > exports > ServeStatic should have certain value 1`] = `[Function]`; 30 | 31 | exports[`Index Entrypoint > exports > arrayEndpointsFactory should have certain value 1`] = ` 32 | EndpointsFactory { 33 | "middlewares": [], 34 | "resultHandler": ResultHandler {}, 35 | "use": [Function], 36 | } 37 | `; 38 | 39 | exports[`Index Entrypoint > exports > arrayResultHandler should have certain value 1`] = `ResultHandler {}`; 40 | 41 | exports[`Index Entrypoint > exports > attachRouting should have certain value 1`] = `[Function]`; 42 | 43 | exports[`Index Entrypoint > exports > createConfig should have certain value 1`] = `[Function]`; 44 | 45 | exports[`Index Entrypoint > exports > createServer should have certain value 1`] = `[Function]`; 46 | 47 | exports[`Index Entrypoint > exports > defaultEndpointsFactory should have certain value 1`] = ` 48 | EndpointsFactory { 49 | "middlewares": [], 50 | "resultHandler": ResultHandler {}, 51 | "use": [Function], 52 | } 53 | `; 54 | 55 | exports[`Index Entrypoint > exports > defaultResultHandler should have certain value 1`] = `ResultHandler {}`; 56 | 57 | exports[`Index Entrypoint > exports > ensureHttpError should have certain value 1`] = `[Function]`; 58 | 59 | exports[`Index Entrypoint > exports > ez should have certain value 1`] = ` 60 | { 61 | "buffer": [Function], 62 | "dateIn": [Function], 63 | "dateOut": [Function], 64 | "form": [Function], 65 | "raw": [Function], 66 | "upload": [Function], 67 | } 68 | `; 69 | 70 | exports[`Index Entrypoint > exports > getExamples should have certain value 1`] = `[Function]`; 71 | 72 | exports[`Index Entrypoint > exports > getMessageFromError should have certain value 1`] = `[Function]`; 73 | 74 | exports[`Index Entrypoint > exports > should have certain entities exposed 1`] = ` 75 | [ 76 | "createConfig", 77 | "EndpointsFactory", 78 | "defaultEndpointsFactory", 79 | "arrayEndpointsFactory", 80 | "getMessageFromError", 81 | "ensureHttpError", 82 | "BuiltinLogger", 83 | "Middleware", 84 | "ResultHandler", 85 | "defaultResultHandler", 86 | "arrayResultHandler", 87 | "DependsOnMethod", 88 | "ServeStatic", 89 | "createServer", 90 | "attachRouting", 91 | "Documentation", 92 | "DocumentationError", 93 | "RoutingError", 94 | "OutputValidationError", 95 | "InputValidationError", 96 | "MissingPeerError", 97 | "testEndpoint", 98 | "testMiddleware", 99 | "Integration", 100 | "EventStreamFactory", 101 | "getExamples", 102 | "ez", 103 | ] 104 | `; 105 | 106 | exports[`Index Entrypoint > exports > testEndpoint should have certain value 1`] = `[Function]`; 107 | 108 | exports[`Index Entrypoint > exports > testMiddleware should have certain value 1`] = `[Function]`; 109 | -------------------------------------------------------------------------------- /express-zod-api/tests/__snapshots__/json-schema-helpers.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`JSON Schema helpers > flattenIO() > should handle records 1`] = ` 4 | { 5 | "examples": [ 6 | { 7 | "one": "test", 8 | "two": "jest", 9 | }, 10 | { 11 | "one": "some", 12 | "two": "another", 13 | }, 14 | { 15 | "four": 456, 16 | "three": 123, 17 | }, 18 | ], 19 | "properties": { 20 | "four": { 21 | "type": "number", 22 | }, 23 | "one": { 24 | "type": "string", 25 | }, 26 | "three": { 27 | "type": "number", 28 | }, 29 | "two": { 30 | "type": "string", 31 | }, 32 | }, 33 | "type": "object", 34 | } 35 | `; 36 | 37 | exports[`JSON Schema helpers > flattenIO() > should pass the object schema through 1`] = ` 38 | { 39 | "examples": [ 40 | { 41 | "one": "test", 42 | }, 43 | ], 44 | "properties": { 45 | "one": { 46 | "type": "string", 47 | }, 48 | }, 49 | "required": [ 50 | "one", 51 | ], 52 | "type": "object", 53 | } 54 | `; 55 | 56 | exports[`JSON Schema helpers > flattenIO() > should pull examples up from object schema props 1`] = ` 57 | { 58 | "examples": [ 59 | { 60 | "one": "test", 61 | "two": 123, 62 | }, 63 | { 64 | "one": "jest", 65 | "two": 123, 66 | }, 67 | ], 68 | "properties": { 69 | "one": { 70 | "examples": [ 71 | "test", 72 | "jest", 73 | ], 74 | "type": "string", 75 | }, 76 | "two": { 77 | "examples": [ 78 | 123, 79 | ], 80 | "type": "number", 81 | }, 82 | }, 83 | "required": [ 84 | "one", 85 | "two", 86 | ], 87 | "type": "object", 88 | } 89 | `; 90 | 91 | exports[`JSON Schema helpers > flattenIO() > should return object schema for the intersection of object schemas 1`] = ` 92 | { 93 | "examples": [ 94 | { 95 | "one": "test", 96 | "two": "jest", 97 | }, 98 | ], 99 | "properties": { 100 | "one": { 101 | "type": "string", 102 | }, 103 | "two": { 104 | "type": "number", 105 | }, 106 | }, 107 | "required": [ 108 | "one", 109 | "two", 110 | ], 111 | "type": "object", 112 | } 113 | `; 114 | 115 | exports[`JSON Schema helpers > flattenIO() > should return object schema for the union of object schemas 1`] = ` 116 | { 117 | "examples": [ 118 | { 119 | "one": "test", 120 | }, 121 | { 122 | "two": "jest", 123 | }, 124 | ], 125 | "properties": { 126 | "one": { 127 | "type": "string", 128 | }, 129 | "two": { 130 | "type": "number", 131 | }, 132 | }, 133 | "type": "object", 134 | } 135 | `; 136 | 137 | exports[`JSON Schema helpers > flattenIO() > should use top level examples of the intersection 1`] = ` 138 | { 139 | "examples": [ 140 | { 141 | "one": "test", 142 | "two": "jest", 143 | }, 144 | ], 145 | "properties": { 146 | "one": { 147 | "type": "string", 148 | }, 149 | "two": { 150 | "type": "number", 151 | }, 152 | }, 153 | "required": [ 154 | "one", 155 | "two", 156 | ], 157 | "type": "object", 158 | } 159 | `; 160 | -------------------------------------------------------------------------------- /express-zod-api/tests/__snapshots__/logger-helpers.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`Logger helpers > formatDuration() > 0 should format 1e-9 ms 1`] = `"0.001 nanoseconds"`; 4 | 5 | exports[`Logger helpers > formatDuration() > 1 should format 1e-8 ms 1`] = `"0.01 nanoseconds"`; 6 | 7 | exports[`Logger helpers > formatDuration() > 2 should format 1e-7 ms 1`] = `"0.1 nanoseconds"`; 8 | 9 | exports[`Logger helpers > formatDuration() > 3 should format 0.000001 ms 1`] = `"1 nanosecond"`; 10 | 11 | exports[`Logger helpers > formatDuration() > 4 should format 0.00001 ms 1`] = `"10 nanoseconds"`; 12 | 13 | exports[`Logger helpers > formatDuration() > 5 should format 0.0001 ms 1`] = `"100 nanoseconds"`; 14 | 15 | exports[`Logger helpers > formatDuration() > 6 should format 0.001 ms 1`] = `"1 microsecond"`; 16 | 17 | exports[`Logger helpers > formatDuration() > 7 should format 0.01 ms 1`] = `"10 microseconds"`; 18 | 19 | exports[`Logger helpers > formatDuration() > 8 should format 0.1 ms 1`] = `"100 microseconds"`; 20 | 21 | exports[`Logger helpers > formatDuration() > 9 should format 1 ms 1`] = `"1 millisecond"`; 22 | 23 | exports[`Logger helpers > formatDuration() > 10 should format 10 ms 1`] = `"10 milliseconds"`; 24 | 25 | exports[`Logger helpers > formatDuration() > 11 should format 100 ms 1`] = `"100 milliseconds"`; 26 | 27 | exports[`Logger helpers > formatDuration() > 12 should format 1000 ms 1`] = `"1 second"`; 28 | 29 | exports[`Logger helpers > formatDuration() > 13 should format 1500 ms 1`] = `"1.5 seconds"`; 30 | 31 | exports[`Logger helpers > formatDuration() > 14 should format 10000 ms 1`] = `"10 seconds"`; 32 | 33 | exports[`Logger helpers > formatDuration() > 15 should format 100000 ms 1`] = `"1.67 minutes"`; 34 | 35 | exports[`Logger helpers > formatDuration() > 16 should format 1000000 ms 1`] = `"16.67 minutes"`; 36 | 37 | exports[`Logger helpers > formatDuration() > 17 should format 10000000 ms 1`] = `"166.67 minutes"`; 38 | 39 | exports[`Logger helpers > formatDuration() > 18 should format 100000000 ms 1`] = `"1666.67 minutes"`; 40 | 41 | exports[`Logger helpers > formatDuration() > 19 should format 1000000000 ms 1`] = `"16666.67 minutes"`; 42 | -------------------------------------------------------------------------------- /express-zod-api/tests/__snapshots__/metadata.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`Metadata > getExamples() > should handle { example: +0 } 1`] = ` 4 | [ 5 | 0, 6 | ] 7 | `; 8 | 9 | exports[`Metadata > getExamples() > should handle { example: 123 } 1`] = ` 10 | [ 11 | 123, 12 | ] 13 | `; 14 | 15 | exports[`Metadata > getExamples() > should handle { example: undefined } 1`] = `[]`; 16 | 17 | exports[`Metadata > getExamples() > should handle { examples: [ 1, 2, 3 ] } 1`] = ` 18 | [ 19 | 1, 20 | 2, 21 | 3, 22 | ] 23 | `; 24 | 25 | exports[`Metadata > getExamples() > should handle { examples: [ 1, 2, 3 ], example: 123 } 1`] = ` 26 | [ 27 | 1, 28 | 2, 29 | 3, 30 | ] 31 | `; 32 | 33 | exports[`Metadata > getExamples() > should handle { examples: [] } 1`] = `[]`; 34 | 35 | exports[`Metadata > getExamples() > should handle { examples: { one: { value: 123 } } } 1`] = ` 36 | [ 37 | 123, 38 | ] 39 | `; 40 | 41 | exports[`Metadata > getExamples() > should handle { examples: undefined } 1`] = `[]`; 42 | 43 | exports[`Metadata > getExamples() > should handle {} 1`] = `[]`; 44 | -------------------------------------------------------------------------------- /express-zod-api/tests/__snapshots__/migration.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`Migration > should consist of one rule being the major version of the package 1`] = ` 4 | { 5 | "rules": { 6 | "v24": { 7 | "create": [Function], 8 | "defaultOptions": [], 9 | "meta": { 10 | "fixable": "code", 11 | "messages": { 12 | "add": "add {{ subject }} to {{ to }}", 13 | "change": "change {{ subject }} from {{ from }} to {{ to }}", 14 | "move": "move {{ subject }} to {{ to }}", 15 | "remove": "remove {{ subject }}", 16 | }, 17 | "schema": [], 18 | "type": "problem", 19 | }, 20 | }, 21 | }, 22 | } 23 | `; 24 | -------------------------------------------------------------------------------- /express-zod-api/tests/__snapshots__/result-handler.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`ResultHandler > 'arrayResultHandler' > Should handle HTTP error 1`] = `"Something not found"`; 4 | 5 | exports[`ResultHandler > 'arrayResultHandler' > Should handle generic error 1`] = ` 6 | [ 7 | [ 8 | "Server side error", 9 | { 10 | "error": InternalServerError({ 11 | "cause": Error({ 12 | "message": "Some error", 13 | }), 14 | "message": "Some error", 15 | }), 16 | "payload": { 17 | "something": 453, 18 | }, 19 | "url": "http://something/v1/anything", 20 | }, 21 | ], 22 | ] 23 | `; 24 | 25 | exports[`ResultHandler > 'arrayResultHandler' > Should handle generic error 2`] = `"Some error"`; 26 | 27 | exports[`ResultHandler > 'arrayResultHandler' > Should handle regular response 1`] = ` 28 | [ 29 | "One", 30 | "Two", 31 | "Three", 32 | ] 33 | `; 34 | 35 | exports[`ResultHandler > 'arrayResultHandler' > Should handle schema error 1`] = `"something: Expected string, got number"`; 36 | 37 | exports[`ResultHandler > 'arrayResultHandler' > should forward output schema examples 1`] = ` 38 | { 39 | "examples": [ 40 | [ 41 | "One", 42 | "Two", 43 | "Three", 44 | ], 45 | ], 46 | } 47 | `; 48 | 49 | exports[`ResultHandler > 'arrayResultHandler' > should generate negative response example 1`] = ` 50 | { 51 | "examples": [ 52 | "Sample error message", 53 | ], 54 | } 55 | `; 56 | 57 | exports[`ResultHandler > 'defaultResultHandler' > Should handle HTTP error 1`] = ` 58 | { 59 | "error": { 60 | "message": "Something not found", 61 | }, 62 | "status": "error", 63 | } 64 | `; 65 | 66 | exports[`ResultHandler > 'defaultResultHandler' > Should handle generic error 1`] = ` 67 | [ 68 | [ 69 | "Server side error", 70 | { 71 | "error": InternalServerError({ 72 | "cause": Error({ 73 | "message": "Some error", 74 | }), 75 | "message": "Some error", 76 | }), 77 | "payload": { 78 | "something": 453, 79 | }, 80 | "url": "http://something/v1/anything", 81 | }, 82 | ], 83 | ] 84 | `; 85 | 86 | exports[`ResultHandler > 'defaultResultHandler' > Should handle generic error 2`] = ` 87 | { 88 | "error": { 89 | "message": "Some error", 90 | }, 91 | "status": "error", 92 | } 93 | `; 94 | 95 | exports[`ResultHandler > 'defaultResultHandler' > Should handle regular response 1`] = ` 96 | { 97 | "data": { 98 | "anything": 118, 99 | "items": [ 100 | "One", 101 | "Two", 102 | "Three", 103 | ], 104 | }, 105 | "status": "success", 106 | } 107 | `; 108 | 109 | exports[`ResultHandler > 'defaultResultHandler' > Should handle schema error 1`] = ` 110 | { 111 | "error": { 112 | "message": "something: Expected string, got number", 113 | }, 114 | "status": "error", 115 | } 116 | `; 117 | 118 | exports[`ResultHandler > 'defaultResultHandler' > should forward output schema examples 1`] = ` 119 | { 120 | "examples": [ 121 | { 122 | "data": { 123 | "items": [ 124 | "One", 125 | "Two", 126 | "Three", 127 | ], 128 | "str": "test", 129 | }, 130 | "status": "success", 131 | }, 132 | ], 133 | } 134 | `; 135 | 136 | exports[`ResultHandler > 'defaultResultHandler' > should generate negative response example 1`] = ` 137 | { 138 | "examples": [ 139 | { 140 | "error": { 141 | "message": "Sample error message", 142 | }, 143 | "status": "error", 144 | }, 145 | ], 146 | } 147 | `; 148 | -------------------------------------------------------------------------------- /express-zod-api/tests/__snapshots__/result-helpers.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`Result helpers > ensureHttpError() > should handle Error: basic 1`] = ` 4 | InternalServerError({ 5 | "cause": Error({ 6 | "message": "basic", 7 | }), 8 | "message": "basic", 9 | }) 10 | `; 11 | 12 | exports[`Result helpers > ensureHttpError() > should handle InputValidationError: Invalid input: expected string, received number 1`] = ` 13 | BadRequestError({ 14 | "cause": ZodError({ 15 | "issues": [ 16 | { 17 | "code": "invalid_type", 18 | "expected": "string", 19 | "message": "Invalid input: expected string, received number", 20 | "path": [], 21 | }, 22 | ], 23 | }), 24 | "message": "Invalid input: expected string, received number", 25 | }) 26 | `; 27 | 28 | exports[`Result helpers > ensureHttpError() > should handle NotFoundError: Not really found 1`] = ` 29 | NotFoundError({ 30 | "message": "Not really found", 31 | }) 32 | `; 33 | 34 | exports[`Result helpers > ensureHttpError() > should handle OutputValidationError: output: Invalid input: expected string, received number 1`] = ` 35 | InternalServerError({ 36 | "cause": ZodError({ 37 | "issues": [ 38 | { 39 | "code": "invalid_type", 40 | "expected": "string", 41 | "message": "Invalid input: expected string, received number", 42 | "path": [], 43 | }, 44 | ], 45 | }), 46 | "message": "output: Invalid input: expected string, received number", 47 | }) 48 | `; 49 | -------------------------------------------------------------------------------- /express-zod-api/tests/__snapshots__/routing.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`Routing > initRouting() > Should check if endpoint supports an explicitly specified method 1`] = ` 4 | RoutingError({ 5 | "cause": { 6 | "method": "get", 7 | "path": "/v1/user/retrieve", 8 | }, 9 | "message": "Method get is not supported by the assigned Endpoint.", 10 | }) 11 | `; 12 | 13 | exports[`Routing > initRouting() > Should check if endpoint supports the method it's assigned to within DependsOnMethod 1`] = ` 14 | RoutingError({ 15 | "cause": { 16 | "method": "post", 17 | "path": "/v1/user", 18 | }, 19 | "message": "Method post is not supported by the assigned Endpoint.", 20 | }) 21 | `; 22 | 23 | exports[`Routing > initRouting() > Should prohibit DependsOnMethod for a route having explicit method 1`] = ` 24 | RoutingError({ 25 | "cause": { 26 | "method": "get", 27 | "path": "/v1/user/retrieve", 28 | }, 29 | "message": "Route with explicit method can only be assigned with Endpoint", 30 | }) 31 | `; 32 | 33 | exports[`Routing > initRouting() > Should prohibit ServeStatic for a route having explicit method 1`] = ` 34 | RoutingError({ 35 | "cause": { 36 | "method": "get", 37 | "path": "/v1/user/retrieve", 38 | }, 39 | "message": "Route with explicit method can only be assigned with Endpoint", 40 | }) 41 | `; 42 | 43 | exports[`Routing > initRouting() > Should prohibit duplicated routes 1`] = ` 44 | RoutingError({ 45 | "cause": { 46 | "method": "get", 47 | "path": "/v1/test", 48 | }, 49 | "message": "Route has a duplicate", 50 | }) 51 | `; 52 | 53 | exports[`Routing > initRouting() > Should prohibit nested routing within a route having explicit method 1`] = ` 54 | RoutingError({ 55 | "cause": { 56 | "method": "get", 57 | "path": "/v1/user/retrieve", 58 | }, 59 | "message": "Route with explicit method can only be assigned with Endpoint", 60 | }) 61 | `; 62 | -------------------------------------------------------------------------------- /express-zod-api/tests/__snapshots__/server-helpers.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`Server helpers > createLoggingMiddleware > should delegate errors in accessLogger 1`] = ` 4 | AssertionError({ 5 | "message": "Something went wrong", 6 | }) 7 | `; 8 | 9 | exports[`Server helpers > createLoggingMiddleware > should delegate errors in childLoggerProvider 1`] = ` 10 | AssertionError({ 11 | "message": "Something went wrong", 12 | }) 13 | `; 14 | -------------------------------------------------------------------------------- /express-zod-api/tests/__snapshots__/upload-schema.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`ez.upload() > parsing > should accept UploadedFile 0 1`] = ` 4 | { 5 | "data": { 6 | "data": { 7 | "data": [ 8 | 115, 9 | 111, 10 | 109, 11 | 101, 12 | 116, 13 | 104, 14 | 105, 15 | 110, 16 | 103, 17 | ], 18 | "type": "Buffer", 19 | }, 20 | "encoding": "utf-8", 21 | "md5": "", 22 | "mimetype": "image/jpeg", 23 | "mv": [MockFunction spy], 24 | "name": "avatar.jpg", 25 | "size": 100500, 26 | "tempFilePath": "", 27 | "truncated": false, 28 | }, 29 | "success": true, 30 | } 31 | `; 32 | 33 | exports[`ez.upload() > parsing > should accept UploadedFile 1 1`] = ` 34 | { 35 | "data": { 36 | "data": { 37 | "data": [ 38 | 115, 39 | 111, 40 | 109, 41 | 101, 42 | 116, 43 | 104, 44 | 105, 45 | 110, 46 | 103, 47 | ], 48 | "type": "Buffer", 49 | }, 50 | "encoding": "utf-8", 51 | "md5": "", 52 | "mimetype": "image/jpeg", 53 | "mv": [MockFunction spy], 54 | "name": "avatar.jpg", 55 | "size": 100500, 56 | "tempFilePath": "", 57 | "truncated": false, 58 | }, 59 | "success": true, 60 | } 61 | `; 62 | -------------------------------------------------------------------------------- /express-zod-api/tests/api-response.spec.ts: -------------------------------------------------------------------------------- 1 | import { defaultStatusCodes, responseVariants } from "../src/api-response"; 2 | 3 | describe("ApiResponse", () => { 4 | describe("defaultStatusCodes", () => { 5 | test("should be 200 and 400", () => { 6 | expect(defaultStatusCodes).toMatchSnapshot(); 7 | }); 8 | }); 9 | 10 | describe("responseVariants", () => { 11 | test("should consist of positive and negative", () => { 12 | expect(responseVariants).toMatchSnapshot(); 13 | }); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /express-zod-api/tests/buffer-schema.spec.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from "node:fs/promises"; 2 | import { z, $brand } from "zod/v4"; 3 | import { ez } from "../src"; 4 | 5 | describe("ez.buffer()", () => { 6 | describe("creation", () => { 7 | test("should create a Buffer", () => { 8 | const schema = ez.buffer(); 9 | expect(schema).toBeInstanceOf(z.ZodCustom); 10 | expectTypeOf(schema._zod.output).toEqualTypeOf>(); 11 | }); 12 | }); 13 | 14 | describe("parsing", () => { 15 | test("should invalidate wrong types", () => { 16 | const result = ez.buffer().safeParse("123"); 17 | expect(result.success).toBeFalsy(); 18 | if (!result.success) { 19 | expect(result.error.issues).toEqual([ 20 | { code: "custom", message: "Expected Buffer", path: [] }, 21 | ]); 22 | } 23 | }); 24 | 25 | test("should accept Buffer", () => { 26 | const schema = ez.buffer(); 27 | const subject = Buffer.from("test", "utf-8"); 28 | const result = schema.safeParse(subject); 29 | expect(result).toEqual({ success: true, data: subject }); 30 | }); 31 | 32 | test("should accept data read into buffer", async () => { 33 | const schema = ez.buffer(); 34 | const data = await readFile("../logo.svg"); 35 | const result = schema.safeParse(data); 36 | expect(result).toEqual({ success: true, data }); 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /express-zod-api/tests/config-type.spec.ts: -------------------------------------------------------------------------------- 1 | import { Express, IRouter } from "express"; 2 | import { createConfig } from "../src"; 3 | 4 | describe("ConfigType", () => { 5 | describe("createConfig()", () => { 6 | const httpConfig = { http: { listen: 3333 } }; 7 | const httpsConfig = { https: { options: {}, listen: 4444 } }; 8 | const both = { ...httpConfig, ...httpsConfig }; 9 | 10 | test.each([httpConfig, httpsConfig, both])( 11 | "should create a config with server %#", 12 | (inc) => { 13 | const argument = { 14 | ...inc, 15 | cors: true, 16 | logger: { level: "debug" as const }, 17 | }; 18 | const config = createConfig(argument); 19 | expect(config).toEqual(argument); 20 | }, 21 | ); 22 | 23 | test("should create a config with app", () => { 24 | const argument = { 25 | app: vi.fn() as unknown as Express, 26 | cors: true, 27 | logger: console, 28 | }; 29 | const config = createConfig(argument); 30 | expect(config).toEqual(argument); 31 | }); 32 | 33 | test("should create a config with router", () => { 34 | const argument = { 35 | app: vi.fn() as unknown as IRouter, 36 | cors: true, 37 | }; 38 | const config = createConfig(argument); 39 | expect(config).toEqual(argument); 40 | }); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /express-zod-api/tests/content-type.spec.ts: -------------------------------------------------------------------------------- 1 | import { contentTypes } from "../src/content-type"; 2 | 3 | describe("contentTypes", () => { 4 | test("should has predefined properties", () => { 5 | expect(contentTypes).toEqual({ 6 | form: "application/x-www-form-urlencoded", 7 | json: "application/json", 8 | upload: "multipart/form-data", 9 | raw: "application/octet-stream", 10 | sse: "text/event-stream", 11 | }); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /express-zod-api/tests/date-in-schema.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v4"; 2 | import { ezDateInBrand } from "../src/date-in-schema"; 3 | import { ez } from "../src"; 4 | import { getBrand } from "../src/metadata"; 5 | 6 | describe("ez.dateIn()", () => { 7 | describe("creation", () => { 8 | test("should create an instance", () => { 9 | const schema = ez.dateIn(); 10 | expect(schema).toBeInstanceOf(z.ZodPipe); 11 | expect(getBrand(schema)).toBe(ezDateInBrand); 12 | }); 13 | }); 14 | 15 | describe("parsing", () => { 16 | test("should handle wrong parsed type", () => { 17 | const schema = ez.dateIn(); 18 | const result = schema.safeParse(123); 19 | expect(result.success).toBeFalsy(); 20 | if (!result.success) expect(result.error.issues).toMatchSnapshot(); 21 | }); 22 | 23 | test.each([ 24 | "2022-12-31T00:00:00.000Z", 25 | "2022-12-31T00:00:00.0Z", 26 | "2022-12-31T00:00:00Z", 27 | "2022-12-31T00:00:00", 28 | "2022-12-31", 29 | ])("should accept valid date string %#", (subject) => { 30 | const schema = ez.dateIn(); 31 | const result = schema.safeParse(subject); 32 | expect(result).toEqual({ 33 | success: true, 34 | data: new Date(subject), 35 | }); 36 | }); 37 | 38 | test("should handle invalid date", () => { 39 | const schema = ez.dateIn(); 40 | const result = schema.safeParse("2022-01-32"); 41 | expect(result.success).toBeFalsy(); 42 | if (!result.success) expect(result.error.issues).toMatchSnapshot(); 43 | }); 44 | 45 | test("should handle invalid format", () => { 46 | const schema = ez.dateIn(); 47 | const result = schema.safeParse("12.01.2021"); 48 | expect(result.success).toBeFalsy(); 49 | if (!result.success) expect(result.error.issues).toMatchSnapshot(); 50 | }); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /express-zod-api/tests/date-out-schema.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v4"; 2 | import { ezDateOutBrand } from "../src/date-out-schema"; 3 | import { ez } from "../src"; 4 | import { getBrand } from "../src/metadata"; 5 | 6 | describe("ez.dateOut()", () => { 7 | describe("creation", () => { 8 | test("should create an instance", () => { 9 | const schema = ez.dateOut(); 10 | expect(schema).toBeInstanceOf(z.ZodPipe); 11 | expect(getBrand(schema)).toBe(ezDateOutBrand); 12 | }); 13 | }); 14 | 15 | describe("parsing", () => { 16 | test("should handle wrong parsed type", () => { 17 | const schema = ez.dateOut(); 18 | const result = schema.safeParse("12.01.2022"); 19 | expect(result.success).toBeFalsy(); 20 | if (!result.success) { 21 | expect(result.error.issues).toEqual([ 22 | { 23 | code: "invalid_type", 24 | expected: "date", 25 | message: "Invalid input: expected date, received string", 26 | path: [], 27 | }, 28 | ]); 29 | } 30 | }); 31 | 32 | test("should accept valid date", () => { 33 | const schema = ez.dateOut(); 34 | const result = schema.safeParse(new Date("2022-12-31")); 35 | expect(result).toEqual({ 36 | success: true, 37 | data: "2022-12-31T00:00:00.000Z", 38 | }); 39 | }); 40 | 41 | test("should handle invalid date", () => { 42 | const schema = ez.dateOut(); 43 | const result = schema.safeParse(new Date("2022-01-32")); 44 | expect(result.success).toBeFalsy(); 45 | if (!result.success) { 46 | expect(result.error.issues).toEqual([ 47 | { 48 | code: "invalid_type", 49 | expected: "date", 50 | message: "Invalid input: expected date, received Date", 51 | path: [], 52 | received: "Invalid Date", 53 | }, 54 | ]); 55 | } 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /express-zod-api/tests/deep-checks.spec.ts: -------------------------------------------------------------------------------- 1 | import { UploadedFile } from "express-fileupload"; 2 | import { z } from "zod/v4"; 3 | import type { $brand, $ZodType } from "zod/v4/core"; 4 | import { ez } from "../src"; 5 | import { findNestedSchema, hasCycle } from "../src/deep-checks"; 6 | import { getBrand } from "../src/metadata"; 7 | import { ezUploadBrand } from "../src/upload-schema"; 8 | 9 | describe("Checks", () => { 10 | describe("findNestedSchema()", () => { 11 | const condition = (subject: $ZodType) => 12 | getBrand(subject) === ezUploadBrand; 13 | 14 | test("should return true for given argument satisfying condition", () => { 15 | expect( 16 | findNestedSchema(ez.upload(), { condition, io: "input" }), 17 | ).toBeTruthy(); 18 | }); 19 | 20 | test.each([ 21 | z.object({ test: ez.upload() }), 22 | ez.upload().or(z.boolean()), 23 | z.object({ test: z.boolean() }).and(z.object({ test2: ez.upload() })), 24 | z.optional(ez.upload()), 25 | ez.upload().nullable(), 26 | ez.upload().default({} as UploadedFile & $brand), 27 | z.record(z.string(), ez.upload()), 28 | ez.upload().refine(() => true), 29 | z.array(ez.upload()), 30 | ])("should return true for wrapped needle %#", (subject) => { 31 | expect( 32 | findNestedSchema(subject, { condition, io: "input" }), 33 | ).toBeTruthy(); 34 | }); 35 | 36 | test.each([ 37 | z.object({}), 38 | z.any(), 39 | z.literal("test"), 40 | z.boolean().and(z.literal(true)), 41 | z.number().or(z.string()), 42 | ])("should return false in other cases %#", (subject) => { 43 | expect( 44 | findNestedSchema(subject, { condition, io: "input" }), 45 | ).toBeUndefined(); 46 | }); 47 | 48 | test("should finish early (from bottom to top)", () => { 49 | const subject = z.object({ 50 | one: z.object({ 51 | two: z.object({ 52 | three: z.object({ four: z.number() }), 53 | }), 54 | }), 55 | }); 56 | const check = vi.fn((schema) => schema instanceof z.ZodNumber); 57 | findNestedSchema(subject, { 58 | condition: check, 59 | io: "input", 60 | }); 61 | expect(check.mock.calls.length).toBe(1); 62 | }); 63 | }); 64 | 65 | describe("hasCycle()", () => { 66 | test.each(["input", "output"] as const)( 67 | "can find circular references %#", 68 | (io) => { 69 | const schema = z.object({ 70 | name: z.string(), 71 | get features() { 72 | return schema.array(); 73 | }, 74 | }); 75 | const result = hasCycle(schema, { io }); 76 | expect(result).toBeTruthy(); 77 | }, 78 | ); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /express-zod-api/tests/depends-on-method.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v4"; 2 | import { 3 | DependsOnMethod, 4 | EndpointsFactory, 5 | defaultResultHandler, 6 | } from "../src"; 7 | import { AbstractEndpoint } from "../src/endpoint"; 8 | 9 | describe("DependsOnMethod", () => { 10 | test("should accept empty object", () => { 11 | const instance = new DependsOnMethod({}); 12 | expect(instance).toBeInstanceOf(DependsOnMethod); 13 | expect(instance.entries).toEqual([]); 14 | }); 15 | 16 | test("should accept an endpoint with a corresponding method", () => { 17 | const instance = new DependsOnMethod({ 18 | post: new EndpointsFactory(defaultResultHandler).build({ 19 | method: "post", 20 | output: z.object({}), 21 | handler: async () => ({}), 22 | }), 23 | }); 24 | expect(instance.entries).toEqual([["post", expect.any(AbstractEndpoint)]]); 25 | }); 26 | 27 | test.each([{ methods: ["get", "post"] } as const, {}])( 28 | "should accept an endpoint capable to handle multiple methods %#", 29 | (inc) => { 30 | const endpoint = new EndpointsFactory(defaultResultHandler).build({ 31 | ...inc, 32 | output: z.object({}), 33 | handler: async () => ({}), 34 | }); 35 | const instance = new DependsOnMethod({ get: endpoint, post: endpoint }); 36 | expect(instance.entries).toEqual([ 37 | ["get", expect.any(AbstractEndpoint)], 38 | ["post", expect.any(AbstractEndpoint)], 39 | ]); 40 | }, 41 | ); 42 | 43 | test("should reject empty assignments", () => { 44 | const instance = new DependsOnMethod({ 45 | get: undefined, 46 | post: undefined, 47 | }); 48 | expect(instance.entries).toEqual([]); 49 | }); 50 | 51 | test("should be able to deprecate the assigned endpoints within a copy of itself", () => { 52 | const instance = new DependsOnMethod({ 53 | post: new EndpointsFactory(defaultResultHandler).build({ 54 | method: "post", 55 | output: z.object({}), 56 | handler: async () => ({}), 57 | }), 58 | }); 59 | expect(instance.entries[0][1].isDeprecated).toBe(false); 60 | const copy = instance.deprecated(); 61 | expect(copy.entries[0][1].isDeprecated).toBe(true); 62 | expect(copy).not.toBe(instance); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /express-zod-api/tests/express-mock.ts: -------------------------------------------------------------------------------- 1 | const expressJsonMock = vi.fn(); 2 | const expressRawMock = vi.fn(); 3 | const expressUrlencodedMock = vi.fn(); 4 | const compressionMock = vi.fn(); 5 | const fileUploadMock = vi.fn(); 6 | 7 | vi.mock("compression", () => ({ default: compressionMock })); 8 | vi.mock("express-fileupload", () => ({ default: fileUploadMock })); 9 | 10 | const staticHandler = vi.fn(); 11 | const staticMock = vi.fn(() => staticHandler); 12 | 13 | const appMock = { 14 | disable: vi.fn(() => appMock), 15 | use: vi.fn(() => appMock), 16 | get: vi.fn(), 17 | post: vi.fn(), 18 | put: vi.fn(), 19 | patch: vi.fn(), 20 | delete: vi.fn(), 21 | options: vi.fn(), 22 | init: vi.fn(), 23 | all: vi.fn(), 24 | }; 25 | 26 | const expressMock = () => appMock; 27 | expressMock.json = () => expressJsonMock; 28 | expressMock.raw = () => expressRawMock; 29 | expressMock.urlencoded = () => expressUrlencodedMock; 30 | expressMock.static = staticMock; 31 | 32 | vi.mock("express", () => ({ default: expressMock })); 33 | 34 | export { 35 | compressionMock, 36 | fileUploadMock, 37 | expressMock, 38 | appMock, 39 | expressJsonMock, 40 | expressRawMock, 41 | expressUrlencodedMock, 42 | staticMock, 43 | staticHandler, 44 | }; 45 | -------------------------------------------------------------------------------- /express-zod-api/tests/form-schema.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v4"; 2 | import { ez } from "../src"; 3 | import { ezFormBrand } from "../src/form-schema"; 4 | import { getBrand } from "../src/metadata"; 5 | 6 | describe("ez.form()", () => { 7 | describe("creation", () => { 8 | test.each([{ name: z.string() }, z.object({ name: z.string() })])( 9 | "should create a branded object instance based on the argument %#", 10 | (base) => { 11 | const schema = ez.form(base); 12 | expect(schema).toBeInstanceOf(z.ZodObject); 13 | expect(getBrand(schema)).toBe(ezFormBrand); 14 | expect(schema._zod.def.shape).toHaveProperty( 15 | "name", 16 | expect.any(z.ZodString), 17 | ); 18 | }, 19 | ); 20 | }); 21 | 22 | describe("parsing", () => { 23 | test("should accept the object of exact shape", () => { 24 | const schema = ez.form({ name: z.string() }); 25 | expect(schema.parse({ name: "test", extra: "removed" })).toEqual({ 26 | name: "test", 27 | }); 28 | }); 29 | 30 | test("should accept extras when the base has .passthrough()", () => { 31 | const schema = ez.form(z.object({ name: z.string() }).loose()); 32 | expect(schema.parse({ name: "test", extra: "kept" })).toEqual({ 33 | name: "test", 34 | extra: "kept", 35 | }); 36 | }); 37 | 38 | test("should throw for missing props as a regular object schema", () => { 39 | const schema = ez.form({ name: z.string() }); 40 | expect(() => 41 | schema.parse({ wrong: "removed" }), 42 | ).toThrowErrorMatchingSnapshot(); 43 | }); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /express-zod-api/tests/graceful-helpers.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | hasResponse, 3 | hasHttpServer, 4 | closeAsync, 5 | weAreClosed, 6 | isEncrypted, 7 | } from "../src/graceful-helpers"; 8 | import http from "node:http"; 9 | import { makeRequestMock } from "../src/testing"; 10 | import { Socket } from "node:net"; 11 | 12 | describe("Graceful helpers", () => { 13 | describe("hasResponse()", () => { 14 | test("should ensure _httpMessage prop to be instance of http.ServerResponse", () => { 15 | const subject = { 16 | _httpMessage: new http.ServerResponse(makeRequestMock()), 17 | }; 18 | expect(hasResponse(subject as unknown as Socket)).toBeTruthy(); 19 | }); 20 | test.each([{}, { _httpResponse: undefined }, { _httpResponse: {} }])( 21 | "should decline otherwise %#", 22 | (subject) => { 23 | expect(hasResponse(subject as Socket)).toBeFalsy(); 24 | }, 25 | ); 26 | }); 27 | 28 | describe("hasHttpServer()", () => { 29 | test("should ensure server prop to be instance of http.Server", () => { 30 | const subject = { server: http.createServer() }; 31 | expect(hasHttpServer(subject as unknown as Socket)).toBeTruthy(); 32 | }); 33 | test.each([{}, { server: undefined }, { server: {} }])( 34 | "should decline otherwise %#", 35 | (subject) => { 36 | expect(hasHttpServer(subject as Socket)).toBeFalsy(); 37 | }, 38 | ); 39 | }); 40 | 41 | describe("isEncrypted()", () => { 42 | test("should ensure encrypted prop is true", () => { 43 | const subject = { encrypted: true }; 44 | expect(isEncrypted(subject as unknown as Socket)).toBeTruthy(); 45 | }); 46 | test.each([{}, { encrypted: undefined }, { encrypted: false }])( 47 | "should decline otherwise %#", 48 | (subject) => { 49 | expect(isEncrypted(subject as Socket)).toBeFalsy(); 50 | }, 51 | ); 52 | }); 53 | 54 | describe("closeAsync()", () => { 55 | test("should promisify .close() method", async () => { 56 | const subject = { 57 | close: vi 58 | .fn<(cb: (err?: Error) => void) => void>() 59 | .mockImplementationOnce((cb) => cb()) 60 | .mockImplementationOnce((cb) => cb(new Error("Sample"))), 61 | }; 62 | await expect( 63 | closeAsync(subject as unknown as http.Server), 64 | ).resolves.toBeUndefined(); 65 | await expect( 66 | closeAsync(subject as unknown as http.Server), 67 | ).rejects.toThrowError("Sample"); 68 | }); 69 | }); 70 | 71 | describe("weAreClosed()", () => { 72 | test("should set connection:close header when they are not sent yet", () => { 73 | const subject = { headersSent: false, setHeader: vi.fn() }; 74 | weAreClosed( 75 | {} as http.IncomingMessage, 76 | subject as unknown as http.ServerResponse, 77 | ); 78 | expect(subject.setHeader).toHaveBeenCalledWith("connection", "close"); 79 | }); 80 | test("should be noop otherwise", () => { 81 | const subject = { headersSent: true, setHeader: vi.fn() }; 82 | weAreClosed( 83 | {} as http.IncomingMessage, 84 | subject as unknown as http.ServerResponse, 85 | ); 86 | expect(subject.setHeader).not.toHaveBeenCalled(); 87 | }); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /express-zod-api/tests/http-mock.ts: -------------------------------------------------------------------------------- 1 | import http from "node:http"; 2 | import https from "node:https"; 3 | import type { MockInstance } from "vitest"; 4 | import type { Application } from "express"; 5 | 6 | const realHttpCreator = http.createServer; 7 | const realHttpsCreator = https.createServer; 8 | 9 | let httpListenSpy: MockInstance; 10 | let httpsListenSpy: MockInstance; 11 | 12 | vi.spyOn(http, "createServer").mockImplementation((app) => { 13 | const server = realHttpCreator(app as Application); 14 | httpListenSpy = vi.spyOn(server, "listen").mockImplementation(({}, cb) => { 15 | if (typeof cb === "function") cb(); 16 | return server; 17 | }); 18 | return server; 19 | }); 20 | 21 | const createHttpsServerSpy = vi 22 | .spyOn(https, "createServer") 23 | .mockImplementation((...args) => { 24 | const server = realHttpsCreator(args[1] as Application); 25 | httpsListenSpy = vi.spyOn(server, "listen").mockImplementation(({}, cb) => { 26 | if (typeof cb === "function") cb(); 27 | return server; 28 | }); 29 | return server; 30 | }); 31 | 32 | export { createHttpsServerSpy, httpsListenSpy, httpListenSpy }; 33 | -------------------------------------------------------------------------------- /express-zod-api/tests/index.spec.ts: -------------------------------------------------------------------------------- 1 | import type { $ZodType, JSONSchema } from "zod/v4/core"; 2 | import { IRouter } from "express"; 3 | import ts from "typescript"; 4 | import { z } from "zod/v4"; 5 | import * as entrypoint from "../src"; 6 | import { 7 | ApiResponse, 8 | AppConfig, 9 | BasicSecurity, 10 | BearerSecurity, 11 | CommonConfig, 12 | CookieSecurity, 13 | HeaderSecurity, 14 | Depicter, 15 | FlatObject, 16 | IOSchema, 17 | InputSecurity, 18 | LoggerOverrides, 19 | Method, 20 | OAuth2Security, 21 | OpenIdSecurity, 22 | Producer, 23 | Routing, 24 | ServerConfig, 25 | } from "../src"; 26 | 27 | describe("Index Entrypoint", () => { 28 | describe("exports", () => { 29 | const entities = Object.keys(entrypoint); 30 | 31 | test("should have certain entities exposed", () => { 32 | expect(entities).toMatchSnapshot(); 33 | }); 34 | 35 | test.each(entities)("%s should have certain value", (entry) => { 36 | const entity = entrypoint[entry as keyof typeof entrypoint]; 37 | if (entity !== undefined) expect(entity).toMatchSnapshot(); 38 | }); 39 | 40 | test("Convenience types should be exposed", () => { 41 | expectTypeOf( 42 | ({ 43 | jsonSchema, 44 | }: { 45 | zodSchema: $ZodType; 46 | jsonSchema: JSONSchema.BaseSchema; 47 | }) => jsonSchema, 48 | ).toExtend(); 49 | expectTypeOf(() => 50 | ts.factory.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword), 51 | ).toExtend(); 52 | }); 53 | 54 | test("Issue 952, 1182, 1269: should expose certain types and interfaces", () => { 55 | expectTypeOf<"get">().toExtend(); 56 | expectTypeOf(z.object({})).toExtend(); 57 | expectTypeOf({}).toExtend(); 58 | expectTypeOf({}).toEqualTypeOf(); 59 | expectTypeOf({}).toExtend(); 60 | expectTypeOf<{ 61 | cors: true; 62 | logger: { level: "silent" }; 63 | }>().toExtend(); 64 | expectTypeOf<{ 65 | app: IRouter; 66 | cors: true; 67 | logger: { level: "silent" }; 68 | }>().toExtend(); 69 | expectTypeOf<{ 70 | http: { listen: 8090 }; 71 | logger: { level: "silent" }; 72 | cors: false; 73 | }>().toExtend(); 74 | expectTypeOf<{ type: "basic" }>().toEqualTypeOf(); 75 | expectTypeOf<{ 76 | type: "bearer"; 77 | format?: string; 78 | }>().toEqualTypeOf(); 79 | expectTypeOf<{ 80 | type: "cookie"; 81 | name: string; 82 | }>().toEqualTypeOf(); 83 | expectTypeOf<{ 84 | type: "header"; 85 | name: string; 86 | }>().toEqualTypeOf(); 87 | expectTypeOf<{ type: "input"; name: string }>().toEqualTypeOf< 88 | InputSecurity 89 | >(); 90 | expectTypeOf<{ type: "oauth2" }>().toExtend>(); 91 | expectTypeOf<{ 92 | type: "openid"; 93 | url: string; 94 | }>().toEqualTypeOf(); 95 | expectTypeOf({ schema: z.string() }).toExtend>(); 96 | }); 97 | 98 | test("Extended Zod prototypes", () => { 99 | expectTypeOf() 100 | .toHaveProperty("example") 101 | .toEqualTypeOf<(value: any) => z.ZodAny>(); 102 | expectTypeOf>() 103 | .toHaveProperty("example") 104 | .toEqualTypeOf<(value: string) => z.ZodDefault>(); 105 | expectTypeOf>() 106 | .toHaveProperty("label") 107 | .toEqualTypeOf<(value: string) => z.ZodDefault>(); 108 | expectTypeOf().toHaveProperty("remap"); 109 | }); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /express-zod-api/tests/json-schema-helpers.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v4"; 2 | import { flattenIO } from "../src/json-schema-helpers"; 3 | 4 | describe("JSON Schema helpers", () => { 5 | describe("flattenIO()", () => { 6 | test("should pass the object schema through", () => { 7 | const subject = flattenIO({ 8 | type: "object", 9 | properties: { one: { type: "string" } }, 10 | required: ["one"], 11 | examples: [{ one: "test" }], 12 | }); 13 | expect(subject).toMatchSnapshot(); 14 | }); 15 | 16 | test("should return object schema for the union of object schemas", () => { 17 | const subject = flattenIO({ 18 | oneOf: [ 19 | { 20 | type: "object", 21 | properties: { one: { type: "string" } }, 22 | required: ["one"], 23 | examples: [{ one: "test" }], 24 | }, 25 | { 26 | type: "object", 27 | properties: { two: { type: "number" } }, 28 | required: ["two"], 29 | examples: [{ two: "jest" }], 30 | }, 31 | ], 32 | }); 33 | expect(subject).toMatchSnapshot(); 34 | }); 35 | 36 | test("should return object schema for the intersection of object schemas", () => { 37 | const subject = flattenIO({ 38 | allOf: [ 39 | { 40 | type: "object", 41 | properties: { one: { type: "string" } }, 42 | required: ["one"], 43 | examples: [{ one: "test" }], 44 | }, 45 | { 46 | type: "object", 47 | properties: { two: { type: "number" } }, 48 | required: ["two"], 49 | examples: [{ two: "jest" }], 50 | }, 51 | ], 52 | }); 53 | expect(subject).toMatchSnapshot(); 54 | }); 55 | 56 | test("should use top level examples of the intersection", () => { 57 | const subject = flattenIO({ 58 | examples: [{ one: "test", two: "jest" }], 59 | allOf: [ 60 | { 61 | type: "object", 62 | properties: { one: { type: "string" } }, 63 | required: ["one"], 64 | }, 65 | { 66 | type: "object", 67 | properties: { two: { type: "number" } }, 68 | required: ["two"], 69 | }, 70 | ], 71 | }); 72 | expect(subject).toMatchSnapshot(); 73 | }); 74 | 75 | test("should pull examples up from object schema props", () => { 76 | const subject = flattenIO({ 77 | allOf: [ 78 | { 79 | type: "object", 80 | properties: { one: { type: "string", examples: ["test", "jest"] } }, 81 | required: ["one"], 82 | }, 83 | { 84 | type: "object", 85 | properties: { two: { type: "number", examples: [123] } }, 86 | required: ["two"], 87 | }, 88 | ], 89 | }); 90 | expect(subject).toMatchSnapshot(); 91 | }); 92 | 93 | test("should handle records", () => { 94 | const subject = z.toJSONSchema( 95 | z 96 | .record(z.literal(["one", "two"]), z.string()) 97 | .meta({ 98 | examples: [ 99 | { one: "test", two: "jest" }, 100 | { one: "some", two: "another" }, 101 | ], 102 | }) 103 | .or( 104 | z 105 | .record(z.enum(["three", "four"]), z.number()) 106 | .meta({ examples: [{ three: 123, four: 456 }] }), 107 | ), 108 | ); 109 | expect(flattenIO(subject)).toMatchSnapshot(); 110 | }); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /express-zod-api/tests/last-resort.spec.ts: -------------------------------------------------------------------------------- 1 | import createHttpError, { HttpError } from "http-errors"; 2 | import { ResultHandlerError } from "../src/errors"; 3 | import { lastResortHandler } from "../src/last-resort"; 4 | import { makeLoggerMock, makeResponseMock } from "../src/testing"; 5 | 6 | describe("Last Resort Handler", () => { 7 | test("should be a function", () => { 8 | expect(typeof lastResortHandler).toBe("function"); 9 | }); 10 | 11 | describe.each(["development", "production"])("%s mode", (mode) => { 12 | beforeAll(() => { 13 | vi.stubEnv("TSUP_STATIC", mode); 14 | vi.stubEnv("NODE_ENV", mode); 15 | }); 16 | afterAll(() => vi.unstubAllEnvs()); 17 | 18 | test.each([ 19 | new Error("something went wrong"), 20 | createHttpError("something went wrong", { expose: true }), 21 | ])( 22 | "should log the supplied error and respond with plain text %#", 23 | (cause) => { 24 | const responseMock = makeResponseMock(); 25 | const loggerMock = makeLoggerMock(); 26 | const error = new ResultHandlerError( 27 | cause, 28 | new Error("what went wrong before"), 29 | ); 30 | lastResortHandler({ 31 | error, 32 | logger: loggerMock, 33 | response: responseMock, 34 | }); 35 | expect(loggerMock._getLogs().error).toEqual([ 36 | ["Result handler failure", error], 37 | ]); 38 | expect(responseMock._getStatusCode()).toBe(500); 39 | expect(responseMock._getHeaders()).toHaveProperty( 40 | "content-type", 41 | "text/plain", 42 | ); 43 | expect(responseMock._getData()).toBe( 44 | mode === "development" || (cause instanceof HttpError && cause.expose) 45 | ? "An error occurred while serving the result: something went wrong.\nOriginal error: what went wrong before." 46 | : "Internal Server Error", 47 | ); 48 | }, 49 | ); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /express-zod-api/tests/logger-helpers.spec.ts: -------------------------------------------------------------------------------- 1 | import { BuiltinLogger } from "../src"; 2 | import { BuiltinLoggerConfig } from "../src/builtin-logger"; 3 | import { 4 | AbstractLogger, 5 | isLoggerInstance, 6 | isSeverity, 7 | isHidden, 8 | makeNumberFormat, 9 | formatDuration, 10 | } from "../src/logger-helpers"; 11 | 12 | describe("Logger helpers", () => { 13 | describe("isSeverity()", () => { 14 | test.each(["debug", "info", "warn", "error"])( 15 | "should recognize %s", 16 | (subject) => { 17 | expect(isSeverity(subject)).toBeTruthy(); 18 | }, 19 | ); 20 | test.each(["something", "", 123, Symbol.dispose])( 21 | "should reject others %#", 22 | (subject) => { 23 | expect(isSeverity(subject)).toBeFalsy(); 24 | }, 25 | ); 26 | }); 27 | 28 | describe("isHidden", () => { 29 | test.each([ 30 | ["debug", "debug", false], 31 | ["debug", "info", true], 32 | ["debug", "warn", true], 33 | ["debug", "error", true], 34 | ["info", "debug", false], 35 | ["info", "info", false], 36 | ["info", "warn", true], 37 | ["info", "error", true], 38 | ["warn", "debug", false], 39 | ["warn", "info", false], 40 | ["warn", "warn", false], 41 | ["warn", "error", true], 42 | ["error", "debug", false], 43 | ["error", "info", false], 44 | ["error", "warn", false], 45 | ["error", "error", false], 46 | ] as const)( 47 | "should compare %s to %s with %s result", 48 | (subject, gate, result) => { 49 | expect(isHidden(subject, gate)).toBe(result); 50 | }, 51 | ); 52 | }); 53 | 54 | describe("isLoggerInstance()", () => { 55 | test.each>([ 56 | { level: "silent" }, 57 | { level: "debug", color: false }, 58 | { level: "info", color: true }, 59 | { level: "warn", depth: 5 }, 60 | { level: "warn", depth: null }, 61 | { level: "warn", depth: Infinity }, 62 | ])("should invalidate built-in logger config %#", (sample) => { 63 | expect(isLoggerInstance(sample)).toBeFalsy(); 64 | }); 65 | 66 | test.each([ 67 | // issue #1605: should not allow methods 68 | { level: "debug", debug: () => {} }, 69 | { level: "warn", error: () => {} }, 70 | // issue #1772: similar to #1605, but the methods are in prototype 71 | new (class { 72 | level = "debug"; 73 | debug() {} 74 | })(), 75 | Object.setPrototypeOf({ level: "debug" }, { debug: () => {} }), 76 | new BuiltinLogger({ level: "debug" }), 77 | ])("should validate logger instances %#", (sample) => { 78 | expect(isLoggerInstance(sample)).toBeTruthy(); 79 | }); 80 | }); 81 | 82 | describe.each([undefined, 0, 2])( 83 | "makeNumberFormat() with %s fraction", 84 | (fraction) => { 85 | const defaultLocale = new Intl.NumberFormat().resolvedOptions().locale; 86 | test.each([ 87 | "nanosecond", 88 | "microsecond", 89 | "millisecond", 90 | "second", 91 | "minute", 92 | ] as const)("should return Intl instance for %s unit", (unit) => { 93 | const instance = makeNumberFormat(unit, fraction); 94 | expect(instance).toBeInstanceOf(Intl.NumberFormat); 95 | expect(instance.resolvedOptions()).toEqual({ 96 | unit, 97 | maximumFractionDigits: fraction || 0, 98 | locale: defaultLocale, 99 | minimumFractionDigits: 0, 100 | minimumIntegerDigits: 1, 101 | notation: "standard", 102 | numberingSystem: "latn", 103 | roundingIncrement: 1, 104 | roundingMode: "halfExpand", 105 | roundingPriority: "auto", 106 | signDisplay: "auto", 107 | style: "unit", 108 | trailingZeroDisplay: "auto", 109 | unitDisplay: "long", 110 | useGrouping: false, 111 | }); 112 | }); 113 | }, 114 | ); 115 | 116 | describe("formatDuration()", () => { 117 | test.each([ 118 | 1e-9, 1e-8, 1e-7, 1e-6, 1e-5, 1e-4, 1e-3, 1e-2, 1e-1, 1, 1e1, 1e2, 1e3, 119 | 15e2, 1e4, 1e5, 1e6, 1e7, 1e8, 1e9, 120 | ])("%# should format %s ms", (duration) => { 121 | expect(formatDuration(duration)).toMatchSnapshot(); 122 | }); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /express-zod-api/tests/metadata.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v4"; 2 | import type { $ZodType, GlobalMeta } from "zod/v4/core"; 3 | import { getBrand, getExamples } from "../src/metadata"; 4 | 5 | describe("Metadata", () => { 6 | describe("getBrand", () => { 7 | test.each([{ brand: "test" }, {}, undefined])( 8 | "should take it from bag", 9 | (bag) => { 10 | const mock = { _zod: { bag } }; 11 | expect(getBrand(mock as unknown as $ZodType)).toBe(bag?.brand); 12 | }, 13 | ); 14 | }); 15 | 16 | describe("getExamples()", () => { 17 | test.each([ 18 | { examples: [1, 2, 3] }, 19 | { examples: [] }, 20 | { examples: undefined }, 21 | { examples: { one: { value: 123 } } }, 22 | { example: 123 }, 23 | { example: 0 }, 24 | { example: undefined }, 25 | { examples: [1, 2, 3], example: 123 }, // priority 26 | {}, 27 | ])("should handle %s", (meta) => { 28 | const schema = z.unknown().meta(meta); 29 | expect(getExamples(schema)).toMatchSnapshot(); 30 | }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /express-zod-api/tests/method.spec.ts: -------------------------------------------------------------------------------- 1 | import * as R from "ramda"; 2 | import { isMethod, methods, Method, AuxMethod } from "../src/method"; 3 | 4 | describe("Method", () => { 5 | describe("methods array", () => { 6 | test("should be the list of selected keys of express router", () => { 7 | expect(methods).toEqual(["get", "post", "put", "delete", "patch"]); 8 | }); 9 | }); 10 | 11 | describe("the type", () => { 12 | test("should match the entries of the methods array", () => { 13 | expectTypeOf<"get">().toExtend(); 14 | expectTypeOf<"post">().toExtend(); 15 | expectTypeOf<"put">().toExtend(); 16 | expectTypeOf<"delete">().toExtend(); 17 | expectTypeOf<"patch">().toExtend(); 18 | expectTypeOf<"wrong">().not.toExtend(); 19 | }); 20 | }); 21 | 22 | describe("AuxMethod", () => { 23 | test("should be options", () => { 24 | expectTypeOf().toEqualTypeOf("options" as const); 25 | }); 26 | }); 27 | 28 | describe("isMethod", () => { 29 | test.each(methods)("should validate %s", (one) => { 30 | expect(isMethod(one)).toBe(true); 31 | }); 32 | test.each([...R.map(R.toUpper, methods), "", " ", "wrong"])( 33 | "should invalidate others %#", 34 | (one) => { 35 | expect(isMethod(one)).toBe(false); 36 | }, 37 | ); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /express-zod-api/tests/middleware.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v4"; 2 | import { InputValidationError, Middleware } from "../src"; 3 | import { EmptyObject } from "../src/common-helpers"; 4 | import { AbstractMiddleware, ExpressMiddleware } from "../src/middleware"; 5 | import { 6 | makeLoggerMock, 7 | makeRequestMock, 8 | makeResponseMock, 9 | } from "../src/testing"; 10 | 11 | describe("Middleware", () => { 12 | describe("constructor()", () => { 13 | test("should inherit from AbstractMiddleware", () => { 14 | const mw = new Middleware({ 15 | input: z.object({ something: z.number() }), 16 | handler: vi.fn(), 17 | }); 18 | expect(mw).toBeInstanceOf(AbstractMiddleware); 19 | expectTypeOf>().toEqualTypeOf<{ 20 | something: number; 21 | }>(); 22 | }); 23 | 24 | test("should allow to omit input schema", () => { 25 | const mw = new Middleware({ handler: vi.fn() }); 26 | expectTypeOf(mw.schema._zod.output).toEqualTypeOf(); 27 | }); 28 | 29 | describe("#600: Top level refinements", () => { 30 | test("should allow refinement", () => { 31 | const mw = new Middleware({ 32 | input: z.object({ something: z.number() }).refine(() => true), 33 | handler: vi.fn(), 34 | }); 35 | expect(mw.schema).toBeInstanceOf(z.ZodObject); 36 | }); 37 | }); 38 | }); 39 | 40 | describe(".execute()", () => { 41 | test("should validate the supplied input or throw an InputValidationError", async () => { 42 | const mw = new Middleware({ 43 | input: z.object({ test: z.string() }), 44 | handler: vi.fn(), 45 | }); 46 | await expect(() => 47 | mw.execute({ 48 | input: { test: 123 }, 49 | options: {}, 50 | logger: makeLoggerMock(), 51 | request: makeRequestMock(), 52 | response: makeResponseMock(), 53 | }), 54 | ).rejects.toThrow(InputValidationError); 55 | }); 56 | 57 | test("should call the handler and return its output", async () => { 58 | const handlerMock = vi.fn(() => ({ result: "test" })); 59 | const mw = new Middleware({ 60 | input: z.object({ test: z.string() }), 61 | handler: handlerMock, 62 | }); 63 | const loggerMock = makeLoggerMock(); 64 | const requestMock = makeRequestMock(); 65 | const responseMock = makeResponseMock(); 66 | expect( 67 | await mw.execute({ 68 | input: { test: "something" }, 69 | options: { opt: "anything " }, 70 | logger: loggerMock, 71 | request: requestMock, 72 | response: responseMock, 73 | }), 74 | ).toEqual({ result: "test" }); 75 | expect(handlerMock).toHaveBeenCalledWith({ 76 | input: { test: "something" }, 77 | options: { opt: "anything " }, 78 | logger: loggerMock, 79 | request: requestMock, 80 | response: responseMock, 81 | }); 82 | }); 83 | }); 84 | }); 85 | 86 | describe("ExpressMiddleware", () => { 87 | test("should inherit from Middleware", () => { 88 | const mw = new ExpressMiddleware(vi.fn()); 89 | expect(mw).toBeInstanceOf(Middleware); 90 | expectTypeOf(mw.schema._zod.output).toEqualTypeOf(); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /express-zod-api/tests/migration.spec.ts: -------------------------------------------------------------------------------- 1 | import { RuleTester } from "@typescript-eslint/rule-tester"; 2 | import migration from "../src/migration"; 3 | import parser from "@typescript-eslint/parser"; 4 | import { version } from "../package.json"; 5 | 6 | RuleTester.afterAll = afterAll; 7 | RuleTester.describe = describe; 8 | RuleTester.it = it; 9 | 10 | const tester = new RuleTester({ 11 | languageOptions: { parser }, 12 | }); 13 | 14 | describe("Migration", () => { 15 | test("should consist of one rule being the major version of the package", () => { 16 | expect(migration.rules).toHaveProperty(`v${version.split(".")[0]}`); 17 | expect(migration).toMatchSnapshot(); 18 | }); 19 | 20 | tester.run("v24", migration.rules.v24, { 21 | valid: [ 22 | `new Documentation({});`, 23 | `new Integration({});`, 24 | `const rule: Depicter = () => {};`, 25 | `import {} from "zod/v4";`, 26 | `ez.buffer();`, 27 | ], 28 | invalid: [ 29 | { 30 | code: `new Documentation({ numericRange: {}, });`, 31 | output: `new Documentation({ });`, 32 | errors: [ 33 | { 34 | messageId: "remove", 35 | data: { subject: "numericRange" }, 36 | }, 37 | ], 38 | }, 39 | { 40 | code: `new Integration({ optionalPropStyle: {}, });`, 41 | output: `new Integration({ });`, 42 | errors: [ 43 | { 44 | messageId: "remove", 45 | data: { subject: "optionalPropStyle" }, 46 | }, 47 | ], 48 | }, 49 | { 50 | code: 51 | `const rule: Depicter = (schema, { next, path, method, isResponse }) ` + 52 | `=> ({ ...next(schema.unwrap()), summary: "test" })`, 53 | output: 54 | `const rule: Depicter = ({ zodSchema: schema, jsonSchema }, { path, method, isResponse }) ` + 55 | `=> ({ ...jsonSchema, summary: "test" })`, 56 | errors: [ 57 | { 58 | messageId: "change", 59 | data: { 60 | subject: "arguments", 61 | from: "[schema, { next, ...rest }]", 62 | to: "[{ zodSchema: schema, jsonSchema }, { ...rest }]", 63 | }, 64 | }, 65 | { 66 | messageId: "change", 67 | data: { subject: "statement", from: "next()", to: "jsonSchema" }, 68 | }, 69 | ], 70 | }, 71 | { 72 | code: `import {} from "zod";`, 73 | output: `import {} from "zod/v4";`, 74 | errors: [ 75 | { 76 | messageId: "change", 77 | data: { subject: "import", from: "zod", to: "zod/v4" }, 78 | }, 79 | ], 80 | }, 81 | { 82 | code: `ez.file("string");`, 83 | output: `z.string();`, 84 | errors: [ 85 | { 86 | messageId: "change", 87 | data: { subject: "schema", from: "ez.file()", to: "z.string()" }, 88 | }, 89 | ], 90 | }, 91 | { 92 | code: `ez.file("buffer");`, 93 | output: `ez.buffer();`, 94 | errors: [ 95 | { 96 | messageId: "change", 97 | data: { subject: "schema", from: "ez.file()", to: "ez.buffer()" }, 98 | }, 99 | ], 100 | }, 101 | { 102 | code: `ez.file("base64");`, 103 | output: `z.base64();`, 104 | errors: [ 105 | { 106 | messageId: "change", 107 | data: { subject: "schema", from: "ez.file()", to: "z.base64()" }, 108 | }, 109 | ], 110 | }, 111 | { 112 | code: `ez.file("binary");`, 113 | output: `ez.buffer().or(z.string());`, 114 | errors: [ 115 | { 116 | messageId: "change", 117 | data: { 118 | subject: "schema", 119 | from: "ez.file()", 120 | to: "ez.buffer().or(z.string())", 121 | }, 122 | }, 123 | ], 124 | }, 125 | ], 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /express-zod-api/tests/peer-helpers.spec.ts: -------------------------------------------------------------------------------- 1 | import { MissingPeerError } from "../src"; 2 | import { loadPeer } from "../src/peer-helpers"; 3 | 4 | describe("Peer loading helpers", () => { 5 | describe("loadPeer()", () => { 6 | test("should load the module", async () => { 7 | expect(await loadPeer("compression")).toBeTruthy(); 8 | }); 9 | test("should throw when module not found", async () => { 10 | await expect(async () => loadPeer("missing")).rejects.toThrow( 11 | new MissingPeerError("missing"), 12 | ); 13 | }); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /express-zod-api/tests/raw-schema.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v4"; 2 | import { ez } from "../src"; 3 | import { getBrand } from "../src/metadata"; 4 | import { ezRawBrand } from "../src/raw-schema"; 5 | 6 | describe("ez.raw()", () => { 7 | describe("creation", () => { 8 | test("should be an instance of branded object", () => { 9 | const schema = ez.raw(); 10 | expect(schema).toBeInstanceOf(z.ZodObject); 11 | expect(getBrand(schema)).toBe(ezRawBrand); 12 | }); 13 | }); 14 | 15 | describe("types", () => { 16 | test("without extension", () => { 17 | const schema = ez.raw(); 18 | expectTypeOf(schema._zod.output).toExtend<{ raw: Buffer }>(); 19 | }); 20 | 21 | test("with empty extension", () => { 22 | const schema = ez.raw({}); 23 | expectTypeOf(schema._zod.output).toExtend<{ raw: Buffer }>(); 24 | }); 25 | 26 | test("with populated extension", () => { 27 | const schema = ez.raw({ extra: z.number() }); 28 | expectTypeOf(schema._zod.output).toExtend<{ 29 | raw: Buffer; 30 | extra: number; 31 | }>(); 32 | }); 33 | }); 34 | 35 | describe("parsing", () => { 36 | test("should accept buffer as the raw prop", () => { 37 | const schema = ez.raw(); 38 | expect(schema.parse({ raw: Buffer.from("test"), extra: 123 })).toEqual({ 39 | raw: expect.any(Buffer), 40 | }); 41 | }); 42 | 43 | test("should allow extension", () => { 44 | const schema = ez.raw({ extra: z.number() }); 45 | expect(schema.parse({ raw: Buffer.from("test"), extra: 123 })).toEqual({ 46 | raw: expect.any(Buffer), 47 | extra: 123, 48 | }); 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /express-zod-api/tests/routable.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v4"; 2 | import { defaultEndpointsFactory, DependsOnMethod } from "../src"; 3 | 4 | const endpoint = defaultEndpointsFactory.build({ 5 | output: z.object({}), 6 | handler: vi.fn(), 7 | }); 8 | 9 | const methodDepending = new DependsOnMethod({ get: endpoint }); 10 | 11 | describe.each([methodDepending, endpoint])("Routable mixin %#", (subject) => { 12 | describe(".nest()", () => { 13 | test("should return Routing arrangement", () => { 14 | expect(subject).toHaveProperty("nest", expect.any(Function)); 15 | expect(subject.nest({ subpath: endpoint })).toEqual({ 16 | "": subject, 17 | subpath: endpoint, 18 | }); 19 | }); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /express-zod-api/tests/serve-static.spec.ts: -------------------------------------------------------------------------------- 1 | import { staticMock, staticHandler } from "./express-mock"; 2 | import { ServeStatic } from "../src"; 3 | 4 | describe("ServeStatic", () => { 5 | describe("constructor()", () => { 6 | test("should create an instance that provides original params", () => { 7 | const serverStatic = new ServeStatic(__dirname, { dotfiles: "deny" }); 8 | expect(serverStatic).toBeInstanceOf(ServeStatic); 9 | const handlerMock = vi.fn(); 10 | serverStatic.apply("/some/path", handlerMock); 11 | expect(staticMock).toHaveBeenCalledWith(__dirname, { dotfiles: "deny" }); 12 | expect(handlerMock).toHaveBeenCalledWith("/some/path", staticHandler); 13 | }); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /express-zod-api/tests/ssl-helpers.ts: -------------------------------------------------------------------------------- 1 | import forge from "node-forge"; 2 | 3 | const certAttr = [ 4 | { name: "commonName", value: "localhost" }, 5 | { name: "countryName", value: "DE" }, 6 | { name: "organizationName", value: "ExpressZodAPI" }, 7 | { shortName: "OU", value: "DEV" }, 8 | ]; 9 | 10 | const certExt = [ 11 | { name: "basicConstraints", cA: true }, 12 | { name: "extKeyUsage", serverAuth: true, clientAuth: true }, 13 | { name: "subjectAltName", altNames: [{ type: 2, value: "localhost" }] }, 14 | { 15 | name: "keyUsage", 16 | keyCertSign: true, 17 | digitalSignature: true, 18 | nonRepudiation: true, 19 | keyEncipherment: true, 20 | dataEncipherment: true, 21 | }, 22 | ]; 23 | 24 | export const signCert = () => { 25 | (forge as any).options.usePureJavaScript = true; 26 | const keys = forge.pki.rsa.generateKeyPair(2048); 27 | const cert = forge.pki.createCertificate(); 28 | cert.publicKey = keys.publicKey; 29 | cert.serialNumber = "01"; 30 | cert.validity.notBefore = new Date(); 31 | cert.validity.notAfter = new Date(); 32 | cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1); 33 | cert.setSubject(certAttr); 34 | cert.setIssuer(certAttr); 35 | cert.setExtensions(certExt); 36 | cert.sign(keys.privateKey, forge.md.sha256.create()); 37 | return { 38 | cert: forge.pki.certificateToPem(cert), 39 | key: forge.pki.privateKeyToPem(keys.privateKey), 40 | }; 41 | }; 42 | -------------------------------------------------------------------------------- /express-zod-api/tests/startup-logo.spec.ts: -------------------------------------------------------------------------------- 1 | import { WriteStream } from "node:tty"; 2 | import { printStartupLogo } from "../src/startup-logo"; 3 | 4 | describe("Startup logo", () => { 5 | describe("printStartupLogo()", () => { 6 | test("does nothing when TTY is too narrow", () => { 7 | const streamMock = { write: vi.fn(), columns: 131 }; 8 | printStartupLogo(streamMock as unknown as WriteStream); 9 | expect(streamMock.write).not.toHaveBeenCalled(); 10 | }); 11 | 12 | test("should print the logo when it fits", () => { 13 | const streamMock = { write: vi.fn(), columns: 132 }; 14 | printStartupLogo(streamMock as unknown as WriteStream); 15 | expect(streamMock.write).toHaveBeenCalledWith(expect.any(String)); 16 | expect(streamMock.write.mock.lastCall![0].split("\n")).toHaveLength(14); 17 | }); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /express-zod-api/tests/testing.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v4"; 2 | import { 3 | CommonConfig, 4 | defaultEndpointsFactory, 5 | Middleware, 6 | ResultHandler, 7 | testEndpoint, 8 | testMiddleware, 9 | } from "../src"; 10 | import type { Mock } from "vitest"; 11 | 12 | describe("Testing", () => { 13 | describe("testEndpoint()", () => { 14 | test("Should test an endpoint", async () => { 15 | const endpoint = defaultEndpointsFactory 16 | .addMiddleware({ 17 | handler: async ({ response }) => { 18 | response 19 | .setHeader("X-Some", "header") 20 | .header("X-Another", "header as well") 21 | .send("this is just for testing mocked methods"); 22 | return {}; 23 | }, 24 | }) 25 | .build({ 26 | output: z.object({}), 27 | handler: async () => ({}), 28 | }); 29 | const { responseMock, requestMock, loggerMock } = await testEndpoint({ 30 | endpoint, 31 | responseOptions: { locals: { prop1: vi.fn(), prop2: 123 } }, 32 | requestProps: { test1: vi.fn(), test2: 456 }, 33 | loggerProps: { feat1: vi.fn(), feat2: 789 }, 34 | }); 35 | expect(responseMock._getHeaders()).toEqual({ 36 | "x-some": "header", 37 | "x-another": "header as well", 38 | }); 39 | expect(responseMock._getData()).toBe( 40 | "this is just for testing mocked methods", 41 | ); 42 | expect(responseMock.locals).toHaveProperty("prop1", expect.any(Function)); 43 | expect(responseMock.locals).toHaveProperty("prop2", 123); 44 | expect(requestMock.res?.locals).toHaveProperty( 45 | "prop1", 46 | expect.any(Function), 47 | ); 48 | expect(requestMock.res?.locals).toHaveProperty("prop2", 123); 49 | expect(requestMock.test1).toEqual(expect.any(Function)); 50 | expect(requestMock.test2).toBe(456); 51 | expect(responseMock.req).toHaveProperty("test1", expect.any(Function)); 52 | expect(responseMock.req).toHaveProperty("test2", 456); 53 | expect(loggerMock.feat1).toEqual(expect.any(Function)); 54 | expect(loggerMock.feat2).toBe(789); 55 | expectTypeOf(requestMock.test1).toEqualTypeOf(); 56 | expectTypeOf(loggerMock.feat1).toEqualTypeOf(); 57 | }); 58 | }); 59 | 60 | describe("testMiddleware()", () => { 61 | test("Should test a middleware", async () => { 62 | const { output } = await testMiddleware({ 63 | requestProps: { method: "POST", body: { test: "something" } }, 64 | options: { prev: "accumulated" }, 65 | middleware: new Middleware({ 66 | input: z.object({ test: z.string() }), 67 | handler: async ({ options, input: { test } }) => ({ 68 | optKeys: Object.keys(options), 69 | inpLen: test.length, 70 | }), 71 | }), 72 | }); 73 | expect(output).toEqual({ 74 | optKeys: ["prev"], 75 | inpLen: 9, 76 | }); 77 | }); 78 | 79 | test.each([ 80 | undefined, 81 | new ResultHandler({ 82 | positive: [], 83 | negative: [], 84 | handler: ({ error, response }) => void response.end(error!.message), 85 | }), 86 | ])( 87 | "Issue #2153: should catch errors using errorHandler %#", 88 | async (errorHandler) => { 89 | const { output, loggerMock, responseMock } = await testMiddleware({ 90 | configProps: { errorHandler }, 91 | middleware: new Middleware({ 92 | input: z.object({}), 93 | handler: async ({ logger }) => { 94 | logger.info("logging something"); 95 | throw new Error("something went wrong"); 96 | }, 97 | }), 98 | }); 99 | expect(output).toEqual({}); 100 | expect(loggerMock._getLogs().info).toEqual([["logging something"]]); 101 | expect(responseMock._isJSON()).toBe(!errorHandler); 102 | if (responseMock._isJSON()) { 103 | expect(responseMock._getJSONData()).toEqual({ 104 | status: "error", 105 | error: { message: "something went wrong" }, 106 | }); 107 | } else { 108 | expect(responseMock._getData()).toMatch(/something went wrong/); 109 | } 110 | }, 111 | ); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /express-zod-api/tests/upload-schema.spec.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v4"; 2 | import { ez } from "../src"; 3 | import { getBrand } from "../src/metadata"; 4 | import { ezUploadBrand } from "../src/upload-schema"; 5 | 6 | describe("ez.upload()", () => { 7 | describe("creation", () => { 8 | test("should create an instance", () => { 9 | const schema = ez.upload(); 10 | expect(schema).toBeInstanceOf(z.ZodCustom); 11 | expect(getBrand(schema)).toBe(ezUploadBrand); 12 | }); 13 | }); 14 | 15 | describe("parsing", () => { 16 | test("should handle wrong parsed type", () => { 17 | const schema = ez.upload(); 18 | const result = schema.safeParse(123); 19 | expect(result.success).toBeFalsy(); 20 | if (!result.success) { 21 | expect(result.error.issues).toEqual([ 22 | { 23 | code: "custom", 24 | message: "Expected file upload, received number", 25 | path: [], 26 | }, 27 | ]); 28 | } 29 | }); 30 | 31 | test.each([vi.fn(async () => {}), vi.fn(() => {})])( 32 | "should accept UploadedFile %#", 33 | (mv) => { 34 | const schema = ez.upload(); 35 | const buffer = Buffer.from("something"); 36 | const result = schema.safeParse({ 37 | name: "avatar.jpg", 38 | mv, 39 | encoding: "utf-8", 40 | mimetype: "image/jpeg", 41 | data: buffer, 42 | tempFilePath: "", 43 | truncated: false, 44 | size: 100500, 45 | md5: "", 46 | }); 47 | expect(result).toMatchSnapshot(); 48 | }, 49 | ); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /express-zod-api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "module": "ES2022", 5 | "moduleResolution": "Bundler", 6 | "resolveJsonModule": true, 7 | "stripInternal": true 8 | }, 9 | "include": [ 10 | "src", 11 | "tests", 12 | "bench", 13 | "*.config.ts", 14 | "*.setup.ts" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /express-zod-api/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, Options } from "tsup"; 2 | import { version, engines, name } from "./package.json"; 3 | import semver from "semver"; 4 | 5 | const minNode = semver.minVersion(engines.node)!; 6 | 7 | const commons: Options = { 8 | format: ["cjs", "esm"], 9 | splitting: false, 10 | sourcemap: false, 11 | clean: true, 12 | dts: true, 13 | minify: true, 14 | target: `node${minNode.major}.${minNode.minor}.${minNode.patch}`, 15 | removeNodeProtocol: false, // @todo will be default in v9 16 | }; 17 | 18 | export default defineConfig([ 19 | { 20 | ...commons, 21 | name, 22 | entry: ["src/index.ts"], 23 | esbuildOptions: (options, { format }) => { 24 | options.supported = options.supported || {}; 25 | if (format === "cjs") { 26 | /** 27 | * Downgrade dynamic imports for CJS even they are actually supported, but still are problematic for Jest 28 | * @example jest with ts-jest 29 | * @link https://github.com/evanw/esbuild/issues/2651 30 | */ 31 | options.supported["dynamic-import"] = false; 32 | } 33 | options.define = { 34 | "process.env.TSUP_BUILD": `"v${version} (${format.toUpperCase()})"`, 35 | "process.env.TSUP_STATIC": `"static"`, // used by isProduction() 36 | }; 37 | }, 38 | }, 39 | { 40 | ...commons, 41 | name: "./migration".padStart(name.length), 42 | entry: { index: "src/migration.ts" }, 43 | outDir: "migration", 44 | /** 45 | * This replaces "export { _default as default }" with "export = _default" in the CJS DTS build 46 | * @link https://github.com/arethetypeswrong/arethetypeswrong.github.io/blob/main/docs/problems/MissingExportEquals.md 47 | * */ 48 | cjsInterop: true, 49 | skipNodeModulesBundle: true, 50 | }, 51 | ]); 52 | -------------------------------------------------------------------------------- /express-zod-api/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | import { fileURLToPath } from "node:url"; 3 | import { dirname, join } from "node:path"; 4 | 5 | export default defineConfig({ 6 | test: { 7 | globals: true, 8 | pool: "threads", 9 | testTimeout: 10000, 10 | setupFiles: join( 11 | dirname(fileURLToPath(import.meta.url)), 12 | "vitest.setup.ts", 13 | ), 14 | fakeTimers: { 15 | // vitest 3 mocks performance.now() by default which is used by BuiltinLogger and should not be affected 16 | toFake: ["setTimeout", "clearTimeout", "Date"], 17 | }, 18 | coverage: { 19 | reporter: [["text", { maxCols: 120 }], "json-summary", "html", "lcov"], 20 | include: ["src/**"], 21 | }, 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /express-zod-api/vitest.setup.ts: -------------------------------------------------------------------------------- 1 | import "./src/zod-plugin"; // required for tests importing sources using the plugin methods 2 | import type { NewPlugin } from "@vitest/pretty-format"; 3 | import { z } from "zod/v4"; 4 | import { ResultHandlerError } from "./src/errors"; 5 | import { getBrand } from "./src/metadata"; 6 | 7 | /** Takes cause and certain props of custom errors into account */ 8 | const errorSerializer: NewPlugin = { 9 | test: (subject) => subject instanceof Error, 10 | serialize: (error: Error, config, indentation, depth, refs, printer) => { 11 | const { name, message, cause } = error; 12 | const { handled } = error instanceof ResultHandlerError ? error : {}; 13 | const { issues } = error instanceof z.ZodError ? error : {}; 14 | const obj = Object.assign( 15 | {}, 16 | issues ? { issues } : { message }, // ZodError.message is a serialization of issues (looks bad in snapshot) 17 | cause && { cause }, 18 | handled && { handled }, 19 | ); 20 | return `${name}(${printer(obj, config, indentation, depth, refs)})`; 21 | }, 22 | }; 23 | 24 | const schemaSerializer: NewPlugin = { 25 | test: (subject) => subject instanceof z.ZodType, 26 | serialize: (entity: z.ZodType, config, indentation, depth, refs, printer) => { 27 | const serialization = z.toJSONSchema(entity, { 28 | unrepresentable: "any", 29 | override: ({ zodSchema, jsonSchema }) => { 30 | if (zodSchema._zod.def.type === "custom") 31 | jsonSchema["x-brand"] = getBrand(zodSchema); 32 | }, 33 | }); 34 | return printer(serialization, config, indentation, depth, refs); 35 | }, 36 | }; 37 | 38 | /** 39 | * @see https://github.com/vitest-dev/vitest/issues/5697 40 | * @see https://vitest.dev/guide/snapshot.html#custom-serializer 41 | */ 42 | const serializers = [errorSerializer, schemaSerializer]; 43 | for (const serializer of serializers) expect.addSnapshotSerializer(serializer); 44 | -------------------------------------------------------------------------------- /issue952-test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "issue952-test", 3 | "private": true, 4 | "version": "0.0.0", 5 | "scripts": { 6 | "test": "tsc -p tsconfig.json", 7 | "posttest": "rm *.d.ts" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /issue952-test/symbols.ts: -------------------------------------------------------------------------------- 1 | import { ez } from "express-zod-api"; 2 | 3 | export const schemas = { 4 | raw: ez.raw(), 5 | file: ez.buffer(), 6 | dateIn: ez.dateIn(), 7 | dateOut: ez.dateOut(), 8 | upload: ez.upload(), 9 | form: ez.form({}), 10 | }; 11 | -------------------------------------------------------------------------------- /issue952-test/tags.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defaultEndpointsFactory, 3 | TagOverrides, 4 | Documentation, 5 | } from "express-zod-api"; 6 | 7 | declare module "express-zod-api" { 8 | export interface TagOverrides { 9 | users: unknown; 10 | files: unknown; 11 | subscriptions: unknown; 12 | } 13 | } 14 | 15 | defaultEndpointsFactory.buildVoid({ 16 | tag: "users", 17 | handler: async () => {}, 18 | }); 19 | 20 | defaultEndpointsFactory.buildVoid({ 21 | tag: ["users", "files"], 22 | handler: async () => {}, 23 | }); 24 | 25 | expectTypeOf().toEqualTypeOf<{ 26 | users: unknown; 27 | files: unknown; 28 | subscriptions: unknown; 29 | }>(); 30 | 31 | new Documentation({ 32 | title: "", 33 | version: "", 34 | serverUrl: "", 35 | routing: {}, 36 | config: { cors: false }, 37 | tags: { 38 | users: "", 39 | files: { description: "", url: "" }, 40 | }, 41 | }); 42 | -------------------------------------------------------------------------------- /issue952-test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "emitDeclarationOnly": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "type": "module", 4 | "workspaces": [ 5 | "express-zod-api", 6 | "example", 7 | "cjs-test", 8 | "esm-test", 9 | "issue952-test" 10 | ], 11 | "scripts": { 12 | "start": "yarn workspace example start", 13 | "prebuild": "tsx tools/contributors.ts && tsx tools/license.ts", 14 | "build": "yarn workspace express-zod-api build && yarn workspace example build", 15 | "postbuild": "tsx tools/make-tests.ts", 16 | "test": "yarn workspace express-zod-api test", 17 | "test:example": "yarn workspace example test", 18 | "test:oas": "yarn workspace example validate", 19 | "test:cjs": "yarn workspace cjs-test test", 20 | "test:esm": "yarn workspace esm-test test", 21 | "test:952": "yarn workspace issue952-test test", 22 | "bench": "yarn workspace express-zod-api bench", 23 | "lint": "eslint && prettier *.md --check", 24 | "mdfix": "prettier *.md --write", 25 | "precommit": "yarn lint && yarn test && yarn build && git add *.md example/example.* LICENSE", 26 | "install_hooks": "husky" 27 | }, 28 | "devDependencies": { 29 | "@tsconfig/node20": "^20.1.5", 30 | "@types/compression": "^1.8.0", 31 | "@types/express": "^5.0.2", 32 | "@types/express-fileupload": "^1.5.0", 33 | "@types/http-errors": "^2.0.2", 34 | "@types/node": "^22.15.29", 35 | "@typescript-eslint/rule-tester": "^8.33.1", 36 | "@vitest/coverage-v8": "^3.2.1", 37 | "compression": "^1.8.0", 38 | "eslint": "^9.28.0", 39 | "eslint-config-prettier": "^10.1.5", 40 | "eslint-plugin-allowed-dependencies": "^1.3.0", 41 | "eslint-plugin-prettier": "^5.4.1", 42 | "express": "^5.1.0", 43 | "express-fileupload": "^1.5.0", 44 | "http-errors": "^2.0.0", 45 | "husky": "^9.0.5", 46 | "prettier": "3.5.3", 47 | "tsx": "^4.19.4", 48 | "typescript": "^5.8.3", 49 | "typescript-eslint": "^8.33.1", 50 | "vitest": "^3.2.1", 51 | "zod": "^3.25.51" 52 | }, 53 | "resolutions": { 54 | "**/@scarf/scarf": "npm:empty-npm-package@1.0.0" 55 | }, 56 | "packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610" 57 | } 58 | -------------------------------------------------------------------------------- /tools/contributors.ts: -------------------------------------------------------------------------------- 1 | import { readFile, writeFile } from "node:fs/promises"; 2 | 3 | const users = new Set(); 4 | const changelog = await readFile("CHANGELOG.md", "utf8"); 5 | const readme = await readFile("README.md", "utf8"); 6 | 7 | const links = changelog.matchAll(/\(https:\/\/github\.com\/([-\w]+)\)/g); 8 | for (const link of links) users.add(link[1]); 9 | 10 | const markdown = Array.from(users) 11 | .map( 12 | (user) => 13 | `[@${user}](https://github.com/${user})`, 14 | ) 15 | .join("\n"); 16 | 17 | const update = readme.replace( 18 | /## Contributors[^#]+#/, 19 | `## Contributors\n\n` + 20 | `These people contributed to the improvement of the framework by reporting bugs, making changes and suggesting ideas:\n\n` + 21 | `${markdown}\n\n#`, 22 | ); 23 | 24 | await writeFile("README.md", update, "utf8"); 25 | -------------------------------------------------------------------------------- /tools/license.ts: -------------------------------------------------------------------------------- 1 | import { writeFile } from "node:fs/promises"; 2 | import manifest from "../express-zod-api/package.json"; 3 | 4 | const text = ` 5 | MIT License 6 | 7 | Copyright (c) ${new Date().getFullYear()} ${manifest.author.name} 8 | 9 | Permission is hereby granted, free of charge, to any person obtaining a copy 10 | of this software and associated documentation files (the "Software"), to deal 11 | in the Software without restriction, including without limitation the rights 12 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | copies of the Software, and to permit persons to whom the Software is 14 | furnished to do so, subject to the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be included in all 17 | copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | SOFTWARE. 26 | `; 27 | 28 | await writeFile("LICENSE", text.trimStart(), "utf-8"); 29 | -------------------------------------------------------------------------------- /tools/make-tests.ts: -------------------------------------------------------------------------------- 1 | import assert from "node:assert/strict"; 2 | import { readFile, writeFile } from "node:fs/promises"; 3 | 4 | const extractQuickStartFromReadme = async () => { 5 | const readme = await readFile("README.md", "utf-8"); 6 | const quickStartSection = readme.match(/# Quick start(.+?)\n#\s[A-Z]+/s); 7 | assert(quickStartSection, "Can not find Quick Start section"); 8 | const tsParts = quickStartSection[1].match(/```typescript(.+?)```/gis); 9 | assert(tsParts, "Can not find typescript code samples"); 10 | return tsParts 11 | .map((part) => part.split("\n").slice(1, -1).join("\n")) 12 | .join("\n\n") 13 | .trim(); 14 | }; 15 | 16 | const quickStart = await extractQuickStartFromReadme(); 17 | 18 | const testContent = { 19 | cjs: quickStart, 20 | esm: quickStart, 21 | compat: quickStart, 22 | issue952: quickStart.replace(/const/g, "export const"), 23 | }; 24 | 25 | for (const testName in testContent) { 26 | await writeFile( 27 | `./${testName}-test/quick-start.ts`, 28 | testContent[testName as keyof typeof testContent], 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /tools/ports.ts: -------------------------------------------------------------------------------- 1 | import * as R from "ramda"; 2 | 3 | const disposer = (function* () { 4 | let port = 8e3 + 1e2 * Number(process.env.VITEST_POOL_ID); 5 | while (true) yield port++; 6 | })(); 7 | 8 | export const givePort = (test?: "example", rsvd = 8090): number => 9 | test 10 | ? rsvd 11 | : R.when(R.equals(rsvd), R.nAry(0, givePort))(disposer.next().value); 12 | -------------------------------------------------------------------------------- /tools/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "module": "ES2022", 5 | "moduleResolution": "Bundler", 6 | "resolveJsonModule": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node20/tsconfig.json", 3 | "compilerOptions": { 4 | "noImplicitAny": true, 5 | "noImplicitOverride": true, 6 | "strictNullChecks": true, 7 | "types": ["vitest/globals"] 8 | } 9 | } 10 | --------------------------------------------------------------------------------