├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ ├── create-pullrequest-prerelease.yml │ ├── publish.yml │ ├── semgrep.yml │ └── test.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .vitepress ├── _redirects └── config.mts ├── CODEOWNERS ├── LICENSE ├── README.md ├── biome.json ├── docs ├── advanced-topics-patterns.md ├── core-concepts.md ├── endpoints │ ├── auto │ │ ├── base.md │ │ └── d1.md │ ├── defining-endpoints.md │ ├── parameters.md │ ├── request-validation.md │ └── response-definition.md ├── error-handling.md ├── examples-and-recipes.md ├── getting-started.md ├── images │ ├── logo-icon.png │ ├── logo-square.png │ └── logo.png ├── index.md ├── introduction.md ├── openapi-configuration-customization.md ├── router-adapters.md └── troubleshooting-and-faq.md ├── package-lock.json ├── package.json ├── src ├── adapters │ ├── hono.ts │ └── ittyRouter.ts ├── contentTypes.ts ├── endpoints │ ├── create.ts │ ├── d1 │ │ ├── create.ts │ │ ├── delete.ts │ │ ├── list.ts │ │ ├── read.ts │ │ └── update.ts │ ├── delete.ts │ ├── list.ts │ ├── read.ts │ ├── types.ts │ └── update.ts ├── exceptions.ts ├── index.ts ├── openapi.ts ├── parameters.ts ├── route.ts ├── types.ts ├── ui.ts ├── utils.ts └── zod │ ├── registry.ts │ └── utils.ts ├── tests ├── bindings.d.ts ├── index.ts ├── integration │ ├── hono-types.test-d.ts │ ├── nested-routers-hono.test.ts │ ├── nested-routers.test.ts │ ├── openapi-schema.test.ts │ ├── openapi.test.disabled.ts │ ├── parameters.test.ts │ ├── router-options.test.ts │ └── zod.ts ├── router.ts ├── tsconfig.json ├── utils.ts ├── vitest.config.ts └── wrangler.toml └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [Makefile] 12 | indent_style = tab 13 | indent_size = 4 14 | 15 | [*.{md,mdx}] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | groups: 8 | major: 9 | update-types: [ "major" ] 10 | prod-deps: 11 | dependency-type: "production" 12 | dev-deps: 13 | dependency-type: "development" 14 | - package-ecosystem: "github-actions" 15 | directory: "/" 16 | schedule: 17 | interval: "weekly" 18 | -------------------------------------------------------------------------------- /.github/workflows/create-pullrequest-prerelease.yml: -------------------------------------------------------------------------------- 1 | name: Create Pull Request Prerelease 2 | on: 3 | push: 4 | branches: 5 | - "**" 6 | 7 | jobs: 8 | build: 9 | if: ${{ github.repository_owner == 'cloudflare' }} 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: 20.x 17 | cache: "npm" 18 | 19 | - run: npm install --frozen-lockfile 20 | - run: npm run build 21 | 22 | - name: Create package 23 | run: npm pack 24 | 25 | - name: Upload packaged chanfana artifact 26 | uses: actions/upload-artifact@v4 27 | with: 28 | name: npm-package-chanfana-${{ github.event.number }} # encode the PR number into the artifact name 29 | path: chanfana-*.tgz 30 | 31 | - name: 'Comment on PR with Link' 32 | uses: marocchino/sticky-pull-request-comment@v2 33 | with: 34 | number: ${{ env.WORKFLOW_RUN_PR }} 35 | message: | 36 | 🧪 A prerelease is available for testing 🧪 37 | 38 | You can install this latest build in your project with: 39 | 40 | ```sh 41 | npm install --save https://prerelease-registry.devprod.cloudflare.dev/chanfana/runs/${{ github.run_id }}/npm-package-chanfana-${{ github.event.number }} 42 | ``` 43 | 44 | Or you can immediately run this with `npx`: 45 | 46 | ```sh 47 | npx https://prerelease-registry.devprod.cloudflare.dev/chanfana/runs/${{ github.run_id }}/npm-package-chanfana-${{ github.event.number }} 48 | ``` 49 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | workflow_dispatch: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | publish: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version: 22.x 18 | cache: 'npm' 19 | registry-url: 'https://registry.npmjs.org' 20 | - name: Install modules 21 | run: npm install 22 | - name: Run build 23 | run: npm run lint && npm run build 24 | - name: Run tests 25 | run: npm run test 26 | - name: Publish to npm 27 | run: npm publish --access public 28 | env: 29 | NODE_AUTH_TOKEN: ${{ secrets.WRANGLER_PUBLISHER_NPM_TOKEN }} 30 | -------------------------------------------------------------------------------- /.github/workflows/semgrep.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: {} 3 | workflow_dispatch: {} 4 | push: 5 | branches: 6 | - main 7 | - master 8 | schedule: 9 | - cron: '0 0 * * *' 10 | name: Semgrep config 11 | jobs: 12 | semgrep: 13 | name: semgrep/ci 14 | runs-on: ubuntu-latest 15 | env: 16 | SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} 17 | SEMGREP_URL: https://cloudflare.semgrep.dev 18 | SEMGREP_APP_URL: https://cloudflare.semgrep.dev 19 | SEMGREP_VERSION_CHECK_URL: https://cloudflare.semgrep.dev/api/check-version 20 | container: 21 | image: semgrep/semgrep 22 | steps: 23 | - uses: actions/checkout@v4 24 | - run: semgrep ci 25 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: 5 | - "**" 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions/setup-node@v4 13 | with: 14 | node-version: 20.x 15 | cache: "npm" 16 | 17 | - run: npm install --frozen-lockfile 18 | - run: npm run build 19 | - run: npm run lint 20 | - run: npm run test 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | .parcel-cache 78 | 79 | # Next.js build output 80 | .next 81 | out 82 | 83 | # Nuxt.js build / generate output 84 | .nuxt 85 | dist 86 | 87 | # Gatsby files 88 | .cache/ 89 | # Comment in the public line in if your project uses Gatsby and not Next.js 90 | # https://nextjs.org/blog/next-9-1#public-directory-support 91 | # public 92 | 93 | # vuepress build output 94 | .vuepress/dist 95 | 96 | # Serverless directories 97 | .serverless/ 98 | 99 | # FuseBox cache 100 | .fusebox/ 101 | 102 | # DynamoDB Local files 103 | .dynamodb/ 104 | 105 | # TernJS port file 106 | .tern-port 107 | 108 | # Stores VSCode versions used for testing VSCode extensions 109 | .vscode-test 110 | 111 | # yarn v2 112 | .yarn/cache 113 | .yarn/unplugged 114 | .yarn/build-state.yml 115 | .yarn/install-state.gz 116 | .pnp.* 117 | 118 | 119 | # Ide 120 | .idea/ 121 | 122 | .DS_Store 123 | docs/site 124 | .wrangler 125 | .vitepress/cache 126 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm run lint 2 | npm run test 3 | -------------------------------------------------------------------------------- /.vitepress/_redirects: -------------------------------------------------------------------------------- 1 | /types / 302 2 | /types/ / 302 3 | /type-hint / 302 4 | /type-hint/ / 302 5 | /routers/hono /router-adapters 302 6 | /routers/hono/ /router-adapters 302 7 | /routers/itty-router /router-adapters 302 8 | /routers/itty-router/ /router-adapters 302 9 | /user-guide/* /endpoints/defining-endpoints 302 10 | /advanced-user-guide/* /examples-and-recipes 302 11 | /generic-cruds/* /endpoints/auto/base 302 12 | -------------------------------------------------------------------------------- /.vitepress/config.mts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from 'vitepress' 2 | 3 | // https://vitepress.dev/reference/site-config 4 | export default defineConfig({ 5 | srcDir: './docs', 6 | title: "chanfana", 7 | description: "OpenAPI 3 and 3.1 schema generator and validator for Hono, itty-router and more!", 8 | cleanUrls: true, 9 | head: [['link', {rel: 'icon', type: "image/png", href: '/images/logo-icon.png'}]], 10 | themeConfig: { 11 | // https://vitepress.dev/reference/default-theme-config 12 | logo: '/images/logo-icon.png', 13 | nav: [ 14 | {text: 'Home', link: '/'}, 15 | {text: 'Docs', link: '/introduction'}, 16 | {text: 'Examples', link: '/examples-and-recipes'} 17 | ], 18 | sidebar: [ 19 | { 20 | text: 'The Basics', 21 | items: [ 22 | {text: 'Introduction', link: '/introduction'}, 23 | {text: 'Getting Started', link: '/getting-started'}, 24 | {text: 'Core Concepts', link: '/core-concepts'}, 25 | {text: 'Router Adapters', link: '/router-adapters'}, 26 | {text: 'Examples and Recipes', link: '/examples-and-recipes'}, 27 | ] 28 | }, 29 | { 30 | text: 'Endpoints', 31 | items: [ 32 | {text: 'Defining Endpoints', link: '/endpoints/defining-endpoints'}, 33 | {text: 'Request Validation', link: '/endpoints/request-validation'}, 34 | {text: 'Response Definition', link: '/endpoints/response-definition'}, 35 | {text: 'Parameters', link: '/endpoints/parameters'}, 36 | ] 37 | }, 38 | { 39 | text: 'Auto Endpoints', 40 | items: [ 41 | {text: "Base Auto Endpoints", link: '/endpoints/auto/base'}, 42 | {text: 'D1 Auto Endpoints', link: '/endpoints/auto/d1'}, 43 | ] 44 | }, 45 | { 46 | text: 'Advanced Stuff', 47 | items: [ 48 | {text: 'Error Handling', link: '/error-handling'}, 49 | {text: 'OpenAPI Customization', link: '/openapi-configuration-customization'}, 50 | {text: 'Advanced Patterns', link: '/advanced-topics-patterns'}, 51 | {text: 'Troubleshooting And FAQ', link: '/troubleshooting-and-faq'}, 52 | ] 53 | }, 54 | ], 55 | socialLinks: [ 56 | {icon: 'github', link: 'https://github.com/cloudflare/chanfana'} 57 | ], 58 | footer: { 59 | message: 'Released under the MIT License.', 60 | copyright: 'Copyright © 2024-present Cloudflare' 61 | } 62 | } 63 | }) 64 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @g4brym @meddulla @carlosefr 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Cloudflare 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | chanfana 4 | 5 |
6 | 7 | 8 |

9 | OpenAPI 3 and 3.1 schema generator and validator for Hono, itty-router and more! 10 |

11 | 12 |
13 | 14 | **Documentation**: chanfana.pages.dev 15 | 16 | **Source Code**: github.com/cloudflare/chanfana 17 | 18 |
19 | 20 | [chanfana](https://github.com/cloudflare/chanfana) **(previously known as itty-router-openapi)** is a library that adds 21 | OpenAPI schema generation and validation to any router ( 22 | Hono, itty-router, etc), meant to be a 23 | powerful and lightweight 24 | library for Cloudflare Workers but runs on any runtime supported by the base router. 25 | 26 | The key features are: 27 | 28 | - OpenAPI 3 and 3.1 schema generator and validator 29 | - Fully written in typescript 30 | - [Class-based endpoints](https://chanfana.pages.dev/user-guide/first-steps/) 31 | - [Query](https://chanfana.pages.dev/user-guide/query-parameters/), [Path](https://chanfana.pages.dev/user-guide/path-parameters/), [Headers](https://chanfana.pages.dev/user-guide/header-parameters/) and [Body](https://chanfana.pages.dev/user-guide/request-body/) typescript inference 32 | - Extend existing [Hono](https://chanfana.pages.dev/routers/hono/), [itty-router](https://chanfana.pages.dev/routers/itty-router/), etc application, without touching old routes 33 | 34 | ## Getting started 35 | 36 | Get started with a template with this command: 37 | 38 | ```bash 39 | npm create cloudflare@latest -- --type openapi 40 | ``` 41 | 42 | ## Installation 43 | 44 | ```bash 45 | npm i chanfana --save 46 | ``` 47 | 48 | ## Minimal Hono Example 49 | 50 | ```ts 51 | import { fromHono, OpenAPIRoute } from 'chanfana' 52 | import { Hono } from 'hono' 53 | import { z } from 'zod' 54 | 55 | export type Env = { 56 | // Example bindings 57 | DB: D1Database 58 | BUCKET: R2Bucket 59 | } 60 | export type AppContext = Context<{ Bindings: Env }> 61 | 62 | export class GetPageNumber extends OpenAPIRoute { 63 | schema = { 64 | request: { 65 | params: z.object({ 66 | id: z.string().min(2).max(10), 67 | }), 68 | query: z.object({ 69 | page: z.number().int().min(0).max(20), 70 | }), 71 | }, 72 | } 73 | 74 | async handle(c: AppContext) { 75 | const data = await this.getValidatedData() 76 | 77 | return c.json({ 78 | id: data.params.id, 79 | page: data.query.page, 80 | }) 81 | } 82 | } 83 | 84 | // Start a Hono app 85 | const app = new Hono<{ Bindings: Env }>() 86 | 87 | // Setup OpenAPI registry 88 | const openapi = fromHono(app) 89 | 90 | // Register OpenAPI endpoints (this will also register the routes in Hono) 91 | openapi.get('/entry/:id', GetPageNumber) 92 | 93 | // Export the Hono app 94 | export default app 95 | ``` 96 | 97 | ## Feedback and contributions 98 | 99 | [chanfana](https://github.com/cloudflare/chanfana) aims to be at the core of new APIs built using 100 | Workers and define a pattern to allow everyone to 101 | have an OpenAPI-compliant schema without worrying about implementation details or reinventing the wheel. 102 | 103 | chanfana is considered stable and production ready and is being used with 104 | the [Radar 2.0 public API](https://developers.cloudflare.com/radar/) and many other Cloudflare products. 105 | 106 | You can also talk to us in the [Cloudflare Community](https://community.cloudflare.com/) or 107 | the [Radar Discord Channel](https://discord.com/channels/595317990191398933/1035553707116478495) 108 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "vcs": { 4 | "enabled": false, 5 | "clientKind": "git", 6 | "useIgnoreFile": false 7 | }, 8 | "files": { 9 | "ignoreUnknown": false, 10 | "ignore": ["dist", "docs", "example"] 11 | }, 12 | "formatter": { 13 | "enabled": true, 14 | "indentStyle": "space", 15 | "indentWidth": 2, 16 | "lineWidth": 120 17 | }, 18 | "organizeImports": { 19 | "enabled": true 20 | }, 21 | "javascript": { 22 | "formatter": { 23 | "quoteStyle": "double" 24 | } 25 | }, 26 | "linter": { 27 | "enabled": true, 28 | "rules": { 29 | "recommended": true, 30 | "complexity": { 31 | "noBannedTypes": "off", 32 | "noThisInStatic": "off" 33 | }, 34 | "suspicious": { 35 | "noExplicitAny": "off", 36 | "noImplicitAnyLet": "off" 37 | }, 38 | "performance": { 39 | "noAccumulatingSpread": "off" 40 | }, 41 | "style": { 42 | "noParameterAssign": "off" 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /docs/core-concepts.md: -------------------------------------------------------------------------------- 1 | # Core Concepts of Chanfana 2 | 3 | To effectively use Chanfana, it's important to understand its core concepts. This section will break down the fundamental ideas behind Chanfana and how they work together to simplify API development and documentation. 4 | 5 | ## OpenAPI Specification: The Foundation 6 | 7 | At its heart, Chanfana is built around the [OpenAPI Specification](https://www.openapis.org/) (formerly known as Swagger Specification). OpenAPI is a standard, language-agnostic format to describe RESTful APIs. It allows both humans and computers to understand the capabilities of an API without access to source code, documentation, or network traffic inspection. 8 | 9 | **Key benefits of using OpenAPI:** 10 | 11 | * **Standardized API Descriptions:** Provides a universal language for describing APIs, making them easier to understand and integrate with. 12 | * **Automated Documentation:** Enables the generation of interactive API documentation, like Swagger UI and ReDoc, directly from the specification. 13 | * **Code Generation:** Tools can automatically generate server stubs and client SDKs from OpenAPI specifications, speeding up development. 14 | * **API Design First:** Encourages designing your API contract before writing code, leading to better API design and consistency. 15 | 16 | Chanfana leverages the OpenAPI specification to generate documentation and perform request validation, ensuring your API is well-defined and robust. 17 | 18 | ## Schema Generation and Validation: Ensuring API Quality 19 | 20 | Chanfana's primary goal is to automate the generation of OpenAPI schemas and use these schemas for request validation. 21 | 22 | * **Schema Generation:** Chanfana automatically generates OpenAPI schemas by analyzing your endpoint definitions, specifically the `schema` property within your `OpenAPIRoute` classes. It uses [zod-to-openapi](https://github.com/asteasolutions/zod-to-openapi) under the hood to convert your Zod schemas into OpenAPI schema objects. This eliminates the need to manually write verbose YAML or JSON OpenAPI specifications. 23 | 24 | * **Request Validation:** Once you define your request schemas (for body, query parameters, path parameters, and headers), Chanfana automatically validates incoming requests against these schemas. This validation happens before your `handle` method is executed. If a request doesn't conform to the schema, Chanfana will automatically return a `400 Bad Request` response with detailed validation errors, protecting your API from invalid data and ensuring data integrity. 25 | 26 | By combining schema generation and validation, Chanfana helps you build APIs that are not only well-documented but also inherently more reliable and secure. 27 | 28 | ## Routers: Hono, Itty Router, and Beyond 29 | 30 | Chanfana is designed to be router-agnostic, meaning it can be integrated with various JavaScript web routers. Currently, it provides first-class adapters for: 31 | 32 | * **[Hono](https://github.com/honojs/hono):** A small, fast, and ultrafast web framework for the Edge. Hono is a popular choice for Cloudflare Workers and other edge runtimes. Chanfana's `fromHono` adapter seamlessly extends Hono with OpenAPI capabilities. 33 | 34 | * **[itty-router](https://github.com/kwhitley/itty-router):** A tiny, functional router, also very popular for Cloudflare Workers due to its simplicity and performance. Chanfana's `fromIttyRouter` adapter brings OpenAPI support to itty-router. 35 | 36 | **Extensibility:** 37 | 38 | Chanfana's architecture is designed to be extensible. While it provides adapters for Hono and itty-router out of the box, it can be adapted to work with other routers as well. The core logic of schema generation and validation is independent of the underlying router. If you are using a different router, you can potentially create a custom adapter to integrate Chanfana. 39 | 40 | ## `OpenAPIRoute`: Building Blocks of Your API 41 | 42 | The `OpenAPIRoute` class is the central building block for defining your API endpoints in Chanfana. It's an abstract class that you extend to create concrete endpoint implementations. 43 | 44 | **Key aspects of `OpenAPIRoute`:** 45 | 46 | * **Class-Based Structure:** Encourages organizing your endpoint logic within classes, promoting code reusability and maintainability. 47 | * **Schema Definition:** The `schema` property within your `OpenAPIRoute` subclass is where you define the OpenAPI schema for your endpoint, including request and response specifications. 48 | * **`handle` Method:** This is the core method where you implement the actual logic of your endpoint. It's executed after successful request validation. 49 | * **`getValidatedData()` Method:** Provides access to the validated request data (body, query parameters, path parameters, headers) within your `handle` method, ensuring you are working with data that conforms to your schema. 50 | * **Lifecycle Hooks (e.g., `before`, `after`, `create`, `update`, `delete`, `fetch`, `list` in auto endpoints):** Some subclasses of `OpenAPIRoute`, like the predefined CRUD endpoints, offer lifecycle hooks to customize behavior at different stages of the request processing. 51 | 52 | By extending `OpenAPIRoute`, you create reusable and well-defined endpoints that automatically benefit from schema generation and validation. 53 | 54 | ## Request and Response Schemas: Defining API Contracts 55 | 56 | The `schema` property in your `OpenAPIRoute` class is crucial for defining the contract of your API endpoint. It's an object that can contain the following key properties: 57 | 58 | * **`request`:** Defines the structure of the incoming request. It can specify schemas for: 59 | * `body`: Request body (typically for `POST`, `PUT`, `PATCH` requests). 60 | * `query`: Query parameters in the URL. 61 | * `params`: Path parameters in the URL path. 62 | * `headers`: HTTP headers. 63 | 64 | * **`responses`:** Defines the possible responses your API endpoint can return. For each response, you specify: 65 | * `statusCode`: The HTTP status code (e.g., "200", "400", "500"). 66 | * `description`: A human-readable description of the response. 67 | * `content`: The response body content, including the media type (e.g., "application/json") and the schema of the response body. 68 | 69 | Chanfana uses Zod schemas to define the structure of both requests and responses. Zod is a TypeScript-first schema declaration and validation library that is highly expressive and type-safe. Chanfana provides helper functions like `contentJson` to simplify defining JSON request and response bodies. 70 | 71 | ## Data Validation: Protecting Your API 72 | 73 | Data validation is automatically performed by Chanfana based on the request schemas you define in your `OpenAPIRoute` classes. 74 | 75 | **How validation works:** 76 | 77 | 1. **Request Interception:** When a request comes in for a Chanfana-managed route, Chanfana intercepts it before it reaches your `handle` method. 78 | 2. **Schema Parsing:** Chanfana parses the request data (body, query parameters, path parameters, headers) and attempts to validate it against the corresponding schemas defined in your endpoint's `schema.request` property. 79 | 3. **Zod Validation:** Under the hood, Chanfana uses Zod to perform the actual validation. Zod checks if the incoming data conforms to the defined schema rules (data types, required fields, formats, etc.). 80 | 4. **Success or Failure:** 81 | * **Success:** If the request data is valid according to the schema, Chanfana proceeds to execute your `handle` method. You can then access the validated data using `this.getValidatedData()`. 82 | * **Failure:** If the request data is invalid, Zod throws a `ZodError` exception. Chanfana catches this exception and automatically generates a `400 Bad Request` response. This response includes a JSON body containing detailed information about the validation errors, helping clients understand what went wrong and how to fix their requests. 83 | 84 | ## Error Handling: Graceful API Responses 85 | 86 | Chanfana provides a structured approach to error handling. When validation fails or when exceptions occur within your `handle` method, Chanfana aims to return informative and consistent error responses. 87 | 88 | * **Automatic Validation Error Responses:** As mentioned above, validation errors are automatically caught and transformed into `400 Bad Request` responses with detailed error messages. 89 | * **Exception Handling:** You can throw custom exceptions within your `handle` method to signal specific error conditions. Chanfana provides base exception classes like `ApiException`, `InputValidationException`, and `NotFoundException` that you can use or extend to create your own API-specific exceptions. These exceptions can be configured to automatically generate appropriate error responses and OpenAPI schema definitions for error responses. 90 | * **Consistent Error Format:** Chanfana encourages a consistent error response format (typically JSON) to make it easier for clients to handle errors from your API. 91 | 92 | By understanding these core concepts, you are well-equipped to start building robust, well-documented, and maintainable APIs with Chanfana. 93 | 94 | --- 95 | 96 | Next, let's explore how to [**Define Endpoints**](./endpoints/defining-endpoints.md) in more detail. 97 | -------------------------------------------------------------------------------- /docs/endpoints/defining-endpoints.md: -------------------------------------------------------------------------------- 1 | # Defining Endpoints 2 | 3 | The `OpenAPIRoute` class is the cornerstone of building APIs with Chanfana. It provides a structured and type-safe way to define your API endpoints, including their schemas and logic. This guide will delve into the details of using `OpenAPIRoute` to create robust and well-documented endpoints. 4 | 5 | ## Understanding the `OpenAPIRoute` Class 6 | 7 | `OpenAPIRoute` is an abstract class that serves as the base for all your API endpoint classes in Chanfana. To create an endpoint, you will extend this class and implement its properties and methods. 8 | 9 | **Key Components of an `OpenAPIRoute` Class:** 10 | 11 | * **`schema` Property:** This is where you define the OpenAPI schema for your endpoint. It's an object that specifies the structure of the request (body, query, params, headers) and the possible responses. The `schema` is crucial for both OpenAPI documentation generation and request validation. 12 | 13 | * **`handle(...args: any[])` Method:** This **asynchronous** method contains the core logic of your endpoint. It's executed when a valid request is received. The arguments passed to `handle` depend on the router adapter you are using (e.g., Hono's `Context` object). You are expected to return a `Response` object, a Promise that resolves to a `Response`, or a plain JavaScript object (which Chanfana will automatically convert to a JSON response). 14 | 15 | * **`getValidatedData()` Method:** This **asynchronous** method is available within your `handle` method. It allows you to access the validated request data. It returns a Promise that resolves to an object containing the validated `body`, `query`, `params`, and `headers` based on the schemas you defined in the `schema.request` property. TypeScript type inference is used to provide type safety based on your schema definition. 16 | 17 | ## Basic Endpoint Structure 18 | 19 | Here's the basic structure of an `OpenAPIRoute` class: 20 | 21 | ```typescript 22 | import { OpenAPIRoute } from 'chanfana'; 23 | import { z } from 'zod'; 24 | import { type Context } from 'hono'; 25 | 26 | class MyEndpoint extends OpenAPIRoute { 27 | schema = { 28 | // Define your OpenAPI schema here (request and responses) 29 | request: { 30 | // ... request schema (optional) 31 | }, 32 | responses: { 33 | // ... response schema (required) 34 | }, 35 | }; 36 | 37 | async handle(c: Context) { 38 | // Implement your endpoint logic here 39 | // Access validated data using this.getValidatedData() 40 | // Return a Response, Promise, or a plain object 41 | } 42 | } 43 | ``` 44 | 45 | ## Defining the `schema` 46 | 47 | The `schema` property is where you define the OpenAPI contract for your endpoint. Let's break down its components: 48 | 49 | ### Request Schema (`request`) 50 | 51 | The `request` property is an optional object that defines the structure of the incoming request. It can contain the following properties, each being a Zod schema: 52 | 53 | * **`body`:** Schema for the request body. Typically used for `POST`, `PUT`, and `PATCH` requests. You'll often use `contentJson` to define JSON request bodies. 54 | * **`query`:** Schema for query parameters in the URL. Use `z.object({})` to define the structure of query parameters. 55 | * **`params`:** Schema for path parameters in the URL path. Use `z.object({})` to define the structure of path parameters. 56 | * **`headers`:** Schema for HTTP headers. Use `z.object({})` to define the structure of headers. 57 | 58 | **Example: Request Schema with Body and Query Parameters** 59 | 60 | ```typescript 61 | import { OpenAPIRoute, contentJson } from 'chanfana'; 62 | import { z } from 'zod'; 63 | import { type Context } from 'hono'; 64 | 65 | class ExampleEndpoint extends OpenAPIRoute { 66 | schema = { 67 | request: { 68 | body: contentJson(z.object({ 69 | name: z.string().min(3), 70 | email: z.string().email(), 71 | })), 72 | query: z.object({ 73 | page: z.number().int().min(1).default(1), 74 | pageSize: z.number().int().min(1).max(100).default(20), 75 | }), 76 | }, 77 | responses: { 78 | // ... response schema 79 | }, 80 | }; 81 | 82 | async handle(c: Context) { 83 | const data = await this.getValidatedData(); 84 | // data.body will be of type { name: string, email: string } 85 | // data.query will be of type { page: number, pageSize: number } 86 | console.log("Validated Body:", data.body); 87 | console.log("Validated Query:", data.query); 88 | return { message: 'Request Validated!' }; 89 | } 90 | } 91 | ``` 92 | 93 | ### Response Schema (`responses`) 94 | 95 | The `responses` property is a **required** object that defines the possible responses your endpoint can return. It's structured as a dictionary where keys are HTTP status codes (e.g., "200", "400", "500") and values are response definitions. 96 | 97 | Each response definition should include: 98 | 99 | * **`description`:** A human-readable description of the response. 100 | * **`content`:** (Optional) Defines the response body content. You'll often use `contentJson` to define JSON response bodies. 101 | 102 | **Example: Response Schema with Success and Error Responses** 103 | 104 | ```typescript 105 | import { OpenAPIRoute, contentJson, InputValidationException } from 'chanfana'; 106 | import { z } from 'zod'; 107 | import { type Context } from 'hono'; 108 | 109 | class AnotherEndpoint extends OpenAPIRoute { 110 | schema = { 111 | responses: { 112 | "200": { 113 | description: 'Successful operation', 114 | content: contentJson(z.object({ 115 | status: z.string().default("success"), 116 | data: z.object({ id: z.number() }), 117 | })), 118 | }, 119 | ...InputValidationException.schema(), 120 | "500": { 121 | description: 'Internal Server Error', 122 | content: contentJson(z.object({ 123 | status: z.string().default("error"), 124 | message: z.string(), 125 | })), 126 | }, 127 | }, 128 | }; 129 | 130 | async handle(c: Context) { 131 | // ... your logic ... 132 | const success = Math.random() > 0.5; 133 | if (success) { 134 | return { status: "success", data: { id: 123 } }; 135 | } else { 136 | throw new Error("Something went wrong!"); // Example of throwing an error 137 | } 138 | } 139 | } 140 | ``` 141 | 142 | ## Implementing the `handle` Method 143 | 144 | The `handle` method is where you write the core logic of your API endpoint. It's an asynchronous method that receives arguments depending on the router adapter. 145 | 146 | **Inside the `handle` method, you typically:** 147 | 148 | 1. **Access Validated Data:** Use `this.getValidatedData()` to retrieve the validated request data. TypeScript will infer the types of `data.body`, `data.query`, `data.params`, and `data.headers` based on your schema. 149 | 2. **Implement Business Logic:** Perform the operations your endpoint is designed for (e.g., database interactions, calculations, external API calls). 150 | 3. **Return a Response:** 151 | * **Return a `Response` object directly:** You can construct a `Response` object using the built-in `Response` constructor or helper functions from your router framework (e.g., `c.json()` in Hono). 152 | * **Return a Promise that resolves to a `Response`:** If your logic is asynchronous, return a Promise that resolves to a `Response`. 153 | * **Return a plain JavaScript object:** Chanfana will automatically convert a plain JavaScript object into a JSON response with a `200 OK` status code. You can customize the status code and headers if needed by returning a `Response` object instead. 154 | 155 | **Example: `handle` Method Logic** 156 | 157 | ```typescript 158 | import { OpenAPIRoute, contentJson } from 'chanfana'; 159 | import { z } from 'zod'; 160 | import { type Context } from 'hono'; 161 | 162 | class UserEndpoint extends OpenAPIRoute { 163 | schema = { 164 | request: { 165 | params: z.object({ 166 | userId: z.string(), 167 | }), 168 | }, 169 | responses: { 170 | "200": { 171 | description: 'User details retrieved', 172 | content: contentJson(z.object({ 173 | id: z.string(), 174 | name: z.string(), 175 | email: z.string(), 176 | })), 177 | }, 178 | // ... error responses 179 | }, 180 | }; 181 | 182 | async handle(c: Context) { 183 | const data = await this.getValidatedData(); 184 | const userId = data.params.userId; 185 | 186 | // Simulate fetching user data (replace with actual database/service call) 187 | const user = { 188 | id: userId, 189 | name: `User ${userId}`, 190 | email: `user${userId}@example.com`, 191 | }; 192 | 193 | return { ...user }; // Return a plain object, Chanfana will convert to JSON 194 | } 195 | } 196 | ``` 197 | 198 | ## Accessing Validated Data with `getValidatedData()` 199 | 200 | The `getValidatedData()` method is crucial for accessing the validated request data within your `handle` method. 201 | 202 | **Key features of `getValidatedData()`:** 203 | 204 | * **Type Safety:** By using `getValidatedData()`, you get strong TypeScript type inference. The returned `data` object will have properties (`body`, `query`, `params`, `headers`) that are typed according to your schema definitions. This significantly improves code safety and developer experience. 205 | * **Asynchronous Operation:** `getValidatedData()` is an asynchronous method because it performs request validation. You need to `await` its result before accessing the validated data. 206 | * **Error Handling:** If the request validation fails, `getValidatedData()` will throw a `ZodError` exception. Chanfana automatically catches this exception and returns a `400 Bad Request` response. You typically don't need to handle validation errors explicitly within your `handle` method unless you want to customize the error response further. 207 | 208 | **Example: Using `getValidatedData()`** 209 | 210 | ```typescript 211 | import { type Context } from 'hono'; 212 | 213 | async handle(c: Context) { 214 | const data = await this.getValidatedData(); 215 | const userName = data.body.name; // TypeScript knows data.body.name is a string 216 | const pageNumber = data.query.page; // TypeScript knows data.query.page is a number 217 | 218 | // ... use validated data in your logic ... 219 | } 220 | ``` 221 | 222 | ## Example: A Simple Greeting Endpoint 223 | 224 | Let's put it all together with a simple greeting endpoint that takes a name as a query parameter and returns a personalized greeting. 225 | 226 | ```typescript 227 | import { Hono, type Context } from 'hono'; 228 | import { fromHono, OpenAPIRoute } from 'chanfana'; 229 | import { z } from 'zod'; 230 | 231 | export type Env = { 232 | // Example bindings, use your own 233 | DB: D1Database 234 | BUCKET: R2Bucket 235 | } 236 | export type AppContext = Context<{ Bindings: Env }> 237 | 238 | class GreetingEndpoint extends OpenAPIRoute { 239 | schema = { 240 | request: { 241 | query: z.object({ 242 | name: z.string().min(1).describe("Name to greet"), 243 | }), 244 | }, 245 | responses: { 246 | "200": { 247 | description: 'Greeting message', 248 | content: contentJson(z.object({ 249 | greeting: z.string(), 250 | })), 251 | }, 252 | }, 253 | }; 254 | 255 | async handle(c: AppContext) { 256 | const data = await this.getValidatedData(); 257 | const name = data.query.name; 258 | return { greeting: `Hello, ${name}! Welcome to Chanfana.` }; 259 | } 260 | } 261 | 262 | const app = new Hono<{ Bindings: Env }>(); 263 | const openapi = fromHono(app); 264 | openapi.get('/greet', GreetingEndpoint); 265 | 266 | export default app; 267 | ``` 268 | 269 | This example demonstrates the basic structure of an `OpenAPIRoute`, defining a schema for query parameters and responses, and implementing the endpoint logic in the `handle` method. 270 | 271 | --- 272 | 273 | In the next sections, we will explore request validation and response definition in more detail, along with the various parameter types Chanfana provides. Let's start with [**Request Validation in Detail**](./request-validation.md). 274 | -------------------------------------------------------------------------------- /docs/endpoints/request-validation.md: -------------------------------------------------------------------------------- 1 | # Request Validation 2 | 3 | Chanfana's automatic request validation is a key feature that ensures your API receives and processes only valid data. This section dives deep into how request validation works for different parts of an HTTP request: body, query parameters, path parameters, and headers. 4 | 5 | ## Validating Request Body 6 | 7 | Request body validation is crucial for `POST`, `PUT`, and `PATCH` requests where clients send data to your API in the request body. Chanfana primarily supports JSON request bodies and uses Zod schemas to define their structure. 8 | 9 | ### Using `contentJson` for JSON Bodies 10 | 11 | The `contentJson` helper function simplifies defining JSON request bodies in your `schema.request.body`. It automatically sets the `content-type` to `application/json` and wraps your Zod schema appropriately for OpenAPI. 12 | 13 | **Example: Validating a User Creation Body** 14 | 15 | ```typescript 16 | import { OpenAPIRoute, contentJson } from 'chanfana'; 17 | import { z } from 'zod'; 18 | import { type Context } from 'hono'; 19 | 20 | class CreateUserEndpoint extends OpenAPIRoute { 21 | schema = { 22 | request: { 23 | body: contentJson(z.object({ 24 | username: z.string().min(3).max(20), 25 | password: z.string().min(8), 26 | email: z.string().email(), 27 | fullName: z.string().optional(), 28 | age: z.number().int().positive().optional(), 29 | })), 30 | }, 31 | responses: { 32 | // ... responses 33 | }, 34 | }; 35 | 36 | async handle(c: Context) { 37 | const data = await this.getValidatedData(); 38 | const userDetails = data.body; // Type-safe access to validated body 39 | 40 | // ... logic to create a user ... 41 | return { message: 'User created successfully' }; 42 | } 43 | } 44 | ``` 45 | 46 | In this example: 47 | 48 | * We use `contentJson` to wrap a Zod object schema that defines the expected structure of the JSON request body. 49 | * The schema specifies fields like `username`, `password`, `email`, `fullName`, and `age` with their respective types and validation rules (e.g., `min`, `max`, `email`, `int`, `positive`, `optional`). 50 | * In the `handle` method, `this.getValidatedData().body` will be automatically typed as: 51 | 52 | ```typescript 53 | { 54 | username: string; 55 | password: string; 56 | email: string; 57 | fullName?: string | undefined; 58 | age?: number | undefined; 59 | } 60 | ``` 61 | 62 | ### Zod Schemas for Body 63 | 64 | You can use the full power of Zod to define complex validation rules for your request bodies. This includes: 65 | 66 | * **Data Types:** `z.string()`, `z.number()`, `z.boolean()`, `z.date()`, `z.array()`, `z.object()`, `z.enum()`, etc. 67 | * **String Validations:** `min()`, `max()`, `email()`, `url()`, `uuid()`, `regex()`, etc. 68 | * **Number Validations:** `int()`, `positive()`, `negative()`, `min()`, `max()`, etc. 69 | * **Array Validations:** `min()`, `max()`, `nonempty()`, `unique()`, etc. 70 | * **Object Validations:** `required()`, `optional()`, `partial()`, `strict()`, `refine()`, etc. 71 | * **Transformations:** `transform()`, `preprocess()`, etc. 72 | * **Effects:** `refinement()`, `superRefine()`, etc. 73 | 74 | Refer to the [Zod documentation](https://zod.dev/) for a comprehensive list of validation methods and features. 75 | 76 | ### Body Type Inference 77 | 78 | Chanfana leverages TypeScript's type inference capabilities. When you use `getValidatedData().body`, TypeScript automatically infers the type of `data.body` based on the Zod schema you defined in `schema.request.body`. This provides excellent type safety and autocompletion in your code editor. 79 | 80 | ## Validating Query Parameters 81 | 82 | Query parameters are key-value pairs appended to the URL after the `?` symbol (e.g., `/items?page=1&pageSize=20`). Chanfana validates query parameters using Zod schemas defined in `schema.request.query`. 83 | 84 | ### Defining Query Parameter Schema with Zod 85 | 86 | Use `z.object({})` within `schema.request.query` to define the expected query parameters and their validation rules. 87 | 88 | **Example: Filtering Resources with Query Parameters** 89 | 90 | ```typescript 91 | import { OpenAPIRoute } from 'chanfana'; 92 | import { z } from 'zod'; 93 | import { type Context } from 'hono'; 94 | 95 | class ListProductsEndpoint extends OpenAPIRoute { 96 | schema = { 97 | request: { 98 | query: z.object({ 99 | category: z.string().optional().describe("Filter by product category"), 100 | minPrice: z.number().min(0).optional().describe("Filter products with minimum price"), 101 | maxPrice: z.number().min(0).optional().describe("Filter products with maximum price"), 102 | sortBy: z.enum(['price', 'name', 'date']).default('name').describe("Sort products by field"), 103 | sortOrder: z.enum(['asc', 'desc']).default('asc').describe("Sort order"), 104 | page: z.number().int().min(1).default(1).describe("Page number for pagination"), 105 | pageSize: z.number().int().min(1).max(100).default(20).describe("Number of items per page"), 106 | }), 107 | }, 108 | responses: { 109 | // ... responses 110 | }, 111 | }; 112 | 113 | async handle(c: Context) { 114 | const data = await this.getValidatedData(); 115 | const queryParams = data.query; // Type-safe access to validated query parameters 116 | 117 | // ... logic to fetch and filter products based on queryParams ... 118 | return { message: 'Product list retrieved' }; 119 | } 120 | } 121 | ``` 122 | 123 | In this example: 124 | 125 | * We define a Zod object schema for `schema.request.query` with various query parameters like `category`, `minPrice`, `maxPrice`, `sortBy`, `sortOrder`, `page`, and `pageSize`. 126 | * Each parameter is defined with its type, validation rules (e.g., `optional()`, `min()`, `enum()`, `default()`), and a `describe()` method to add descriptions for OpenAPI documentation. 127 | * `this.getValidatedData().query` will be typed according to the schema, providing type-safe access to validated query parameters. 128 | 129 | ### Query Parameter Type Inference 130 | 131 | Similar to request bodies, Chanfana infers the types of query parameters based on your Zod schema. `data.query` will be an object with properties corresponding to your query parameter names, and their types will match the Zod schema definitions. 132 | 133 | ## Validating Path Parameters 134 | 135 | Path parameters are dynamic segments in the URL path, denoted by colons (e.g., `/users/:userId`). Chanfana validates path parameters using Zod schemas defined in `schema.request.params`. 136 | 137 | ### Defining Path Parameter Schema with Zod 138 | 139 | Use `z.object({})` within `schema.request.params` to define the expected path parameters and their validation rules. 140 | 141 | **Example: Retrieving a Resource by ID** 142 | 143 | ```typescript 144 | import { OpenAPIRoute } from 'chanfana'; 145 | import { z } from 'zod'; 146 | import { type Context } from 'hono'; 147 | 148 | class GetProductEndpoint extends OpenAPIRoute { 149 | schema = { 150 | request: { 151 | params: z.object({ 152 | productId: z.string().uuid().describe("Unique ID of the product"), 153 | }), 154 | }, 155 | responses: { 156 | // ... responses 157 | }, 158 | }; 159 | 160 | async handle(c: Context) { 161 | const data = await this.getValidatedData(); 162 | const productId = data.params.productId; // Type-safe access to validated path parameter 163 | 164 | // ... logic to fetch product details based on productId ... 165 | return { message: `Product ${productId} details retrieved` }; 166 | } 167 | } 168 | ``` 169 | 170 | In this example: 171 | 172 | * We define a Zod object schema for `schema.request.params` with a single path parameter `productId`. 173 | * The `productId` is defined as a `z.string().uuid()` to ensure it's a valid UUID. 174 | * `this.getValidatedData().params` will be typed as: 175 | 176 | ```typescript 177 | { 178 | productId: string; 179 | } 180 | ``` 181 | 182 | ### Path Parameter Type Inference 183 | 184 | Type inference works similarly for path parameters. `data.params` will be an object with properties corresponding to your path parameter names, and their types will be inferred from your Zod schema. 185 | 186 | ## Validating Headers 187 | 188 | Headers are metadata sent with HTTP requests. Chanfana allows you to validate specific headers using Zod schemas defined in `schema.request.headers`. 189 | 190 | ### Defining Header Schema with Zod 191 | 192 | Use `z.object({})` within `schema.request.headers` to define the headers you want to validate and their rules. 193 | 194 | **Example: API Key Authentication via Headers** 195 | 196 | ```typescript 197 | import { OpenAPIRoute } from 'chanfana'; 198 | import { z } from 'zod'; 199 | import { type Context } from 'hono'; 200 | 201 | class AuthenticatedEndpoint extends OpenAPIRoute { 202 | schema = { 203 | request: { 204 | headers: z.object({ 205 | 'X-API-Key': z.string().describe("API Key for authentication"), 206 | }), 207 | }, 208 | responses: { 209 | // ... responses 210 | }, 211 | }; 212 | 213 | async handle(c: Context) { 214 | const data = await this.getValidatedData(); 215 | const apiKey = data.headers['X-API-Key']; // Type-safe access to validated header 216 | 217 | // ... logic to authenticate user based on apiKey ... 218 | return { message: 'Authenticated request' }; 219 | } 220 | } 221 | ``` 222 | 223 | In this example: 224 | 225 | * We define a Zod object schema for `schema.request.headers` to validate the `X-API-Key` header. 226 | * `this.getValidatedData().headers` will be typed as: 227 | 228 | ```typescript 229 | { 230 | 'X-API-Key': string; 231 | } 232 | ``` 233 | 234 | ### Header Parameter Type Inference 235 | 236 | Type inference also applies to headers. `data.headers` will be an object with properties corresponding to your header names (in lowercase), and their types will be inferred from your Zod schema. 237 | 238 | --- 239 | 240 | By leveraging Chanfana's request validation capabilities, you can build APIs that are more secure, reliable, and easier to maintain. Validation ensures that your API logic only processes valid data, reducing errors and improving the overall API experience. 241 | 242 | Next, let's move on to [**Crafting Response Definitions**](./response-definition.md) to learn how to define the responses your API endpoints will return. 243 | -------------------------------------------------------------------------------- /docs/endpoints/response-definition.md: -------------------------------------------------------------------------------- 1 | # Response Definitions 2 | 3 | Defining clear and comprehensive response definitions is as important as request validation for building well-documented and predictable APIs. Chanfana makes it easy to define your API responses within the `schema.responses` property of your `OpenAPIRoute` classes. This section will guide you through crafting effective response definitions. 4 | 5 | ## Structuring the `responses` Schema 6 | 7 | The `responses` property in your `OpenAPIRoute.schema` is an object that defines all possible HTTP responses your endpoint can return. It's structured as a dictionary where: 8 | 9 | * **Keys are HTTP Status Codes:** These are strings representing HTTP status codes (e.g., `"200"`, `"201"`, `"400"`, `"404"`, `"500"`). You should define responses for all relevant status codes your endpoint might return, including success and error scenarios. 10 | * **Values are Response Definitions:** Each value is an object that defines the details of the response for the corresponding status code. 11 | 12 | ## Defining Response Status Codes 13 | 14 | You should define responses for all relevant HTTP status codes that your endpoint might return. Common categories include: 15 | 16 | ### Success Responses (2xx) 17 | 18 | These status codes indicate that the request was successfully processed. Common success status codes include: 19 | 20 | * **`"200"` (OK):** Standard response for successful GET, PUT, PATCH, and DELETE requests. Typically used when returning data or confirming a successful operation. 21 | * **`"201"` (Created):** Response for successful POST requests that result in the creation of a new resource. Often includes details of the newly created resource in the response body and a `Location` header pointing to the resource's URL. 22 | * **`"204"` (No Content):** Response for successful requests that don't return any content in the response body, such as successful DELETE operations or updates where no data needs to be returned. 23 | 24 | **Example: Success Responses** 25 | 26 | ```typescript 27 | import { OpenAPIRoute, contentJson } from 'chanfana'; 28 | import { z } from 'zod'; 29 | import { type Context } from 'hono'; 30 | 31 | class CreateResourceEndpoint extends OpenAPIRoute { 32 | schema = { 33 | // ... request schema ... 34 | responses: { 35 | "201": { 36 | description: 'Resource created successfully', 37 | content: contentJson(z.object({ 38 | id: z.string(), 39 | createdAt: z.string().datetime(), 40 | })), 41 | headers: z.object({ 42 | 'Location': z.string().url().describe('URL of the newly created resource'), 43 | }), 44 | }, 45 | "400": { description: 'Bad Request' }, // Example error response 46 | }, 47 | }; 48 | 49 | async handle(c: Context) { 50 | // ... logic to create resource ... 51 | const newResourceId = 'resource-123'; 52 | const resource = { id: newResourceId, createdAt: new Date().toISOString() }; 53 | return new Response(JSON.stringify(resource), { 54 | status: 201, 55 | headers: { 'Location': `/resources/${newResourceId}` }, 56 | }); 57 | } 58 | } 59 | ``` 60 | 61 | ### Error Responses (4xx, 5xx) 62 | 63 | These status codes indicate that an error occurred during request processing. It's crucial to define error responses to provide clients with information about what went wrong and how to fix it. Common error status code categories include: 64 | 65 | * **4xx Client Errors:** Indicate errors caused by the client's request (e.g., invalid input, unauthorized access). 66 | * **`"400"` (Bad Request):** Generic client error, often used for validation failures. 67 | * **`"401"` (Unauthorized):** Indicates missing or invalid authentication credentials. 68 | * **`"403"` (Forbidden):** Indicates that the client is authenticated but doesn't have permission to access the resource. 69 | * **`"404"` (Not Found):** Indicates that the requested resource could not be found. 70 | * **`"409"` (Conflict):** Indicates a conflict with the current state of the resource (e.g., trying to create a resource that already exists). 71 | * **`"422"` (Unprocessable Entity):** Used for validation errors when the server understands the request entity but is unable to process it (more semantically correct than 400 for validation errors in some contexts). 72 | 73 | * **5xx Server Errors:** Indicate errors on the server side. 74 | * **`"500"` (Internal Server Error):** Generic server error, should be avoided in detailed API responses but can be used as a fallback. 75 | * **`"503"` (Service Unavailable):** Indicates that the server is temporarily unavailable (e.g., due to overload or maintenance). 76 | 77 | **Example: Error Responses** 78 | 79 | ```typescript 80 | import { OpenAPIRoute, contentJson, InputValidationException, NotFoundException } from 'chanfana'; 81 | import { z } from 'zod'; 82 | import { type Context } from 'hono'; 83 | 84 | class GetItemEndpoint extends OpenAPIRoute { 85 | schema = { 86 | request: { 87 | params: z.object({ itemId: z.string() }), 88 | }, 89 | responses: { 90 | "200": { 91 | description: 'Item details retrieved', 92 | content: contentJson(z.object({ 93 | id: z.string(), 94 | name: z.string(), 95 | })), 96 | }, 97 | ...InputValidationException.schema(), 98 | ...NotFoundException.schema(), 99 | "500": { 100 | description: 'Internal Server Error', 101 | content: contentJson(z.object({ 102 | error: z.string(), 103 | })), 104 | }, 105 | }, 106 | }; 107 | 108 | getItemFromDatabase(itemId: string) { 109 | // ... your database lookup logic ... 110 | // Simulate item not found for certain IDs 111 | if (itemId === 'item-not-found') return null; 112 | return { id: itemId, name: `Item ${itemId}` }; 113 | } 114 | 115 | async handle(c: Context) { 116 | const data = await this.getValidatedData(); 117 | const itemId = data.params.itemId; 118 | 119 | // Simulate item retrieval (replace with actual logic) 120 | const item = this.getItemFromDatabase(itemId); // Assume this function might return null 121 | 122 | if (!item) { 123 | throw new NotFoundException(`Item with ID '${itemId}' not found`); 124 | } 125 | 126 | return { ...item }; 127 | } 128 | } 129 | ``` 130 | 131 | In this example, we define responses for: 132 | 133 | * `"200"` (OK) for successful item retrieval. 134 | * `"400"` (Bad Request) using the schema from `InputValidationException` (for validation errors). 135 | * `"404"` (Not Found) using the schema from `NotFoundException` (when the item is not found). 136 | * `"500"` (Internal Server Error) for generic server-side errors. 137 | 138 | ## Defining Response Bodies 139 | 140 | For each response status code, you can define a response body using the `content` property. Similar to request bodies, you'll often use `contentJson` for JSON response bodies. 141 | 142 | ### Using `contentJson` for JSON Responses 143 | 144 | `contentJson` simplifies defining JSON response bodies. It sets the `content-type` to `application/json` and wraps your Zod schema for OpenAPI. 145 | 146 | **Example: Defining Response Body Schema with Zod** 147 | 148 | In the examples above, we've already seen how to use `contentJson` to define response bodies. Here's a recap: 149 | 150 | ```typescript 151 | responses: { 152 | "200": { 153 | description: 'Successful response with user data', 154 | content: contentJson(z.object({ 155 | id: z.string(), 156 | username: z.string(), 157 | email: z.string(), 158 | })), 159 | }, 160 | // ... other responses ... 161 | } 162 | ``` 163 | 164 | ### Zod Schemas for Response Bodies 165 | 166 | You can use the same Zod schema capabilities for response bodies as you do for request bodies. Define the structure and data types of your response payloads using Zod's rich set of validation and schema definition methods. 167 | 168 | ## Example Responses 169 | 170 | Let's look at a complete example that defines both request and response schemas for a simple endpoint: 171 | 172 | ```typescript 173 | import { Hono, type Context } from 'hono'; 174 | import { fromHono, OpenAPIRoute, contentJson, InputValidationException, NotFoundException } from 'chanfana'; 175 | import { z } from 'zod'; 176 | 177 | export type Env = { 178 | // Example bindings, use your own 179 | DB: D1Database 180 | BUCKET: R2Bucket 181 | } 182 | export type AppContext = Context<{ Bindings: Env }> 183 | 184 | class ProductEndpoint extends OpenAPIRoute { 185 | schema = { 186 | request: { 187 | params: z.object({ 188 | productId: z.string().uuid().describe("Unique ID of the product"), 189 | }), 190 | }, 191 | responses: { 192 | "200": { 193 | description: 'Product details retrieved', 194 | content: contentJson(z.object({ 195 | id: z.string(), 196 | name: z.string(), 197 | description: z.string().optional(), 198 | price: z.number().positive(), 199 | imageUrl: z.string().url().optional(), 200 | })), 201 | }, 202 | ...InputValidationException.schema(), 203 | ...NotFoundException.schema(), 204 | "500": { 205 | description: 'Internal Server Error', 206 | content: contentJson(z.object({ 207 | error: z.string(), 208 | })), 209 | }, 210 | }, 211 | }; 212 | 213 | getProductFromDatabase(productId: string) { 214 | // ... your database lookup logic ... 215 | // Simulate product data 216 | return { 217 | id: productId, 218 | name: `Awesome Product ${productId}`, 219 | description: 'This is a simulated product for demonstration.', 220 | price: 99.99, 221 | imageUrl: 'https://example.com/product-image.jpg', 222 | }; 223 | } 224 | 225 | async handle(c: AppContext) { 226 | const data = await this.getValidatedData(); 227 | const productId = data.params.productId; 228 | 229 | // Simulate fetching product data (replace with actual logic) 230 | const product = this.getProductFromDatabase(productId); 231 | 232 | if (!product) { 233 | throw new NotFoundException(`Product with ID '${productId}' not found`); 234 | } 235 | 236 | return { ...product }; 237 | } 238 | } 239 | 240 | const app = new Hono<{ Bindings: Env }>(); 241 | const openapi = fromHono(app); 242 | openapi.get('/products/:productId', ProductEndpoint); 243 | 244 | export default app; 245 | ``` 246 | 247 | This comprehensive example demonstrates how to define both success and error responses with detailed schemas for the response bodies, making your API documentation clear and your API behavior predictable for clients. 248 | 249 | --- 250 | 251 | Next, we will explore [**Leveraging Auto Endpoints for CRUD Operations**](./auto/base.md) to see how Chanfana simplifies common API patterns. 252 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | This guide will walk you through the initial steps to get Chanfana up and running. We'll cover installation, setting up basic examples with both Hono and itty-router, and exploring the automatically generated OpenAPI documentation. 4 | 5 | ## Prerequisites 6 | 7 | Before you begin, ensure you have the following installed: 8 | 9 | * **Node.js** (version 18 or later recommended) and **npm** (Node Package Manager) or **yarn**. 10 | * **A text editor or IDE** (like VS Code) for writing code. 11 | 12 | ## Installation 13 | 14 | Installing Chanfana is straightforward using npm or yarn: 15 | 16 | **Using npm:** 17 | 18 | ```bash 19 | npm install chanfana --save 20 | ``` 21 | 22 | **Using yarn:** 23 | 24 | ```bash 25 | yarn add chanfana 26 | ``` 27 | 28 | ## Quick Start with Hono 29 | 30 | Let's create a simple "Hello, World!" API endpoint using Hono and Chanfana. 31 | 32 | ### Creating Your First Endpoint (Hono) 33 | 34 | 1. **Create a new project directory:** 35 | 36 | ```bash 37 | mkdir chanfana-hono-example 38 | cd chanfana-hono-example 39 | npm init -y # or yarn init -y 40 | ``` 41 | 42 | 2. **Install dependencies:** 43 | 44 | ```bash 45 | npm install hono chanfana zod --save # or yarn add hono chanfana zod 46 | ``` 47 | 48 | 3. **Create a file named `index.ts` (or `src/index.ts` if you are setting up a more structured project) and add the following code:** 49 | 50 | ```typescript 51 | import { Hono, type Context } from 'hono'; 52 | import { fromHono, OpenAPIRoute } from 'chanfana'; 53 | import { z } from 'zod'; 54 | 55 | export type Env = { 56 | // Example bindings, use your own 57 | DB: D1Database 58 | BUCKET: R2Bucket 59 | } 60 | export type AppContext = Context<{ Bindings: Env }> 61 | 62 | // Define a simple endpoint class 63 | class HelloEndpoint extends OpenAPIRoute { 64 | schema = { 65 | responses: { 66 | 200: { 67 | description: 'Successful response', 68 | content: { 69 | 'application/json': { 70 | schema: z.object({ message: z.string() }), 71 | }, 72 | }, 73 | }, 74 | }, 75 | }; 76 | 77 | async handle(c: AppContext) { 78 | return { message: 'Hello, Chanfana!' }; 79 | } 80 | } 81 | 82 | // Create a Hono app 83 | const app = new Hono<{ Bindings: Env }>(); 84 | 85 | // Initialize Chanfana for Hono 86 | const openapi = fromHono(app); 87 | 88 | // Register the endpoint 89 | openapi.get('/hello', HelloEndpoint); 90 | 91 | // Export the Hono app (for Cloudflare Workers or other runtimes) 92 | export default app; 93 | ``` 94 | 95 | ### Running the Example (Hono) 96 | 97 | 1. **Run your application.** The command to run your application will depend on your environment. For a simple Node.js environment, you can use `tsx` or `node`: 98 | 99 | ```bash 100 | npx tsx index.ts # or node index.js if you compiled to JS 101 | ``` 102 | 103 | If you are using Cloudflare Workers, you would typically use `wrangler dev` or `wrangler publish`. 104 | 105 | 2. **Access your API.** By default, Hono applications listen on port 3000. You can access your API endpoint in your browser or using `curl`: 106 | 107 | ```bash 108 | curl http://localhost:3000/hello 109 | ``` 110 | 111 | You should see the JSON response: 112 | 113 | ```json 114 | {"message": "Hello, Chanfana!"} 115 | ``` 116 | 117 | ### Exploring the OpenAPI Documentation (Hono) 118 | 119 | 1. **Navigate to the documentation URL.** Open your browser and go to the `/docs` URL you configured in the `fromHono` options (in this example, `http://localhost:3000/docs`). 120 | 121 | 2. **Explore the Swagger UI.** You should see the Swagger UI interface, automatically generated from your endpoint schema. You can explore your API's endpoints, schemas, and even try out API calls directly from the documentation. 122 | 123 | You can also access the raw OpenAPI JSON schema at `/openapi.json` (e.g., `http://localhost:3000/openapi.json`). 124 | 125 | ## Quick Start with Itty Router 126 | 127 | Now, let's do the same with itty-router. 128 | 129 | ### Creating Your First Endpoint (Itty Router) 130 | 131 | 1. **Create a new project directory:** 132 | 133 | ```bash 134 | mkdir chanfana-itty-router-example 135 | cd chanfana-itty-router-example 136 | npm init -y # or yarn init -y 137 | ``` 138 | 139 | 2. **Install dependencies:** 140 | 141 | ```bash 142 | npm install itty-router chanfana zod --save # or yarn add itty-router chanfana zod 143 | ``` 144 | 145 | 3. **Create a file named `index.ts` (or `src/index.ts`) and add the following code:** 146 | 147 | ```typescript 148 | import { Router } from 'itty-router'; 149 | import { fromIttyRouter, OpenAPIRoute } from 'chanfana'; 150 | import { z } from 'zod'; 151 | 152 | // Define a simple endpoint class 153 | class HelloEndpoint extends OpenAPIRoute { 154 | schema = { 155 | responses: { 156 | 200: { 157 | description: 'Successful response', 158 | content: { 159 | 'application/json': { 160 | schema: z.object({ message: z.string() }), 161 | }, 162 | }, 163 | }, 164 | }, 165 | }; 166 | 167 | async handle(request: Request, env, ctx) { 168 | return { message: 'Hello, Chanfana for itty-router!' }; 169 | } 170 | } 171 | 172 | // Create an itty-router router 173 | const router = Router(); 174 | 175 | // Initialize Chanfana for itty-router 176 | const openapi = fromIttyRouter(router); 177 | 178 | // Register the endpoint 179 | openapi.get('/hello', HelloEndpoint); 180 | 181 | // Add a default handler for itty-router (required) 182 | router.all('*', () => new Response("Not Found.", { status: 404 })); 183 | 184 | // Export the router's fetch handler (for Cloudflare Workers or other runtimes) 185 | export const fetch = router.handle; 186 | ``` 187 | 188 | ### Running the Example (Itty Router) 189 | 190 | 1. **Run your application.** Similar to Hono, the command depends on your environment. For Node.js: 191 | 192 | ```bash 193 | npx tsx index.ts # or node index.js if compiled 194 | ``` 195 | 196 | For Cloudflare Workers, use `wrangler dev` or `wrangler publish`. 197 | 198 | 2. **Access your API.** itty-router also defaults to port 3000. Access the endpoint: 199 | 200 | ```bash 201 | curl http://localhost:3000/hello 202 | ``` 203 | 204 | You should see: 205 | 206 | ```json 207 | {"message": "Hello, Chanfana for itty-router!"} 208 | ``` 209 | 210 | ### Exploring the OpenAPI Documentation (Itty Router) 211 | 212 | 1. **Navigate to the documentation URL.** Open your browser to `/docs` (e.g., `http://localhost:3000/docs`). 213 | 214 | 2. **Explore the Swagger UI.** You'll see the Swagger UI, now documenting your itty-router API endpoint. 215 | 216 | ## Using the Template 217 | 218 | For an even faster start, Chanfana provides a template that sets up a Cloudflare Worker project with OpenAPI documentation out of the box. 219 | 220 | **Create a new project using the template:** 221 | 222 | ```bash 223 | npm create cloudflare@latest -- --type openapi 224 | ``` 225 | 226 | Follow the prompts to set up your project. This template includes Chanfana, Hono, and a basic endpoint structure, ready for you to expand upon. 227 | 228 | --- 229 | 230 | Congratulations! You've successfully set up Chanfana with both Hono and itty-router and explored the automatically generated OpenAPI documentation. 231 | 232 | Next, we'll dive deeper into the [**Core Concepts**](./core-concepts.md) of Chanfana to understand how it works and how to leverage its full potential. 233 | -------------------------------------------------------------------------------- /docs/images/logo-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudflare/chanfana/main/docs/images/logo-icon.png -------------------------------------------------------------------------------- /docs/images/logo-square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudflare/chanfana/main/docs/images/logo-square.png -------------------------------------------------------------------------------- /docs/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudflare/chanfana/main/docs/images/logo.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | hero: 6 | name: "chanfana" 7 | image: images/logo-icon.png 8 | tagline: OpenAPI 3 and 3.1 schema generator and validator for Hono, itty-router and more! 9 | actions: 10 | - theme: brand 11 | text: Getting Started 12 | link: /getting-started 13 | - theme: alt 14 | text: Auto Endpoints 15 | link: /endpoints/auto/base 16 | 17 | features: 18 | - title: ✨ OpenAPI Schema Generation 19 | details: Automatically generate OpenAPI v3 & v3.1 compliant schemas from your TypeScript API endpoint definitions. 20 | link: /getting-started 21 | - title: ✅ Automatic Request Validation 22 | details: Enforce API contracts by automatically validating incoming requests against your defined schemas. 23 | link: /endpoints/request-validation 24 | - title: 🚀 Class-Based Endpoints 25 | details: Organize your API logic in a clean and structured way using class-based endpoints, promoting code reusability. 26 | link: /endpoints/defining-endpoints 27 | - title: 📦 Auto CRUD Endpoints 28 | details: Auto generate endpoints for common CRUD operations, reducing boilerplate. 29 | link: /endpoints/auto/base 30 | - title: ⌨️ TypeScript Type Inference 31 | details: Automatic type inference for request parameters, providing a type-safe and developer-friendly experience. 32 | link: /getting-started 33 | - title: 🔌 Router Adapters 34 | details: Seamlessly integrate Chanfana with popular routers like Hono and itty-router. 35 | link: /router-adapters 36 | --- 37 | -------------------------------------------------------------------------------- /docs/introduction.md: -------------------------------------------------------------------------------- 1 | # Welcome to Chanfana 2 | 3 | ![Chanfana Logo](https://raw.githubusercontent.com/cloudflare/chanfana/refs/heads/main/docs/images/logo.png) 4 | 5 | Chanfana is a powerful and lightweight TypeScript library designed to effortlessly bring the benefits of OpenAPI to your web APIs. Built with modern JavaScript runtimes in mind, especially Cloudflare Workers, Chanfana provides schema generation and validation for popular routers like [Hono](https://github.com/honojs/hono) and [itty-router](https://github.com/kwhitley/itty-router), and is adaptable to many more. 6 | 7 | ## What is Chanfana? 8 | 9 | Chanfana, previously known as `itty-router-openapi`, is more than just an OpenAPI generator. It's a comprehensive toolkit that allows you to: 10 | 11 | * **Define your API contracts using TypeScript classes and Zod schemas.** Write your API logic and schema in one place, ensuring type safety and reducing boilerplate. 12 | * **Automatically generate OpenAPI v3 and v3.1 compliant schemas.** No more manual schema writing! Chanfana infers your API structure directly from your code. 13 | * **Enforce request validation.** Protect your API by automatically validating incoming requests against your defined schemas. 14 | * **Serve interactive API documentation.** Chanfana seamlessly integrates with Swagger UI and ReDoc to provide beautiful, interactive documentation for your API consumers. 15 | * **Extend your existing router applications.** Integrate Chanfana into your Hono or itty-router projects without rewriting your existing routes. 16 | 17 | Chanfana is built to be both powerful and lightweight, making it ideal for serverless environments like Cloudflare Workers, but it runs perfectly well in any JavaScript runtime. 18 | 19 | ## Key Features at a Glance 20 | 21 | * **OpenAPI v3 & v3.1 Schema Generation:** Supports the latest OpenAPI specifications. 22 | * **TypeScript First:** Fully written in TypeScript, providing excellent type safety and developer experience. 23 | * **Class-Based Endpoints:** Organize your API logic using clean, reusable classes. 24 | * **Automatic Type Inference:** Leverage TypeScript's power for automatic inference of request parameters (query, path, headers, body). 25 | * **Extensible Router Support:** Designed to work seamlessly with Hono, itty-router, and adaptable to other routers. 26 | * **Built-in Validation:** Automatic request validation based on your OpenAPI schemas. 27 | * **Interactive Documentation:** Effortless integration with Swagger UI and ReDoc for API documentation. 28 | * **Lightweight and Performant:** Optimized for serverless environments and fast runtimes. 29 | * **Production Ready:** Used in production at Cloudflare and powering public APIs like [Radar 2.0](https://developers.cloudflare.com/radar/). 30 | 31 | ## Why Choose Chanfana? 32 | 33 | In today's API-driven world, having a well-defined and documented API is crucial. Chanfana simplifies this process by: 34 | 35 | * **Reducing Development Time:** Automatic schema generation eliminates the tedious task of manually writing OpenAPI specifications. 36 | * **Improving API Quality:** Schema validation ensures that your API behaves as expected and reduces integration issues. 37 | * **Enhancing Developer Experience:** TypeScript and class-based endpoints provide a structured and enjoyable development workflow. 38 | * **Facilitating Collaboration:** OpenAPI documentation makes it easy for teams to understand and work with your APIs. 39 | * **Boosting Confidence:** Production readiness and usage in large-scale projects give you confidence in Chanfana's reliability. 40 | 41 | ## Who is Chanfana For? 42 | 43 | Chanfana is designed for developers who want to build robust, well-documented, and maintainable APIs, especially if you are: 44 | 45 | * **Building APIs with Hono or itty-router.** Chanfana provides first-class support for these popular routers. 46 | * **Developing serverless APIs on Cloudflare Workers or similar platforms.** Chanfana's lightweight nature is perfect for serverless environments. 47 | * **Seeking to adopt OpenAPI for your APIs.** Chanfana makes it easy to generate and utilize OpenAPI specifications. 48 | * **Looking for a TypeScript-first API development experience.** Chanfana leverages TypeScript to its fullest potential. 49 | * **Wanting to automate API documentation and validation.** Chanfana handles these tasks so you can focus on your API logic. 50 | 51 | Whether you are building a small personal project or a large-scale enterprise API, Chanfana can help you create better APIs, faster. 52 | 53 | --- 54 | 55 | Ready to get started? Let's move on to the [**Getting Started**](./getting-started.md) guide to set up your first Chanfana API! 56 | -------------------------------------------------------------------------------- /docs/openapi-configuration-customization.md: -------------------------------------------------------------------------------- 1 | # OpenAPI Configuration and Customization 2 | 3 | Chanfana offers various options to configure and customize the generation of your OpenAPI document. These configurations are primarily set through the `RouterOptions` object when you initialize Chanfana using `fromHono` or `fromIttyRouter`. This section will explore the available configuration options and how to use them to tailor your OpenAPI specification. 4 | 5 | ## Configuring OpenAPI Document Generation 6 | 7 | The primary way to configure OpenAPI document generation in Chanfana is through the `RouterOptions` object, which you pass as the second argument to `fromHono` or `fromIttyRouter`. 8 | 9 | **Example: Configuring Router Options** 10 | 11 | ```typescript 12 | import { Hono } from 'hono'; 13 | import { fromHono } from 'chanfana'; 14 | 15 | const app = new Hono(); 16 | 17 | const openapi = fromHono(app, { 18 | base: '/api/v1', // Base path for all API routes 19 | schema: { 20 | info: { 21 | title: 'My Awesome API', 22 | version: '2.0.0', 23 | description: 'This is the documentation for my awesome API.', 24 | termsOfService: 'https://example.com/terms/', 25 | contact: { 26 | name: 'API Support', 27 | url: 'https://example.com/support', 28 | email: 'support@example.com', 29 | }, 30 | license: { 31 | name: 'Apache 2.0', 32 | url: 'https://www.apache.org/licenses/LICENSE-2.0.html', 33 | }, 34 | }, 35 | servers: [ 36 | { url: 'https://api.example.com/api/v1', description: 'Production server' }, 37 | { url: 'http://localhost:3000/api/v1', description: 'Development server' }, 38 | ], 39 | tags: [ 40 | { name: 'users', description: 'Operations related to users' }, 41 | { name: 'products', description: 'Operations related to products' }, 42 | ], 43 | }, 44 | docs_url: '/api/v1/docs', 45 | redoc_url: '/api/v1/redocs', 46 | openapi_url: '/api/v1/openapi.json', 47 | openapiVersion: '3.1', // or '3' for OpenAPI v3.0.3 48 | generateOperationIds: true, 49 | raiseUnknownParameters: false, 50 | }); 51 | 52 | // ... register your endpoints using 'openapi' ... 53 | ``` 54 | 55 | ## `RouterOptions`: Controlling OpenAPI Behavior 56 | 57 | The `RouterOptions` object accepts the following properties to customize Chanfana's behavior: 58 | 59 | ### `base`: Setting the Base Path for API Routes 60 | 61 | * **Type:** `string` 62 | * **Default:** `undefined` 63 | 64 | The `base` option allows you to set a base path for all API routes managed by Chanfana. If provided, this base path will be prepended to all route paths when generating the OpenAPI document. This is useful when your API is served under a specific path prefix (e.g., `/api/v1`). 65 | 66 | **Example:** 67 | 68 | ```typescript 69 | fromHono(app, { base: '/api/v1' }); 70 | 71 | openapi.get('/users', UserListEndpoint); // OpenAPI path will be /api/v1/users 72 | openapi.get('/users/:userId', UserGetEndpoint); // OpenAPI path will be /api/v1/users/{userId} 73 | ``` 74 | 75 | ### `schema`: Customizing OpenAPI Document Information 76 | 77 | * **Type:** `Partial` 78 | * **Default:** `{ info: { title: 'OpenAPI', version: '1.0.0' } }` 79 | 80 | The `schema` option allows you to provide a partial OpenAPI object configuration to customize the root-level properties of your generated OpenAPI document. This is where you can set metadata like API title, version, description, contact information, license, servers, and tags. 81 | 82 | **Properties you can customize within `schema`:** 83 | 84 | * **`info`:** (OpenAPI `Info` Object) Provides metadata about the API. 85 | * `title`: API title (required). 86 | * `version`: API version (required). 87 | * `description`: API description. 88 | * `termsOfService`: Terms of service URL. 89 | * `contact`: Contact information for the API. 90 | * `name`: Contact name. 91 | * `url`: Contact URL. 92 | * `email`: Contact email. 93 | * `license`: License information for the API. 94 | * `name`: License name. 95 | * `url`: License URL. 96 | * **`servers`:** (Array of OpenAPI `Server` Objects) Defines the API servers. 97 | * `url`: Server URL (required). 98 | * `description`: Server description (optional). 99 | * `variables`: Server variables (optional). 100 | * **`tags`:** (Array of OpenAPI `Tag` Objects) Defines tags for organizing operations in the OpenAPI document. 101 | * `name`: Tag name (required). 102 | * `description`: Tag description (optional). 103 | * `externalDocs`: External documentation for the tag (optional). 104 | * **`externalDocs`:** (OpenAPI `ExternalDocumentation` Object) Provides external documentation for the API as a whole. 105 | * `description`: Description of external documentation. 106 | * `url`: URL for external documentation (required). 107 | 108 | Refer to the [OpenAPI Specification](https://spec.openapis.org/oas/v3.1.0.html#oasObject) for details on these properties and their structure. 109 | 110 | ### `docs_url`, `redoc_url`, `openapi_url`: Configuring Documentation Endpoints 111 | 112 | * **Type:** `string | null` 113 | * **Default:** 114 | * `docs_url`: `"/docs"` 115 | * `redoc_url`: `"/redocs"` 116 | * `openapi_url`: `"/openapi.json"` 117 | 118 | These options control the URLs at which Chanfana serves the OpenAPI documentation and schema: 119 | 120 | * **`docs_url`:** URL path to serve Swagger UI. Set to `null` to disable Swagger UI documentation. 121 | * **`redoc_url`:** URL path to serve ReDoc UI. Set to `null` to disable ReDoc UI documentation. 122 | * **`openapi_url`:** URL path to serve the raw OpenAPI JSON schema. Set to `null` to disable serving the OpenAPI schema. 123 | 124 | Chanfana automatically creates routes in your router to serve these documentation UIs and the OpenAPI schema at the specified URLs. 125 | 126 | ### `openapiVersion`: Selecting OpenAPI Version (3 or 3.1) 127 | 128 | * **Type:** `"3" | "3.1"` 129 | * **Default:** `"3.1"` 130 | 131 | The `openapiVersion` option allows you to choose between generating OpenAPI v3.0.3 or v3.1.0 compliant schemas. 132 | 133 | * `"3"`: Generates OpenAPI v3.0.3 specification. 134 | * `"3.1"`: Generates OpenAPI v3.1.0 specification (default). 135 | 136 | Choose the version that best suits your needs and the tools you are using to consume your OpenAPI document. OpenAPI 3.1 is the latest version and offers some advantages, but OpenAPI 3.0.3 is still widely supported. 137 | 138 | ### `generateOperationIds`: Controlling Operation ID Generation 139 | 140 | * **Type:** `boolean` 141 | * **Default:** `true` 142 | 143 | The `generateOperationIds` option controls whether Chanfana should automatically generate `operationId` values for your OpenAPI operations. 144 | 145 | * `true`: (Default) Chanfana automatically generates `operationId` values based on the HTTP method and route path (e.g., `get_users_userId`). 146 | * `false`: Chanfana will **not** automatically generate `operationId` values. In this case, you **must** provide `operationId` explicitly in your endpoint's `schema` definition. If you don't provide `operationId` when `generateOperationIds` is `false`, Chanfana will throw an error. 147 | 148 | `operationId` values are used to uniquely identify operations in your OpenAPI document and are often used by code generation tools and API clients. 149 | 150 | ### `raiseUnknownParameters`: Strict Parameter Validation 151 | 152 | * **Type:** `boolean` 153 | * **Default:** `true` 154 | 155 | The `raiseUnknownParameters` option controls whether Chanfana should perform strict validation of request parameters (query, path, headers, body). 156 | 157 | * `true`: (Default) Strict validation is enabled. If the incoming request contains parameters that are **not** defined in your schema, Chanfana will consider it a validation error and return a `400 Bad Request` response. This is generally recommended for API robustness and security. 158 | * `false`: Strict validation is disabled. Chanfana will only validate the parameters that are defined in your schema and ignore any unknown parameters in the request. This can be useful for backward compatibility or when you want to allow clients to send extra parameters that your API might not explicitly handle. 159 | 160 | ## Customizing OpenAPI Schema Output 161 | 162 | While `RouterOptions` allows you to configure the overall OpenAPI document, you can also customize the schema output for individual endpoints and parameters using Zod's OpenAPI metadata features and Chanfana's parameter types (e.g., `Str`, `Num`, `Enumeration`). 163 | 164 | * **`describe()`:** Use Zod's `describe()` method to add descriptions to your schema fields, which will be included in the OpenAPI documentation. 165 | * **`openapi()`:** Use Zod's `openapi()` method to provide OpenAPI-specific metadata, such as examples, formats, and other schema extensions. 166 | * **Chanfana Parameter Type Options:** Options like `description`, `example`, `format`, `required`, and `default` in Chanfana's parameter types (`Str`, `Num`, `Enumeration`, etc.) are directly translated into OpenAPI schema properties. 167 | 168 | Refer to the [Zod-to-OpenAPI documentation](https://github.com/asteasolutions/zod-to-openapi) and [Zod documentation](https://zod.dev/) for more details on schema customization options. 169 | 170 | ## Serving OpenAPI Documentation (Swagger UI, ReDoc) 171 | 172 | Chanfana makes it easy to serve interactive API documentation using Swagger UI and ReDoc. By default, if you provide `docs_url` and `openapi_url` (or `redoc_url` and `openapi_url`) in `RouterOptions`, Chanfana automatically sets up routes to serve these documentation UIs. 173 | 174 | * **Swagger UI:** Served at the URL specified in `docs_url` (default: `/docs`). Provides an interactive, visual interface for exploring and testing your API. 175 | * **ReDoc:** Served at the URL specified in `redoc_url` (default: `/redocs`). Provides a clean, three-panel documentation layout, often preferred for its readability. 176 | 177 | Both Swagger UI and ReDoc are served as static HTML pages that fetch your OpenAPI schema from the `openapi_url` (default: `/openapi.json`) and render the documentation dynamically. 178 | 179 | You can customize the URLs for these documentation endpoints using the `docs_url`, `redoc_url`, and `openapi_url` options in `RouterOptions`, or disable serving specific documentation UIs by setting their corresponding URL option to `null`. 180 | 181 | --- 182 | 183 | By understanding and utilizing these OpenAPI configuration and customization options, you can fine-tune Chanfana to generate OpenAPI documents that accurately represent your API, meet your documentation requirements, and enhance the developer experience for your API consumers. 184 | -------------------------------------------------------------------------------- /docs/router-adapters.md: -------------------------------------------------------------------------------- 1 | # Adapters: Integrating with Routers 2 | 3 | Chanfana is designed to be router-agnostic, allowing you to integrate it with various JavaScript web routers. Adapters are the bridge that connects Chanfana's OpenAPI functionality to specific router implementations. Currently, Chanfana provides adapters for [Hono](https://github.com/honojs/hono) and [itty-router](https://github.com/kwhitley/itty-router), two popular choices for modern JavaScript runtimes, especially Cloudflare Workers. 4 | 5 | ## Introduction to Adapters 6 | 7 | Adapters in Chanfana serve the following key purposes: 8 | 9 | * **Router Integration:** They provide specific functions and classes to seamlessly integrate Chanfana's OpenAPI schema generation, validation, and documentation features into the routing mechanism of a particular router library. 10 | * **Request Handling Abstraction:** Adapters abstract away the router-specific details of request and response handling, allowing Chanfana's core logic to remain router-independent. 11 | * **Middleware Compatibility:** They ensure compatibility with the middleware ecosystem of the target router, allowing you to use existing middleware alongside Chanfana's features. 12 | 13 | Chanfana provides two main adapters: 14 | 15 | * **Hono Adapter (`fromHono`):** For integrating with Hono applications. 16 | * **Itty Router Adapter (`fromIttyRouter`):** For integrating with itty-router applications. 17 | 18 | ## Hono Adapter (`fromHono`) 19 | 20 | The `fromHono` adapter is used to extend your Hono applications with Chanfana's OpenAPI capabilities. It provides the `fromHono` function and the `HonoOpenAPIRouterType` type. 21 | 22 | ### Setting up Chanfana with Hono 23 | 24 | To integrate Chanfana into your Hono application, you use the `fromHono` function. 25 | 26 | **Example: Basic Hono Integration** 27 | 28 | ```typescript 29 | import { Hono, type Context } from 'hono'; 30 | import { fromHono, OpenAPIRoute } from 'chanfana'; 31 | import { z } from 'zod'; 32 | 33 | export type Env = { 34 | // Example bindings 35 | DB: D1Database 36 | BUCKET: R2Bucket 37 | } 38 | export type AppContext = Context<{ Bindings: Env }> 39 | 40 | class MyEndpoint extends OpenAPIRoute { 41 | schema = { 42 | responses: { 43 | "200": { description: 'Success' }, 44 | }, 45 | }; 46 | async handle(c: AppContext) { 47 | return { message: 'Hello from Hono!' }; 48 | } 49 | } 50 | 51 | const app = new Hono<{ Bindings: Env }>(); 52 | 53 | // Initialize Chanfana for Hono using fromHono 54 | const openapi = fromHono(app); 55 | 56 | // Register your OpenAPIRoute endpoints using the openapi instance 57 | openapi.get('/hello', MyEndpoint); 58 | 59 | export default app; 60 | ``` 61 | 62 | **Explanation:** 63 | 64 | 1. **Import `fromHono`:** Import the `fromHono` function from `chanfana/adapters/hono`. 65 | 2. **Create a Hono App:** Create a standard Hono application instance using `new Hono()`. 66 | 3. **Initialize Chanfana with `fromHono`:** Call `fromHono(app, options)` to initialize Chanfana for your Hono app. 67 | * The first argument is your Hono application instance (`app`). 68 | * The second argument is an optional `RouterOptions` object to configure Chanfana (e.g., `openapi_url`, `docs_url`). 69 | 4. **Use `openapi` to Register Routes:** Use the `openapi` instance (returned by `fromHono`) to register your `OpenAPIRoute` classes for different HTTP methods (`get`, `post`, `put`, `delete`, `patch`, `all`, `on`, `route`). These methods work similarly to Hono's routing methods but are extended with Chanfana's OpenAPI features. 70 | 71 | ### Extending Existing Hono Applications 72 | 73 | `fromHono` is designed to be non-intrusive and can be easily integrated into existing Hono applications without requiring major code changes. You can gradually add OpenAPI documentation and validation to your existing routes by converting your route handlers to `OpenAPIRoute` classes and registering them using the `openapi` instance. 74 | 75 | **Example: Extending an Existing Hono App** 76 | 77 | ```typescript 78 | import { Hono, type Context } from 'hono'; 79 | import { fromHono, OpenAPIRoute } from 'chanfana'; 80 | 81 | export type Env = { 82 | // Example bindings, use your own 83 | DB: D1Database 84 | BUCKET: R2Bucket 85 | } 86 | export type AppContext = Context<{ Bindings: Env }> 87 | 88 | const app = new Hono<{ Bindings: Env }>(); 89 | 90 | // Existing Hono route (without OpenAPI) 91 | app.get('/legacy-route', (c) => c.text('This is a legacy route')); 92 | 93 | // Initialize Chanfana 94 | const openapi = fromHono(app); 95 | 96 | // New OpenAPI-documented route 97 | class NewEndpoint extends OpenAPIRoute { 98 | schema = { 99 | responses: { 100 | "200": { description: 'Success' }, 101 | }, 102 | }; 103 | async handle(c: AppContext) { 104 | return { message: 'This is a new OpenAPI route!' }; 105 | } 106 | } 107 | openapi.get('/new-route', NewEndpoint); 108 | 109 | export default app; 110 | ``` 111 | 112 | In this example, we have an existing Hono route `/legacy-route` that is not managed by Chanfana. We then initialize Chanfana using `fromHono` and register a new route `/new-route` using `OpenAPIRoute` and `openapi.get()`. Both routes will coexist in the same Hono application. Only the `/new-route` will have OpenAPI documentation and validation. 113 | 114 | ### `HonoOpenAPIRouterType` 115 | 116 | The `fromHono` function returns an object of type `HonoOpenAPIRouterType`. This type is an intersection of `Hono` and `OpenAPIRouterType`, extending the standard Hono application instance with Chanfana's OpenAPI routing methods and properties. 117 | 118 | **Key extensions provided by `HonoOpenAPIRouterType`:** 119 | 120 | * **OpenAPI Routing Methods:** `get()`, `post()`, `put()`, `delete()`, `patch()`, `all()`, `on()`, `route()` methods are extended to handle `OpenAPIRoute` classes and automatically register them for OpenAPI documentation and validation. 121 | * **`original` Property:** Provides access to the original Hono application instance. 122 | * **`options` Property:** Provides access to the `RouterOptions` passed to `fromHono`. 123 | * **`registry` Property:** Provides access to the OpenAPI registry used by Chanfana to collect schema definitions. 124 | * **`schema()` Method:** Returns the generated OpenAPI schema as a JavaScript object. 125 | 126 | ### Example with Hono 127 | 128 | Refer to the [Quick Start with Hono](./getting-started.md#quick-start-with-hono) section for a complete example of setting up Chanfana with Hono and creating a basic endpoint. 129 | 130 | ## Itty Router Adapter (`fromIttyRouter`) 131 | 132 | The `fromIttyRouter` adapter is used to integrate Chanfana with [itty-router](https://github.com/kwhitley/itty-router) applications. It provides the `fromIttyRouter` function and the `IttyRouterOpenAPIRouterType` type. 133 | 134 | ### Setting up Chanfana with Itty Router 135 | 136 | To integrate Chanfana into your itty-router application, you use the `fromIttyRouter` function. 137 | 138 | **Example: Basic Itty Router Integration** 139 | 140 | ```typescript 141 | import { Router } from 'itty-router'; 142 | import { fromIttyRouter, OpenAPIRoute } from 'chanfana'; 143 | import { z } from 'zod'; 144 | 145 | class MyEndpoint extends OpenAPIRoute { 146 | schema = { 147 | responses: { 148 | "200": { description: 'Success' }, 149 | }, 150 | }; 151 | async handle(request: Request, env, ctx) { 152 | return { message: 'Hello from itty-router!' }; 153 | } 154 | } 155 | 156 | const router = Router(); 157 | 158 | // Initialize Chanfana for itty-router using fromIttyRouter 159 | const openapi = fromIttyRouter(router); 160 | 161 | // Register your OpenAPIRoute endpoints using the openapi instance 162 | openapi.get('/hello', MyEndpoint); 163 | 164 | // Add a default handler for itty-router (required) 165 | router.all('*', () => new Response("Not Found.", { status: 404 })); 166 | 167 | export const fetch = router.handle; // Export the fetch handler 168 | ``` 169 | 170 | **Explanation:** 171 | 172 | 1. **Import `fromIttyRouter`:** Import the `fromIttyRouter` function from `chanfana/adapters/ittyRouter`. 173 | 2. **Create an Itty Router Instance:** Create an itty-router instance using `Router()`. 174 | 3. **Initialize Chanfana with `fromIttyRouter`:** Call `fromIttyRouter(router, options)` to initialize Chanfana for your itty-router instance. 175 | * The first argument is your itty-router instance (`router`). 176 | * The second argument is the optional `RouterOptions` object. 177 | 4. **Use `openapi` to Register Routes:** Use the `openapi` instance to register your `OpenAPIRoute` classes for different HTTP methods (`get`, `post`, `put`, `delete`, `patch`, `all`). These methods extend itty-router's routing methods with Chanfana's OpenAPI features. 178 | 5. **Add Default Handler:** Itty-router requires a default handler to be registered using `router.all('*', ...)`. This is necessary for itty-router to function correctly. 179 | 6. **Export `fetch` Handler:** Export the `router.handle` function as `fetch`. This is the standard way to export an itty-router application for Cloudflare Workers or other Fetch API environments. 180 | 181 | ### Extending Existing Itty Router Applications 182 | 183 | Similar to Hono, `fromIttyRouter` can be integrated into existing itty-router applications without major refactoring. You can gradually add OpenAPI documentation and validation to your routes. 184 | 185 | ### `IttyRouterOpenAPIRouterType` 186 | 187 | The `fromIttyRouter` function returns an object of type `IttyRouterOpenAPIRouterType`. This type extends the original itty-router instance with Chanfana's OpenAPI routing methods and properties, similar to `HonoOpenAPIRouterType`. 188 | 189 | ### Example with Itty Router 190 | 191 | Refer to the [Quick Start with Itty Router](./getting-started.md#quick-start-with-itty-router) section for a complete example of setting up Chanfana with itty-router and creating a basic endpoint. 192 | 193 | ## Choosing the Right Adapter 194 | 195 | Choose the adapter that corresponds to the web router you are using in your project: 196 | 197 | * Use `fromHono` for Hono applications. 198 | * Use `fromIttyRouter` for itty-router applications. 199 | 200 | If you are using a different router, you might need to create a custom adapter. Chanfana's architecture is designed to be extensible, and creating a new adapter is possible, although it might require a deeper understanding of Chanfana's internals and the target router's API. 201 | -------------------------------------------------------------------------------- /docs/troubleshooting-and-faq.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting and FAQ 2 | 3 | This section provides solutions to common issues you might encounter while using Chanfana, frequently asked questions, debugging tips, and resources for getting help and support. 4 | 5 | ## Common Issues and Solutions 6 | 7 | **1. "TypeError: Router.get is not a function" or similar errors when using `fromHono` or `fromIttyRouter`:** 8 | 9 | * **Cause:** You might be calling `fromHono` or `fromIttyRouter` on an object that is not a valid Hono or itty-router router instance. 10 | * **Solution:** Ensure that you are passing a valid router instance to `fromHono` or `fromIttyRouter`. Double-check your router initialization code: 11 | 12 | ```typescript 13 | // Hono example: 14 | import { Hono } from 'hono'; 15 | const app = new Hono(); // Correct: Initialize Hono router 16 | const openapi = fromHono(app, {/* options */}); 17 | 18 | // Itty-router example: 19 | import { Router } from 'itty-router'; 20 | const router = Router(); // Correct: Initialize itty-router 21 | const openapi = fromIttyRouter(router, {/* options */}); 22 | ``` 23 | 24 | **2. OpenAPI documentation is not showing up at `/docs` or `/openapi.json`:** 25 | 26 | * **Cause 1:** You haven't registered any routes with Chanfana's `openapi` instance. 27 | * **Solution:** Make sure you are registering your `OpenAPIRoute` classes using methods like `openapi.get()`, `openapi.post()`, etc., and **not** directly on the original router instance (`app.get()` in Hono or `router.get()` in itty-router after initialization with Chanfana). 28 | 29 | ```typescript 30 | // Correct: Register route with Chanfana's openapi instance 31 | openapi.get('/my-endpoint', MyEndpointClass); 32 | 33 | // Incorrect: Registering directly on the Hono app instance (OpenAPI not enabled for this route) 34 | app.get('/another-endpoint', () => new Response("Hello")); 35 | ``` 36 | 37 | * **Cause 2:** You have set `docs_url`, `redoc_url`, or `openapi_url` to `null` in `RouterOptions`. 38 | * **Solution:** Check your `RouterOptions` and ensure that `docs_url`, `redoc_url`, and `openapi_url` are set to valid URL paths (strings) if you want to enable documentation UIs and schema endpoints. 39 | 40 | * **Cause 3:** Your application is not running or is not accessible at the expected address and port. 41 | * **Solution:** Verify that your application is running correctly and is accessible in your browser or using `curl` at the URL where you expect to see the documentation. 42 | 43 | **3. Request validation errors are not being handled as expected:** 44 | 45 | * **Cause 1:** You are not defining request schemas in your `OpenAPIRoute` classes. 46 | * **Solution:** Ensure that you have defined request schemas (e.g., `schema.request.body`, `schema.request.query`, etc.) in your `OpenAPIRoute` classes for the endpoints where you want request validation to be performed. 47 | 48 | * **Cause 2:** You are catching and handling `ZodError` exceptions manually in your `handle` method, potentially overriding Chanfana's default error handling. 49 | * **Solution:** In most cases, you should **not** manually catch `ZodError` exceptions within your `handle` method. Let Chanfana automatically handle validation errors and return `400 Bad Request` responses. Only catch exceptions if you need to perform custom error handling logic for specific error types other than validation errors. 50 | 51 | **4. "TypeError: Cannot read properties of undefined (reading 'schema')" or similar errors related to `_meta`:** 52 | 53 | * **Cause:** You are using auto endpoints (`CreateEndpoint`, `ReadEndpoint`, etc.) without properly defining the `_meta` property in your endpoint class. 54 | * **Solution:** When using auto endpoints, you **must** define the `_meta` property and assign a valid `Meta` object to it. Ensure that your `Meta` object includes the `model` property with `schema`, `primaryKeys`, and `tableName` (if applicable). 55 | 56 | ```typescript 57 | class MyCreateEndpoint extends CreateEndpoint { 58 | _meta = { // Correct: Define _meta property 59 | model: { 60 | schema: MyDataModel, 61 | primaryKeys: ['id'], 62 | tableName: 'my_table', 63 | }, 64 | }; 65 | { /* ... */ }; 66 | // ... 67 | } 68 | ``` 69 | 70 | **5. OpenAPI schema is missing descriptions or examples:** 71 | 72 | * **Cause:** You have not provided descriptions or examples in your Zod schemas or Chanfana parameter types. 73 | * **Solution:** Use Zod's `describe()` method and Chanfana parameter type options like `description` and `example` to add metadata to your schemas. This metadata is used to generate more informative OpenAPI documentation. 74 | 75 | ```typescript 76 | const nameSchema = Str({ description: 'User name', example: 'John Doe' }); // Add description and example 77 | const ageSchema = Int({ description: 'User age' }); // Add description 78 | ``` 79 | 80 | **6. D1 endpoints are not working, "Binding 'DB' is not defined in worker" error:** 81 | 82 | * **Cause:** You have not correctly configured the D1 database binding in your `wrangler.toml` file, or the binding name in your code (`dbName = 'DB'`) does not match the binding name in `wrangler.toml`. 83 | * **Solution:** 84 | * Verify your `wrangler.toml` file and ensure that you have a `[[d1_databases]]` section with a `binding` name (e.g., `binding = "DB"`). 85 | * Make sure that the `dbName` property in your D1 endpoint class matches the `binding` name in `wrangler.toml` (case-sensitive). 86 | * Ensure that you have deployed your Cloudflare Worker or are running it in a local environment where the D1 binding is correctly set up. 87 | 88 | ## Frequently Asked Questions (FAQ) 89 | 90 | **Q: Can I use Chanfana with routers other than Hono and itty-router?** 91 | 92 | **A:** Chanfana is designed to be router-agnostic in principle. While it provides official adapters for Hono and itty-router, you can potentially create custom adapters for other routers. However, creating a custom adapter might require a deeper understanding of Chanfana's internals and the API of the target router. 93 | 94 | **Q: Does Chanfana support OpenAPI 3.1?** 95 | 96 | **A:** Yes, Chanfana fully supports OpenAPI 3.1 (which is the default) and also OpenAPI 3.0.3. You can select the OpenAPI version using the `openapiVersion` option in `RouterOptions`. 97 | 98 | **Q: Can I customize the generated OpenAPI document beyond the `RouterOptions`?** 99 | 100 | **A:** Yes, you can customize the OpenAPI schema output extensively using Zod's `describe()` and `openapi()` methods, as well as Chanfana's parameter type options. For more advanced customizations or modifications to the generated OpenAPI document structure, you might need to extend or modify Chanfana's core classes (which is generally not recommended unless you have a deep understanding of the library). 101 | 102 | **Q: Is Chanfana suitable for production APIs?** 103 | 104 | **A:** Yes, Chanfana is considered stable and production-ready. It is used in production at Cloudflare and powers public APIs like [Radar 2.0](https://developers.cloudflare.com/radar/). 105 | 106 | **Q: Does Chanfana support authentication and authorization?** 107 | 108 | **A:** Chanfana itself does not provide built-in authentication or authorization mechanisms. However, it is designed to work seamlessly with middleware and custom logic for implementing authentication and authorization in your API endpoints. Refer to the [Examples and Recipes](./examples-and-recipes.md) section for a basic example of API key authentication. 109 | 110 | **Q: How can I handle different content types (e.g., XML, plain text) in request and responses?** 111 | 112 | **A:** Chanfana primarily focuses on JSON APIs and provides `contentJson` for simplifying JSON content handling. For handling other content types like XML or plain text, you might need to: 113 | 114 | * Manually define OpenAPI `content` objects in your schema without using `contentJson`. 115 | * Use Zod schemas that are appropriate for the data format (e.g., `z.string()` for plain text). 116 | * Handle request parsing and response serialization for non-JSON content types within your endpoint `handle` methods using router-specific or Fetch API methods. 117 | 118 | *(Future versions of Chanfana might introduce more utilities for handling different content types.)* 119 | 120 | ## Debugging Tips 121 | 122 | * **Check Browser Console:** When using Swagger UI or ReDoc, inspect the browser's developer console for any JavaScript errors or warnings that might indicate issues with the documentation rendering or OpenAPI schema. 123 | * **Validate OpenAPI Schema:** Use online OpenAPI validators (e.g., Swagger Editor, ReDoc online validator) to validate your generated `openapi.json` schema file for any structural or syntax errors. 124 | * **Verbose Logging:** Add `console.log` statements in your endpoint `handle` methods and middleware to trace the request flow, data validation, and error handling logic. 125 | * **Simplify and Isolate:** If you encounter complex issues, try to simplify your endpoint definitions and isolate the problematic part of your code to narrow down the source of the error. 126 | * **Example Projects:** Refer to the example projects and code snippets in the documentation and Chanfana's repository for working examples and best practices. 127 | 128 | ## Getting Help and Support 129 | 130 | * **Chanfana GitHub Repository:** [https://github.com/cloudflare/chanfana/](https://github.com/cloudflare/chanfana/) - Check the repository for issues, discussions, and updates. 131 | * **Cloudflare Developer Community:** [https://community.cloudflare.com/](https://community.cloudflare.com/) - Ask questions and seek help from the Cloudflare developer community. 132 | * **Radar Discord Channel:** [https://discord.com/channels/595317990191398933/1035553707116478495](https://discord.com/channels/595317990191398933/1035553707116478495) - Join the Radar Discord channel for discussions and support related to Chanfana and Radar API development. 133 | * **Report Issues:** If you encounter bugs or have feature requests, please open an issue in the Chanfana GitHub repository. 134 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chanfana", 3 | "version": "2.8.0", 4 | "description": "OpenAPI 3 and 3.1 schema generator and validator for Hono, itty-router and more!", 5 | "main": "dist/index.js", 6 | "module": "dist/index.mjs", 7 | "types": "dist/index.d.ts", 8 | "files": [ 9 | "dist", 10 | "LICENSE", 11 | "README.md" 12 | ], 13 | "scripts": { 14 | "prepare": "husky", 15 | "build": "rm -rf dist/ && tsup src/index.ts --format cjs,esm --dts --config tsconfig.json --external Hono", 16 | "lint": "npx @biomejs/biome check src/ tests/ || (npx @biomejs/biome check --write src/ tests/; exit 1)", 17 | "test": "vitest run --root tests", 18 | "docs:deploy": "npm run docs:build && wrangler pages deploy .vitepress/dist/ --project-name chanfana --branch main", 19 | "docs:dev": "vitepress dev", 20 | "docs:build": "vitepress build && cp .vitepress/_redirects .vitepress/dist/", 21 | "docs:preview": "vitepress preview" 22 | }, 23 | "keywords": [ 24 | "cloudflare", 25 | "worker", 26 | "workers", 27 | "serverless", 28 | "cloudflare workers", 29 | "router", 30 | "openapi", 31 | "swagger", 32 | "openapi generator", 33 | "cf", 34 | "optional", 35 | "middleware", 36 | "parameters", 37 | "typescript", 38 | "npm", 39 | "package", 40 | "cjs", 41 | "esm", 42 | "umd", 43 | "typed" 44 | ], 45 | "author": "Gabriel Massadas (https://github.com/g4brym)", 46 | "license": "MIT", 47 | "homepage": "https://chanfana.pages.dev", 48 | "repository": { 49 | "type": "git", 50 | "url": "https://github.com/cloudflare/chanfana.git" 51 | }, 52 | "bugs": { 53 | "url": "https://github.com/cloudflare/chanfana/issues" 54 | }, 55 | "devDependencies": { 56 | "@biomejs/biome": "1.9.4", 57 | "@cloudflare/vitest-pool-workers": "^0.6.0", 58 | "@cloudflare/workers-types": "4.20250109.0", 59 | "@types/js-yaml": "^4.0.9", 60 | "@types/node": "22.10.5", 61 | "@types/service-worker-mock": "^2.0.1", 62 | "hono": "4.6.16", 63 | "husky": "9.1.7", 64 | "itty-router": "5.0.18", 65 | "tsup": "8.3.5", 66 | "typescript": "5.7.3", 67 | "vitepress": "^1.6.3", 68 | "vitest": "2.1.8", 69 | "vitest-openapi": "^1.0.3", 70 | "wrangler": "3.101.0" 71 | }, 72 | "dependencies": { 73 | "@asteasolutions/zod-to-openapi": "^7.2.0", 74 | "js-yaml": "^4.1.0", 75 | "openapi3-ts": "^4.4.0", 76 | "zod": "^3.23.8" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/adapters/hono.ts: -------------------------------------------------------------------------------- 1 | import type { Hono, Input } from "hono"; 2 | import type { 3 | BlankInput, 4 | Env, 5 | H, 6 | HandlerResponse, 7 | MergePath, 8 | MergeSchemaPath, 9 | Schema, 10 | ToSchema, 11 | TypedResponse, 12 | } from "hono/types"; 13 | import { OpenAPIHandler, type OpenAPIRouterType } from "../openapi"; 14 | import type { OpenAPIRoute } from "../route"; 15 | import type { RouterOptions } from "../types"; 16 | 17 | type MergeTypedResponse = T extends Promise 18 | ? T2 extends TypedResponse 19 | ? T2 20 | : TypedResponse 21 | : T extends TypedResponse 22 | ? T 23 | : TypedResponse; 24 | 25 | const HIJACKED_METHODS = new Set(["basePath", "on", "route", "delete", "get", "patch", "post", "put", "all"]); 26 | 27 | export type HonoOpenAPIRouterType< 28 | E extends Env = Env, 29 | S extends Schema = {}, 30 | BasePath extends string = "/", 31 | > = OpenAPIRouterType> & { 32 | on(method: string, path: string, endpoint: typeof OpenAPIRoute): Hono["on"]; 33 | on(method: string, path: string, router: Hono): Hono["on"]; 34 | 35 | route( 36 | path: SubPath, 37 | app: HonoOpenAPIRouterType, 38 | ): HonoOpenAPIRouterType> | S, BasePath>; 39 | 40 | all

= any>( 41 | path: P, 42 | endpoint: typeof OpenAPIRoute | H, 43 | ): HonoOpenAPIRouterType, I, MergeTypedResponse>, BasePath>; 44 | 45 | delete

= any>( 46 | path: P, 47 | endpoint: typeof OpenAPIRoute | H, 48 | ): HonoOpenAPIRouterType, I, MergeTypedResponse>, BasePath>; 49 | delete(path: string, router: Hono): Hono["delete"]; 50 | get

= any>( 51 | path: P, 52 | endpoint: typeof OpenAPIRoute | H, 53 | ): HonoOpenAPIRouterType, I, MergeTypedResponse>, BasePath>; 54 | get(path: string, router: Hono): Hono["get"]; 55 | patch

= any>( 56 | path: P, 57 | endpoint: typeof OpenAPIRoute | H, 58 | ): HonoOpenAPIRouterType, I, MergeTypedResponse>, BasePath>; 59 | patch(path: string, router: Hono): Hono["patch"]; 60 | post

= any>( 61 | path: P, 62 | endpoint: typeof OpenAPIRoute | H, 63 | ): HonoOpenAPIRouterType, I, MergeTypedResponse>, BasePath>; 64 | post(path: string, router: Hono): Hono["post"]; 65 | put

= any>( 66 | path: P, 67 | endpoint: typeof OpenAPIRoute | H, 68 | ): HonoOpenAPIRouterType, I, MergeTypedResponse>, BasePath>; 69 | put(path: string, router: Hono): Hono["put"]; 70 | // Hono must be defined last, for the overwrite method to have priority! 71 | } & Hono; 72 | 73 | export class HonoOpenAPIHandler extends OpenAPIHandler { 74 | getRequest(args: any[]) { 75 | return args[0].req.raw; 76 | } 77 | 78 | getUrlParams(args: any[]): Record { 79 | return args[0].req.param(); 80 | } 81 | 82 | getBindings(args: any[]): Record { 83 | return args[0].env; 84 | } 85 | } 86 | 87 | export function fromHono< 88 | M extends Hono, 89 | E extends Env = M extends Hono ? E : never, 90 | S extends Schema = M extends Hono ? S : never, 91 | BasePath extends string = M extends Hono ? BP : never, 92 | >(router: M, options?: RouterOptions): HonoOpenAPIRouterType { 93 | const openapiRouter = new HonoOpenAPIHandler(router, options); 94 | 95 | const proxy = new Proxy(router, { 96 | get: (target: any, prop: string, ...args: any[]) => { 97 | const _result = openapiRouter.handleCommonProxy(target, prop, ...args); 98 | if (_result !== undefined) { 99 | return _result; 100 | } 101 | 102 | if (typeof target[prop] !== "function") { 103 | return target[prop]; 104 | } 105 | 106 | return (route: string, ...handlers: any[]) => { 107 | if (prop !== "fetch") { 108 | if (prop === "route" && handlers.length === 1 && handlers[0].isChanfana === true) { 109 | openapiRouter.registerNestedRouter({ 110 | method: "", 111 | nestedRouter: handlers[0], 112 | path: route, 113 | }); 114 | 115 | // Hacky clone 116 | const subApp = handlers[0].original.basePath(""); 117 | 118 | const excludePath = new Set(["/openapi.json", "/openapi.yaml", "/docs", "/redocs"]); 119 | subApp.routes = subApp.routes.filter((obj: any) => { 120 | return !excludePath.has(obj.path); 121 | }); 122 | 123 | router.route(route, subApp); 124 | return proxy; 125 | } 126 | 127 | if (prop === "all" && handlers.length === 1 && handlers[0].isRoute) { 128 | handlers = openapiRouter.registerRoute({ 129 | method: prop, 130 | path: route, 131 | handlers: handlers, 132 | doRegister: false, 133 | }); 134 | } else if (openapiRouter.allowedMethods.includes(prop)) { 135 | handlers = openapiRouter.registerRoute({ 136 | method: prop, 137 | path: route, 138 | handlers: handlers, 139 | }); 140 | } else if (prop === "on") { 141 | const methods: string | string[] = route; 142 | const paths: string | string[] = handlers.shift(); 143 | 144 | if (Array.isArray(methods) || Array.isArray(paths)) { 145 | throw new Error("chanfana only supports single method+path on hono.on('method', 'path', EndpointClass)"); 146 | } 147 | 148 | handlers = openapiRouter.registerRoute({ 149 | method: methods.toLowerCase(), 150 | path: paths, 151 | handlers: handlers, 152 | }); 153 | 154 | handlers = [paths, ...handlers]; 155 | } 156 | } 157 | 158 | const resp = Reflect.get(target, prop, ...args)(route, ...handlers); 159 | 160 | if (HIJACKED_METHODS.has(prop)) { 161 | return proxy; 162 | } 163 | 164 | return resp; 165 | }; 166 | }, 167 | }); 168 | 169 | return proxy as HonoOpenAPIRouterType; 170 | } 171 | -------------------------------------------------------------------------------- /src/adapters/ittyRouter.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIHandler, type OpenAPIRouterType } from "../openapi"; 2 | import type { OpenAPIRoute } from "../route"; 3 | import type { RouterOptions } from "../types"; 4 | 5 | export type IttyRouterOpenAPIRouterType = OpenAPIRouterType & { 6 | all(path: string, endpoint: typeof OpenAPIRoute): (M & any)["all"]; 7 | all(path: string, router: M): (M & any)["all"]; 8 | delete(path: string, endpoint: typeof OpenAPIRoute): (M & any)["delete"]; 9 | delete(path: string, router: M): (M & any)["delete"]; 10 | get(path: string, endpoint: typeof OpenAPIRoute): (M & any)["get"]; 11 | get(path: string, router: M): (M & any)["get"]; 12 | head(path: string, endpoint: typeof OpenAPIRoute): (M & any)["head"]; 13 | head(path: string, router: M): (M & any)["head"]; 14 | patch(path: string, endpoint: typeof OpenAPIRoute): (M & any)["patch"]; 15 | patch(path: string, router: M): (M & any)["patch"]; 16 | post(path: string, endpoint: typeof OpenAPIRoute): (M & any)["post"]; 17 | post(path: string, router: M): (M & any)["post"]; 18 | put(path: string, endpoint: typeof OpenAPIRoute): (M & any)["put"]; 19 | put(path: string, router: M): (M & any)["put"]; 20 | }; 21 | 22 | export class IttyRouterOpenAPIHandler extends OpenAPIHandler { 23 | getRequest(args: any[]) { 24 | return args[0]; 25 | } 26 | 27 | getUrlParams(args: any[]): Record { 28 | return args[0].params; 29 | } 30 | 31 | getBindings(args: any[]): Record { 32 | return args[1]; 33 | } 34 | } 35 | 36 | export function fromIttyRouter(router: M, options?: RouterOptions): M & IttyRouterOpenAPIRouterType { 37 | const openapiRouter = new IttyRouterOpenAPIHandler(router, options); 38 | 39 | return new Proxy(router, { 40 | get: (target: any, prop: string, ...args: any[]) => { 41 | const _result = openapiRouter.handleCommonProxy(target, prop, ...args); 42 | if (_result !== undefined) { 43 | return _result; 44 | } 45 | 46 | return (route: string, ...handlers: any[]) => { 47 | if (prop !== "fetch") { 48 | if (handlers.length === 1 && handlers[0].isChanfana === true) { 49 | handlers = openapiRouter.registerNestedRouter({ 50 | method: prop, 51 | nestedRouter: handlers[0], 52 | path: undefined, 53 | }); 54 | } else if (openapiRouter.allowedMethods.includes(prop)) { 55 | handlers = openapiRouter.registerRoute({ 56 | method: prop, 57 | path: route, 58 | handlers: handlers, 59 | }); 60 | } 61 | } 62 | 63 | return Reflect.get(target, prop, ...args)(route, ...handlers); 64 | }; 65 | }, 66 | }); 67 | } 68 | -------------------------------------------------------------------------------- /src/contentTypes.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { legacyTypeIntoZod } from "./zod/utils"; 3 | 4 | type JsonContent = { 5 | content: { 6 | "application/json": { 7 | schema: z.ZodType; 8 | }; 9 | }; 10 | }; 11 | 12 | type InferSchemaType = T extends z.ZodType ? z.infer : T; 13 | 14 | export const contentJson = (schema: T): JsonContent> => ({ 15 | content: { 16 | "application/json": { 17 | schema: schema instanceof z.ZodType ? schema : legacyTypeIntoZod(schema), 18 | }, 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /src/endpoints/create.ts: -------------------------------------------------------------------------------- 1 | import { contentJson } from "../contentTypes"; 2 | import { InputValidationException } from "../exceptions"; 3 | import { OpenAPIRoute } from "../route"; 4 | import { MetaGenerator, type MetaInput, type O } from "./types"; 5 | 6 | export class CreateEndpoint = Array> extends OpenAPIRoute { 7 | // @ts-ignore 8 | _meta: MetaInput; 9 | 10 | get meta() { 11 | return MetaGenerator(this._meta); 12 | } 13 | 14 | getSchema() { 15 | const bodyParameters = this.meta.fields.omit( 16 | (this.params.urlParams || []).reduce((a, v) => ({ ...a, [v]: true }), {}), 17 | ); 18 | const pathParameters = this.meta.fields.pick( 19 | (this.params.urlParams || []).reduce((a, v) => ({ ...a, [v]: true }), {}), 20 | ); 21 | 22 | return { 23 | request: { 24 | body: contentJson(bodyParameters), 25 | params: Object.keys(pathParameters.shape).length ? pathParameters : undefined, 26 | ...this.schema?.request, 27 | }, 28 | responses: { 29 | "201": { 30 | description: "Returns the created Object", 31 | ...contentJson({ 32 | success: Boolean, 33 | result: this.meta.model.serializerSchema, 34 | }), 35 | ...this.schema?.responses?.[200], 36 | }, 37 | ...InputValidationException.schema(), 38 | ...this.schema?.responses, 39 | }, 40 | ...this.schema, 41 | }; 42 | } 43 | 44 | async getObject(): Promise> { 45 | const data = await this.getValidatedData(); 46 | 47 | // @ts-ignore TODO: check this 48 | const newData: any = { 49 | ...(data.body as object), 50 | }; 51 | 52 | for (const param of this.params.urlParams) { 53 | newData[param] = (data.params as any)[param]; 54 | } 55 | 56 | return newData; 57 | } 58 | 59 | async before(data: O): Promise> { 60 | return data; 61 | } 62 | 63 | async after(data: O): Promise> { 64 | return data; 65 | } 66 | 67 | async create(data: O): Promise> { 68 | return data; 69 | } 70 | 71 | async handle(...args: HandleArgs) { 72 | let obj = await this.getObject(); 73 | 74 | obj = await this.before(obj); 75 | 76 | obj = await this.create(obj); 77 | 78 | obj = await this.after(obj); 79 | 80 | return Response.json( 81 | { 82 | success: true, 83 | result: this.meta.model.serializer(obj as object), 84 | }, 85 | { status: 201 }, 86 | ); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/endpoints/d1/create.ts: -------------------------------------------------------------------------------- 1 | import { ApiException, type InputValidationException } from "../../exceptions"; 2 | import { CreateEndpoint } from "../create"; 3 | import type { Logger, O } from "../types"; 4 | 5 | export class D1CreateEndpoint = Array> extends CreateEndpoint { 6 | dbName = "DB"; 7 | logger?: Logger; 8 | constraintsMessages: Record = {}; 9 | 10 | getDBBinding(): D1Database { 11 | const env = this.params.router.getBindings(this.args); 12 | if (env[this.dbName] === undefined) { 13 | throw new ApiException(`Binding "${this.dbName}" is not defined in worker`); 14 | } 15 | 16 | if (env[this.dbName].prepare === undefined) { 17 | throw new ApiException(`Binding "${this.dbName}" is not a D1 binding`); 18 | } 19 | 20 | return env[this.dbName]; 21 | } 22 | 23 | async create(data: O): Promise> { 24 | let inserted; 25 | try { 26 | const result = await this.getDBBinding() 27 | .prepare( 28 | `INSERT INTO ${this.meta.model.tableName} (${Object.keys(data).join(", ")}) VALUES (${Object.values(data) 29 | .map(() => "?") 30 | .join(", ")}) RETURNING *`, 31 | ) 32 | .bind(...Object.values(data)) 33 | .all(); 34 | 35 | inserted = result.results[0] as O; 36 | } catch (e: any) { 37 | if (this.logger) 38 | this.logger.error(`Caught exception while trying to create ${this.meta.model.tableName}: ${e.message}`); 39 | if (e.message.includes("UNIQUE constraint failed")) { 40 | const constraintMessage = e.message.split("UNIQUE constraint failed:")[1].split(":")[0].trim(); 41 | if (this.constraintsMessages[constraintMessage]) { 42 | throw this.constraintsMessages[constraintMessage]; 43 | } 44 | } 45 | 46 | throw new ApiException(e.message); 47 | } 48 | 49 | if (this.logger) this.logger.log(`Successfully created ${this.meta.model.tableName}`); 50 | 51 | return inserted; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/endpoints/d1/delete.ts: -------------------------------------------------------------------------------- 1 | import { ApiException } from "../../exceptions"; 2 | import { DeleteEndpoint } from "../delete"; 3 | import type { Filters, Logger, O } from "../types"; 4 | 5 | export class D1DeleteEndpoint = Array> extends DeleteEndpoint { 6 | dbName = "DB"; 7 | logger?: Logger; 8 | 9 | getDBBinding(): D1Database { 10 | const env = this.params.router.getBindings(this.args); 11 | if (env[this.dbName] === undefined) { 12 | throw new ApiException(`Binding "${this.dbName}" is not defined in worker`); 13 | } 14 | 15 | if (env[this.dbName].prepare === undefined) { 16 | throw new ApiException(`Binding "${this.dbName}" is not a D1 binding`); 17 | } 18 | 19 | return env[this.dbName]; 20 | } 21 | 22 | getSafeFilters(filters: Filters) { 23 | const conditions: string[] = []; 24 | const conditionsParams: string[] = []; 25 | 26 | for (const f of filters.filters) { 27 | if (f.operator === "EQ") { 28 | conditions.push(`${f.field} = ?${conditionsParams.length + 1}`); 29 | conditionsParams.push(f.value as any); 30 | } else { 31 | throw new ApiException(`operator ${f.operator} Not implemented`); 32 | } 33 | } 34 | 35 | return { conditions, conditionsParams }; 36 | } 37 | 38 | async getObject(filters: Filters): Promise | null> { 39 | const safeFilters = this.getSafeFilters(filters); 40 | 41 | const oldObj = await this.getDBBinding() 42 | .prepare(`SELECT * FROM ${this.meta.model.tableName} WHERE ${safeFilters.conditions.join(" AND ")} LIMIT 1`) 43 | .bind(...safeFilters.conditionsParams) 44 | .all(); 45 | 46 | if (!oldObj.results || oldObj.results.length === 0) { 47 | return null; 48 | } 49 | 50 | return oldObj.results[0] as O; 51 | } 52 | 53 | async delete(oldObj: O, filters: Filters): Promise | null> { 54 | const safeFilters = this.getSafeFilters(filters); 55 | 56 | let result; 57 | try { 58 | result = await this.getDBBinding() 59 | .prepare( 60 | `DELETE FROM ${this.meta.model.tableName} WHERE ${safeFilters.conditions.join(" AND ")} RETURNING * LIMIT 1`, 61 | ) 62 | .bind(...safeFilters.conditionsParams) 63 | .all(); 64 | } catch (e: any) { 65 | if (this.logger) 66 | this.logger.error(`Caught exception while trying to delete ${this.meta.model.tableName}: ${e.message}`); 67 | throw new ApiException(e.message); 68 | } 69 | 70 | if (result.meta.changes === 0) { 71 | return null; 72 | } 73 | 74 | if (this.logger) this.logger.log(`Successfully deleted ${this.meta.model.tableName}`); 75 | 76 | return oldObj; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/endpoints/d1/list.ts: -------------------------------------------------------------------------------- 1 | import { ApiException } from "../../exceptions"; 2 | import { ListEndpoint } from "../list"; 3 | import type { ListFilters, ListResult, Logger, O } from "../types"; 4 | 5 | export class D1ListEndpoint = Array> extends ListEndpoint { 6 | dbName = "DB"; 7 | logger?: Logger; 8 | 9 | getDBBinding(): D1Database { 10 | const env = this.params.router.getBindings(this.args); 11 | if (env[this.dbName] === undefined) { 12 | throw new ApiException(`Binding "${this.dbName}" is not defined in worker`); 13 | } 14 | 15 | if (env[this.dbName].prepare === undefined) { 16 | throw new ApiException(`Binding "${this.dbName}" is not a D1 binding`); 17 | } 18 | 19 | return env[this.dbName]; 20 | } 21 | 22 | async list(filters: ListFilters): Promise> & { result_info: object }> { 23 | const offset = (filters.options.per_page || 20) * (filters.options.page || 0) - (filters.options.per_page || 20); 24 | const limit = filters.options.per_page; 25 | 26 | const conditions: string[] = []; 27 | const conditionsParams: string[] = []; 28 | 29 | for (const f of filters.filters) { 30 | if (this.searchFields && f.field === this.searchFieldName) { 31 | const searchCondition = this.searchFields 32 | .map((obj) => { 33 | return `UPPER(${obj}) like UPPER(?${conditionsParams.length + 1})`; 34 | }) 35 | .join(" or "); 36 | conditions.push(`(${searchCondition})`); 37 | conditionsParams.push(`%${f.value}%`); 38 | } else if (f.operator === "EQ") { 39 | conditions.push(`${f.field} = ?${conditionsParams.length + 1}`); 40 | conditionsParams.push(f.value as any); 41 | } else { 42 | throw new ApiException(`operator ${f.operator} Not implemented`); 43 | } 44 | } 45 | 46 | let where = ""; 47 | if (conditions.length > 0) { 48 | where = `WHERE ${conditions.join(" AND ")}`; 49 | } 50 | 51 | let orderBy = `ORDER BY ${this.defaultOrderBy || `${this.meta.model.primaryKeys[0]} DESC`}`; 52 | if (filters.options.order_by) { 53 | orderBy = `ORDER BY ${filters.options.order_by} ${filters.options.order_by_direction || "ASC"}`; 54 | } 55 | 56 | const results = await this.getDBBinding() 57 | .prepare(`SELECT * FROM ${this.meta.model.tableName} ${where} ${orderBy} LIMIT ${limit} OFFSET ${offset}`) 58 | .bind(...conditionsParams) 59 | .all(); 60 | 61 | const total_count = await this.getDBBinding() 62 | .prepare(`SELECT count(*) as total FROM ${this.meta.model.tableName} ${where} LIMIT ${limit}`) 63 | .bind(...conditionsParams) 64 | .all(); 65 | 66 | return { 67 | result: results.results, 68 | result_info: { 69 | count: results.results.length, 70 | page: filters.options.page, 71 | per_page: filters.options.per_page, 72 | total_count: total_count.results[0]?.total, 73 | }, 74 | }; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/endpoints/d1/read.ts: -------------------------------------------------------------------------------- 1 | import { ApiException } from "../../exceptions"; 2 | import { ReadEndpoint } from "../read"; 3 | import type { ListFilters, Logger, O } from "../types"; 4 | 5 | export class D1ReadEndpoint = Array> extends ReadEndpoint { 6 | dbName = "DB"; 7 | logger?: Logger; 8 | 9 | getDBBinding(): D1Database { 10 | const env = this.params.router.getBindings(this.args); 11 | if (env[this.dbName] === undefined) { 12 | throw new ApiException(`Binding "${this.dbName}" is not defined in worker`); 13 | } 14 | 15 | if (env[this.dbName].prepare === undefined) { 16 | throw new ApiException(`Binding "${this.dbName}" is not a D1 binding`); 17 | } 18 | 19 | return env[this.dbName]; 20 | } 21 | 22 | async fetch(filters: ListFilters): Promise | null> { 23 | const conditions = filters.filters.map((obj) => `${obj.field} = ?`); 24 | 25 | const obj = await this.getDBBinding() 26 | .prepare(`SELECT * FROM ${this.meta.model.tableName} WHERE ${conditions.join(" AND ")} LIMIT 1`) 27 | .bind(...filters.filters.map((obj) => obj.value)) 28 | .all(); 29 | 30 | if (!obj.results || obj.results.length === 0) { 31 | return null; 32 | } 33 | 34 | return obj.results[0] as O; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/endpoints/d1/update.ts: -------------------------------------------------------------------------------- 1 | import { ApiException, type InputValidationException } from "../../exceptions"; 2 | import type { Logger, O, UpdateFilters } from "../types"; 3 | import { UpdateEndpoint } from "../update"; 4 | 5 | export class D1UpdateEndpoint = Array> extends UpdateEndpoint { 6 | dbName = "DB"; 7 | logger?: Logger; 8 | constraintsMessages: Record = {}; 9 | 10 | getDBBinding(): D1Database { 11 | const env = this.params.router.getBindings(this.args); 12 | if (env[this.dbName] === undefined) { 13 | throw new ApiException(`Binding "${this.dbName}" is not defined in worker`); 14 | } 15 | 16 | if (env[this.dbName].prepare === undefined) { 17 | throw new ApiException(`Binding "${this.dbName}" is not a D1 binding`); 18 | } 19 | 20 | return env[this.dbName]; 21 | } 22 | 23 | getSafeFilters(filters: UpdateFilters) { 24 | // Filters should only apply to primary keys 25 | const safeFilters = filters.filters.filter((f) => { 26 | return this.meta.model.primaryKeys.includes(f.field); 27 | }); 28 | 29 | const conditions: string[] = []; 30 | const conditionsParams: string[] = []; 31 | 32 | for (const f of safeFilters) { 33 | if (f.operator === "EQ") { 34 | conditions.push(`${f.field} = ?${conditionsParams.length + 1}`); 35 | conditionsParams.push(f.value as any); 36 | } else { 37 | throw new ApiException(`operator ${f.operator} Not implemented`); 38 | } 39 | } 40 | 41 | return { conditions, conditionsParams }; 42 | } 43 | 44 | async getObject(filters: UpdateFilters): Promise { 45 | const safeFilters = this.getSafeFilters(filters); 46 | 47 | const oldObj = await this.getDBBinding() 48 | .prepare( 49 | `SELECT * 50 | FROM ${this.meta.model.tableName} WHERE ${safeFilters.conditions.join(" AND ")} LIMIT 1`, 51 | ) 52 | .bind(...safeFilters.conditionsParams) 53 | .all(); 54 | 55 | if (!oldObj.results || oldObj.results.length === 0) { 56 | return null; 57 | } 58 | 59 | return oldObj.results[0] as O; 60 | } 61 | 62 | async update(oldObj: O, filters: UpdateFilters): Promise> { 63 | const safeFilters = this.getSafeFilters(filters); 64 | 65 | let result; 66 | try { 67 | const obj = await this.getDBBinding() 68 | .prepare( 69 | `UPDATE ${this.meta.model.tableName} SET ${Object.keys(filters.updatedData).map((key, index) => `${key} = ?${safeFilters.conditionsParams.length + index + 1}`)} WHERE ${safeFilters.conditions.join(" AND ")} RETURNING *`, 70 | ) 71 | .bind(...safeFilters.conditionsParams, ...Object.values(filters.updatedData)) 72 | .all(); 73 | 74 | result = obj.results[0]; 75 | } catch (e: any) { 76 | if (this.logger) 77 | this.logger.error(`Caught exception while trying to update ${this.meta.model.tableName}: ${e.message}`); 78 | if (e.message.includes("UNIQUE constraint failed")) { 79 | const constraintMessage = e.message.split("UNIQUE constraint failed:")[1].split(":")[0].trim(); 80 | if (this.constraintsMessages[constraintMessage]) { 81 | throw this.constraintsMessages[constraintMessage]; 82 | } 83 | } 84 | 85 | throw new ApiException(e.message); 86 | } 87 | 88 | if (this.logger) this.logger.log(`Successfully updated ${this.meta.model.tableName}`); 89 | 90 | return result as O; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/endpoints/delete.ts: -------------------------------------------------------------------------------- 1 | import { contentJson } from "../contentTypes"; 2 | import { NotFoundException } from "../exceptions"; 3 | import { OpenAPIRoute } from "../route"; 4 | import { type FilterCondition, type Filters, MetaGenerator, type MetaInput, type O } from "./types"; 5 | 6 | export class DeleteEndpoint = Array> extends OpenAPIRoute { 7 | // @ts-ignore 8 | _meta: MetaInput; 9 | 10 | get meta() { 11 | return MetaGenerator(this._meta); 12 | } 13 | 14 | getSchema() { 15 | const bodyParameters = this.meta.fields 16 | .pick((this.meta.model.primaryKeys || []).reduce((a, v) => ({ ...a, [v]: true }), {})) 17 | .omit((this.params.urlParams || []).reduce((a, v) => ({ ...a, [v]: true }), {})); 18 | const pathParameters = this.meta.fields 19 | .pick((this.meta.model.primaryKeys || []).reduce((a, v) => ({ ...a, [v]: true }), {})) 20 | .pick((this.params.urlParams || []).reduce((a, v) => ({ ...a, [v]: true }), {})); 21 | 22 | return { 23 | request: { 24 | body: Object.keys(bodyParameters.shape).length ? contentJson(bodyParameters) : undefined, 25 | params: Object.keys(pathParameters.shape).length ? pathParameters : undefined, 26 | ...this.schema?.request, 27 | }, 28 | responses: { 29 | "200": { 30 | description: "Returns the Object if it was successfully deleted", 31 | ...contentJson({ 32 | success: Boolean, 33 | result: this.meta.model.serializerSchema, 34 | }), 35 | ...this.schema?.responses?.[200], 36 | }, 37 | ...NotFoundException.schema(), 38 | ...this.schema?.responses, 39 | }, 40 | ...this.schema, 41 | }; 42 | } 43 | 44 | async getFilters(): Promise { 45 | const data = await this.getValidatedData(); 46 | 47 | const filters: Array = []; 48 | 49 | for (const part of [data.params, data.body]) { 50 | if (part) { 51 | for (const [key, value] of Object.entries(part)) { 52 | filters.push({ 53 | field: key, 54 | operator: "EQ", 55 | value: value as string, 56 | }); 57 | } 58 | } 59 | } 60 | 61 | return { 62 | filters, 63 | }; 64 | } 65 | 66 | async before(oldObj: O, filters: Filters): Promise { 67 | return filters; 68 | } 69 | 70 | async after(data: O): Promise> { 71 | return data; 72 | } 73 | 74 | async delete(oldObj: O, filters: Filters): Promise | null> { 75 | return null; 76 | } 77 | 78 | async getObject(filters: Filters): Promise | null> { 79 | return null; 80 | } 81 | 82 | async handle(...args: HandleArgs) { 83 | let filters = await this.getFilters(); 84 | 85 | const oldObj = await this.getObject(filters); 86 | 87 | if (oldObj === null) { 88 | throw new NotFoundException(); 89 | } 90 | 91 | filters = await this.before(oldObj, filters); 92 | 93 | let obj = await this.delete(oldObj, filters); 94 | 95 | if (obj === null) { 96 | throw new NotFoundException(); 97 | } 98 | 99 | obj = await this.after(obj); 100 | 101 | return { 102 | success: true, 103 | result: this.meta.model.serializer(obj), 104 | }; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/endpoints/list.ts: -------------------------------------------------------------------------------- 1 | import { type AnyZodObject, z } from "zod"; 2 | import { contentJson } from "../contentTypes"; 3 | import { Enumeration, Str } from "../parameters"; 4 | import { OpenAPIRoute } from "../route"; 5 | import { 6 | type FilterCondition, 7 | type ListFilters, 8 | type ListResult, 9 | MetaGenerator, 10 | type MetaInput, 11 | type O, 12 | } from "./types"; 13 | 14 | export class ListEndpoint = Array> extends OpenAPIRoute { 15 | // @ts-ignore 16 | _meta: MetaInput; 17 | 18 | get meta() { 19 | return MetaGenerator(this._meta); 20 | } 21 | 22 | filterFields?: Array; 23 | searchFields?: Array; 24 | searchFieldName = "search"; 25 | optionFields = ["page", "per_page", "order_by", "order_by_direction"]; 26 | orderByFields = []; 27 | defaultOrderBy?: string; 28 | 29 | getSchema() { 30 | const parsedQueryParameters = this.meta.fields 31 | .pick((this.filterFields || []).reduce((a, v) => ({ ...a, [v]: true }), {})) 32 | .omit((this.params.urlParams || []).reduce((a, v) => ({ ...a, [v]: true }), {})).shape; 33 | const pathParameters = this.meta.fields.pick( 34 | (this.params.urlParams || this.meta.model.primaryKeys || []).reduce((a, v) => ({ ...a, [v]: true }), {}), 35 | ); 36 | 37 | for (const [key, value] of Object.entries(parsedQueryParameters)) { 38 | // @ts-ignore TODO: check this 39 | parsedQueryParameters[key] = (value as AnyZodObject).optional(); 40 | } 41 | 42 | if (this.searchFields) { 43 | // @ts-ignore TODO: check this 44 | parsedQueryParameters[this.searchFieldName] = z 45 | .string() 46 | .optional() 47 | .openapi({ 48 | description: `Search by ${this.searchFields.join(", ")}`, 49 | }); 50 | } 51 | 52 | let queryParameters = z 53 | .object({ 54 | page: z.number().int().min(1).optional().default(1), 55 | per_page: z.number().int().min(1).max(100).optional().default(20), 56 | }) 57 | .extend(parsedQueryParameters); 58 | 59 | if (this.orderByFields && this.orderByFields.length > 0) { 60 | queryParameters = queryParameters.extend({ 61 | order_by: Enumeration({ 62 | default: this.orderByFields[0], 63 | values: this.orderByFields, 64 | description: "Order By Column Name", 65 | required: false, 66 | }), 67 | order_by_direction: Enumeration({ 68 | default: "asc", 69 | values: ["asc", "desc"], 70 | description: "Order By Direction", 71 | required: false, 72 | }), 73 | }); 74 | } 75 | 76 | return { 77 | request: { 78 | params: Object.keys(pathParameters.shape).length ? pathParameters : undefined, 79 | query: queryParameters, 80 | ...this.schema?.request, 81 | }, 82 | responses: { 83 | "200": { 84 | description: "List objects", 85 | ...contentJson({ 86 | success: Boolean, 87 | result: [this.meta.model.serializerSchema], 88 | }), 89 | ...this.schema?.responses?.[200], 90 | }, 91 | ...this.schema?.responses, 92 | }, 93 | ...this.schema, 94 | }; 95 | } 96 | 97 | async getFilters(): Promise { 98 | const data = await this.getValidatedData(); 99 | 100 | const filters: Array = []; 101 | const options: Record = {}; // TODO: fix this type 102 | 103 | for (const part of [data.params, data.query]) { 104 | if (part) { 105 | for (const [key, value] of Object.entries(part)) { 106 | if (this.searchFields && key === this.searchFieldName) { 107 | filters.push({ 108 | field: key, 109 | operator: "LIKE", 110 | value: value as string, 111 | }); 112 | } else if (this.optionFields.includes(key)) { 113 | options[key] = value as string; 114 | } else { 115 | filters.push({ 116 | field: key, 117 | operator: "EQ", 118 | value: value as string, 119 | }); 120 | } 121 | } 122 | } 123 | } 124 | 125 | return { 126 | options, 127 | filters, 128 | }; 129 | } 130 | 131 | async before(filters: ListFilters): Promise { 132 | return filters; 133 | } 134 | 135 | async after(data: ListResult>): Promise>> { 136 | return data; 137 | } 138 | 139 | async list(filters: ListFilters): Promise>> { 140 | return { 141 | result: [], 142 | }; 143 | } 144 | 145 | async handle(...args: HandleArgs) { 146 | let filters = await this.getFilters(); 147 | 148 | filters = await this.before(filters); 149 | 150 | let objs = await this.list(filters); 151 | 152 | objs = await this.after(objs); 153 | 154 | objs = { 155 | ...objs, 156 | result: objs.result.map(this.meta.model.serializer), 157 | }; 158 | 159 | return { 160 | success: true, 161 | ...objs, 162 | }; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/endpoints/read.ts: -------------------------------------------------------------------------------- 1 | import { contentJson } from "../contentTypes"; 2 | import { NotFoundException } from "../exceptions"; 3 | import { OpenAPIRoute } from "../route"; 4 | import { type FilterCondition, type ListFilters, MetaGenerator, type MetaInput, type O } from "./types"; 5 | 6 | export class ReadEndpoint = Array> extends OpenAPIRoute { 7 | // @ts-ignore 8 | _meta: MetaInput; 9 | 10 | get meta() { 11 | return MetaGenerator(this._meta); 12 | } 13 | 14 | getSchema() { 15 | if ( 16 | !this.meta.pathParameters && 17 | this.meta.model.primaryKeys.sort().toString() !== this.params.urlParams.sort().toString() 18 | ) { 19 | throw Error( 20 | `Model primaryKeys differ from urlParameters on: ${this.params.route}: ${JSON.stringify(this.meta.model.primaryKeys)} !== ${JSON.stringify(this.params.urlParams)}, fix url parameters or define pathParameters in your Model`, 21 | ); 22 | } 23 | 24 | const inputPathParameters = this.meta.pathParameters ?? this.meta.model.primaryKeys; 25 | 26 | //const queryParameters = this.model.omit((this.primaryKey || []).reduce((a, v) => ({ ...a, [v]: true }), {})); 27 | const pathParameters = this.meta.fields.pick( 28 | (inputPathParameters || []).reduce((a, v) => ({ ...a, [v]: true }), {}), 29 | ); 30 | 31 | return { 32 | request: { 33 | //query: queryParameters, 34 | params: Object.keys(pathParameters.shape).length ? pathParameters : undefined, 35 | ...this.schema?.request, 36 | }, 37 | responses: { 38 | "200": { 39 | description: "Returns a single object if found", 40 | ...contentJson({ 41 | success: Boolean, 42 | result: this.meta.model.serializerSchema, 43 | }), 44 | ...this.schema?.responses?.[200], 45 | }, 46 | ...NotFoundException.schema(), 47 | ...this.schema?.responses, 48 | }, 49 | ...this.schema, 50 | }; 51 | } 52 | 53 | async getFilters(): Promise { 54 | const data = await this.getValidatedData(); 55 | 56 | const filters: Array = []; 57 | 58 | for (const part of [data.params, data.query]) { 59 | if (part) { 60 | for (const [key, value] of Object.entries(part)) { 61 | filters.push({ 62 | field: key, 63 | operator: "EQ", 64 | value: value as string, 65 | }); 66 | } 67 | } 68 | } 69 | 70 | return { 71 | filters: filters, 72 | options: {}, // TODO: make a new type for this 73 | }; 74 | } 75 | 76 | async before(filters: ListFilters): Promise { 77 | return filters; 78 | } 79 | 80 | async after(data: O): Promise> { 81 | return data; 82 | } 83 | 84 | async fetch(filters: ListFilters): Promise | null> { 85 | return null; 86 | } 87 | 88 | async handle(...args: HandleArgs) { 89 | let filters = await this.getFilters(); 90 | 91 | filters = await this.before(filters); 92 | 93 | let obj = await this.fetch(filters); 94 | 95 | if (!obj) { 96 | throw new NotFoundException(); 97 | } 98 | 99 | obj = await this.after(obj); 100 | 101 | return { 102 | success: true, 103 | result: this.meta.model.serializer(obj), 104 | }; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/endpoints/types.ts: -------------------------------------------------------------------------------- 1 | import type { AnyZodObject, z } from "zod"; 2 | import type { SetRequired } from "../types"; 3 | 4 | export type FilterCondition = { 5 | field: string; 6 | operator: string; 7 | value: string | number | boolean | null; 8 | }; 9 | 10 | export type ListFilters = { 11 | filters: Array; 12 | options: { 13 | page?: number; 14 | per_page?: number; 15 | order_by?: string; 16 | order_by_direction?: "asc" | "desc"; 17 | }; 18 | }; 19 | 20 | export type Filters = { 21 | filters: Array; 22 | }; 23 | 24 | export type UpdateFilters = { 25 | filters: Array; 26 | updatedData: Record; 27 | }; 28 | 29 | export type Model = { 30 | tableName: string; 31 | schema: AnyZodObject; 32 | primaryKeys: Array; 33 | serializer?: (obj: object) => object; 34 | serializerSchema?: AnyZodObject; 35 | }; 36 | 37 | export type ModelComplete = SetRequired; 38 | 39 | export type MetaInput = { 40 | model: Model; 41 | fields?: AnyZodObject; 42 | pathParameters?: Array; 43 | }; 44 | 45 | export type Meta = { 46 | model: ModelComplete; 47 | fields: AnyZodObject; 48 | }; 49 | 50 | export type O = z.infer; 51 | 52 | export type ListResult = { 53 | result: Array; 54 | }; 55 | 56 | export function MetaGenerator(meta: MetaInput) { 57 | return { 58 | fields: meta.fields ?? meta.model.schema, 59 | model: { 60 | serializer: (obj: any): any => obj, 61 | serializerSchema: meta.model.schema, 62 | ...meta.model, 63 | }, 64 | pathParameters: meta.pathParameters ?? null, 65 | }; 66 | } 67 | 68 | export type Logger = { 69 | log: (...args: any[]) => void; 70 | info: (...args: any[]) => void; 71 | warn: (...args: any[]) => void; 72 | error: (...args: any[]) => void; 73 | debug: (...args: any[]) => void; 74 | trace: (...args: any[]) => void; 75 | }; 76 | -------------------------------------------------------------------------------- /src/endpoints/update.ts: -------------------------------------------------------------------------------- 1 | import { contentJson } from "../contentTypes"; 2 | import { InputValidationException, NotFoundException } from "../exceptions"; 3 | import { OpenAPIRoute } from "../route"; 4 | import { type FilterCondition, MetaGenerator, type MetaInput, type O, type UpdateFilters } from "./types"; 5 | 6 | export class UpdateEndpoint = Array> extends OpenAPIRoute { 7 | // @ts-ignore 8 | _meta: MetaInput; 9 | 10 | get meta() { 11 | return MetaGenerator(this._meta); 12 | } 13 | 14 | getSchema() { 15 | const bodyParameters = this.meta.fields.omit( 16 | (this.params.urlParams || []).reduce((a, v) => ({ ...a, [v]: true }), {}), 17 | ); 18 | const pathParameters = this.meta.model.schema.pick( 19 | (this.params.urlParams || []).reduce((a, v) => ({ ...a, [v]: true }), {}), 20 | ); 21 | 22 | return { 23 | request: { 24 | body: contentJson(bodyParameters), 25 | params: Object.keys(pathParameters.shape).length ? pathParameters : undefined, 26 | ...this.schema?.request, 27 | }, 28 | responses: { 29 | "200": { 30 | description: "Returns the updated Object", 31 | ...contentJson({ 32 | success: Boolean, 33 | result: this.meta.model.serializerSchema, 34 | }), 35 | ...this.schema?.responses?.[200], 36 | }, 37 | ...InputValidationException.schema(), 38 | ...NotFoundException.schema(), 39 | ...this.schema?.responses, 40 | }, 41 | ...this.schema, 42 | }; 43 | } 44 | 45 | async getFilters(): Promise { 46 | const data = await this.getValidatedData(); 47 | 48 | const filters: Array = []; 49 | const updatedData: Record = {}; // TODO: fix this type 50 | 51 | for (const part of [data.params, data.body]) { 52 | if (part) { 53 | for (const [key, value] of Object.entries(part)) { 54 | if ((this.meta.model.primaryKeys || []).includes(key)) { 55 | filters.push({ 56 | field: key, 57 | operator: "EQ", 58 | value: value as string, 59 | }); 60 | } else { 61 | updatedData[key] = value as string; 62 | } 63 | } 64 | } 65 | } 66 | 67 | return { 68 | filters, 69 | updatedData, 70 | }; 71 | } 72 | 73 | async before(oldObj: O, filters: UpdateFilters): Promise { 74 | return filters; 75 | } 76 | 77 | async after(data: O): Promise> { 78 | return data; 79 | } 80 | 81 | async getObject(filters: UpdateFilters): Promise | null> { 82 | return null; 83 | } 84 | 85 | async update(oldObj: O, filters: UpdateFilters): Promise> { 86 | return oldObj; 87 | } 88 | 89 | async handle(...args: HandleArgs) { 90 | let filters = await this.getFilters(); 91 | 92 | const oldObj = await this.getObject(filters); 93 | 94 | if (oldObj === null) { 95 | throw new NotFoundException(); 96 | } 97 | 98 | filters = await this.before(oldObj, filters); 99 | 100 | let obj = await this.update(oldObj, filters); 101 | 102 | obj = await this.after(obj); 103 | 104 | return { 105 | success: true, 106 | result: this.meta.model.serializer(obj), 107 | }; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/exceptions.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { contentJson } from "./contentTypes"; 3 | 4 | export class ApiException extends Error { 5 | isVisible = true; 6 | message: string; 7 | default_message = "Internal Error"; 8 | status = 500; 9 | code = 7000; 10 | includesPath = false; 11 | 12 | constructor(message = "") { 13 | super(message); 14 | this.message = message; 15 | } 16 | 17 | buildResponse() { 18 | return [ 19 | { 20 | code: this.code, 21 | message: this.isVisible ? this.message || this.default_message : "Internal Error", 22 | }, 23 | ]; 24 | } 25 | 26 | static schema() { 27 | const inst = new this(); 28 | const innerError = { 29 | code: inst.code, 30 | message: inst.default_message, 31 | }; 32 | 33 | if (inst.includesPath === true) { 34 | // @ts-ignore 35 | innerError.path = ["body", "fieldName"]; 36 | } 37 | 38 | return { 39 | [inst.status]: { 40 | description: inst.default_message, 41 | ...contentJson({ 42 | success: z.literal(false), 43 | errors: [innerError], 44 | }), 45 | }, 46 | }; 47 | } 48 | } 49 | 50 | export class InputValidationException extends ApiException { 51 | isVisible = true; 52 | default_message = "Input Validation Error"; 53 | status = 400; 54 | code = 7001; 55 | path = null; 56 | includesPath = true; 57 | 58 | constructor(message?: string, path?: any) { 59 | super(message); 60 | this.path = path; 61 | } 62 | 63 | buildResponse() { 64 | return [ 65 | { 66 | code: this.code, 67 | message: this.isVisible ? this.message : "Internal Error", 68 | path: this.path, 69 | }, 70 | ]; 71 | } 72 | } 73 | 74 | export class MultiException extends ApiException { 75 | isVisible = true; 76 | errors: Array; 77 | status = 400; 78 | 79 | constructor(errors: Array) { 80 | super("Multiple Exceptions"); 81 | this.errors = errors; 82 | 83 | // Because the API can only return 1 status code, always return the highest 84 | for (const err of errors) { 85 | if (err.status > this.status) { 86 | this.status = err.status; 87 | } 88 | 89 | if (!err.isVisible && this.isVisible) { 90 | this.isVisible = false; 91 | } 92 | } 93 | } 94 | 95 | buildResponse() { 96 | return this.errors.flatMap((err) => err.buildResponse()); 97 | } 98 | } 99 | 100 | export class NotFoundException extends ApiException { 101 | isVisible = true; 102 | default_message = "Not Found"; 103 | status = 404; 104 | code = 7002; 105 | } 106 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; 2 | export * from "./openapi"; 3 | export * from "./parameters"; 4 | export * from "./types"; 5 | export * from "./route"; 6 | export * from "./ui"; 7 | export * from "./utils"; 8 | export * from "./contentTypes"; 9 | export * from "./adapters/ittyRouter"; 10 | export * from "./adapters/hono"; 11 | export * from "./zod/registry"; 12 | export * from "./zod/utils"; 13 | export * from "./exceptions"; 14 | 15 | export * from "./endpoints/types"; 16 | export * from "./endpoints/create"; 17 | export * from "./endpoints/delete"; 18 | export * from "./endpoints/read"; 19 | export * from "./endpoints/list"; 20 | export * from "./endpoints/update"; 21 | 22 | export * from "./endpoints/d1/create"; 23 | export * from "./endpoints/d1/delete"; 24 | export * from "./endpoints/d1/read"; 25 | export * from "./endpoints/d1/list"; 26 | export * from "./endpoints/d1/update"; 27 | -------------------------------------------------------------------------------- /src/openapi.ts: -------------------------------------------------------------------------------- 1 | import { OpenApiGeneratorV3, OpenApiGeneratorV31 } from "@asteasolutions/zod-to-openapi"; 2 | import yaml from "js-yaml"; 3 | import { z } from "zod"; 4 | import type { OpenAPIRoute } from "./route"; 5 | import type { OpenAPIRouteSchema, RouterOptions } from "./types"; 6 | import { getReDocUI, getSwaggerUI } from "./ui"; 7 | import { OpenAPIRegistryMerger } from "./zod/registry"; 8 | 9 | export type OpenAPIRouterType = { 10 | original: M; 11 | options: RouterOptions; 12 | registry: OpenAPIRegistryMerger; 13 | }; 14 | 15 | export class OpenAPIHandler { 16 | router: any; 17 | options: RouterOptions; 18 | registry: OpenAPIRegistryMerger; 19 | 20 | allowedMethods = ["get", "head", "post", "put", "delete", "patch"]; 21 | 22 | constructor(router: any, options?: RouterOptions) { 23 | this.router = router; 24 | this.options = options || {}; 25 | this.registry = new OpenAPIRegistryMerger(); 26 | 27 | this.createDocsRoutes(); 28 | } 29 | 30 | createDocsRoutes() { 31 | if (this.options?.docs_url !== null && this.options?.openapi_url !== null) { 32 | this.router.get(this.options?.docs_url || "/docs", () => { 33 | return new Response(getSwaggerUI((this.options?.base || "") + (this.options?.openapi_url || "/openapi.json")), { 34 | headers: { 35 | "content-type": "text/html; charset=UTF-8", 36 | }, 37 | status: 200, 38 | }); 39 | }); 40 | } 41 | 42 | if (this.options?.redoc_url !== null && this.options?.openapi_url !== null) { 43 | this.router.get(this.options?.redoc_url || "/redocs", () => { 44 | return new Response(getReDocUI((this.options?.base || "") + (this.options?.openapi_url || "/openapi.json")), { 45 | headers: { 46 | "content-type": "text/html; charset=UTF-8", 47 | }, 48 | status: 200, 49 | }); 50 | }); 51 | } 52 | 53 | if (this.options?.openapi_url !== null) { 54 | this.router.get((this.options?.base || "") + (this.options?.openapi_url || "/openapi.json"), () => { 55 | return new Response(JSON.stringify(this.getGeneratedSchema()), { 56 | headers: { 57 | "content-type": "application/json;charset=UTF-8", 58 | }, 59 | status: 200, 60 | }); 61 | }); 62 | 63 | this.router.get( 64 | (this.options?.base || "") + (this.options?.openapi_url || "/openapi.json").replace(".json", ".yaml"), 65 | () => { 66 | return new Response(yaml.dump(this.getGeneratedSchema()), { 67 | headers: { 68 | "content-type": "text/yaml;charset=UTF-8", 69 | }, 70 | status: 200, 71 | }); 72 | }, 73 | ); 74 | } 75 | } 76 | 77 | getGeneratedSchema() { 78 | let openapiGenerator: any = OpenApiGeneratorV31; 79 | if (this.options?.openapiVersion === "3") openapiGenerator = OpenApiGeneratorV3; 80 | 81 | const generator = new openapiGenerator(this.registry.definitions); 82 | 83 | return generator.generateDocument({ 84 | openapi: this.options?.openapiVersion === "3" ? "3.0.3" : "3.1.0", 85 | info: { 86 | version: this.options?.schema?.info?.version || "1.0.0", 87 | title: this.options?.schema?.info?.title || "OpenAPI", 88 | ...this.options?.schema?.info, 89 | }, 90 | ...this.options?.schema, 91 | }); 92 | } 93 | 94 | registerNestedRouter(params: { 95 | method: string; 96 | nestedRouter: any; 97 | path?: string; 98 | }) { 99 | // Only overwrite the path if the nested router don't have a base already 100 | const path = params.nestedRouter.options?.base 101 | ? undefined 102 | : params.path 103 | ? params.path 104 | .replaceAll(/\/+(\/|$)/g, "$1") // strip double & trailing splash 105 | .replaceAll(/:(\w+)/g, "{$1}") // convert parameters into openapi compliant 106 | : undefined; // ; 107 | 108 | this.registry.merge(params.nestedRouter.registry, path); 109 | 110 | return [params.nestedRouter.fetch]; 111 | } 112 | 113 | parseRoute(path: string): string { 114 | return ((this.options.base || "") + path) 115 | .replaceAll(/\/+(\/|$)/g, "$1") // strip double & trailing splash 116 | .replaceAll(/:(\w+)/g, "{$1}"); // convert parameters into openapi compliant 117 | } 118 | 119 | registerRoute(params: { method: string; path: string; handlers: any[]; doRegister?: boolean }) { 120 | const parsedRoute = this.parseRoute(params.path); 121 | 122 | const parsedParams = ((this.options.base || "") + params.path).match(/:(\w+)/g); 123 | let urlParams: string[] = []; 124 | if (parsedParams) { 125 | urlParams = parsedParams.map((obj) => obj.replace(":", "")); 126 | } 127 | 128 | // @ts-ignore 129 | let schema: OpenAPIRouteSchema = undefined; 130 | // @ts-ignore 131 | let operationId: string = undefined; 132 | 133 | for (const handler of params.handlers) { 134 | if (handler.name) { 135 | operationId = `${params.method}_${handler.name}`; 136 | } 137 | 138 | if (handler.isRoute === true) { 139 | schema = new handler({ 140 | route: parsedRoute, 141 | urlParams: urlParams, 142 | }).getSchemaZod(); 143 | break; 144 | } 145 | } 146 | 147 | if (operationId === undefined) { 148 | operationId = `${params.method}_${parsedRoute.replaceAll("/", "_")}`; 149 | } 150 | 151 | if (schema === undefined) { 152 | // No schema for this route, try to guest the parameters 153 | 154 | // @ts-ignore 155 | schema = { 156 | operationId: operationId, 157 | responses: { 158 | 200: { 159 | description: "Successful response.", 160 | }, 161 | }, 162 | }; 163 | 164 | if (urlParams.length > 0) { 165 | schema.request = { 166 | params: z.object( 167 | urlParams.reduce( 168 | (obj, item) => 169 | Object.assign(obj, { 170 | [item]: z.string(), 171 | }), 172 | {}, 173 | ), 174 | ), 175 | }; 176 | } 177 | } else { 178 | // Schema was provided in the endpoint 179 | if (!schema.operationId) { 180 | if (this.options?.generateOperationIds === false && !schema.operationId) { 181 | throw new Error(`Route ${params.path} don't have operationId set!`); 182 | } 183 | 184 | schema.operationId = operationId; 185 | } 186 | } 187 | 188 | if (params.doRegister === undefined || params.doRegister) { 189 | this.registry.registerPath({ 190 | ...schema, 191 | // @ts-ignore 192 | method: params.method, 193 | path: parsedRoute, 194 | }); 195 | } 196 | 197 | return params.handlers.map((handler: any) => { 198 | if (handler.isRoute) { 199 | return (...params: any[]) => 200 | new handler({ 201 | router: this, 202 | route: parsedRoute, 203 | urlParams: urlParams, 204 | // raiseUnknownParameters: openapiConfig.raiseUnknownParameters, TODO 205 | }).execute(...params); 206 | } 207 | 208 | return handler; 209 | }); 210 | } 211 | 212 | handleCommonProxy(target: any, prop: string, ...args: any[]) { 213 | // This is a hack to allow older versions of wrangler to use this library 214 | // https://github.com/cloudflare/workers-sdk/issues/5420 215 | if (prop === "middleware") { 216 | return []; 217 | } 218 | 219 | if (prop === "isChanfana") { 220 | return true; 221 | } 222 | if (prop === "original") { 223 | return this.router; 224 | } 225 | if (prop === "schema") { 226 | return this.getGeneratedSchema(); 227 | } 228 | if (prop === "registry") { 229 | return this.registry; 230 | } 231 | 232 | return undefined; 233 | } 234 | 235 | getRequest(args: any[]) { 236 | throw new Error("getRequest not implemented"); 237 | } 238 | 239 | getUrlParams(args: any[]): Record { 240 | throw new Error("getUrlParams not implemented"); 241 | } 242 | 243 | getBindings(args: any[]): Record { 244 | throw new Error("getBindings not implemented"); 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /src/parameters.ts: -------------------------------------------------------------------------------- 1 | import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; 2 | import { z } from "zod"; 3 | import type { EnumerationParameterType, ParameterType, RegexParameterType, RouteParameter } from "./types"; 4 | import { isSpecificZodType, legacyTypeIntoZod } from "./zod/utils"; 5 | 6 | extendZodWithOpenApi(z); 7 | export function convertParams(field: any, params: any): M { 8 | params = params || {}; 9 | if (params.required === false) 10 | // @ts-ignore 11 | field = field.optional(); 12 | 13 | if (params.description) field = field.describe(params.description); 14 | 15 | if (params.default) 16 | // @ts-ignore 17 | field = field.default(params.default); 18 | 19 | if (params.example) { 20 | field = field.openapi({ example: params.example }); 21 | } 22 | 23 | if (params.format) { 24 | field = field.openapi({ format: params.format }); 25 | } 26 | 27 | return field; 28 | } 29 | 30 | export function Arr(innerType: any, params?: ParameterType): z.ZodArray { 31 | return convertParams(legacyTypeIntoZod(innerType).array(), params); 32 | } 33 | 34 | export function Obj(fields: object, params?: ParameterType): z.ZodObject { 35 | const parsed: Record = {}; 36 | for (const [key, value] of Object.entries(fields)) { 37 | parsed[key] = legacyTypeIntoZod(value); 38 | } 39 | 40 | return convertParams(z.object(parsed), params); 41 | } 42 | 43 | export function Num(params?: ParameterType): z.ZodNumber { 44 | return convertParams(z.number(), params).openapi({ 45 | type: "number", 46 | }); 47 | } 48 | 49 | export function Int(params?: ParameterType): z.ZodNumber { 50 | return convertParams(z.number().int(), params).openapi({ 51 | type: "integer", 52 | }); 53 | } 54 | 55 | export function Str(params?: ParameterType): z.ZodString { 56 | return convertParams(z.string(), params); 57 | } 58 | 59 | export function DateTime(params?: ParameterType): z.ZodString { 60 | return convertParams( 61 | z.string().datetime({ 62 | message: "Must be in the following format: YYYY-mm-ddTHH:MM:ssZ", 63 | }), 64 | params, 65 | ); 66 | } 67 | 68 | export function Regex(params: RegexParameterType): z.ZodString { 69 | return convertParams( 70 | // @ts-ignore 71 | z 72 | .string() 73 | .regex(params.pattern, params.patternError || "Invalid"), 74 | params, 75 | ); 76 | } 77 | 78 | export function Email(params?: ParameterType): z.ZodString { 79 | return convertParams(z.string().email(), params); 80 | } 81 | 82 | export function Uuid(params?: ParameterType): z.ZodString { 83 | return convertParams(z.string().uuid(), params); 84 | } 85 | 86 | export function Hostname(params?: ParameterType): z.ZodString { 87 | return convertParams( 88 | z 89 | .string() 90 | .regex( 91 | /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/, 92 | ), 93 | params, 94 | ); 95 | } 96 | 97 | export function Ipv4(params?: ParameterType): z.ZodString { 98 | return convertParams(z.string().ip({ version: "v4" }), params); 99 | } 100 | 101 | export function Ipv6(params?: ParameterType): z.ZodString { 102 | return convertParams(z.string().ip({ version: "v6" }), params); 103 | } 104 | 105 | export function Ip(params?: ParameterType): z.ZodString { 106 | return convertParams(z.string().ip(), params); 107 | } 108 | 109 | export function DateOnly(params?: ParameterType): z.ZodString { 110 | return convertParams(z.date(), params); 111 | } 112 | 113 | export function Bool(params?: ParameterType): z.ZodBoolean { 114 | return convertParams(z.boolean(), params).openapi({ 115 | type: "boolean", 116 | }); 117 | } 118 | 119 | export function Enumeration(params: EnumerationParameterType): z.ZodEnum { 120 | let { values } = params; 121 | const originalValues = { ...values }; 122 | 123 | if (Array.isArray(values)) values = Object.fromEntries(values.map((x) => [x, x])); 124 | 125 | const originalKeys: [string, ...string[]] = Object.keys(values) as [string, ...string[]]; 126 | 127 | if (params.enumCaseSensitive === false) { 128 | values = Object.keys(values).reduce((accumulator, key) => { 129 | // @ts-ignore 130 | accumulator[key.toLowerCase()] = values[key]; 131 | return accumulator; 132 | }, {}); 133 | } 134 | 135 | const keys: [string, ...string[]] = Object.keys(values) as [string, ...string[]]; 136 | 137 | let field; 138 | if ([undefined, true].includes(params.enumCaseSensitive)) { 139 | field = z.enum(keys); 140 | } else { 141 | field = z.preprocess((val) => String(val).toLowerCase(), z.enum(keys)).openapi({ enum: originalKeys }); 142 | } 143 | 144 | field = field.transform((val) => values[val]); 145 | 146 | const result = convertParams>(field, params); 147 | 148 | // Keep retro compatibility 149 | //@ts-ignore 150 | result.values = originalValues; 151 | 152 | return result; 153 | } 154 | 155 | // This should only be used for query, params, headers and cookies 156 | export function coerceInputs(data: Record, schema?: RouteParameter): Record | null { 157 | // For older node versions, searchParams is just an object without the size property 158 | if (data.size === 0 || (data.size === undefined && typeof data === "object" && Object.keys(data).length === 0)) { 159 | return null; 160 | } 161 | 162 | const params: Record = {}; 163 | const entries = data.entries ? data.entries() : Object.entries(data); 164 | for (let [key, value] of entries) { 165 | // Query, path and headers can be empty strings, that should equal to null as nothing was provided 166 | if (value === "") { 167 | // @ts-ignore 168 | value = null; 169 | } 170 | 171 | if (params[key] === undefined) { 172 | params[key] = value; 173 | } else if (!Array.isArray(params[key])) { 174 | params[key] = [params[key], value]; 175 | } else { 176 | params[key].push(value); 177 | } 178 | 179 | let innerType; 180 | if (schema && (schema as z.AnyZodObject).shape && (schema as z.AnyZodObject).shape[key]) { 181 | innerType = (schema as z.AnyZodObject).shape[key]; 182 | } else if (schema) { 183 | // Fallback for Zod effects 184 | innerType = schema; 185 | } 186 | 187 | // Soft transform query strings into arrays 188 | if (innerType) { 189 | if (isSpecificZodType(innerType, "ZodArray") && !Array.isArray(params[key])) { 190 | params[key] = [params[key]]; 191 | } else if (isSpecificZodType(innerType, "ZodBoolean")) { 192 | const _val = (params[key] as string).toLowerCase().trim(); 193 | if (_val === "true" || _val === "false") { 194 | params[key] = _val === "true"; 195 | } 196 | } else if (isSpecificZodType(innerType, "ZodNumber") || innerType instanceof z.ZodNumber) { 197 | params[key] = Number.parseFloat(params[key]); 198 | } else if (isSpecificZodType(innerType, "ZodBigInt") || innerType instanceof z.ZodBigInt) { 199 | params[key] = Number.parseInt(params[key]); 200 | } else if (isSpecificZodType(innerType, "ZodDate") || innerType instanceof z.ZodDate) { 201 | params[key] = new Date(params[key]); 202 | } 203 | } 204 | } 205 | 206 | return params; 207 | } 208 | -------------------------------------------------------------------------------- /src/route.ts: -------------------------------------------------------------------------------- 1 | import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; 2 | import { type AnyZodObject, z } from "zod"; 3 | import { type ApiException, InputValidationException, MultiException } from "./exceptions"; 4 | import { coerceInputs } from "./parameters"; 5 | import type { OpenAPIRouteSchema, RouteOptions, ValidatedData } from "./types"; 6 | import { jsonResp } from "./utils"; 7 | extendZodWithOpenApi(z); 8 | 9 | export class OpenAPIRoute = any> { 10 | handle(...args: any[]): Response | Promise | object | Promise { 11 | throw new Error("Method not implemented."); 12 | } 13 | 14 | static isRoute = true; 15 | 16 | args: HandleArgs; // Args the execute() was called with 17 | validatedData: any = undefined; // this acts as a cache, in case the users calls the validate method twice 18 | params: RouteOptions; 19 | schema: OpenAPIRouteSchema = {}; 20 | 21 | constructor(params: RouteOptions) { 22 | this.params = params; 23 | this.args = [] as any; 24 | } 25 | 26 | async getValidatedData(): Promise> { 27 | const request = this.params.router.getRequest(this.args); 28 | 29 | if (this.validatedData !== undefined) return this.validatedData; 30 | 31 | const data = await this.validateRequest(request); 32 | 33 | this.validatedData = data; 34 | return data; 35 | } 36 | 37 | getSchema(): OpenAPIRouteSchema { 38 | // Use this function to overwrite schema properties 39 | return this.schema; 40 | } 41 | 42 | getSchemaZod(): OpenAPIRouteSchema { 43 | // Deep copy 44 | const schema = { ...this.getSchema() }; 45 | 46 | if (!schema.responses) { 47 | // No response was provided in the schema, default to a blank one 48 | schema.responses = { 49 | "200": { 50 | description: "Successful response", 51 | content: { 52 | "application/json": { 53 | schema: {}, 54 | }, 55 | }, 56 | }, 57 | }; 58 | } 59 | 60 | // @ts-ignore 61 | return schema; 62 | } 63 | 64 | handleValidationError(errors: z.ZodIssue[]): Response { 65 | return jsonResp( 66 | { 67 | errors: errors, 68 | success: false, 69 | result: {}, 70 | }, 71 | { 72 | status: 400, 73 | }, 74 | ); 75 | 76 | // In the future, errors will be handled as exceptions 77 | // Errors caught here are always validation errors 78 | // const updatedError: Array = errors.map((err) => { 79 | // // @ts-ignore 80 | // if ((err as ApiException).buildResponse) { 81 | // // Error is already an internal exception 82 | // return err; 83 | // } 84 | // return new InputValidationException(err.message, err.path); 85 | // }); 86 | // 87 | // throw new MultiException(updatedError as Array); 88 | } 89 | 90 | async execute(...args: HandleArgs) { 91 | this.validatedData = undefined; 92 | this.args = args; 93 | 94 | let resp; 95 | try { 96 | resp = await this.handle(...args); 97 | } catch (e) { 98 | if (e instanceof z.ZodError) { 99 | return this.handleValidationError(e.errors); 100 | } 101 | 102 | throw e; 103 | } 104 | 105 | if (!(resp instanceof Response) && typeof resp === "object") { 106 | return jsonResp(resp); 107 | } 108 | 109 | return resp; 110 | } 111 | 112 | async validateRequest(request: Request) { 113 | const schema: OpenAPIRouteSchema = this.getSchemaZod(); 114 | const unvalidatedData: any = {}; 115 | 116 | const rawSchema: any = {}; 117 | if (schema.request?.params) { 118 | rawSchema.params = schema.request?.params; 119 | unvalidatedData.params = coerceInputs(this.params.router.getUrlParams(this.args), schema.request?.params); 120 | } 121 | if (schema.request?.query) { 122 | rawSchema.query = schema.request?.query; 123 | unvalidatedData.query = {}; 124 | } 125 | 126 | if (schema.request?.headers) { 127 | rawSchema.headers = schema.request?.headers; 128 | unvalidatedData.headers = {}; 129 | } 130 | 131 | const { searchParams } = new URL(request.url); 132 | const queryParams = coerceInputs(searchParams, schema.request?.query); 133 | if (queryParams !== null) unvalidatedData.query = queryParams; 134 | 135 | if (schema.request?.headers) { 136 | const tmpHeaders: Record = {}; 137 | 138 | const rHeaders = new Headers(request.headers); 139 | for (const header of Object.keys((schema.request?.headers as AnyZodObject).shape)) { 140 | tmpHeaders[header] = rHeaders.get(header); 141 | } 142 | 143 | unvalidatedData.headers = coerceInputs(tmpHeaders, schema.request?.headers as AnyZodObject); 144 | } 145 | 146 | if ( 147 | request.method.toLowerCase() !== "get" && 148 | schema.request?.body && 149 | schema.request?.body.content["application/json"] && 150 | schema.request?.body.content["application/json"].schema 151 | ) { 152 | rawSchema.body = schema.request.body.content["application/json"].schema; 153 | 154 | try { 155 | unvalidatedData.body = await request.json(); 156 | } catch (e) { 157 | unvalidatedData.body = {}; 158 | } 159 | } 160 | 161 | let validationSchema: any = z.object(rawSchema); 162 | 163 | if (this.params?.raiseUnknownParameters === undefined || this.params?.raiseUnknownParameters === true) { 164 | validationSchema = validationSchema.strict(); 165 | } 166 | 167 | return await validationSchema.parseAsync(unvalidatedData); 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { RouteConfig, ZodMediaTypeObject } from "@asteasolutions/zod-to-openapi"; 2 | import type { HeadersObject as HeadersObject30, LinksObject as LinksObject30, OpenAPIObject } from "openapi3-ts/oas30"; 3 | import type { HeadersObject as HeadersObject31, LinksObject as LinksObject31 } from "openapi3-ts/oas31"; 4 | import type { AnyZodObject, ZodEffects, ZodType, z } from "zod"; 5 | 6 | export type Simplify = { [KeyType in keyof T]: T[KeyType] } & {}; 7 | 8 | export type IsEqual = (() => G extends A ? 1 : 2) extends () => G extends B ? 1 : 2 ? true : false; 9 | 10 | type Filter = IsEqual extends true 11 | ? never 12 | : KeyType extends ExcludeType 13 | ? never 14 | : KeyType; 15 | 16 | type ExceptOptions = { 17 | requireExactProps?: boolean; 18 | }; 19 | 20 | export type Except< 21 | ObjectType, 22 | KeysType extends keyof ObjectType, 23 | Options extends ExceptOptions = { requireExactProps: false }, 24 | > = { 25 | [KeyType in keyof ObjectType as Filter]: ObjectType[KeyType]; 26 | } & (Options["requireExactProps"] extends true ? Partial> : {}); 27 | 28 | export type SetOptional = Simplify< 29 | // Pick just the keys that are readonly from the base type. 30 | Except & 31 | // Pick the keys that should be mutable from the base type and make them mutable. 32 | Partial> 33 | >; 34 | 35 | export type SetRequired = BaseType extends unknown 36 | ? Simplify< 37 | // Pick just the keys that are optional from the base type. 38 | Except & 39 | // Pick the keys that should be required from the base type and make them required. 40 | Required> 41 | > 42 | : never; 43 | 44 | // The following types are copied from @asteasolutions/zod-to-openapi as they are not exported 45 | export type OpenAPIObjectConfig = Omit; 46 | export type OpenAPIObjectConfigV31 = Omit; 47 | 48 | type HeadersObject = HeadersObject30 | HeadersObject31; 49 | type LinksObject = LinksObject30 | LinksObject31; 50 | 51 | export type ZodMediaType = "application/json" | "text/html" | "text/plain" | "application/xml" | (string & {}); 52 | export type ZodContentObject = Partial>; 53 | export interface ZodRequestBody { 54 | description?: string; 55 | content: ZodContentObject; 56 | required?: boolean; 57 | } 58 | export interface ResponseConfig { 59 | description: string; 60 | headers?: AnyZodObject | HeadersObject; 61 | links?: LinksObject; 62 | content?: ZodContentObject; 63 | } 64 | export type RouteParameter = AnyZodObject | ZodEffects | undefined; 65 | 66 | export interface RouterOptions { 67 | base?: string; 68 | schema?: Partial; 69 | docs_url?: string | null; 70 | redoc_url?: string | null; 71 | openapi_url?: string | null; 72 | raiseUnknownParameters?: boolean; 73 | generateOperationIds?: boolean; 74 | openapiVersion?: "3" | "3.1"; 75 | } 76 | 77 | export interface RouteOptions { 78 | router: any; 79 | raiseUnknownParameters: boolean; 80 | route: string; 81 | urlParams: Array; 82 | } 83 | 84 | export interface ParameterType { 85 | default?: string | number | boolean; 86 | description?: string; 87 | example?: string | number | boolean; 88 | required?: boolean; 89 | deprecated?: boolean; 90 | } 91 | 92 | export interface StringParameterType extends ParameterType { 93 | format?: string; 94 | } 95 | 96 | export interface EnumerationParameterType extends StringParameterType { 97 | values: Record; 98 | enumCaseSensitive?: boolean; 99 | } 100 | 101 | export interface RegexParameterType extends StringParameterType { 102 | pattern: RegExp; 103 | patternError?: string; 104 | } 105 | 106 | export type RequestTypes = { 107 | body?: ZodRequestBody; 108 | params?: AnyZodObject; 109 | query?: AnyZodObject; 110 | cookies?: AnyZodObject; 111 | headers?: AnyZodObject | ZodType[]; 112 | }; 113 | 114 | // Changes over the original RouteConfig: 115 | // - Make responses optional (a default one is generated) 116 | // - Removes method and path (its inject on boot) 117 | export type OpenAPIRouteSchema = Simplify< 118 | Omit & { 119 | request?: RequestTypes; 120 | responses?: { 121 | [statusCode: string]: ResponseConfig; 122 | }; 123 | } 124 | >; 125 | 126 | export type ValidatedData = S extends OpenAPIRouteSchema 127 | ? { 128 | query: GetRequest extends NonNullable> ? GetOutput, "query"> : undefined; 129 | params: GetRequest extends NonNullable> ? GetOutput, "params"> : undefined; 130 | headers: GetRequest extends NonNullable> ? GetOutput, "headers"> : undefined; 131 | body: GetRequest extends NonNullable> ? GetBody, "body">> : undefined; 132 | } 133 | : { 134 | query: undefined; 135 | params: undefined; 136 | headers: undefined; 137 | body: undefined; 138 | }; 139 | 140 | type GetRequest = T["request"]; 141 | 142 | type GetOutput = T extends NonNullable 143 | ? T[P] extends AnyZodObject 144 | ? z.output 145 | : undefined 146 | : undefined; 147 | 148 | type GetPartBody = T[P] extends ZodRequestBody ? T[P] : undefined; 149 | 150 | type GetBody = T extends NonNullable 151 | ? T["content"]["application/json"] extends NonNullable 152 | ? T["content"]["application/json"]["schema"] extends z.ZodTypeAny 153 | ? z.output 154 | : undefined 155 | : undefined 156 | : undefined; 157 | -------------------------------------------------------------------------------- /src/ui.ts: -------------------------------------------------------------------------------- 1 | export function getSwaggerUI(schemaUrl: string): string { 2 | schemaUrl = schemaUrl.replace(/\/+(\/|$)/g, "$1"); // strip double & trailing splash 3 | return ` 4 | 5 | 6 | 7 | 8 | 9 | SwaggerUI 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 29 | 30 | `; 31 | } 32 | 33 | export function getReDocUI(schemaUrl: string): string { 34 | schemaUrl = schemaUrl.replace(/\/+(\/|$)/g, "$1"); // strip double & trailing splash 35 | return ` 36 | 37 | 38 | ReDocUI 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 49 | 55 | 56 | 57 | 58 | 59 | 60 | `; 61 | } 62 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export function jsonResp(data: any, params?: object): Response { 2 | return new Response(JSON.stringify(data), { 3 | headers: { 4 | "content-type": "application/json;charset=UTF-8", 5 | }, 6 | // @ts-ignore 7 | status: params?.status ? params.status : 200, 8 | ...params, 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /src/zod/registry.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi"; 2 | 3 | // @ts-ignore 4 | export class OpenAPIRegistryMerger extends OpenAPIRegistry { 5 | public _definitions: { route: { path: string } }[] = []; 6 | 7 | merge(registry: OpenAPIRegistryMerger, basePath?: string): void { 8 | if (!registry || !registry._definitions) return; 9 | 10 | for (const definition of registry._definitions) { 11 | if (basePath) { 12 | this._definitions.push({ 13 | ...definition, 14 | route: { 15 | ...definition.route, 16 | path: `${basePath}${definition.route.path}`, 17 | }, 18 | }); 19 | } else { 20 | this._definitions.push({ ...definition }); 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/zod/utils.ts: -------------------------------------------------------------------------------- 1 | import type { z } from "zod"; 2 | import { Arr, Bool, DateTime, Num, Obj, Str, convertParams } from "../parameters"; 3 | 4 | export function isAnyZodType(schema: object): schema is z.ZodType { 5 | // @ts-ignore 6 | return schema._def !== undefined; 7 | } 8 | 9 | export function isSpecificZodType(field: any, typeName: string): boolean { 10 | return ( 11 | field._def.typeName === typeName || 12 | field._def.innerType?._def.typeName === typeName || 13 | field._def.schema?._def.innerType?._def.typeName === typeName || 14 | field.unwrap?.()._def.typeName === typeName || 15 | field.unwrap?.().unwrap?.()._def.typeName === typeName || 16 | field._def.innerType?._def?.innerType?._def?.typeName === typeName 17 | ); 18 | } 19 | 20 | export function legacyTypeIntoZod(type: any, params?: any): z.ZodType { 21 | params = params || {}; 22 | 23 | if (type === null) { 24 | return Str({ required: false, ...params }); 25 | } 26 | 27 | if (isAnyZodType(type)) { 28 | if (params) { 29 | return convertParams(type, params); 30 | } 31 | 32 | return type; 33 | } 34 | 35 | if (type === String) { 36 | return Str(params); 37 | } 38 | 39 | if (typeof type === "string") { 40 | return Str({ example: type }); 41 | } 42 | 43 | if (type === Number) { 44 | return Num(params); 45 | } 46 | 47 | if (typeof type === "number") { 48 | return Num({ example: type }); 49 | } 50 | 51 | if (type === Boolean) { 52 | return Bool(params); 53 | } 54 | 55 | if (typeof type === "boolean") { 56 | return Bool({ example: type }); 57 | } 58 | 59 | if (type === Date) { 60 | return DateTime(params); 61 | } 62 | 63 | if (Array.isArray(type)) { 64 | if (type.length === 0) { 65 | throw new Error("Arr must have a type"); 66 | } 67 | 68 | return Arr(type[0], params); 69 | } 70 | 71 | if (typeof type === "object") { 72 | return Obj(type, params); 73 | } 74 | 75 | // Legacy support 76 | return type(params); 77 | } 78 | -------------------------------------------------------------------------------- /tests/bindings.d.ts: -------------------------------------------------------------------------------- 1 | export type Env = { 2 | test: string; 3 | }; 4 | 5 | declare module "cloudflare:test" { 6 | interface ProvidedEnv extends Env {} 7 | } 8 | -------------------------------------------------------------------------------- /tests/index.ts: -------------------------------------------------------------------------------- 1 | import type { Env } from "./bindings"; 2 | 3 | export default { 4 | async fetch(request: Request, env: Env) { 5 | return new Response("test"); 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /tests/integration/hono-types.test-d.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { AutoRouter } from "itty-router"; 3 | import { describe, expectTypeOf, it } from "vitest"; 4 | import { z } from "zod"; 5 | import { fromHono } from "../../src"; 6 | import { fromIttyRouter } from "../../src/adapters/ittyRouter"; 7 | import { OpenAPIRoute } from "../../src/route"; 8 | import { jsonResp } from "../../src/utils"; 9 | import { buildRequest } from "../utils"; 10 | 11 | class ToDoGet extends OpenAPIRoute { 12 | schema = { 13 | tags: ["ToDo"], 14 | summary: "Get a single ToDo", 15 | request: { 16 | params: z.object({ 17 | id: z.number(), 18 | }), 19 | }, 20 | responses: { 21 | "200": { 22 | description: "example", 23 | content: { 24 | "application/json": { 25 | schema: { 26 | todo: { 27 | lorem: String, 28 | ipsum: String, 29 | }, 30 | }, 31 | }, 32 | }, 33 | }, 34 | }, 35 | }; 36 | 37 | async handle(request: Request, env: any, context: any) { 38 | return { 39 | todo: { 40 | lorem: "lorem", 41 | ipsum: "ipsum", 42 | }, 43 | }; 44 | } 45 | } 46 | 47 | const innerRouter = fromHono(new Hono()); 48 | 49 | innerRouter.get("/todo/:id", ToDoGet); 50 | innerRouter.all("*", () => jsonResp({ message: "Not Found" }, { status: 404 })); 51 | 52 | const router = fromHono(new Hono(), { 53 | schema: { 54 | info: { 55 | title: "Radar Worker API", 56 | version: "1.0", 57 | }, 58 | }, 59 | }); 60 | 61 | router.route("/api/v1", innerRouter); 62 | router.all("*", () => new Response("Not Found.", { status: 404 })); 63 | 64 | import { hc } from "hono/client"; 65 | 66 | describe("innerRouter", () => { 67 | it("nested routers type", async () => { 68 | const authorsApp = fromHono(new Hono()).get("/", ToDoGet).post("/", ToDoGet).get("/:id", ToDoGet); 69 | 70 | const booksApp = new Hono() 71 | .get("/", (c) => c.json({ result: "list books" })) 72 | .post("/", (c) => c.json({ result: "create a book" }, 201)); 73 | 74 | const app = new Hono().route("/authors", authorsApp).route("/books", booksApp); 75 | 76 | type AppType = typeof app; 77 | 78 | const c = hc("http://asd.com"); 79 | // expectTypeOf(c).toEqualTypeOf(typeof Hono); // TODO 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /tests/integration/nested-routers-hono.test.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { AutoRouter } from "itty-router"; 3 | import { describe, expect, it } from "vitest"; 4 | import { z } from "zod"; 5 | import { fromHono, fromIttyRouter } from "../../src"; 6 | import { OpenAPIRoute } from "../../src/route"; 7 | import { jsonResp } from "../../src/utils"; 8 | 9 | class ToDoGet extends OpenAPIRoute { 10 | schema = { 11 | tags: ["ToDo"], 12 | summary: "Get a single ToDo", 13 | request: { 14 | params: z.object({ 15 | id: z.number(), 16 | }), 17 | }, 18 | responses: { 19 | "200": { 20 | description: "example", 21 | content: { 22 | "application/json": { 23 | schema: { 24 | todo: { 25 | lorem: String, 26 | ipsum: String, 27 | }, 28 | }, 29 | }, 30 | }, 31 | }, 32 | }, 33 | }; 34 | 35 | async handle(request: Request, env: any, context: any) { 36 | return { 37 | todo: { 38 | lorem: "lorem", 39 | ipsum: "ipsum", 40 | }, 41 | }; 42 | } 43 | } 44 | 45 | const innerRouter = fromHono(new Hono()); 46 | 47 | innerRouter.all("/todo/:id", ToDoGet); 48 | innerRouter.all("*", () => jsonResp({ message: "Not Found" }, { status: 404 })); 49 | 50 | const router = fromHono(new Hono(), { 51 | schema: { 52 | info: { 53 | title: "Radar Worker API", 54 | version: "1.0", 55 | }, 56 | }, 57 | }); 58 | 59 | router.route("/api/v1", innerRouter); 60 | router.all("*", () => new Response("Not Found.", { status: 404 })); 61 | 62 | describe("innerRouter", () => { 63 | it("simpleSuccessfulCall", async () => { 64 | const request = await router.fetch(new Request("http://localhost:8080/api/v1/todo/1")); 65 | const resp = await request.json(); 66 | 67 | expect(request.status).toEqual(200); 68 | expect(resp).toEqual({ 69 | todo: { 70 | lorem: "lorem", 71 | ipsum: "ipsum", 72 | }, 73 | }); 74 | }); 75 | 76 | it("test .all()", async () => { 77 | const request = await router.fetch( 78 | new Request("http://localhost:8080/api/v1/todo/1", { 79 | method: "POST", 80 | }), 81 | ); 82 | const resp = await request.json(); 83 | 84 | expect(request.status).toEqual(200); 85 | expect(resp).toEqual({ 86 | todo: { 87 | lorem: "lorem", 88 | ipsum: "ipsum", 89 | }, 90 | }); 91 | const request2 = await router.fetch( 92 | new Request("http://localhost:8080/api/v1/todo/1", { 93 | method: "PATCH", 94 | }), 95 | ); 96 | const resp2 = await request2.json(); 97 | 98 | expect(request2.status).toEqual(200); 99 | expect(resp2).toEqual({ 100 | todo: { 101 | lorem: "lorem", 102 | ipsum: "ipsum", 103 | }, 104 | }); 105 | }); 106 | 107 | it("innerCatchAll", async () => { 108 | const request = await router.fetch(new Request("http://localhost:8080/api/v1/asd")); 109 | const resp = await request.json(); 110 | 111 | expect(request.status).toEqual(404); 112 | expect(resp).toEqual({ message: "Not Found" }); 113 | }); 114 | 115 | it("outerCatchAll", async () => { 116 | const request = await router.fetch(new Request("http://localhost:8080/asd")); 117 | const resp = await request.text(); 118 | 119 | expect(request.status).toEqual(404); 120 | expect(resp).toEqual("Not Found."); 121 | }); 122 | 123 | it("nested router with base path", async () => { 124 | const innerRouter = fromHono(new Hono()); 125 | innerRouter.get("/todo/:id", ToDoGet); 126 | 127 | const router = fromHono(new Hono()); 128 | router.route("/api/v1/:prefix", innerRouter); 129 | 130 | const request = await router.fetch(new Request("http://localhost:8080/openapi.json")); 131 | const resp = await request.json(); 132 | 133 | expect(request.status).toEqual(200); 134 | expect(resp).toEqual({ 135 | components: { 136 | parameters: {}, 137 | schemas: {}, 138 | }, 139 | info: { 140 | title: "OpenAPI", 141 | version: "1.0.0", 142 | }, 143 | openapi: "3.1.0", 144 | paths: { 145 | "/api/v1/{prefix}/todo/{id}": { 146 | get: { 147 | operationId: "get_ToDoGet", 148 | parameters: [ 149 | { 150 | in: "path", 151 | name: "id", 152 | required: true, 153 | schema: { 154 | type: "number", 155 | }, 156 | }, 157 | ], 158 | responses: { 159 | "200": { 160 | content: { 161 | "application/json": { 162 | schema: { 163 | todo: {}, 164 | }, 165 | }, 166 | }, 167 | description: "example", 168 | }, 169 | }, 170 | summary: "Get a single ToDo", 171 | tags: ["ToDo"], 172 | }, 173 | }, 174 | }, 175 | webhooks: {}, 176 | }); 177 | }); 178 | }); 179 | -------------------------------------------------------------------------------- /tests/integration/nested-routers.test.ts: -------------------------------------------------------------------------------- 1 | import { AutoRouter } from "itty-router"; 2 | import { describe, expect, it } from "vitest"; 3 | import { z } from "zod"; 4 | import { fromIttyRouter } from "../../src/adapters/ittyRouter"; 5 | import { OpenAPIRoute } from "../../src/route"; 6 | import { jsonResp } from "../../src/utils"; 7 | import { buildRequest } from "../utils"; 8 | 9 | const innerRouter = fromIttyRouter(AutoRouter({ base: "/api/v1" }), { 10 | base: "/api/v1", 11 | }); 12 | 13 | class ToDoGet extends OpenAPIRoute { 14 | schema = { 15 | tags: ["ToDo"], 16 | summary: "Get a single ToDo", 17 | request: { 18 | params: z.object({ 19 | id: z.number(), 20 | }), 21 | }, 22 | responses: { 23 | "200": { 24 | description: "example", 25 | content: { 26 | "application/json": { 27 | schema: { 28 | todo: { 29 | lorem: String, 30 | ipsum: String, 31 | }, 32 | }, 33 | }, 34 | }, 35 | }, 36 | }, 37 | }; 38 | 39 | async handle(request: Request, env: any, context: any) { 40 | return { 41 | todo: { 42 | lorem: "lorem", 43 | ipsum: "ipsum", 44 | }, 45 | }; 46 | } 47 | } 48 | 49 | innerRouter.get("/todo/:id", ToDoGet); 50 | innerRouter.all("*", () => jsonResp({ message: "Not Found" }, { status: 404 })); 51 | 52 | const router = fromIttyRouter(AutoRouter(), { 53 | schema: { 54 | info: { 55 | title: "Radar Worker API", 56 | version: "1.0", 57 | }, 58 | }, 59 | }); 60 | 61 | router.all("/api/v1/*", innerRouter); 62 | router.all("*", () => new Response("Not Found.", { status: 404 })); 63 | 64 | describe("innerRouter", () => { 65 | it("simpleSuccessfulCall", async () => { 66 | const request = await router.fetch(buildRequest({ method: "GET", path: "/api/v1/todo/1" })); 67 | const resp = await request.json(); 68 | 69 | expect(request.status).toEqual(200); 70 | expect(resp).toEqual({ 71 | todo: { 72 | lorem: "lorem", 73 | ipsum: "ipsum", 74 | }, 75 | }); 76 | }); 77 | 78 | it("innerCatchAll", async () => { 79 | const request = await router.fetch(buildRequest({ method: "GET", path: "/api/v1/asd" })); 80 | const resp = await request.json(); 81 | 82 | expect(request.status).toEqual(404); 83 | expect(resp).toEqual({ message: "Not Found" }); 84 | }); 85 | 86 | it("outerCatchAll", async () => { 87 | const request = await router.fetch(buildRequest({ method: "GET", path: "/asd" })); 88 | const resp = await request.text(); 89 | 90 | expect(request.status).toEqual(404); 91 | expect(resp).toEqual("Not Found."); 92 | }); 93 | 94 | it("nested router with base path", async () => { 95 | const innerRouter = fromIttyRouter(AutoRouter(), { 96 | base: "/api/v1/:prefix", 97 | }); 98 | innerRouter.get("/todo/:id", ToDoGet); 99 | 100 | const router = fromIttyRouter(AutoRouter()); 101 | router.all("/api/v1/:prefix/*", innerRouter); 102 | 103 | const request = await router.fetch(new Request("http://localhost:8080/openapi.json")); 104 | const resp = await request.json(); 105 | 106 | expect(request.status).toEqual(200); 107 | expect(resp).toEqual({ 108 | components: { 109 | parameters: {}, 110 | schemas: {}, 111 | }, 112 | info: { 113 | title: "OpenAPI", 114 | version: "1.0.0", 115 | }, 116 | openapi: "3.1.0", 117 | paths: { 118 | "/api/v1/{prefix}/todo/{id}": { 119 | get: { 120 | operationId: "get_ToDoGet", 121 | parameters: [ 122 | { 123 | in: "path", 124 | name: "id", 125 | required: true, 126 | schema: { 127 | type: "number", 128 | }, 129 | }, 130 | ], 131 | responses: { 132 | "200": { 133 | content: { 134 | "application/json": { 135 | schema: { 136 | todo: {}, 137 | }, 138 | }, 139 | }, 140 | description: "example", 141 | }, 142 | }, 143 | summary: "Get a single ToDo", 144 | tags: ["ToDo"], 145 | }, 146 | }, 147 | }, 148 | webhooks: {}, 149 | }); 150 | }); 151 | }); 152 | -------------------------------------------------------------------------------- /tests/integration/openapi-schema.test.ts: -------------------------------------------------------------------------------- 1 | import { AutoRouter } from "itty-router"; 2 | import { describe, expect, it } from "vitest"; 3 | import { fromIttyRouter } from "../../src"; 4 | import { ToDoGet, ToDoList, todoRouter } from "../router"; 5 | import { buildRequest, findError } from "../utils"; 6 | 7 | describe("openapi schema", () => { 8 | it("custom content type", async () => { 9 | const request = await todoRouter.fetch(buildRequest({ method: "GET", path: "/openapi.json" })); 10 | const resp = await request.json(); 11 | const respSchema = resp.paths["/contenttype"].get.responses[200]; 12 | 13 | expect(respSchema.contentType).toBeUndefined(); 14 | expect(respSchema.content).toEqual({ 15 | "text/csv": { 16 | schema: { 17 | type: "string", 18 | }, 19 | }, 20 | }); 21 | }); 22 | 23 | it("with base defined", async () => { 24 | const router = fromIttyRouter(AutoRouter(), { 25 | base: "/api", 26 | }); 27 | router.get("/todo", ToDoGet); 28 | 29 | const request = await router.fetch(buildRequest({ method: "GET", path: "/api/openapi.json" })); 30 | const resp = await request.json(); 31 | 32 | expect(Object.keys(resp.paths)[0]).toEqual("/api/todo"); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /tests/integration/openapi.test.disabled.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | import vitestOpenAPI from "vitest-openapi"; 3 | import { todoRouter } from "../router"; 4 | 5 | describe("openapiValidation", () => { 6 | it("loadSpec", async () => { 7 | console.log(todoRouter.schema); 8 | vitestOpenAPI(todoRouter.schema); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /tests/integration/router-options.test.ts: -------------------------------------------------------------------------------- 1 | import { AutoRouter } from "itty-router"; 2 | import { describe, expect, it } from "vitest"; 3 | import { fromIttyRouter } from "../../src"; 4 | import { OpenAPIRoute } from "../../src/route"; 5 | import { buildRequest } from "../utils"; 6 | 7 | class EndpointWithoutOperationId extends OpenAPIRoute { 8 | schema = { 9 | summary: "Get a single ToDo", 10 | responses: {}, 11 | }; 12 | 13 | async handle(request: Request, env: any, context: any) { 14 | return { 15 | msg: "EndpointWithoutOperationId", 16 | }; 17 | } 18 | } 19 | 20 | class EndpointWithOperationId extends OpenAPIRoute { 21 | schema = { 22 | responses: {}, 23 | operationId: "get_my_todo", 24 | summary: "Get a single ToDo", 25 | }; 26 | 27 | async handle(request: Request, env: any, context: any) { 28 | return { 29 | msg: "EndpointWithOperationId", 30 | }; 31 | } 32 | } 33 | 34 | describe("routerOptions", () => { 35 | it("generate operation ids false", async () => { 36 | const t = () => { 37 | const router = fromIttyRouter(AutoRouter(), { 38 | generateOperationIds: false, 39 | }); 40 | router.get("/todo", EndpointWithoutOperationId); 41 | }; 42 | 43 | expect(t).toThrow("Route /todo don't have operationId set!"); 44 | }); 45 | 46 | it("generate operation ids true and unset", async () => { 47 | const routerTrue = fromIttyRouter(AutoRouter(), { 48 | generateOperationIds: true, 49 | }); 50 | routerTrue.get("/todo", EndpointWithoutOperationId); 51 | 52 | if (routerTrue.schema.paths?.["/todo"]?.get) { 53 | expect(routerTrue.schema.paths["/todo"].get.operationId).toEqual("get_EndpointWithoutOperationId"); 54 | } else { 55 | throw new Error("/todo not found in schema"); 56 | } 57 | 58 | const routerUnset = fromIttyRouter(AutoRouter()); 59 | routerUnset.get("/todo", EndpointWithoutOperationId); 60 | 61 | if (routerUnset.schema.paths?.["/todo"]?.get) { 62 | expect(routerUnset.schema.paths["/todo"].get.operationId).toEqual("get_EndpointWithoutOperationId"); 63 | } else { 64 | throw new Error("/todo not found in schema"); 65 | } 66 | }); 67 | 68 | it("generate operation ids true on endpoint with operation id", async () => { 69 | const router = fromIttyRouter(AutoRouter(), { 70 | generateOperationIds: true, 71 | }); 72 | router.get("/todo", EndpointWithOperationId); 73 | 74 | if (router.schema.paths?.["/todo"]?.get) { 75 | expect(router.schema.paths["/todo"].get.operationId).toEqual("get_my_todo"); 76 | } else { 77 | throw new Error("/todo not found in schema"); 78 | } 79 | }); 80 | 81 | it("with base empty", async () => { 82 | const router = fromIttyRouter(AutoRouter()); 83 | router.get("/todo", EndpointWithOperationId); 84 | 85 | const request = await router.fetch(buildRequest({ method: "GET", path: "/todo" })); 86 | const resp = await request.json(); 87 | 88 | expect(resp.msg).toEqual("EndpointWithOperationId"); 89 | }); 90 | 91 | it("with base defined", async () => { 92 | const router = fromIttyRouter(AutoRouter({ base: "/api" }), { 93 | base: "/api", 94 | }); 95 | router.get("/todo", EndpointWithOperationId); 96 | 97 | const request = await router.fetch(buildRequest({ method: "GET", path: "/api/todo" })); 98 | const resp = await request.json(); 99 | 100 | expect(resp.msg).toEqual("EndpointWithOperationId"); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /tests/integration/zod.ts: -------------------------------------------------------------------------------- 1 | import { AutoRouter } from "itty-router"; 2 | import { describe, expect, it } from "vitest"; 3 | import { z } from "zod"; 4 | import { fromIttyRouter } from "../../src"; 5 | import { OpenAPIRoute } from "../../src/route"; 6 | import { buildRequest } from "../utils"; 7 | 8 | const zodRouter = fromIttyRouter(AutoRouter()); 9 | 10 | class ToDoGet extends OpenAPIRoute { 11 | schema = { 12 | tags: ["ToDo"], 13 | summary: "Get a single ToDo", 14 | request: { 15 | params: z.object({ 16 | id: z.number(), 17 | }), 18 | body: { 19 | content: { 20 | "application/json": { 21 | schema: z.object({ 22 | title: z.string(), 23 | description: z.string(), //.optional(), 24 | type: z.nativeEnum({ 25 | nextWeek: "nextWeek", 26 | nextMonth: "nextMonth", 27 | }), 28 | }), 29 | }, 30 | }, 31 | }, 32 | }, 33 | responses: { 34 | "200": { 35 | description: "example", 36 | content: { 37 | "application/json": { 38 | schema: { 39 | todo: { 40 | lorem: String, 41 | ipsum: String, 42 | }, 43 | }, 44 | }, 45 | }, 46 | }, 47 | }, 48 | }; 49 | 50 | async handle(request: Request, env: any, context: any) { 51 | return { 52 | todo: { 53 | lorem: "lorem", 54 | ipsum: "ipsum", 55 | }, 56 | }; 57 | } 58 | } 59 | 60 | zodRouter.put("/todo/:id", ToDoGet); 61 | 62 | describe("zod validations", () => { 63 | it("simpleSuccessfulCall", async () => { 64 | const request = await zodRouter.fetch(buildRequest({ method: "PUT", path: "/todo/1" })); 65 | 66 | const resp = await request.json(); 67 | 68 | expect(request.status).toEqual(200); 69 | expect(resp).toEqual({ 70 | todo: { 71 | lorem: "lorem", 72 | ipsum: "ipsum", 73 | }, 74 | }); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /tests/router.ts: -------------------------------------------------------------------------------- 1 | import { AutoRouter } from "itty-router"; 2 | import { z } from "zod"; 3 | import { OpenAPIRoute, extendZodWithOpenApi } from "../src"; 4 | import { fromIttyRouter } from "../src/adapters/ittyRouter"; 5 | import { contentJson } from "../src/contentTypes"; 6 | import { 7 | Bool, 8 | DateOnly, 9 | DateTime, 10 | Email, 11 | Enumeration, 12 | Hostname, 13 | Int, 14 | Ipv4, 15 | Ipv6, 16 | Num, 17 | Regex, 18 | Str, 19 | Uuid, 20 | } from "../src/parameters"; 21 | 22 | extendZodWithOpenApi(z); 23 | 24 | export class ToDoList extends OpenAPIRoute { 25 | schema = { 26 | tags: ["ToDo"], 27 | summary: "List all ToDos", 28 | request: { 29 | query: z.object({ 30 | p_array_dates: z.string().date().array(), 31 | p_number: z.number(), 32 | p_string: z.string(), 33 | p_boolean: z.boolean(), 34 | p_int: Int(), 35 | p_num: Num(), 36 | p_str: Str(), 37 | p_bool: Bool(), 38 | p_enumeration: Enumeration({ 39 | values: { 40 | json: "ENUM_JSON", 41 | csv: "ENUM_CSV", 42 | }, 43 | }), 44 | p_enumeration_insensitive: Enumeration({ 45 | values: { 46 | json: "json", 47 | csv: "csv", 48 | }, 49 | enumCaseSensitive: false, 50 | }), 51 | p_datetime: DateTime(), 52 | p_dateonly: DateOnly(), 53 | p_regex: Regex({ 54 | pattern: /^[\\+]?[(]?[0-9]{3}[)]?[-\\s\\.]?[0-9]{3}[-\\s\\.]?[0-9]{4,6}$/, 55 | }), 56 | p_email: Email(), 57 | p_uuid: Uuid(), 58 | p_hostname: Hostname(), 59 | p_ipv4: Ipv4(), 60 | p_ipv6: Ipv6(), 61 | p_optional: z.number().optional(), 62 | }), 63 | }, 64 | responses: { 65 | "200": { 66 | description: "example", 67 | ...contentJson({ 68 | params: {}, 69 | results: ["lorem"], 70 | }), 71 | }, 72 | }, 73 | }; 74 | 75 | async handle(request: Request, env: any, context: any) { 76 | const data = await this.getValidatedData(); 77 | 78 | return { 79 | params: data, 80 | results: ["lorem", "ipsum"], 81 | }; 82 | } 83 | } 84 | 85 | export class ToDoGet extends OpenAPIRoute { 86 | schema = { 87 | tags: ["ToDo"], 88 | summary: "Get a single ToDo", 89 | request: { 90 | params: z.object({ 91 | id: Num(), 92 | }), 93 | }, 94 | responses: { 95 | "200": { 96 | description: "Successful Response", 97 | content: { 98 | "application/json": { 99 | schema: z.object({ 100 | todo: z.object({ 101 | lorem: z.string(), 102 | ipsum: Str(), 103 | }), 104 | }), 105 | }, 106 | }, 107 | }, 108 | }, 109 | }; 110 | 111 | async handle(request: Request, env: any, context: any) { 112 | return { 113 | todo: { 114 | lorem: "lorem", 115 | ipsum: "ipsum", 116 | }, 117 | }; 118 | } 119 | } 120 | 121 | export class ContentTypeGet extends OpenAPIRoute { 122 | schema = { 123 | responses: { 124 | "200": { 125 | description: "Successful Response", 126 | content: { 127 | "text/csv": { 128 | schema: z.string(), 129 | }, 130 | }, 131 | }, 132 | }, 133 | }; 134 | 135 | async handle(request: Request, env: any, context: any) { 136 | return { 137 | todo: { 138 | lorem: "lorem", 139 | ipsum: "ipsum", 140 | }, 141 | }; 142 | } 143 | } 144 | 145 | export class ToDoCreate extends OpenAPIRoute { 146 | schema = { 147 | tags: ["ToDo"], 148 | summary: "Create a new ToDo", 149 | request: { 150 | body: { 151 | content: { 152 | "application/json": { 153 | schema: z.object({ 154 | title: Str(), 155 | description: Str({ required: false }), 156 | type: Enumeration({ 157 | values: { 158 | nextWeek: "nextWeek", 159 | nextMonth: "nextMonth", 160 | }, 161 | }), 162 | }), 163 | }, 164 | }, 165 | }, 166 | }, 167 | responses: { 168 | "200": { 169 | description: "example", 170 | content: { 171 | "application/json": { 172 | schema: z.object({ 173 | todo: z.object({ 174 | title: Str(), 175 | description: Str(), 176 | type: Str(), 177 | }), 178 | }), 179 | }, 180 | }, 181 | }, 182 | }, 183 | }; 184 | 185 | async handle(request: Request, env: any, context: any) { 186 | const data = await this.getValidatedData(); 187 | 188 | return { 189 | todo: data.body, 190 | }; 191 | } 192 | } 193 | 194 | const query = z.object({ 195 | p_int: Int(), 196 | p_num: Num(), 197 | p_str: Str(), 198 | p_arrstr: z.array(Str()), 199 | p_bool: Bool(), 200 | p_enumeration: Enumeration({ 201 | values: { 202 | json: "ENUM_JSON", 203 | csv: "ENUM_CSV", 204 | }, 205 | }), 206 | p_enumeration_insensitive: Enumeration({ 207 | values: { 208 | json: "json", 209 | csv: "csv", 210 | }, 211 | enumCaseSensitive: false, 212 | }), 213 | p_datetime: DateTime(), 214 | p_regex: Regex({ 215 | pattern: /^[\\+]?[(]?[0-9]{3}[)]?[-\\s\\.]?[0-9]{3}[-\\s\\.]?[0-9]{4,6}$/, 216 | }), 217 | p_email: Email(), 218 | p_uuid: Uuid(), 219 | 220 | p_ipv4: Ipv4(), 221 | p_ipv6: Ipv6(), 222 | p_optional: Int({ 223 | required: false, 224 | }), 225 | }); 226 | 227 | export class ToDoCreateTyped extends OpenAPIRoute { 228 | schema = { 229 | tags: ["ToDo"], 230 | summary: "List all ToDos", 231 | request: { 232 | query: query, 233 | headers: z.object({ 234 | p_hostname: Hostname(), 235 | }), 236 | body: { 237 | content: { 238 | "application/json": { 239 | schema: z.object({ 240 | title: z.string(), 241 | description: z.string().optional(), 242 | type: z.enum(["nextWeek", "nextMoth"]), 243 | }), 244 | }, 245 | }, 246 | }, 247 | }, 248 | responses: { 249 | "200": { 250 | description: "example", 251 | ...contentJson({ 252 | params: {}, 253 | results: ["lorem"], 254 | }), 255 | }, 256 | }, 257 | }; 258 | 259 | async handle(request: Request, env: any, context: any) { 260 | return {}; 261 | } 262 | } 263 | 264 | export class ToDoHeaderCheck extends OpenAPIRoute { 265 | schema = { 266 | tags: ["ToDo"], 267 | summary: "List all ToDos", 268 | request: { 269 | headers: z.object({ 270 | p_hostname: Hostname(), 271 | }), 272 | }, 273 | }; 274 | 275 | async handle(request: Request, env: any, context: any) { 276 | const data = await this.getValidatedData(); 277 | 278 | return { 279 | headers: data.headers, 280 | }; 281 | } 282 | } 283 | 284 | export const todoRouter = fromIttyRouter(AutoRouter(), { openapiVersion: "3" }); 285 | todoRouter.get("/todos", ToDoList); 286 | todoRouter.get("/todos/:id", ToDoGet); 287 | todoRouter.post("/todos", ToDoCreate); 288 | todoRouter.post("/todos-typed", ToDoCreateTyped); 289 | todoRouter.get("/contenttype", ContentTypeGet); 290 | todoRouter.get("/header", ToDoHeaderCheck); 291 | 292 | // 404 for everything else 293 | todoRouter.all("*", () => new Response("Not Found.", { status: 404 })); 294 | -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "moduleResolution": "bundler", 5 | "types": ["@cloudflare/vitest-pool-workers"] 6 | }, 7 | "include": ["./**/*.ts", "./bindings.d.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /tests/utils.ts: -------------------------------------------------------------------------------- 1 | export const buildRequest = ({ method = "GET", path = "/", ...other }) => ({ 2 | method: method.toUpperCase(), 3 | path, 4 | url: `https://example.com${path}`, 5 | ...other, 6 | }); 7 | 8 | export function findError(errors: any, field: any) { 9 | for (const error of errors) { 10 | if (error.path.includes(field)) { 11 | return error.message; 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tests/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"; 2 | 3 | export default defineWorkersConfig({ 4 | test: { 5 | // typecheck: { 6 | // enabled: true, 7 | // tsconfig: "tsconfig.json", // Path to your tsconfig file 8 | // include: ["**/*.{test,spec}-d.ts"], // Explicitly include type-check test files 9 | // }, 10 | include: ["**/*.{test,spec}.?(c|m)[jt]s?(x)"], // Regular test files 11 | poolOptions: { 12 | workers: { 13 | wrangler: { 14 | configPath: "./wrangler.toml", 15 | }, 16 | }, 17 | }, 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /tests/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "test" 2 | main = "index.ts" 3 | compatibility_date = "2024-11-06" 4 | compatibility_flags = ["nodejs_compat"] 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Base Options: */ 4 | "esModuleInterop": true, 5 | "skipLibCheck": true, 6 | "target": "es2022", 7 | "verbatimModuleSyntax": true, 8 | "allowJs": true, 9 | "resolveJsonModule": true, 10 | "moduleDetection": "force", 11 | /* Strictness */ 12 | "strict": true, 13 | "noUncheckedIndexedAccess": true, 14 | /* If NOT transpiling with TypeScript: */ 15 | "moduleResolution": "Bundler", 16 | "module": "ESNext", 17 | "noEmit": true, 18 | /* If your code runs in the DOM: */ 19 | "lib": ["es2022", "dom", "dom.iterable"], 20 | "types": ["@types/node", "@types/service-worker-mock", "@cloudflare/workers-types/experimental"] 21 | }, 22 | "include": ["src"] 23 | } 24 | --------------------------------------------------------------------------------