├── .changeset ├── README.md └── config.json ├── .github ├── publish.yml └── workflows │ ├── build-and-test.yaml │ └── publish.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .prettierrc ├── LICENSE ├── README.md ├── package.json ├── packages ├── typed-openapi │ ├── CHANGELOG.md │ ├── bin.js │ ├── package.json │ ├── src │ │ ├── asserts.ts │ │ ├── box-factory.ts │ │ ├── box.ts │ │ ├── cli.ts │ │ ├── format.ts │ │ ├── generate-client-files.ts │ │ ├── generator.ts │ │ ├── index.ts │ │ ├── is-reference-object.ts │ │ ├── map-openapi-endpoints.ts │ │ ├── node.export.ts │ │ ├── openapi-schema-to-ts.ts │ │ ├── ref-resolver.ts │ │ ├── string-utils.ts │ │ ├── tanstack-query.generator.ts │ │ ├── topological-sort.ts │ │ ├── ts-factory.ts │ │ └── types.ts │ ├── tests │ │ ├── generate-runtime.test.ts │ │ ├── generator-basic-schemas.test.ts │ │ ├── generator.test.ts │ │ ├── map-openapi-endpoints.test.ts │ │ ├── openapi-schema-to-ts.test.ts │ │ ├── ref-resolver.test.ts │ │ ├── samples │ │ │ ├── docker.openapi.yaml │ │ │ ├── long-operation-id.yaml │ │ │ ├── parameters.yaml │ │ │ ├── petstore.yaml │ │ │ └── springboot.actuator.yaml │ │ ├── snapshots │ │ │ ├── docker.openapi.client.ts │ │ │ ├── docker.openapi.io-ts.ts │ │ │ ├── docker.openapi.typebox.ts │ │ │ ├── docker.openapi.valibot.ts │ │ │ ├── docker.openapi.yup.ts │ │ │ ├── docker.openapi.zod.ts │ │ │ ├── long-operation-id.arktype.ts │ │ │ ├── long-operation-id.client.ts │ │ │ ├── long-operation-id.io-ts.ts │ │ │ ├── long-operation-id.typebox.ts │ │ │ ├── long-operation-id.valibot.ts │ │ │ ├── long-operation-id.yup.ts │ │ │ ├── long-operation-id.zod.ts │ │ │ ├── package.json │ │ │ ├── petstore.arktype.ts │ │ │ ├── petstore.client.ts │ │ │ ├── petstore.io-ts.ts │ │ │ ├── petstore.typebox.ts │ │ │ ├── petstore.valibot.ts │ │ │ ├── petstore.yup.ts │ │ │ ├── petstore.zod.ts │ │ │ └── pnpm-lock.yaml │ │ └── tanstack-query.generator.test.ts │ ├── tsconfig.build.json │ ├── tsconfig.json │ ├── tsup.config.ts │ └── vitest.config.ts └── web │ ├── declarations │ ├── arktype.d.ts │ ├── io-ts.d.ts │ ├── typebox.d.ts │ ├── valibot.d.ts │ ├── yup.d.ts │ └── zod.d.ts │ ├── fs.shim.ts │ ├── get-ts-declarations.ts │ ├── index.html │ ├── module.shim.ts │ ├── package.json │ ├── panda.config.ts │ ├── postcss.config.cjs │ ├── public │ ├── favicon.ico │ └── github-icon.svg │ ├── react.d.ts │ ├── src │ ├── Playground │ │ ├── Playground.machine.ts │ │ ├── Playground.machine.typegen.ts │ │ ├── Playground.tsx │ │ ├── PlaygroundMachineProvider.ts │ │ ├── PlaygroundWithMachine.tsx │ │ ├── ResizeHandle.tsx │ │ ├── format.ts │ │ ├── petstore.yaml │ │ └── url-saver.ts │ ├── components │ │ ├── button.tsx │ │ ├── color-mode-switch.tsx │ │ ├── github-icon.tsx │ │ ├── icon-button.tsx │ │ ├── select-demo.tsx │ │ ├── select.tsx │ │ └── twitter-icon.tsx │ ├── main.tsx │ ├── pages │ │ └── Home.tsx │ ├── run-if-fn.ts │ ├── styles.css │ └── vite-themes │ │ ├── provider.tsx │ │ └── vite-themes-types.ts │ ├── theme │ ├── preset.ts │ ├── semantic-tokens.ts │ ├── text-styles.ts │ └── tokens.ts │ ├── tsconfig.json │ └── vite.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── tsconfig.json /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": ["typed-openapi-web"] 11 | } 12 | -------------------------------------------------------------------------------- /.github/publish.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout Repo 16 | uses: actions/checkout@v3 17 | 18 | - name: Setup Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version-file: ".nvmrc" 22 | 23 | - name: Setup pnpm 24 | uses: pnpm/action-setup@v4 25 | 26 | - name: Install dependencies 27 | run: pnpm install --frozen-lockfile 28 | 29 | - name: Build 30 | run: pnpm build 31 | 32 | - name: Test 33 | run: pnpm test 34 | 35 | - name: Create Release Pull Request or Publish to npm 36 | id: changesets 37 | uses: changesets/action@v1 38 | with: 39 | publish: pnpm release 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 43 | -------------------------------------------------------------------------------- /.github/workflows/build-and-test.yaml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build-and-test: 7 | name: Build and Test 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | 12 | - name: Setup pnpm 13 | uses: pnpm/action-setup@v4 14 | 15 | - name: Setup Node.js 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version-file: ".nvmrc" 19 | 20 | - name: Install dependencies 21 | run: pnpm install --frozen-lockfile 22 | 23 | - name: Build 24 | run: pnpm build 25 | 26 | - name: Test 27 | run: pnpm test 28 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout Repo 16 | uses: actions/checkout@v3 17 | 18 | - name: Setup Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version-file: ".nvmrc" 22 | 23 | - name: Setup pnpm 24 | uses: pnpm/action-setup@v4 25 | 26 | - name: Install dependencies 27 | run: pnpm install --frozen-lockfile 28 | 29 | - name: Build 30 | run: pnpm build 31 | 32 | - name: Test 33 | run: pnpm test 34 | 35 | - name: Create Release Pull Request or Publish to npm 36 | id: changesets 37 | uses: changesets/action@v1 38 | with: 39 | publish: pnpm release 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | # generated types 4 | .astro/ 5 | 6 | # dependencies 7 | node_modules/ 8 | 9 | # logs 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | 23 | ## Panda 24 | styled-system 25 | styled-system-static 26 | .vercel 27 | *.tsbuildinfo 28 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | prefer-offline=true 3 | strict-peer-dependencies=true 4 | resolve-peers-from-workspace-root=true 5 | enable-pre-post-scripts=true 6 | auto-install-peers=true 7 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v22.11.0 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "printWidth": 120, 4 | "bracketSpacing": true, 5 | "jsxSingleQuote": false, 6 | "proseWrap": "always", 7 | "semi": true, 8 | "tabWidth": 2, 9 | "trailingComma": "all" 10 | } 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2025 Alexandre Stahmer 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # typed-openapi 2 | 3 | Generate a Typescript API client from an OpenAPI spec 4 | 5 | See [the online playground](https://typed-openapi-astahmer.vercel.app/) 6 | 7 | ![Screenshot 2023-08-08 at 00 48 42](https://github.com/astahmer/typed-openapi/assets/47224540/3016fa92-e09a-41f3-a95f-32caa41325da) 8 | 9 | ## Features 10 | 11 | - Headless API client, bring your own fetcher ! (fetch, axios, ky, etc...) 12 | - Generates a fully typesafe API client with just types by default (instant suggestions) 13 | - Or you can also generate a client with runtime validation using one of the following runtimes: 14 | - [zod](https://zod.dev/) 15 | - [typebox](https://github.com/sinclairzx81/typebox) 16 | - [arktype](https://arktype.io/) 17 | - [valibot](https://valibot.dev/) 18 | - [io-ts](https://gcanti.github.io/io-ts/) 19 | - [yup](https://github.com/jquense/yup) 20 | 21 | The generated client is a single file that can be used in the browser or in node. Runtime validation schemas are 22 | provided by the excellent [typebox-codegen](https://github.com/sinclairzx81/typebox-codegen) 23 | 24 | ## Install & usage 25 | 26 | ```sh 27 | pnpm add typed-openapi 28 | ``` 29 | 30 | It exports a bunch of functions that can be used to build your own tooling on top of it. You can look at the 31 | [CLI code](packages/typed-openapi/src/cli.ts) so see how to use them. 32 | 33 | ## CLI 34 | 35 | ```sh 36 | npx typed-openapi -h 37 | ``` 38 | 39 | ```sh 40 | typed-openapi/0.1.3 41 | 42 | Usage: $ typed-openapi 43 | 44 | Commands: Generate 45 | 46 | For more info, run any command with the `--help` flag: $ typed-openapi --help 47 | 48 | Options: -o, --output Output path for the api client ts file (defaults to `..ts`) -r, --runtime 49 | Runtime to use for validation; defaults to `none`; available: 'none' | 'arktype' | 'io-ts' | 'typebox' | 50 | 'valibot' | 'yup' | 'zod' (default: none) -h, --help Display this message -v, --version Display version number 51 | ``` 52 | 53 | ## Non-goals 54 | 55 | - Caring too much about the runtime validation code. If that works (thanks to 56 | [typebox-codegen](https://github.com/sinclairzx81/typebox-codegen)), that's great, otherwise I'm not really interested 57 | in fixing it. If you are, feel free to open a PR. 58 | 59 | - Supporting all the OpenAPI spec. Regex, dates, files, whatever, that's not the point here. 60 | [openapi-zod-client](https://github.com/astahmer/openapi-zod-client) does a great job at that, but it's slow to 61 | generate the client and the suggestions in the IDE are not instant. I'm only interested in supporting the subset of 62 | the spec that makes the API client typesafe and fast to provide suggetions in the IDE. 63 | 64 | - Splitting the generated client into multiple files. Nope. Been there, done that. Let's keep it simple. 65 | 66 | Basically, let's focus on having a fast and typesafe API client generation instead. 67 | 68 | ## Alternatives 69 | 70 | [openapi-zod-client](https://github.com/astahmer/openapi-zod-client), which generates a 71 | [zodios](https://github.com/ecyrbe/zodios) client but can be slow to provide IDE suggestions when the OpenAPI spec is 72 | large. Also, you might not always want to use zod or even runtime validation, hence this project. 73 | 74 | ## Contributing 75 | 76 | - `pnpm i` 77 | - `pnpm build` 78 | - `pnpm test` 79 | 80 | When you're done with your changes, please run `pnpm changeset` in the root of the repo and follow the instructions 81 | described [here](https://github.com/changesets/changesets/blob/main/docs/intro-to-using-changesets.md). 82 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typed-openapi", 3 | "private": true, 4 | "repository": { 5 | "type": "git", 6 | "url": "https://github.com/astahmer/typed-openapi.git" 7 | }, 8 | "license": "MIT", 9 | "scripts": { 10 | "build": "cd packages/typed-openapi && pnpm build", 11 | "build:all": "pnpm -r run build", 12 | "release": "changeset publish", 13 | "release-dev": "changeset version --snapshot dev && changeset publish --tag dev", 14 | "test": "cd packages/typed-openapi && pnpm run test" 15 | }, 16 | "devDependencies": { 17 | "@changesets/cli": "^2.29.4" 18 | }, 19 | "packageManager": "pnpm@9.6.0+sha256.dae0f7e822c56b20979bb5965e3b73b8bdabb6b8b8ef121da6d857508599ca35" 20 | } 21 | -------------------------------------------------------------------------------- /packages/typed-openapi/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # typed-openapi 2 | 3 | ## 1.4.3 4 | 5 | ### Patch Changes 6 | 7 | - fb0fe07: closes: #79 by handling singleton enum of type number 8 | 9 | ## 1.4.2 10 | 11 | ### Patch Changes 12 | 13 | - 9f70b13: Ensure dir is created before generating files 14 | 15 | ## 1.4.1 16 | 17 | ### Patch Changes 18 | 19 | - f367a04: Treat boolean values as literal in enum 20 | 21 | ## 1.4.0 22 | 23 | ### Minor Changes 24 | 25 | - dad912c: feat: add build-and-test github workflow 26 | - 0440b2b: add `?:` to get optional parameters instead of having to set those to undefined 27 | - a718a33: Add CLI option `--schemas-only` to allow generation of only the schema without endpoints and api client 28 | 29 | ## 1.3.2 30 | 31 | ### Patch Changes 32 | 33 | - ceb15f6: Export generateClientFiles fn (same used as in the CLI) 34 | 35 | ## 1.3.1 36 | 37 | ### Patch Changes 38 | 39 | - 86a384f: add mutation selectFn + endpoint type-only property in .mutation 40 | 41 | ## 1.3.0 42 | 43 | ### Minor Changes 44 | 45 | - 91b005f: add parenthesis to handle priority between union/intersection 46 | 47 | this fixes an issue where `(A | B | C) & D` would be ambiguous and could be interpreted as `A | B | (C & D` 48 | 49 | ## 1.2.0 50 | 51 | ### Minor Changes 52 | 53 | - ed15081: Rename .options to .queryOptions 54 | 55 | ## 1.1.2 56 | 57 | ### Patch Changes 58 | 59 | - 4846bc4: fix mutationOptions parameters typings 60 | 61 | ## 1.1.1 62 | 63 | ### Patch Changes 64 | 65 | - 73c1ef1: feat: mutationOptions + .mutation (if input is not available before) 66 | 67 | ## 1.1.0 68 | 69 | ### Minor Changes 70 | 71 | - f029e94: Fetcher is now expected to return a Response, so that the api client can have a .request method that returns 72 | the raw object 73 | 74 | all methods (get post etc) will be parsed using the overridable "parseResponse" api client fn property 75 | 76 | - c1b9dcb: fix: anyOf to ts 77 | 78 | https://github.com/astahmer/typed-openapi/issues/31 79 | 80 | ### Patch Changes 81 | 82 | - d7eda3d: rm AllEndpoints type 83 | - 2abc8b4: chore: export Fetcher type 84 | - 6dfbd19: fix: tanstack client output path 85 | - f66571d: chore: make "endpoint" a type-only property 86 | - 93bd157: better endpoint alias 87 | - da6af35: fix: unused QueryClient import 88 | 89 | ## 1.0.1 90 | 91 | ### Patch Changes 92 | 93 | - 4a909eb: Fix CLI & package.json by removing CJS usage 94 | 95 | ## 1.0.0 96 | 97 | ### Major Changes 98 | 99 | - 8ec5d0b: bump all deps 100 | 101 | ### Minor Changes 102 | 103 | - 8ec5d0b: Add @tanstack/react-query generated client 104 | - 8ec5d0b: Fix `Schemas.null` references in TS output 105 | - 8ec5d0b: Better output when using `schema.additionalProperties`, especially when specifying 106 | `additionalProperties.type` 107 | 108 | ## 0.10.1 109 | 110 | ### Patch Changes 111 | 112 | - dd91027: Move changesets to devDeps 113 | 114 | ## 0.10.0 115 | 116 | ### Minor Changes 117 | 118 | - be0ba5f: Bump @sinclair/typebox-codegen version 119 | 120 | ### Patch Changes 121 | 122 | - 739e5b5: Add options to `Method` type in `generateApiClient` function as fix for 123 | [#55](https://github.com/astahmer/typed-openapi/issues/55) 124 | 125 | ## 0.9.0 126 | 127 | ### Minor Changes 128 | 129 | - b122616: Add requestFormat property to endpoint schema. 130 | 131 | - json 132 | - form-data 133 | - form-url 134 | - binary 135 | - text 136 | 137 | ## 0.8.0 138 | 139 | ### Minor Changes 140 | 141 | - d260cd4: Fix zod and yup runtime generated endpoint schema type errors due to long operationId 142 | 143 | ## 0.7.0 144 | 145 | ### Minor Changes 146 | 147 | - cf83e52: Add type cast in ApiClient methods to match the desired type 148 | 149 | ## 0.6.0 150 | 151 | ### Minor Changes 152 | 153 | - c5daa58: Upgraded codegen dependency to provide newer runtime validator output 154 | 155 | This is a BREAKING CHANGE for valibot/yup users 156 | 157 | ## 0.5.0 158 | 159 | ### Minor Changes 160 | 161 | - f0886a0: Thanks to @0237h: 162 | 163 | Allow for finer marking of optional parameters Current behavior allows only for marking _all_ parameters as optional, 164 | or none. 165 | 166 | This change checks first if all parameters are optional, keeping the old behavior if that's the case, otherwise 167 | iterates through the parameters to mark only those that **should** be optional from the OpenAPI spec. 168 | 169 | ## 0.4.1 170 | 171 | ### Patch Changes 172 | 173 | - 4fac0aa: Fix typecast in zod-based ApiClient methods 174 | 175 | ## 0.4.0 176 | 177 | ### Minor Changes 178 | 179 | - ffcdaa7: zod-runtime: add typecast in ApiClient methods to match the desired type 180 | 181 | ## 0.3.0 182 | 183 | ### Minor Changes 184 | 185 | - b9b4772: Fix default response behavior (only use "default" as a fallback) 186 | - 23f3dc3: Support path parameters 187 | 188 | ### Patch Changes 189 | 190 | - bb937d4: fix: refer Schema namespace in generated body type 191 | 192 | ## 0.2.0 193 | 194 | ### Minor Changes 195 | 196 | - 00eb659: Fixed parameter.body on post endpoints - #8. 197 | 198 | ## 0.1.5 199 | 200 | ### Patch Changes 201 | 202 | - 7f0ecd4: fix: query/path/headers parameters are all marked as required if one of them is required 203 | 204 | ## 0.1.4 205 | 206 | ### Patch Changes 207 | 208 | - ae34ed1: support OpenAPI v3.0 schema.nullable 209 | 210 | ```json 211 | { 212 | "type": "object", 213 | "properties": { 214 | "id": { "type": "integer" }, 215 | "parent_id": { 216 | "type": "integer", 217 | "nullable": true 218 | }, 219 | "children": { 220 | "type": "array", 221 | "items": { 222 | "$ref": "#/components/schemas/TestClass" 223 | } 224 | } 225 | }, 226 | "required": ["id", "parent_id"] 227 | } 228 | ``` 229 | 230 | output: 231 | 232 | ```diff 233 | export type TestClass = { 234 | id: number; 235 | - parent_id: number; 236 | + parent_id: number | null; 237 | children?: Array | undefined 238 | }; 239 | ``` 240 | 241 | - 088f3e4: Fix optional types 242 | 243 | ```json 244 | { 245 | "type": "object", 246 | "properties": { "str": { "type": "string" }, "nb": { "type": "number" } }, 247 | "required": ["str"] 248 | } 249 | ``` 250 | 251 | output: 252 | 253 | ```diff 254 | export type _Test = { 255 | str: string; 256 | - "nb?": number | undefined 257 | + nb?: number | undefined 258 | }; 259 | ``` 260 | 261 | ## 0.1.3 262 | 263 | ### Patch Changes 264 | 265 | - 8568d69: Not a CLI anymore ! Exposed functions & types to be used when installed from npm 266 | 267 | ## 0.1.2 268 | 269 | ### Patch Changes 270 | 271 | - 0947ac5: - replace dprint by prettier 2.X (cause v3 needs async and dprint has trouble with finding the wasm module) 272 | - only wrap in TS namespaces when NOT using a runtime (= generating TS types only) 273 | 274 | ## 0.1.1 275 | 276 | ### Patch Changes 277 | 278 | - 95e8477: init 279 | -------------------------------------------------------------------------------- /packages/typed-openapi/bin.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import "./dist/cli.js"; 4 | -------------------------------------------------------------------------------- /packages/typed-openapi/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typed-openapi", 3 | "type": "module", 4 | "version": "1.4.3", 5 | "main": "dist/index.js", 6 | "module": "dist/index.js", 7 | "exports": { 8 | ".": "./dist/index.js", 9 | "./node": "./dist/node.export.js" 10 | }, 11 | "bin": { 12 | "typed-openapi": "bin.js" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/astahmer/typed-openapi.git", 17 | "directory": "packages/typed-openapi" 18 | }, 19 | "scripts": { 20 | "start": "node ./dist/cli.js", 21 | "dev": "tsup --watch", 22 | "build": "tsup", 23 | "test": "vitest", 24 | "fmt": "prettier --write src", 25 | "typecheck": "tsc -b ./tsconfig.build.json" 26 | }, 27 | "dependencies": { 28 | "@apidevtools/swagger-parser": "^10.1.1", 29 | "@sinclair/typebox-codegen": "^0.11.1", 30 | "arktype": "2.1.20", 31 | "cac": "^6.7.14", 32 | "openapi3-ts": "^4.4.0", 33 | "pastable": "^2.2.1", 34 | "pathe": "^2.0.3", 35 | "prettier": "3.5.3", 36 | "ts-pattern": "^5.7.0" 37 | }, 38 | "devDependencies": { 39 | "@changesets/cli": "^2.29.4", 40 | "@types/node": "^22.15.17", 41 | "@types/prettier": "3.0.0", 42 | "tsup": "^8.4.0", 43 | "typescript": "^5.8.3", 44 | "vitest": "^3.1.3" 45 | }, 46 | "files": [ 47 | "src", 48 | "dist", 49 | "cli", 50 | "README.md" 51 | ], 52 | "keywords": [ 53 | "typescript", 54 | "openapi", 55 | "generator", 56 | "runtime", 57 | "typesafe", 58 | "zod", 59 | "arktype", 60 | "typebox", 61 | "valibot", 62 | "yup", 63 | "io-ts" 64 | ], 65 | "sideEffects": false, 66 | "publishConfig": { 67 | "access": "public" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /packages/typed-openapi/src/asserts.ts: -------------------------------------------------------------------------------- 1 | import type { LibSchemaObject } from "./types.ts"; 2 | 3 | export type SingleType = Exclude; 4 | export const isPrimitiveType = (type: unknown): type is PrimitiveType => primitiveTypeList.includes(type as any); 5 | 6 | const primitiveTypeList = ["string", "number", "integer", "boolean", "null"] as const; 7 | export type PrimitiveType = (typeof primitiveTypeList)[number]; 8 | -------------------------------------------------------------------------------- /packages/typed-openapi/src/box-factory.ts: -------------------------------------------------------------------------------- 1 | import type { ReferenceObject } from "openapi3-ts/oas31"; 2 | import { Box } from "./box.ts"; 3 | import { AnyBoxDef, BoxFactory, OpenapiSchemaConvertContext, StringOrBox, type LibSchemaObject } from "./types.ts"; 4 | 5 | export const unwrap = (param: StringOrBox) => (typeof param === "string" ? param : param.value); 6 | export const createFactory = (f: T) => f; 7 | 8 | /** 9 | * Create a box-factory using your schema provider and automatically add the input schema to each box. 10 | */ 11 | export const createBoxFactory = (schema: LibSchemaObject | ReferenceObject, ctx: OpenapiSchemaConvertContext) => { 12 | const f = typeof ctx.factory === "function" ? ctx.factory(schema, ctx) : ctx.factory; 13 | const callback = (box: Box) => { 14 | if (f.callback) { 15 | box = f.callback(box) as Box; 16 | } 17 | 18 | if (ctx?.onBox) { 19 | box = ctx.onBox?.(box) as Box; 20 | } 21 | 22 | return box; 23 | }; 24 | 25 | const box: BoxFactory = { 26 | union: (types) => callback(new Box({ ctx, schema, type: "union", params: { types }, value: f.union(types) })), 27 | intersection: (types) => 28 | callback(new Box({ ctx, schema, type: "intersection", params: { types }, value: f.intersection(types) })), 29 | array: (type) => callback(new Box({ ctx, schema, type: "array", params: { type }, value: f.array(type) })), 30 | optional: (type) => callback(new Box({ ctx, schema, type: "optional", params: { type }, value: f.optional(type) })), 31 | reference: (name, generics) => 32 | callback( 33 | new Box({ 34 | ctx, 35 | schema, 36 | type: "ref", 37 | params: generics ? { name, generics } : { name }, 38 | value: f.reference(name, generics), 39 | }), 40 | ), 41 | literal: (value) => callback(new Box({ ctx, schema, type: "literal", params: {}, value: f.literal(value) })), 42 | string: () => callback(new Box({ ctx, schema, type: "keyword", params: { name: "string" }, value: f.string() })), 43 | number: () => callback(new Box({ ctx, schema, type: "keyword", params: { name: "number" }, value: f.number() })), 44 | boolean: () => callback(new Box({ ctx, schema, type: "keyword", params: { name: "boolean" }, value: f.boolean() })), 45 | unknown: () => callback(new Box({ ctx, schema, type: "keyword", params: { name: "unknown" }, value: f.unknown() })), 46 | any: () => callback(new Box({ ctx, schema, type: "keyword", params: { name: "any" }, value: f.any() })), 47 | never: () => callback(new Box({ ctx, schema, type: "keyword", params: { name: "never" }, value: f.never() })), 48 | object: (props) => callback(new Box({ ctx, schema, type: "object", params: { props }, value: f.object(props) })), 49 | }; 50 | 51 | return box; 52 | }; 53 | -------------------------------------------------------------------------------- /packages/typed-openapi/src/box.ts: -------------------------------------------------------------------------------- 1 | import { openApiSchemaToTs } from "./openapi-schema-to-ts.ts"; 2 | import { 3 | AnyBoxDef, 4 | BoxArray, 5 | BoxIntersection, 6 | BoxKeyword, 7 | BoxLiteral, 8 | BoxObject, 9 | BoxOptional, 10 | BoxRef, 11 | BoxUnion, 12 | OpenapiSchemaConvertContext, 13 | type LibSchemaObject, 14 | } from "./types.ts"; 15 | 16 | // TODO rename SchemaBox 17 | export class Box { 18 | type: T["type"]; 19 | value: T["value"]; 20 | params: T["params"]; 21 | schema: T["schema"]; 22 | ctx: T["ctx"]; 23 | 24 | constructor(public definition: T) { 25 | this.definition = definition; 26 | this.type = definition.type; 27 | this.value = definition.value; 28 | this.params = definition.params; 29 | this.schema = definition.schema; 30 | this.ctx = definition.ctx; 31 | } 32 | 33 | toJSON() { 34 | return { type: this.type, value: this.value }; 35 | } 36 | 37 | toString() { 38 | return JSON.stringify(this.toJSON(), null, 2); 39 | } 40 | 41 | recompute(callback: OpenapiSchemaConvertContext["onBox"]) { 42 | return openApiSchemaToTs({ schema: this.schema as LibSchemaObject, ctx: { ...this.ctx, onBox: callback! } }); 43 | } 44 | 45 | static fromJSON(json: string) { 46 | return new Box(JSON.parse(json)); 47 | } 48 | 49 | static isBox(box: unknown): box is Box { 50 | return box instanceof Box; 51 | } 52 | 53 | static isUnion(box: Box): box is Box { 54 | return box.type === "union"; 55 | } 56 | 57 | static isIntersection(box: Box): box is Box { 58 | return box.type === "intersection"; 59 | } 60 | 61 | static isArray(box: Box): box is Box { 62 | return box.type === "array"; 63 | } 64 | 65 | static isOptional(box: Box): box is Box { 66 | return box.type === "optional"; 67 | } 68 | 69 | static isReference(box: Box): box is Box { 70 | return box.type === "ref"; 71 | } 72 | 73 | static isKeyword(box: Box): box is Box { 74 | return box.type === "keyword"; 75 | } 76 | 77 | static isObject(box: Box): box is Box { 78 | return box.type === "object"; 79 | } 80 | 81 | static isLiteral(box: Box): box is Box { 82 | return box.type === "literal"; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /packages/typed-openapi/src/cli.ts: -------------------------------------------------------------------------------- 1 | import { cac } from "cac"; 2 | 3 | import { readFileSync } from "fs"; 4 | import { generateClientFiles } from "./generate-client-files.ts"; 5 | import { allowedRuntimes } from "./generator.ts"; 6 | 7 | const { name, version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); 8 | const cli = cac(name); 9 | 10 | cli 11 | .command("", "Generate") 12 | .option("-o, --output ", "Output path for the api client ts file (defaults to `..ts`)") 13 | .option( 14 | "-r, --runtime ", 15 | `Runtime to use for validation; defaults to \`none\`; available: ${allowedRuntimes.toString()}`, 16 | { default: "none" }, 17 | ) 18 | .option( 19 | "--schemas-only", 20 | "Only generate schemas, skipping client generation (defaults to false)", 21 | { default: false }, 22 | ) 23 | .option( 24 | "--tanstack [name]", 25 | "Generate tanstack client, defaults to false, can optionally specify a name for the generated file", 26 | ) 27 | .action(async (input, _options) => { 28 | return generateClientFiles(input, _options); 29 | }); 30 | 31 | cli.help(); 32 | cli.version(version); 33 | cli.parse(); 34 | -------------------------------------------------------------------------------- /packages/typed-openapi/src/format.ts: -------------------------------------------------------------------------------- 1 | import prettier, { type Options } from "prettier"; 2 | import parserTypescript from "prettier/parser-typescript"; 3 | 4 | /** @see https://github.dev/stephenh/ts-poet/blob/5ea0dbb3c9f1f4b0ee51a54abb2d758102eda4a2/src/Code.ts#L231 */ 5 | function maybePretty(input: string, options?: Options | null) { 6 | try { 7 | return prettier.format(input, { 8 | parser: "typescript", 9 | plugins: [parserTypescript], 10 | ...options, 11 | }); 12 | } catch (err) { 13 | console.warn("Failed to format code"); 14 | console.warn(err); 15 | return input; // assume it's invalid syntax and ignore 16 | } 17 | } 18 | 19 | export const prettify = (str: string, options?: Options | null) => 20 | maybePretty(str, { printWidth: 120, trailingComma: "all", ...options }); 21 | -------------------------------------------------------------------------------- /packages/typed-openapi/src/generate-client-files.ts: -------------------------------------------------------------------------------- 1 | import SwaggerParser from "@apidevtools/swagger-parser"; 2 | import type { OpenAPIObject } from "openapi3-ts/oas31"; 3 | import { basename, join, dirname } from "pathe"; 4 | import { type } from "arktype"; 5 | import { mkdir, writeFile } from "fs/promises"; 6 | import { allowedRuntimes, generateFile } from "./generator.ts"; 7 | import { mapOpenApiEndpoints } from "./map-openapi-endpoints.ts"; 8 | import { generateTanstackQueryFile } from "./tanstack-query.generator.ts"; 9 | import { prettify } from "./format.ts"; 10 | 11 | const cwd = process.cwd(); 12 | const now = new Date(); 13 | 14 | async function ensureDir(dirPath: string): Promise { 15 | try { 16 | await mkdir(dirPath, { recursive: true }); 17 | } catch (error) { 18 | console.error(`Error ensuring directory: ${(error as Error).message}`); 19 | } 20 | } 21 | 22 | export const optionsSchema = type({ 23 | "output?": "string", 24 | runtime: allowedRuntimes, 25 | tanstack: "boolean | string", 26 | schemasOnly: "boolean", 27 | }); 28 | 29 | export async function generateClientFiles(input: string, options: typeof optionsSchema.infer) { 30 | const openApiDoc = (await SwaggerParser.bundle(input)) as OpenAPIObject; 31 | 32 | const ctx = mapOpenApiEndpoints(openApiDoc); 33 | console.log(`Found ${ctx.endpointList.length} endpoints`); 34 | 35 | const content = await prettify(generateFile({ 36 | ...ctx, 37 | runtime: options.runtime, 38 | schemasOnly: options.schemasOnly, 39 | })); 40 | const outputPath = join( 41 | cwd, 42 | options.output ?? input + `.${options.runtime === "none" ? "client" : options.runtime}.ts`, 43 | ); 44 | 45 | console.log("Generating client...", outputPath); 46 | await ensureDir(dirname(outputPath)); 47 | await writeFile(outputPath, content); 48 | 49 | if (options.tanstack) { 50 | const tanstackContent = await generateTanstackQueryFile({ 51 | ...ctx, 52 | relativeApiClientPath: './' + basename(outputPath), 53 | }); 54 | const tanstackOutputPath = join(dirname(outputPath), typeof options.tanstack === "string" ? options.tanstack : `tanstack.client.ts`); 55 | console.log("Generating tanstack client...", tanstackOutputPath); 56 | await ensureDir(dirname(tanstackOutputPath)); 57 | await writeFile(tanstackOutputPath, tanstackContent); 58 | } 59 | 60 | console.log(`Done in ${new Date().getTime() - now.getTime()}ms !`); 61 | } 62 | -------------------------------------------------------------------------------- /packages/typed-openapi/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./box-factory.ts"; 2 | export { generateFile, type OutputRuntime } from "./generator.ts"; 3 | export * from "./tanstack-query.generator.ts"; 4 | export * from "./map-openapi-endpoints.ts"; 5 | export * from "./openapi-schema-to-ts.ts"; 6 | export * from "./ref-resolver.ts"; 7 | export * from "./ts-factory.ts"; 8 | export * from "./types.ts"; 9 | -------------------------------------------------------------------------------- /packages/typed-openapi/src/is-reference-object.ts: -------------------------------------------------------------------------------- 1 | // taken from 2 | // https://github.dev/metadevpro/openapi3-ts/blob/a62ff445207af599f591532ef776e671c456cc37/src/model/OpenApi.ts#L261-L269 3 | // to avoid the runtime dependency on `openapi3-ts` 4 | // which itself depends on `yaml` import (which use CJS `require` and thus can't be imported in a ESM module) 5 | 6 | import type { ReferenceObject } from "openapi3-ts/oas31"; 7 | 8 | /** 9 | * A type guard to check if the given value is a `ReferenceObject`. 10 | * See https://www.typescriptlang.org/docs/handbook/advanced-types.html#type-guards-and-differentiating-types 11 | * 12 | * @param obj The value to check. 13 | */ 14 | export function isReferenceObject(obj: any): obj is ReferenceObject { 15 | return obj != null && Object.prototype.hasOwnProperty.call(obj, "$ref"); 16 | } 17 | -------------------------------------------------------------------------------- /packages/typed-openapi/src/map-openapi-endpoints.ts: -------------------------------------------------------------------------------- 1 | import type { OpenAPIObject, ResponseObject } from "openapi3-ts/oas31"; 2 | import { OperationObject, ParameterObject } from "openapi3-ts/oas31"; 3 | import { capitalize, pick } from "pastable/server"; 4 | import { Box } from "./box.ts"; 5 | import { createBoxFactory } from "./box-factory.ts"; 6 | import { openApiSchemaToTs } from "./openapi-schema-to-ts.ts"; 7 | import { createRefResolver } from "./ref-resolver.ts"; 8 | import { tsFactory } from "./ts-factory.ts"; 9 | import { AnyBox, BoxRef, OpenapiSchemaConvertContext } from "./types.ts"; 10 | import { pathToVariableName } from "./string-utils.ts"; 11 | import { match, P } from "ts-pattern"; 12 | 13 | const factory = tsFactory; 14 | 15 | export const mapOpenApiEndpoints = (doc: OpenAPIObject) => { 16 | const refs = createRefResolver(doc, factory); 17 | const ctx: OpenapiSchemaConvertContext = { refs, factory }; 18 | const endpointList = [] as Array; 19 | 20 | Object.entries(doc.paths ?? {}).forEach(([path, pathItemObj]) => { 21 | const pathItem = pick(pathItemObj, ["get", "put", "post", "delete", "options", "head", "patch", "trace"]); 22 | Object.entries(pathItem).forEach(([method, operation]) => { 23 | if (operation.deprecated) return; 24 | 25 | const endpoint = { 26 | operation, 27 | method: method as Method, 28 | path, 29 | requestFormat: "json", 30 | response: openApiSchemaToTs({ schema: {}, ctx }), 31 | meta: { 32 | alias: getAlias({ path, method, operation } as Endpoint), 33 | areParametersRequired: false, 34 | hasParameters: false, 35 | }, 36 | } as Endpoint; 37 | 38 | // Build a list of parameters by type + fill an object with all of them 39 | const lists = { query: [] as ParameterObject[], path: [] as ParameterObject[], header: [] as ParameterObject[] }; 40 | const paramObjects = [...(pathItemObj.parameters ?? []), ...(operation.parameters ?? [])].reduce( 41 | (acc, paramOrRef) => { 42 | const param = refs.unwrap(paramOrRef); 43 | const schema = openApiSchemaToTs({ schema: refs.unwrap(param.schema ?? {}), ctx }); 44 | 45 | if (param.required) endpoint.meta.areParametersRequired = true; 46 | endpoint.meta.hasParameters = true; 47 | 48 | if (param.in === "query") { 49 | lists.query.push(param); 50 | acc.query[param.name] = schema; 51 | } 52 | if (param.in === "path") { 53 | lists.path.push(param); 54 | acc.path[param.name] = schema; 55 | } 56 | if (param.in === "header") { 57 | lists.header.push(param); 58 | acc.header[param.name] = schema; 59 | } 60 | 61 | return acc; 62 | }, 63 | { query: {} as Record, path: {} as Record, header: {} as Record }, 64 | ); 65 | 66 | // Filter out empty objects 67 | const params = Object.entries(paramObjects).reduce( 68 | (acc, [key, value]) => { 69 | if (Object.keys(value).length) { 70 | // @ts-expect-error 71 | acc[key] = value; 72 | } 73 | return acc; 74 | }, 75 | {} as { query?: Record; path?: Record; header?: Record; body?: Box }, 76 | ); 77 | 78 | // Body 79 | if (operation.requestBody) { 80 | endpoint.meta.hasParameters = true; 81 | const requestBody = refs.unwrap(operation.requestBody ?? {}); 82 | const content = requestBody.content; 83 | const matchingMediaType = Object.keys(content).find(isAllowedParamMediaTypes); 84 | 85 | if (matchingMediaType && content[matchingMediaType]) { 86 | params.body = openApiSchemaToTs({ 87 | schema: content[matchingMediaType]?.schema ?? {} ?? {}, 88 | ctx, 89 | }); 90 | } 91 | 92 | endpoint.requestFormat = match(matchingMediaType) 93 | .with("application/octet-stream", () => "binary" as const) 94 | .with("multipart/form-data", () => "form-data" as const) 95 | .with("application/x-www-form-urlencoded", () => "form-url" as const) 96 | .with(P.string.includes("json"), () => "json" as const) 97 | .otherwise(() => "text" as const); 98 | } 99 | 100 | // Make parameters optional if all or some of them are not required 101 | if (params) { 102 | const t = createBoxFactory({}, ctx); 103 | const filtered_params = ["query", "path", "header"] as Array< 104 | keyof Pick 105 | >; 106 | 107 | for (const k of filtered_params) { 108 | if (params[k] && lists[k].length) { 109 | if (lists[k].every((param) => !param.required)) { 110 | params[k] = t.reference("Partial", [t.object(params[k]!)]) as any; 111 | } else { 112 | for (const p of lists[k]) { 113 | if (!p.required) { 114 | params[k]![p.name] = t.optional(params[k]![p.name] as any); 115 | } 116 | } 117 | } 118 | } 119 | } 120 | 121 | // No need to pass empty objects, it's confusing 122 | endpoint.parameters = Object.keys(params).length ? (params as any as EndpointParameters) : undefined; 123 | } 124 | 125 | // Match the first 2xx-3xx response found, or fallback to default one otherwise 126 | let responseObject: ResponseObject | undefined; 127 | Object.entries(operation.responses ?? {}).map(([status, responseOrRef]) => { 128 | const statusCode = Number(status); 129 | if (statusCode >= 200 && statusCode < 300) { 130 | responseObject = refs.unwrap(responseOrRef); 131 | } 132 | }); 133 | if (!responseObject && operation.responses?.default) { 134 | responseObject = refs.unwrap(operation.responses.default); 135 | } 136 | 137 | const content = responseObject?.content; 138 | if (content) { 139 | const matchingMediaType = Object.keys(content).find(isResponseMediaType); 140 | if (matchingMediaType && content[matchingMediaType]) { 141 | endpoint.response = openApiSchemaToTs({ 142 | schema: content[matchingMediaType]?.schema ?? {} ?? {}, 143 | ctx, 144 | }); 145 | } 146 | } 147 | 148 | endpointList.push(endpoint); 149 | }); 150 | }); 151 | 152 | return { doc, refs, endpointList, factory }; 153 | }; 154 | 155 | const allowedParamMediaTypes = [ 156 | "application/octet-stream", 157 | "multipart/form-data", 158 | "application/x-www-form-urlencoded", 159 | "*/*", 160 | ] as const; 161 | const isAllowedParamMediaTypes = ( 162 | mediaType: string, 163 | ): mediaType is (typeof allowedParamMediaTypes)[number] | `application/${string}json${string}` | `text/${string}` => 164 | (mediaType.includes("application/") && mediaType.includes("json")) || 165 | allowedParamMediaTypes.includes(mediaType as any) || 166 | mediaType.includes("text/"); 167 | 168 | const isResponseMediaType = (mediaType: string) => mediaType === "application/json"; 169 | const getAlias = ({ path, method, operation }: Endpoint) => 170 | (method + "_" + capitalize(operation.operationId ?? pathToVariableName(path))).replace(/-/g, "__"); 171 | 172 | type MutationMethod = "post" | "put" | "patch" | "delete"; 173 | type Method = "get" | "head" | "options" | MutationMethod; 174 | 175 | export type EndpointParameters = { 176 | body?: Box; 177 | query?: Box | Record; 178 | header?: Box | Record; 179 | path?: Box | Record; 180 | }; 181 | 182 | type RequestFormat = "json" | "form-data" | "form-url" | "binary" | "text"; 183 | 184 | type DefaultEndpoint = { 185 | parameters?: EndpointParameters | undefined; 186 | response: AnyBox; 187 | }; 188 | 189 | export type Endpoint = { 190 | operation: OperationObject; 191 | method: Method; 192 | path: string; 193 | parameters?: TConfig["parameters"]; 194 | requestFormat: RequestFormat; 195 | meta: { 196 | alias: string; 197 | hasParameters: boolean; 198 | areParametersRequired: boolean; 199 | }; 200 | response: TConfig["response"]; 201 | }; 202 | -------------------------------------------------------------------------------- /packages/typed-openapi/src/node.export.ts: -------------------------------------------------------------------------------- 1 | export { prettify } from "./format.ts"; 2 | export { generateClientFiles } from "./generate-client-files.ts"; 3 | -------------------------------------------------------------------------------- /packages/typed-openapi/src/openapi-schema-to-ts.ts: -------------------------------------------------------------------------------- 1 | import { isPrimitiveType } from "./asserts.ts"; 2 | import { Box } from "./box.ts"; 3 | import { createBoxFactory } from "./box-factory.ts"; 4 | import { isReferenceObject } from "./is-reference-object.ts"; 5 | import { AnyBoxDef, OpenapiSchemaConvertArgs, type LibSchemaObject } from "./types.ts"; 6 | import { wrapWithQuotesIfNeeded } from "./string-utils.ts"; 7 | 8 | export const openApiSchemaToTs = ({ schema, meta: _inheritedMeta, ctx }: OpenapiSchemaConvertArgs): Box => { 9 | const meta = {} as OpenapiSchemaConvertArgs["meta"]; 10 | 11 | if (!schema) { 12 | throw new Error("Schema is required"); 13 | } 14 | 15 | const t = createBoxFactory(schema as LibSchemaObject, ctx); 16 | const getTs = () => { 17 | if (isReferenceObject(schema)) { 18 | const refInfo = ctx.refs.getInfosByRef(schema.$ref); 19 | 20 | return t.reference(refInfo.normalized); 21 | } 22 | 23 | if (Array.isArray(schema.type)) { 24 | if (schema.type.length === 1) { 25 | return openApiSchemaToTs({ schema: { ...schema, type: schema.type[0]! }, ctx, meta }); 26 | } 27 | 28 | return t.union(schema.type.map((prop) => openApiSchemaToTs({ schema: { ...schema, type: prop }, ctx, meta }))); 29 | } 30 | 31 | if (schema.type === "null") { 32 | return t.literal("null"); 33 | } 34 | 35 | if (schema.oneOf) { 36 | if (schema.oneOf.length === 1) { 37 | return openApiSchemaToTs({ schema: schema.oneOf[0]!, ctx, meta }); 38 | } 39 | 40 | return t.union(schema.oneOf.map((prop) => openApiSchemaToTs({ schema: prop, ctx, meta }))); 41 | } 42 | 43 | // tl;dr: anyOf = oneOf 44 | // oneOf matches exactly one subschema, and anyOf can match one or more subschemas. 45 | // https://swagger.io/docs/specification/v3_0/data-models/oneof-anyof-allof-not/ 46 | if (schema.anyOf) { 47 | if (schema.anyOf.length === 1) { 48 | return openApiSchemaToTs({ schema: schema.anyOf[0]!, ctx, meta }); 49 | } 50 | 51 | return t.union(schema.anyOf.map((prop) => openApiSchemaToTs({ schema: prop, ctx, meta }))); 52 | } 53 | 54 | if (schema.allOf) { 55 | if (schema.allOf.length === 1) { 56 | return openApiSchemaToTs({ schema: schema.allOf[0]!, ctx, meta }); 57 | } 58 | 59 | const types = schema.allOf.map((prop) => openApiSchemaToTs({ schema: prop, ctx, meta })); 60 | return t.intersection(types); 61 | } 62 | 63 | const schemaType = schema.type ? (schema.type.toLowerCase() as NonNullable) : undefined; 64 | if (schemaType && isPrimitiveType(schemaType)) { 65 | if (schema.enum) { 66 | if (schema.enum.length === 1) { 67 | const value = schema.enum[0]; 68 | if (value === null) { 69 | return t.literal("null"); 70 | } else if (value === true) { 71 | return t.literal("true"); 72 | } else if (value === false) { 73 | return t.literal("false"); 74 | } else if (typeof value === "number") { 75 | return t.literal(`${value}`) 76 | } else { 77 | return t.literal(`"${value}"`); 78 | } 79 | } 80 | 81 | if (schemaType === "string") { 82 | return t.union(schema.enum.map((value) => t.literal(`"${value}"`))); 83 | } 84 | 85 | if (schema.enum.some((e) => typeof e === "string")) { 86 | return t.never(); 87 | } 88 | 89 | return t.union(schema.enum.map((value) => t.literal(value === null ? "null" : value))); 90 | } 91 | 92 | if (schemaType === "string") return t.string(); 93 | if (schemaType === "boolean") return t.boolean(); 94 | if (schemaType === "number" || schemaType === "integer") return t.number(); 95 | if (schemaType === "null") return t.literal("null"); 96 | } 97 | 98 | if (schemaType === "array") { 99 | if (schema.items) { 100 | let arrayOfType = openApiSchemaToTs({ schema: schema.items, ctx, meta }); 101 | if (typeof arrayOfType === "string") { 102 | arrayOfType = t.reference(arrayOfType); 103 | } 104 | 105 | return t.array(arrayOfType); 106 | } 107 | 108 | return t.array(t.any()); 109 | } 110 | 111 | if (schemaType === "object" || schema.properties || schema.additionalProperties) { 112 | if (!schema.properties) { 113 | if ( 114 | schema.additionalProperties && 115 | !isReferenceObject(schema.additionalProperties) && 116 | typeof schema.additionalProperties !== "boolean" && 117 | schema.additionalProperties.type 118 | ) { 119 | const valueSchema = openApiSchemaToTs({ schema: schema.additionalProperties, ctx, meta }); 120 | return t.literal(`Record`); 121 | } 122 | 123 | return t.literal("Record"); 124 | } 125 | 126 | let additionalProperties; 127 | if (schema.additionalProperties) { 128 | let additionalPropertiesType; 129 | if ( 130 | (typeof schema.additionalProperties === "boolean" && schema.additionalProperties) || 131 | (typeof schema.additionalProperties === "object" && Object.keys(schema.additionalProperties).length === 0) 132 | ) { 133 | additionalPropertiesType = t.any(); 134 | } else if (typeof schema.additionalProperties === "object") { 135 | additionalPropertiesType = openApiSchemaToTs({ 136 | schema: schema.additionalProperties, 137 | ctx, 138 | meta, 139 | }); 140 | } 141 | 142 | additionalProperties = t.object({ [t.string().value]: additionalPropertiesType! }); 143 | } 144 | 145 | const hasRequiredArray = schema.required && schema.required.length > 0; 146 | const isPartial = !schema.required?.length; 147 | 148 | const props = Object.fromEntries( 149 | Object.entries(schema.properties).map(([prop, propSchema]) => { 150 | let propType = openApiSchemaToTs({ schema: propSchema, ctx, meta }); 151 | if (typeof propType === "string") { 152 | // TODO Partial ? 153 | propType = t.reference(propType); 154 | } 155 | 156 | const isRequired = Boolean(isPartial ? true : hasRequiredArray ? schema.required?.includes(prop) : false); 157 | const isOptional = !isPartial && !isRequired; 158 | return [`${wrapWithQuotesIfNeeded(prop)}`, isOptional ? t.optional(propType) : propType]; 159 | }), 160 | ); 161 | 162 | const objectType = additionalProperties 163 | ? t.intersection([t.object(props), additionalProperties]) 164 | : t.object(props); 165 | 166 | return isPartial ? t.reference("Partial", [objectType]) : objectType; 167 | } 168 | 169 | if (!schemaType) return t.unknown(); 170 | 171 | throw new Error(`Unsupported schema type: ${schemaType}`); 172 | }; 173 | 174 | let output = getTs(); 175 | if (!isReferenceObject(schema)) { 176 | // OpenAPI 3.1 does not have nullable, but OpenAPI 3.0 does 177 | if ((schema as LibSchemaObject).nullable) { 178 | output = t.union([output, t.literal("null")]); 179 | } 180 | } 181 | 182 | return output; 183 | }; 184 | -------------------------------------------------------------------------------- /packages/typed-openapi/src/ref-resolver.ts: -------------------------------------------------------------------------------- 1 | import type { OpenAPIObject, ReferenceObject } from "openapi3-ts/oas31"; 2 | import { get } from "pastable/server"; 3 | 4 | import { Box } from "./box.ts"; 5 | import { isReferenceObject } from "./is-reference-object.ts"; 6 | import { openApiSchemaToTs } from "./openapi-schema-to-ts.ts"; 7 | import { normalizeString } from "./string-utils.ts"; 8 | import { AnyBoxDef, GenericFactory, type LibSchemaObject } from "./types.ts"; 9 | import { topologicalSort } from "./topological-sort.ts"; 10 | 11 | const autocorrectRef = (ref: string) => (ref[1] === "/" ? ref : "#/" + ref.slice(1)); 12 | const componentsWithSchemas = ["schemas", "responses", "parameters", "requestBodies", "headers"]; 13 | 14 | export type RefInfo = { 15 | /** 16 | * The (potentially autocorrected) ref 17 | * @example "#/components/schemas/MySchema" 18 | */ 19 | ref: string; 20 | /** 21 | * The name of the ref 22 | * @example "MySchema" 23 | * */ 24 | name: string; 25 | normalized: string; 26 | kind: "schemas" | "responses" | "parameters" | "requestBodies" | "headers"; 27 | }; 28 | 29 | export const createRefResolver = (doc: OpenAPIObject, factory: GenericFactory) => { 30 | // both used for debugging purpose 31 | const nameByRef = new Map(); 32 | const refByName = new Map(); 33 | 34 | const byRef = new Map(); 35 | const byNormalized = new Map(); 36 | 37 | const boxByRef = new Map>(); 38 | 39 | const getSchemaByRef = (ref: string) => { 40 | // #components -> #/components 41 | const correctRef = autocorrectRef(ref); 42 | const split = correctRef.split("/"); 43 | 44 | // "#/components/schemas/Something.jsonld" -> #/components/schemas 45 | const path = split.slice(1, -1).join("/")!; 46 | const normalizedPath = path.replace("#/", "").replace("#", "").replaceAll("/", "."); 47 | const map = get(doc, normalizedPath) ?? ({} as any); 48 | 49 | // "#/components/schemas/Something.jsonld" -> "Something.jsonld" 50 | const name = split[split.length - 1]!; 51 | const normalized = normalizeString(name); 52 | 53 | nameByRef.set(correctRef, normalized); 54 | refByName.set(normalized, correctRef); 55 | 56 | const infos = { ref: correctRef, name, normalized, kind: normalizedPath.split(".")[1] as RefInfo["kind"] }; 57 | byRef.set(infos.ref, infos); 58 | byNormalized.set(infos.normalized, infos); 59 | 60 | // doc.components.schemas["Something.jsonld"] 61 | const schema = map[name] as T; 62 | if (!schema) { 63 | throw new Error(`Unresolved ref "${name}" not found in "${path}"`); 64 | } 65 | 66 | return schema; 67 | }; 68 | 69 | const getInfosByRef = (ref: string) => byRef.get(autocorrectRef(ref))!; 70 | 71 | const schemaEntries = Object.entries(doc.components ?? {}).filter(([key]) => componentsWithSchemas.includes(key)); 72 | 73 | schemaEntries.forEach(([key, component]) => { 74 | Object.keys(component).map((name) => { 75 | const ref = `#/components/${key}/${name}`; 76 | getSchemaByRef(ref); 77 | }); 78 | }); 79 | 80 | const directDependencies = new Map>(); 81 | 82 | // need to be done after all refs are resolved 83 | schemaEntries.forEach(([key, component]) => { 84 | Object.keys(component).map((name) => { 85 | const ref = `#/components/${key}/${name}`; 86 | const schema = getSchemaByRef(ref); 87 | boxByRef.set(ref, openApiSchemaToTs({ schema, ctx: { factory, refs: { getInfosByRef } as any } })); 88 | 89 | if (!directDependencies.has(ref)) { 90 | directDependencies.set(ref, new Set()); 91 | } 92 | setSchemaDependencies(schema, directDependencies.get(ref)!); 93 | }); 94 | }); 95 | 96 | const transitiveDependencies = getTransitiveDependencies(directDependencies); 97 | 98 | return { 99 | get: getSchemaByRef, 100 | unwrap: (component: T) => { 101 | return (isReferenceObject(component) ? getSchemaByRef(component.$ref) : component) as Exclude; 102 | }, 103 | getInfosByRef: getInfosByRef, 104 | infos: byRef, 105 | /** 106 | * Get the schemas in the order they should be generated, depending on their dependencies 107 | * so that a schema is generated before the ones that depend on it 108 | */ 109 | getOrderedSchemas: () => { 110 | const schemaOrderedByDependencies = topologicalSort(transitiveDependencies).map((ref) => { 111 | const infos = getInfosByRef(ref); 112 | return [boxByRef.get(infos.ref)!, infos] as [schema: Box, infos: RefInfo]; 113 | }); 114 | 115 | return schemaOrderedByDependencies; 116 | }, 117 | directDependencies, 118 | transitiveDependencies, 119 | }; 120 | }; 121 | 122 | export interface RefResolver extends ReturnType {} 123 | 124 | const setSchemaDependencies = (schema: LibSchemaObject, deps: Set) => { 125 | const visit = (schema: LibSchemaObject | ReferenceObject): void => { 126 | if (!schema) return; 127 | 128 | if (isReferenceObject(schema)) { 129 | deps.add(schema.$ref); 130 | return; 131 | } 132 | 133 | if (schema.allOf) { 134 | for (const allOf of schema.allOf) { 135 | visit(allOf); 136 | } 137 | 138 | return; 139 | } 140 | 141 | if (schema.oneOf) { 142 | for (const oneOf of schema.oneOf) { 143 | visit(oneOf); 144 | } 145 | 146 | return; 147 | } 148 | 149 | if (schema.anyOf) { 150 | for (const anyOf of schema.anyOf) { 151 | visit(anyOf); 152 | } 153 | 154 | return; 155 | } 156 | 157 | if (schema.type === "array") { 158 | if (!schema.items) return; 159 | return void visit(schema.items); 160 | } 161 | 162 | if (schema.type === "object" || schema.properties || schema.additionalProperties) { 163 | if (schema.properties) { 164 | for (const property in schema.properties) { 165 | visit(schema.properties[property]!); 166 | } 167 | } 168 | 169 | if (schema.additionalProperties && typeof schema.additionalProperties === "object") { 170 | visit(schema.additionalProperties); 171 | } 172 | } 173 | }; 174 | 175 | visit(schema); 176 | }; 177 | 178 | const getTransitiveDependencies = (directDependencies: Map>) => { 179 | const transitiveDependencies = new Map>(); 180 | const visitedsDeepRefs = new Set(); 181 | 182 | directDependencies.forEach((deps, ref) => { 183 | if (!transitiveDependencies.has(ref)) { 184 | transitiveDependencies.set(ref, new Set()); 185 | } 186 | 187 | const visit = (depRef: string) => { 188 | transitiveDependencies.get(ref)!.add(depRef); 189 | 190 | const deps = directDependencies.get(depRef); 191 | if (deps && ref !== depRef) { 192 | deps.forEach((transitive) => { 193 | const key = ref + "__" + transitive; 194 | if (visitedsDeepRefs.has(key)) return; 195 | 196 | visitedsDeepRefs.add(key); 197 | visit(transitive); 198 | }); 199 | } 200 | }; 201 | 202 | deps.forEach((dep) => visit(dep)); 203 | }); 204 | 205 | return transitiveDependencies; 206 | }; 207 | -------------------------------------------------------------------------------- /packages/typed-openapi/src/string-utils.ts: -------------------------------------------------------------------------------- 1 | import { capitalize, kebabToCamel } from "pastable/server"; 2 | 3 | export const toSchemasRef = (name: string) => `#/components/schemas/${name}`; 4 | 5 | export function normalizeString(text: string) { 6 | const prefixed = prefixStringStartingWithNumberIfNeeded(text); 7 | return prefixed 8 | .normalize("NFKD") // The normalize() using NFKD method returns the Unicode Normalization Form of a given string. 9 | .trim() // Remove whitespace from both sides of a string (optional) 10 | .replace(/\s+/g, "_") // Replace spaces with _ 11 | .replace(/-+/g, "_") // Replace - with _ 12 | .replace(/[^\w\-]+/g, "_") // Remove all non-word chars 13 | .replace(/--+/g, "-"); // Replace multiple - with single - 14 | } 15 | 16 | const onlyWordRegex = /^\w+$/; 17 | export const wrapWithQuotesIfNeeded = (str: string) => { 18 | if (str[0] === '"' && str[str.length - 1] === '"') return str; 19 | if (onlyWordRegex.test(str)) { 20 | return str; 21 | } 22 | 23 | return `"${str}"`; 24 | }; 25 | 26 | const prefixStringStartingWithNumberIfNeeded = (str: string) => { 27 | const firstAsNumber = Number(str[0]); 28 | if (typeof firstAsNumber === "number" && !Number.isNaN(firstAsNumber)) { 29 | return "_" + str; 30 | } 31 | 32 | return str; 33 | }; 34 | 35 | const pathParamWithBracketsRegex = /({\w+})/g; 36 | const wordPrecededByNonWordCharacter = /[^\w\-]+/g; 37 | 38 | /** @example turns `/media-objects/{id}` into `MediaObjectsId` */ 39 | export const pathToVariableName = (path: string) => 40 | capitalize(kebabToCamel(path).replaceAll("/", "_")) // /media-objects/{id} -> MediaObjects{id} 41 | .replace(pathParamWithBracketsRegex, (group) => capitalize(group.slice(1, -1))) // {id} -> Id 42 | .replace(wordPrecededByNonWordCharacter, "_"); // "/robots.txt" -> "/robots_txt" 43 | -------------------------------------------------------------------------------- /packages/typed-openapi/src/tanstack-query.generator.ts: -------------------------------------------------------------------------------- 1 | import { capitalize } from "pastable/server"; 2 | import { prettify } from "./format.ts"; 3 | import type { mapOpenApiEndpoints } from "./map-openapi-endpoints.ts"; 4 | 5 | type GeneratorOptions = ReturnType; 6 | type GeneratorContext = Required; 7 | 8 | export const generateTanstackQueryFile = async (ctx: GeneratorContext & { relativeApiClientPath: string }) => { 9 | const endpointMethods = new Set(ctx.endpointList.map((endpoint) => endpoint.method.toLowerCase())); 10 | 11 | const file = ` 12 | import { queryOptions } from "@tanstack/react-query" 13 | import type { EndpointByMethod, ApiClient } from "${ctx.relativeApiClientPath}" 14 | 15 | type EndpointQueryKey = [ 16 | TOptions & { 17 | _id: string; 18 | _infinite?: boolean; 19 | } 20 | ]; 21 | 22 | const createQueryKey = (id: string, options?: TOptions, infinite?: boolean): [ 23 | EndpointQueryKey[0] 24 | ] => { 25 | const params: EndpointQueryKey[0] = { _id: id, } as EndpointQueryKey[0]; 26 | if (infinite) { 27 | params._infinite = infinite; 28 | } 29 | if (options?.body) { 30 | params.body = options.body; 31 | } 32 | if (options?.header) { 33 | params.header = options.header; 34 | } 35 | if (options?.path) { 36 | params.path = options.path; 37 | } 38 | if (options?.query) { 39 | params.query = options.query; 40 | } 41 | return [ 42 | params 43 | ]; 44 | }; 45 | 46 | // 47 | ${Array.from(endpointMethods) 48 | .map((method) => `export type ${capitalize(method)}Endpoints = EndpointByMethod["${method}"];`) 49 | .join("\n")} 50 | // 51 | 52 | // 53 | export type EndpointParameters = { 54 | body?: unknown; 55 | query?: Record; 56 | header?: Record; 57 | path?: Record; 58 | }; 59 | 60 | type RequiredKeys = { 61 | [P in keyof T]-?: undefined extends T[P] ? never : P; 62 | }[keyof T]; 63 | 64 | type MaybeOptionalArg = RequiredKeys extends never ? [config?: T] : [config: T]; 65 | 66 | // 67 | 68 | // 69 | export class TanstackQueryApiClient { 70 | constructor(public client: ApiClient) { } 71 | 72 | ${Array.from(endpointMethods) 73 | .map( 74 | (method) => ` 75 | // 76 | ${method}( 77 | path: Path, 78 | ...params: MaybeOptionalArg 79 | ) { 80 | const queryKey = createQueryKey(path, params[0]); 81 | const query = { 82 | /** type-only property if you need easy access to the endpoint params */ 83 | "~endpoint": {} as TEndpoint, 84 | queryKey, 85 | queryOptions: queryOptions({ 86 | queryFn: async ({ queryKey, signal, }) => { 87 | const res = await this.client.${method}(path, { 88 | ...params, 89 | ...queryKey[0], 90 | signal, 91 | }); 92 | return res as TEndpoint["response"]; 93 | }, 94 | queryKey: queryKey 95 | }), 96 | mutationOptions: { 97 | mutationKey: queryKey, 98 | mutationFn: async (localOptions: TEndpoint extends { parameters: infer Parameters} ? Parameters: never) => { 99 | const res = await this.client.${method}(path, { 100 | ...params, 101 | ...queryKey[0], 102 | ...localOptions, 103 | }); 104 | return res as TEndpoint["response"]; 105 | } 106 | } 107 | }; 108 | 109 | return query 110 | } 111 | // 112 | `, 113 | ) 114 | .join("\n")} 115 | 116 | // 117 | /** 118 | * Generic mutation method with full type-safety for any endpoint that doesnt require parameters to be passed initially 119 | */ 120 | mutation< 121 | TMethod extends keyof EndpointByMethod, 122 | TPath extends keyof EndpointByMethod[TMethod], 123 | TEndpoint extends EndpointByMethod[TMethod][TPath], 124 | TSelection, 125 | >(method: TMethod, path: TPath, selectFn?: (res: Omit & { 126 | /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/json) */ 127 | json: () => Promise; 128 | }) => TSelection) { 129 | const mutationKey = [{ method, path }] as const; 130 | return { 131 | /** type-only property if you need easy access to the endpoint params */ 132 | "~endpoint": {} as TEndpoint, 133 | mutationKey: mutationKey, 134 | mutationOptions: { 135 | mutationKey: mutationKey, 136 | mutationFn: async (params: TEndpoint extends { parameters: infer Parameters } ? Parameters : never) => { 137 | const response = await this.client.request(method, path, params); 138 | const res = selectFn ? selectFn(response) : response 139 | return res as unknown extends TSelection ? typeof response : Awaited 140 | }, 141 | }, 142 | }; 143 | } 144 | // 145 | } 146 | `; 147 | 148 | return prettify(file); 149 | }; 150 | -------------------------------------------------------------------------------- /packages/typed-openapi/src/topological-sort.ts: -------------------------------------------------------------------------------- 1 | /** @see https://gist.github.com/RubyTuesdayDONO/5006455 */ 2 | export function topologicalSort(graph: Map>) { 3 | const sorted: string[] = [], // sorted list of IDs ( returned value ) 4 | visited: Record = {}; // hash: id of already visited node => true 5 | 6 | function visit(name: string, ancestors: string[]) { 7 | if (!Array.isArray(ancestors)) ancestors = []; 8 | ancestors.push(name); 9 | visited[name] = true; 10 | 11 | const deps = graph.get(name); 12 | if (deps) { 13 | deps.forEach((dep) => { 14 | if (ancestors.includes(dep)) { 15 | // if already in ancestors, a closed chain (recursive relation) exists 16 | return; 17 | // throw new Error( 18 | // 'Circular dependency "' + dep + '" is required by "' + name + '": ' + ancestors.join(" -> ") 19 | // ); 20 | } 21 | 22 | // if already exists, do nothing 23 | if (visited[dep]) return; 24 | visit(dep, ancestors.slice(0)); // recursive call 25 | }); 26 | } 27 | 28 | if (!sorted.includes(name)) sorted.push(name); 29 | } 30 | 31 | // 2. topological sort 32 | graph.forEach((_, name) => visit(name, [])); 33 | 34 | return sorted; 35 | } 36 | -------------------------------------------------------------------------------- /packages/typed-openapi/src/ts-factory.ts: -------------------------------------------------------------------------------- 1 | import { Box } from "./box.ts"; 2 | import { createFactory, unwrap } from "./box-factory.ts"; 3 | import { wrapWithQuotesIfNeeded } from "./string-utils.ts"; 4 | 5 | export const tsFactory = createFactory({ 6 | union: (types) => `(${types.map(unwrap).join(" | ")})`, 7 | intersection: (types) => `(${types.map(unwrap).join(" & ")})`, 8 | array: (type) => `Array<${unwrap(type)}>`, 9 | optional: (type) => `${unwrap(type)} | undefined`, 10 | reference: (name, typeArgs) => `${name}${typeArgs ? `<${typeArgs.map(unwrap).join(", ")}>` : ""}`, 11 | literal: (value) => value.toString(), 12 | string: () => "string" as const, 13 | number: () => "number" as const, 14 | boolean: () => "boolean" as const, 15 | unknown: () => "unknown" as const, 16 | any: () => "any" as const, 17 | never: () => "never" as const, 18 | object: (props) => { 19 | const propsString = Object.entries(props) 20 | .map( 21 | ([prop, type]) => 22 | `${wrapWithQuotesIfNeeded(prop)}${typeof type !== "string" && Box.isOptional(type) ? "?" : ""}: ${unwrap( 23 | type, 24 | )}`, 25 | ) 26 | .join(", "); 27 | 28 | return `{ ${propsString} }`; 29 | }, 30 | }); 31 | -------------------------------------------------------------------------------- /packages/typed-openapi/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { ReferenceObject, SchemaObject } from "openapi3-ts/oas31"; 2 | import type { SchemaObject as SchemaObject3 } from "openapi3-ts/oas30"; 3 | 4 | import type { RefResolver } from "./ref-resolver.ts"; 5 | import { Box } from "./box.ts"; 6 | 7 | export type LibSchemaObject = SchemaObject & SchemaObject3; 8 | 9 | export type BoxDefinition = { 10 | type: string; 11 | params: unknown; 12 | value: string; 13 | }; 14 | export type BoxParams = string | BoxDefinition; 15 | export type WithSchema = { 16 | schema: LibSchemaObject | ReferenceObject | undefined; 17 | ctx: OpenapiSchemaConvertContext; 18 | }; 19 | 20 | export type BoxUnion = WithSchema & { 21 | type: "union"; 22 | params: { 23 | types: Array; 24 | }; 25 | value: string; 26 | }; 27 | 28 | export type BoxIntersection = WithSchema & { 29 | type: "intersection"; 30 | params: { 31 | types: Array; 32 | }; 33 | value: string; 34 | }; 35 | 36 | export type BoxArray = WithSchema & { 37 | type: "array"; 38 | params: { 39 | type: BoxParams; 40 | }; 41 | value: string; 42 | }; 43 | 44 | export type BoxOptional = WithSchema & { 45 | type: "optional"; 46 | params: { 47 | type: BoxParams; 48 | }; 49 | value: string; 50 | }; 51 | 52 | export type BoxRef = WithSchema & { 53 | type: "ref"; 54 | params: { name: string; generics?: BoxParams[] | undefined }; 55 | value: string; 56 | }; 57 | 58 | export type BoxLiteral = WithSchema & { 59 | type: "literal"; 60 | params: {}; 61 | value: string; 62 | }; 63 | 64 | export type BoxKeyword = WithSchema & { 65 | type: "keyword"; 66 | params: { name: string }; 67 | value: string; 68 | }; 69 | 70 | export type BoxObject = WithSchema & { 71 | type: "object"; 72 | params: { props: Record }; 73 | value: string; 74 | }; 75 | 76 | export type AnyBoxDef = 77 | | BoxUnion 78 | | BoxIntersection 79 | | BoxArray 80 | | BoxOptional 81 | | BoxRef 82 | | BoxLiteral 83 | | BoxKeyword 84 | | BoxObject; 85 | export type AnyBox = Box; 86 | 87 | export type OpenapiSchemaConvertArgs = { 88 | schema: SchemaObject | ReferenceObject; 89 | ctx: OpenapiSchemaConvertContext; 90 | meta?: {} | undefined; 91 | }; 92 | 93 | export type FactoryCreator = ( 94 | schema: SchemaObject | ReferenceObject, 95 | ctx: OpenapiSchemaConvertContext, 96 | ) => GenericFactory; 97 | export type OpenapiSchemaConvertContext = { 98 | factory: FactoryCreator | GenericFactory; 99 | refs: RefResolver; 100 | onBox?: (box: Box) => Box; 101 | }; 102 | 103 | export type StringOrBox = string | Box; 104 | 105 | export type BoxFactory = { 106 | union: (types: Array) => Box; 107 | intersection: (types: Array) => Box; 108 | array: (type: StringOrBox) => Box; 109 | object: (props: Record) => Box; 110 | optional: (type: StringOrBox) => Box; 111 | reference: (name: string, generics?: Array | undefined) => Box; 112 | literal: (value: StringOrBox) => Box; 113 | string: () => Box; 114 | number: () => Box; 115 | boolean: () => Box; 116 | unknown: () => Box; 117 | any: () => Box; 118 | never: () => Box; 119 | }; 120 | 121 | export type GenericFactory = { 122 | callback?: OpenapiSchemaConvertContext["onBox"]; 123 | union: (types: Array) => string; 124 | intersection: (types: Array) => string; 125 | array: (type: StringOrBox) => string; 126 | object: (props: Record) => string; 127 | optional: (type: StringOrBox) => string; 128 | reference: (name: string, generics?: Array | undefined) => string; 129 | literal: (value: StringOrBox) => string; 130 | string: () => string; 131 | number: () => string; 132 | boolean: () => string; 133 | unknown: () => string; 134 | any: () => string; 135 | never: () => string; 136 | }; 137 | -------------------------------------------------------------------------------- /packages/typed-openapi/tests/generate-runtime.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import SwaggerParser from "@apidevtools/swagger-parser"; 3 | import type { OpenAPIObject } from "openapi3-ts/oas31"; 4 | import { mapOpenApiEndpoints } from "../src/map-openapi-endpoints.ts"; 5 | import { allowedRuntimes, generateFile } from "../src/generator.ts"; 6 | import { prettify } from "../src/format.ts"; 7 | 8 | const samples = ["petstore", "docker.openapi", "long-operation-id"]; 9 | // @ts-expect-error 10 | const runtimes = allowedRuntimes.toJsonSchema().enum; 11 | 12 | samples.forEach((sample) => { 13 | describe(`generate-rutime-${sample}`, async () => { 14 | const filePath = `${__dirname}/samples/${sample}.yaml`; 15 | const openApiDoc = (await SwaggerParser.parse(filePath)) as OpenAPIObject; 16 | const ctx = mapOpenApiEndpoints(openApiDoc); 17 | 18 | runtimes.forEach((runtime: string) => { 19 | if (runtime === "arktype" && sample === "docker.openapi") return; 20 | 21 | test(`generate ${runtime}`, async () => { 22 | const tsRouter = await prettify(generateFile({ ...ctx, runtime: runtime as any })); 23 | const runtimeName = runtime === "none" ? "client" : runtime; 24 | await expect(tsRouter).toMatchFileSnapshot(`./snapshots/${sample}.` + runtimeName + ".ts"); 25 | }); 26 | }); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /packages/typed-openapi/tests/ref-resolver.test.ts: -------------------------------------------------------------------------------- 1 | import SwaggerParser from "@apidevtools/swagger-parser"; 2 | import type { OpenAPIObject } from "openapi3-ts/oas31"; 3 | import { describe, test } from "vitest"; 4 | import { createRefResolver } from "../src/ref-resolver.ts"; 5 | import { tsFactory } from "../src/ts-factory.ts"; 6 | 7 | describe("generator", () => { 8 | test("petstore", async ({ expect }) => { 9 | const openApiDoc = (await SwaggerParser.parse("./tests/samples/petstore.yaml")) as OpenAPIObject; 10 | const ref = createRefResolver(openApiDoc, tsFactory); 11 | expect(ref).toMatchInlineSnapshot(` 12 | { 13 | "directDependencies": Map { 14 | "#/components/schemas/Order" => Set {}, 15 | "#/components/schemas/Customer" => Set { 16 | "#/components/schemas/Address", 17 | }, 18 | "#/components/schemas/Address" => Set {}, 19 | "#/components/schemas/Category" => Set {}, 20 | "#/components/schemas/User" => Set {}, 21 | "#/components/schemas/Tag" => Set {}, 22 | "#/components/schemas/Pet" => Set { 23 | "#/components/schemas/Category", 24 | "#/components/schemas/Tag", 25 | }, 26 | "#/components/schemas/ApiResponse" => Set {}, 27 | "#/components/requestBodies/Pet" => Set {}, 28 | "#/components/requestBodies/UserArray" => Set {}, 29 | }, 30 | "get": [Function], 31 | "getInfosByRef": [Function], 32 | "getOrderedSchemas": [Function], 33 | "infos": Map { 34 | "#/components/schemas/Order" => { 35 | "kind": "schemas", 36 | "name": "Order", 37 | "normalized": "Order", 38 | "ref": "#/components/schemas/Order", 39 | }, 40 | "#/components/schemas/Customer" => { 41 | "kind": "schemas", 42 | "name": "Customer", 43 | "normalized": "Customer", 44 | "ref": "#/components/schemas/Customer", 45 | }, 46 | "#/components/schemas/Address" => { 47 | "kind": "schemas", 48 | "name": "Address", 49 | "normalized": "Address", 50 | "ref": "#/components/schemas/Address", 51 | }, 52 | "#/components/schemas/Category" => { 53 | "kind": "schemas", 54 | "name": "Category", 55 | "normalized": "Category", 56 | "ref": "#/components/schemas/Category", 57 | }, 58 | "#/components/schemas/User" => { 59 | "kind": "schemas", 60 | "name": "User", 61 | "normalized": "User", 62 | "ref": "#/components/schemas/User", 63 | }, 64 | "#/components/schemas/Tag" => { 65 | "kind": "schemas", 66 | "name": "Tag", 67 | "normalized": "Tag", 68 | "ref": "#/components/schemas/Tag", 69 | }, 70 | "#/components/schemas/Pet" => { 71 | "kind": "schemas", 72 | "name": "Pet", 73 | "normalized": "Pet", 74 | "ref": "#/components/schemas/Pet", 75 | }, 76 | "#/components/schemas/ApiResponse" => { 77 | "kind": "schemas", 78 | "name": "ApiResponse", 79 | "normalized": "ApiResponse", 80 | "ref": "#/components/schemas/ApiResponse", 81 | }, 82 | "#/components/requestBodies/Pet" => { 83 | "kind": "requestBodies", 84 | "name": "Pet", 85 | "normalized": "Pet", 86 | "ref": "#/components/requestBodies/Pet", 87 | }, 88 | "#/components/requestBodies/UserArray" => { 89 | "kind": "requestBodies", 90 | "name": "UserArray", 91 | "normalized": "UserArray", 92 | "ref": "#/components/requestBodies/UserArray", 93 | }, 94 | }, 95 | "transitiveDependencies": Map { 96 | "#/components/schemas/Order" => Set {}, 97 | "#/components/schemas/Customer" => Set { 98 | "#/components/schemas/Address", 99 | }, 100 | "#/components/schemas/Address" => Set {}, 101 | "#/components/schemas/Category" => Set {}, 102 | "#/components/schemas/User" => Set {}, 103 | "#/components/schemas/Tag" => Set {}, 104 | "#/components/schemas/Pet" => Set { 105 | "#/components/schemas/Category", 106 | "#/components/schemas/Tag", 107 | }, 108 | "#/components/schemas/ApiResponse" => Set {}, 109 | "#/components/requestBodies/Pet" => Set {}, 110 | "#/components/requestBodies/UserArray" => Set {}, 111 | }, 112 | "unwrap": [Function], 113 | } 114 | `); 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /packages/typed-openapi/tests/samples/long-operation-id.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.3 2 | info: 3 | title: Sample API 4 | description: Optional multiline or single-line description in [CommonMark](http://commonmark.org/help/) or HTML. 5 | version: 0.1.9 6 | paths: 7 | /users: 8 | get: 9 | operationId: get_users 10 | summary: Returns a list of users. 11 | responses: 12 | '200': 13 | description: A JSON array of user names 14 | content: 15 | application/json: 16 | schema: 17 | type: array 18 | items: 19 | type: string 20 | post: 21 | operationId: very_very_very_very_very_very_very_very_very_very_long 22 | summary: Creates a user. 23 | requestBody: 24 | required: true 25 | content: 26 | application/json: 27 | schema: 28 | type: object 29 | properties: 30 | username: 31 | type: string 32 | responses: 33 | '201': 34 | description: Created 35 | -------------------------------------------------------------------------------- /packages/typed-openapi/tests/samples/parameters.yaml: -------------------------------------------------------------------------------- 1 | # https://swagger.io/docs/specification/describing-parameters/#common-for-path 2 | openapi: 3.0.3 3 | info: 4 | title: Spec with both path-level and operation-level parameters 5 | paths: 6 | /users/{id}: 7 | parameters: 8 | - in: path 9 | name: id 10 | schema: 11 | type: integer 12 | required: true 13 | description: The user ID. 14 | # GET/users/{id}?metadata=true 15 | get: 16 | summary: Gets a user by ID 17 | # Note we only define the query parameter, because the {id} is defined at the path level. 18 | parameters: 19 | - in: query 20 | name: metadata 21 | schema: 22 | type: boolean 23 | required: false 24 | description: If true, the endpoint returns only the user metadata. 25 | responses: 26 | '200': 27 | description: OK 28 | -------------------------------------------------------------------------------- /packages/typed-openapi/tests/snapshots/long-operation-id.arktype.ts: -------------------------------------------------------------------------------- 1 | import { scope, type } from "arktype"; 2 | 3 | export const types = scope({ 4 | __ENDPOINTS_START__: type({}), 5 | get_Get_users: type({ 6 | method: '"GET"', 7 | path: '"/users"', 8 | requestFormat: '"json"', 9 | parameters: "never", 10 | response: "string[]", 11 | }), 12 | post_Very_very_very_very_very_very_very_very_very_very_long: type({ 13 | method: '"POST"', 14 | path: '"/users"', 15 | requestFormat: '"json"', 16 | parameters: type({ 17 | body: type({ 18 | "username?": "string", 19 | }), 20 | }), 21 | response: "unknown", 22 | }), 23 | __ENDPOINTS_END__: type({}), 24 | }).export(); 25 | 26 | export type __ENDPOINTS_START__ = typeof __ENDPOINTS_START__.infer; 27 | export const __ENDPOINTS_START__ = types.__ENDPOINTS_START__; 28 | export type get_Get_users = typeof get_Get_users.infer; 29 | export const get_Get_users = types.get_Get_users; 30 | export type post_Very_very_very_very_very_very_very_very_very_very_long = 31 | typeof post_Very_very_very_very_very_very_very_very_very_very_long.infer; 32 | export const post_Very_very_very_very_very_very_very_very_very_very_long = 33 | types.post_Very_very_very_very_very_very_very_very_very_very_long; 34 | export type __ENDPOINTS_END__ = typeof __ENDPOINTS_END__.infer; 35 | export const __ENDPOINTS_END__ = types.__ENDPOINTS_END__; 36 | 37 | // 38 | export const EndpointByMethod = { 39 | get: { 40 | "/users": get_Get_users, 41 | }, 42 | post: { 43 | "/users": post_Very_very_very_very_very_very_very_very_very_very_long, 44 | }, 45 | }; 46 | export type EndpointByMethod = typeof EndpointByMethod; 47 | // 48 | 49 | // 50 | export type GetEndpoints = EndpointByMethod["get"]; 51 | export type PostEndpoints = EndpointByMethod["post"]; 52 | // 53 | 54 | // 55 | export type EndpointParameters = { 56 | body?: unknown; 57 | query?: Record; 58 | header?: Record; 59 | path?: Record; 60 | }; 61 | 62 | export type MutationMethod = "post" | "put" | "patch" | "delete"; 63 | export type Method = "get" | "head" | "options" | MutationMethod; 64 | 65 | type RequestFormat = "json" | "form-data" | "form-url" | "binary" | "text"; 66 | 67 | export type DefaultEndpoint = { 68 | parameters?: EndpointParameters | undefined; 69 | response: unknown; 70 | }; 71 | 72 | export type Endpoint = { 73 | operationId: string; 74 | method: Method; 75 | path: string; 76 | requestFormat: RequestFormat; 77 | parameters?: TConfig["parameters"]; 78 | meta: { 79 | alias: string; 80 | hasParameters: boolean; 81 | areParametersRequired: boolean; 82 | }; 83 | response: TConfig["response"]; 84 | }; 85 | 86 | export type Fetcher = (method: Method, url: string, parameters?: EndpointParameters | undefined) => Promise; 87 | 88 | type RequiredKeys = { 89 | [P in keyof T]-?: undefined extends T[P] ? never : P; 90 | }[keyof T]; 91 | 92 | type MaybeOptionalArg = RequiredKeys extends never ? [config?: T] : [config: T]; 93 | 94 | // 95 | 96 | // 97 | export class ApiClient { 98 | baseUrl: string = ""; 99 | 100 | constructor(public fetcher: Fetcher) {} 101 | 102 | setBaseUrl(baseUrl: string) { 103 | this.baseUrl = baseUrl; 104 | return this; 105 | } 106 | 107 | parseResponse = async (response: Response): Promise => { 108 | const contentType = response.headers.get("content-type"); 109 | if (contentType?.includes("application/json")) { 110 | return response.json(); 111 | } 112 | return response.text() as unknown as T; 113 | }; 114 | 115 | // 116 | get( 117 | path: Path, 118 | ...params: MaybeOptionalArg 119 | ): Promise { 120 | return this.fetcher("get", this.baseUrl + path, params[0]).then((response) => 121 | this.parseResponse(response), 122 | ) as Promise; 123 | } 124 | // 125 | 126 | // 127 | post( 128 | path: Path, 129 | ...params: MaybeOptionalArg 130 | ): Promise { 131 | return this.fetcher("post", this.baseUrl + path, params[0]).then((response) => 132 | this.parseResponse(response), 133 | ) as Promise; 134 | } 135 | // 136 | 137 | // 138 | /** 139 | * Generic request method with full type-safety for any endpoint 140 | */ 141 | request< 142 | TMethod extends keyof EndpointByMethod, 143 | TPath extends keyof EndpointByMethod[TMethod], 144 | TEndpoint extends EndpointByMethod[TMethod][TPath], 145 | >( 146 | method: TMethod, 147 | path: TPath, 148 | ...params: MaybeOptionalArg 149 | ): Promise< 150 | Omit & { 151 | /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/json) */ 152 | json: () => Promise; 153 | } 154 | > { 155 | return this.fetcher(method, this.baseUrl + (path as string), params[0] as EndpointParameters); 156 | } 157 | // 158 | } 159 | 160 | export function createApiClient(fetcher: Fetcher, baseUrl?: string) { 161 | return new ApiClient(fetcher).setBaseUrl(baseUrl ?? ""); 162 | } 163 | 164 | /** 165 | Example usage: 166 | const api = createApiClient((method, url, params) => 167 | fetch(url, { method, body: JSON.stringify(params) }).then((res) => res.json()), 168 | ); 169 | api.get("/users").then((users) => console.log(users)); 170 | api.post("/users", { body: { name: "John" } }).then((user) => console.log(user)); 171 | api.put("/users/:id", { path: { id: 1 }, body: { name: "John" } }).then((user) => console.log(user)); 172 | */ 173 | 174 | // 3 | // 4 | } 5 | 6 | export namespace Endpoints { 7 | // 8 | 9 | export type get_Get_users = { 10 | method: "GET"; 11 | path: "/users"; 12 | requestFormat: "json"; 13 | parameters: never; 14 | response: Array; 15 | }; 16 | export type post_Very_very_very_very_very_very_very_very_very_very_long = { 17 | method: "POST"; 18 | path: "/users"; 19 | requestFormat: "json"; 20 | parameters: { 21 | body: Partial<{ username: string }>; 22 | }; 23 | response: unknown; 24 | }; 25 | 26 | // 27 | } 28 | 29 | // 30 | export type EndpointByMethod = { 31 | get: { 32 | "/users": Endpoints.get_Get_users; 33 | }; 34 | post: { 35 | "/users": Endpoints.post_Very_very_very_very_very_very_very_very_very_very_long; 36 | }; 37 | }; 38 | 39 | // 40 | 41 | // 42 | export type GetEndpoints = EndpointByMethod["get"]; 43 | export type PostEndpoints = EndpointByMethod["post"]; 44 | // 45 | 46 | // 47 | export type EndpointParameters = { 48 | body?: unknown; 49 | query?: Record; 50 | header?: Record; 51 | path?: Record; 52 | }; 53 | 54 | export type MutationMethod = "post" | "put" | "patch" | "delete"; 55 | export type Method = "get" | "head" | "options" | MutationMethod; 56 | 57 | type RequestFormat = "json" | "form-data" | "form-url" | "binary" | "text"; 58 | 59 | export type DefaultEndpoint = { 60 | parameters?: EndpointParameters | undefined; 61 | response: unknown; 62 | }; 63 | 64 | export type Endpoint = { 65 | operationId: string; 66 | method: Method; 67 | path: string; 68 | requestFormat: RequestFormat; 69 | parameters?: TConfig["parameters"]; 70 | meta: { 71 | alias: string; 72 | hasParameters: boolean; 73 | areParametersRequired: boolean; 74 | }; 75 | response: TConfig["response"]; 76 | }; 77 | 78 | export type Fetcher = (method: Method, url: string, parameters?: EndpointParameters | undefined) => Promise; 79 | 80 | type RequiredKeys = { 81 | [P in keyof T]-?: undefined extends T[P] ? never : P; 82 | }[keyof T]; 83 | 84 | type MaybeOptionalArg = RequiredKeys extends never ? [config?: T] : [config: T]; 85 | 86 | // 87 | 88 | // 89 | export class ApiClient { 90 | baseUrl: string = ""; 91 | 92 | constructor(public fetcher: Fetcher) {} 93 | 94 | setBaseUrl(baseUrl: string) { 95 | this.baseUrl = baseUrl; 96 | return this; 97 | } 98 | 99 | parseResponse = async (response: Response): Promise => { 100 | const contentType = response.headers.get("content-type"); 101 | if (contentType?.includes("application/json")) { 102 | return response.json(); 103 | } 104 | return response.text() as unknown as T; 105 | }; 106 | 107 | // 108 | get( 109 | path: Path, 110 | ...params: MaybeOptionalArg 111 | ): Promise { 112 | return this.fetcher("get", this.baseUrl + path, params[0]).then((response) => 113 | this.parseResponse(response), 114 | ) as Promise; 115 | } 116 | // 117 | 118 | // 119 | post( 120 | path: Path, 121 | ...params: MaybeOptionalArg 122 | ): Promise { 123 | return this.fetcher("post", this.baseUrl + path, params[0]).then((response) => 124 | this.parseResponse(response), 125 | ) as Promise; 126 | } 127 | // 128 | 129 | // 130 | /** 131 | * Generic request method with full type-safety for any endpoint 132 | */ 133 | request< 134 | TMethod extends keyof EndpointByMethod, 135 | TPath extends keyof EndpointByMethod[TMethod], 136 | TEndpoint extends EndpointByMethod[TMethod][TPath], 137 | >( 138 | method: TMethod, 139 | path: TPath, 140 | ...params: MaybeOptionalArg 141 | ): Promise< 142 | Omit & { 143 | /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/json) */ 144 | json: () => Promise; 145 | } 146 | > { 147 | return this.fetcher(method, this.baseUrl + (path as string), params[0] as EndpointParameters); 148 | } 149 | // 150 | } 151 | 152 | export function createApiClient(fetcher: Fetcher, baseUrl?: string) { 153 | return new ApiClient(fetcher).setBaseUrl(baseUrl ?? ""); 154 | } 155 | 156 | /** 157 | Example usage: 158 | const api = createApiClient((method, url, params) => 159 | fetch(url, { method, body: JSON.stringify(params) }).then((res) => res.json()), 160 | ); 161 | api.get("/users").then((users) => console.log(users)); 162 | api.post("/users", { body: { name: "John" } }).then((user) => console.log(user)); 163 | api.put("/users/:id", { path: { id: 1 }, body: { name: "John" } }).then((user) => console.log(user)); 164 | */ 165 | 166 | // ; 4 | export const __ENDPOINTS_START__ = t.type({}); 5 | 6 | export type get_Get_users = t.TypeOf; 7 | export const get_Get_users = t.type({ 8 | method: t.literal("GET"), 9 | path: t.literal("/users"), 10 | requestFormat: t.literal("json"), 11 | parameters: t.never, 12 | response: t.array(t.string), 13 | }); 14 | 15 | export type post_Very_very_very_very_very_very_very_very_very_very_long = t.TypeOf< 16 | typeof post_Very_very_very_very_very_very_very_very_very_very_long 17 | >; 18 | export const post_Very_very_very_very_very_very_very_very_very_very_long = t.type({ 19 | method: t.literal("POST"), 20 | path: t.literal("/users"), 21 | requestFormat: t.literal("json"), 22 | parameters: t.type({ 23 | body: t.type({ 24 | username: t.union([t.undefined, t.string]), 25 | }), 26 | }), 27 | response: t.unknown, 28 | }); 29 | 30 | export type __ENDPOINTS_END__ = t.TypeOf; 31 | export const __ENDPOINTS_END__ = t.type({}); 32 | 33 | // 34 | export const EndpointByMethod = { 35 | get: { 36 | "/users": get_Get_users, 37 | }, 38 | post: { 39 | "/users": post_Very_very_very_very_very_very_very_very_very_very_long, 40 | }, 41 | }; 42 | export type EndpointByMethod = typeof EndpointByMethod; 43 | // 44 | 45 | // 46 | export type GetEndpoints = EndpointByMethod["get"]; 47 | export type PostEndpoints = EndpointByMethod["post"]; 48 | // 49 | 50 | // 51 | export type EndpointParameters = { 52 | body?: unknown; 53 | query?: Record; 54 | header?: Record; 55 | path?: Record; 56 | }; 57 | 58 | export type MutationMethod = "post" | "put" | "patch" | "delete"; 59 | export type Method = "get" | "head" | "options" | MutationMethod; 60 | 61 | type RequestFormat = "json" | "form-data" | "form-url" | "binary" | "text"; 62 | 63 | export type DefaultEndpoint = { 64 | parameters?: EndpointParameters | undefined; 65 | response: unknown; 66 | }; 67 | 68 | export type Endpoint = { 69 | operationId: string; 70 | method: Method; 71 | path: string; 72 | requestFormat: RequestFormat; 73 | parameters?: TConfig["parameters"]; 74 | meta: { 75 | alias: string; 76 | hasParameters: boolean; 77 | areParametersRequired: boolean; 78 | }; 79 | response: TConfig["response"]; 80 | }; 81 | 82 | export type Fetcher = (method: Method, url: string, parameters?: EndpointParameters | undefined) => Promise; 83 | 84 | type RequiredKeys = { 85 | [P in keyof T]-?: undefined extends T[P] ? never : P; 86 | }[keyof T]; 87 | 88 | type MaybeOptionalArg = RequiredKeys extends never ? [config?: T] : [config: T]; 89 | 90 | // 91 | 92 | // 93 | export class ApiClient { 94 | baseUrl: string = ""; 95 | 96 | constructor(public fetcher: Fetcher) {} 97 | 98 | setBaseUrl(baseUrl: string) { 99 | this.baseUrl = baseUrl; 100 | return this; 101 | } 102 | 103 | parseResponse = async (response: Response): Promise => { 104 | const contentType = response.headers.get("content-type"); 105 | if (contentType?.includes("application/json")) { 106 | return response.json(); 107 | } 108 | return response.text() as unknown as T; 109 | }; 110 | 111 | // 112 | get( 113 | path: Path, 114 | ...params: MaybeOptionalArg["parameters"]> 115 | ): Promise["response"]> { 116 | return this.fetcher("get", this.baseUrl + path, params[0]).then((response) => 117 | this.parseResponse(response), 118 | ) as Promise["response"]>; 119 | } 120 | // 121 | 122 | // 123 | post( 124 | path: Path, 125 | ...params: MaybeOptionalArg["parameters"]> 126 | ): Promise["response"]> { 127 | return this.fetcher("post", this.baseUrl + path, params[0]).then((response) => 128 | this.parseResponse(response), 129 | ) as Promise["response"]>; 130 | } 131 | // 132 | 133 | // 134 | /** 135 | * Generic request method with full type-safety for any endpoint 136 | */ 137 | request< 138 | TMethod extends keyof EndpointByMethod, 139 | TPath extends keyof EndpointByMethod[TMethod], 140 | TEndpoint extends EndpointByMethod[TMethod][TPath], 141 | >( 142 | method: TMethod, 143 | path: TPath, 144 | ...params: MaybeOptionalArg["parameters"]> 145 | ): Promise< 146 | Omit & { 147 | /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/json) */ 148 | json: () => Promise; 149 | } 150 | > { 151 | return this.fetcher(method, this.baseUrl + (path as string), params[0] as EndpointParameters); 152 | } 153 | // 154 | } 155 | 156 | export function createApiClient(fetcher: Fetcher, baseUrl?: string) { 157 | return new ApiClient(fetcher).setBaseUrl(baseUrl ?? ""); 158 | } 159 | 160 | /** 161 | Example usage: 162 | const api = createApiClient((method, url, params) => 163 | fetch(url, { method, body: JSON.stringify(params) }).then((res) => res.json()), 164 | ); 165 | api.get("/users").then((users) => console.log(users)); 166 | api.post("/users", { body: { name: "John" } }).then((user) => console.log(user)); 167 | api.put("/users/:id", { path: { id: 1 }, body: { name: "John" } }).then((user) => console.log(user)); 168 | */ 169 | 170 | // ; 4 | const __ENDPOINTS_START__ = Type.Object({}); 5 | 6 | export type get_Get_users = Static; 7 | export const get_Get_users = Type.Object({ 8 | method: Type.Literal("GET"), 9 | path: Type.Literal("/users"), 10 | requestFormat: Type.Literal("json"), 11 | parameters: Type.Never(), 12 | response: Type.Array(Type.String()), 13 | }); 14 | 15 | export type post_Very_very_very_very_very_very_very_very_very_very_long = Static< 16 | typeof post_Very_very_very_very_very_very_very_very_very_very_long 17 | >; 18 | export const post_Very_very_very_very_very_very_very_very_very_very_long = Type.Object({ 19 | method: Type.Literal("POST"), 20 | path: Type.Literal("/users"), 21 | requestFormat: Type.Literal("json"), 22 | parameters: Type.Object({ 23 | body: Type.Partial( 24 | Type.Object({ 25 | username: Type.String(), 26 | }), 27 | ), 28 | }), 29 | response: Type.Unknown(), 30 | }); 31 | 32 | type __ENDPOINTS_END__ = Static; 33 | const __ENDPOINTS_END__ = Type.Object({}); 34 | 35 | // 36 | export const EndpointByMethod = { 37 | get: { 38 | "/users": get_Get_users, 39 | }, 40 | post: { 41 | "/users": post_Very_very_very_very_very_very_very_very_very_very_long, 42 | }, 43 | }; 44 | export type EndpointByMethod = typeof EndpointByMethod; 45 | // 46 | 47 | // 48 | export type GetEndpoints = EndpointByMethod["get"]; 49 | export type PostEndpoints = EndpointByMethod["post"]; 50 | // 51 | 52 | // 53 | export type EndpointParameters = { 54 | body?: unknown; 55 | query?: Record; 56 | header?: Record; 57 | path?: Record; 58 | }; 59 | 60 | export type MutationMethod = "post" | "put" | "patch" | "delete"; 61 | export type Method = "get" | "head" | "options" | MutationMethod; 62 | 63 | type RequestFormat = "json" | "form-data" | "form-url" | "binary" | "text"; 64 | 65 | export type DefaultEndpoint = { 66 | parameters?: EndpointParameters | undefined; 67 | response: unknown; 68 | }; 69 | 70 | export type Endpoint = { 71 | operationId: string; 72 | method: Method; 73 | path: string; 74 | requestFormat: RequestFormat; 75 | parameters?: TConfig["parameters"]; 76 | meta: { 77 | alias: string; 78 | hasParameters: boolean; 79 | areParametersRequired: boolean; 80 | }; 81 | response: TConfig["response"]; 82 | }; 83 | 84 | export type Fetcher = (method: Method, url: string, parameters?: EndpointParameters | undefined) => Promise; 85 | 86 | type RequiredKeys = { 87 | [P in keyof T]-?: undefined extends T[P] ? never : P; 88 | }[keyof T]; 89 | 90 | type MaybeOptionalArg = RequiredKeys extends never ? [config?: T] : [config: T]; 91 | 92 | // 93 | 94 | // 95 | export class ApiClient { 96 | baseUrl: string = ""; 97 | 98 | constructor(public fetcher: Fetcher) {} 99 | 100 | setBaseUrl(baseUrl: string) { 101 | this.baseUrl = baseUrl; 102 | return this; 103 | } 104 | 105 | parseResponse = async (response: Response): Promise => { 106 | const contentType = response.headers.get("content-type"); 107 | if (contentType?.includes("application/json")) { 108 | return response.json(); 109 | } 110 | return response.text() as unknown as T; 111 | }; 112 | 113 | // 114 | get( 115 | path: Path, 116 | ...params: MaybeOptionalArg["parameters"]> 117 | ): Promise["response"]> { 118 | return this.fetcher("get", this.baseUrl + path, params[0]).then((response) => 119 | this.parseResponse(response), 120 | ) as Promise["response"]>; 121 | } 122 | // 123 | 124 | // 125 | post( 126 | path: Path, 127 | ...params: MaybeOptionalArg["parameters"]> 128 | ): Promise["response"]> { 129 | return this.fetcher("post", this.baseUrl + path, params[0]).then((response) => 130 | this.parseResponse(response), 131 | ) as Promise["response"]>; 132 | } 133 | // 134 | 135 | // 136 | /** 137 | * Generic request method with full type-safety for any endpoint 138 | */ 139 | request< 140 | TMethod extends keyof EndpointByMethod, 141 | TPath extends keyof EndpointByMethod[TMethod], 142 | TEndpoint extends EndpointByMethod[TMethod][TPath], 143 | >( 144 | method: TMethod, 145 | path: TPath, 146 | ...params: MaybeOptionalArg["parameters"]> 147 | ): Promise< 148 | Omit & { 149 | /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/json) */ 150 | json: () => Promise; 151 | } 152 | > { 153 | return this.fetcher(method, this.baseUrl + (path as string), params[0] as EndpointParameters); 154 | } 155 | // 156 | } 157 | 158 | export function createApiClient(fetcher: Fetcher, baseUrl?: string) { 159 | return new ApiClient(fetcher).setBaseUrl(baseUrl ?? ""); 160 | } 161 | 162 | /** 163 | Example usage: 164 | const api = createApiClient((method, url, params) => 165 | fetch(url, { method, body: JSON.stringify(params) }).then((res) => res.json()), 166 | ); 167 | api.get("/users").then((users) => console.log(users)); 168 | api.post("/users", { body: { name: "John" } }).then((user) => console.log(user)); 169 | api.put("/users/:id", { path: { id: 1 }, body: { name: "John" } }).then((user) => console.log(user)); 170 | */ 171 | 172 | // ; 4 | export const __ENDPOINTS_START__ = v.object({}); 5 | 6 | export type get_Get_users = v.InferOutput; 7 | export const get_Get_users = v.object({ 8 | method: v.literal("GET"), 9 | path: v.literal("/users"), 10 | requestFormat: v.literal("json"), 11 | parameters: v.never(), 12 | response: v.array(v.string()), 13 | }); 14 | 15 | export type post_Very_very_very_very_very_very_very_very_very_very_long = v.InferOutput< 16 | typeof post_Very_very_very_very_very_very_very_very_very_very_long 17 | >; 18 | export const post_Very_very_very_very_very_very_very_very_very_very_long = v.object({ 19 | method: v.literal("POST"), 20 | path: v.literal("/users"), 21 | requestFormat: v.literal("json"), 22 | parameters: v.object({ 23 | body: v.object({ 24 | username: v.optional(v.string()), 25 | }), 26 | }), 27 | response: v.unknown(), 28 | }); 29 | 30 | export type __ENDPOINTS_END__ = v.InferOutput; 31 | export const __ENDPOINTS_END__ = v.object({}); 32 | 33 | // 34 | export const EndpointByMethod = { 35 | get: { 36 | "/users": get_Get_users, 37 | }, 38 | post: { 39 | "/users": post_Very_very_very_very_very_very_very_very_very_very_long, 40 | }, 41 | }; 42 | export type EndpointByMethod = typeof EndpointByMethod; 43 | // 44 | 45 | // 46 | export type GetEndpoints = EndpointByMethod["get"]; 47 | export type PostEndpoints = EndpointByMethod["post"]; 48 | // 49 | 50 | // 51 | export type EndpointParameters = { 52 | body?: unknown; 53 | query?: Record; 54 | header?: Record; 55 | path?: Record; 56 | }; 57 | 58 | export type MutationMethod = "post" | "put" | "patch" | "delete"; 59 | export type Method = "get" | "head" | "options" | MutationMethod; 60 | 61 | type RequestFormat = "json" | "form-data" | "form-url" | "binary" | "text"; 62 | 63 | export type DefaultEndpoint = { 64 | parameters?: EndpointParameters | undefined; 65 | response: unknown; 66 | }; 67 | 68 | export type Endpoint = { 69 | operationId: string; 70 | method: Method; 71 | path: string; 72 | requestFormat: RequestFormat; 73 | parameters?: TConfig["parameters"]; 74 | meta: { 75 | alias: string; 76 | hasParameters: boolean; 77 | areParametersRequired: boolean; 78 | }; 79 | response: TConfig["response"]; 80 | }; 81 | 82 | export type Fetcher = (method: Method, url: string, parameters?: EndpointParameters | undefined) => Promise; 83 | 84 | type RequiredKeys = { 85 | [P in keyof T]-?: undefined extends T[P] ? never : P; 86 | }[keyof T]; 87 | 88 | type MaybeOptionalArg = RequiredKeys extends never ? [config?: T] : [config: T]; 89 | 90 | // 91 | 92 | // 93 | export class ApiClient { 94 | baseUrl: string = ""; 95 | 96 | constructor(public fetcher: Fetcher) {} 97 | 98 | setBaseUrl(baseUrl: string) { 99 | this.baseUrl = baseUrl; 100 | return this; 101 | } 102 | 103 | parseResponse = async (response: Response): Promise => { 104 | const contentType = response.headers.get("content-type"); 105 | if (contentType?.includes("application/json")) { 106 | return response.json(); 107 | } 108 | return response.text() as unknown as T; 109 | }; 110 | 111 | // 112 | get( 113 | path: Path, 114 | ...params: MaybeOptionalArg["parameters"]> 115 | ): Promise["response"]> { 116 | return this.fetcher("get", this.baseUrl + path, params[0]).then((response) => 117 | this.parseResponse(response), 118 | ) as Promise["response"]>; 119 | } 120 | // 121 | 122 | // 123 | post( 124 | path: Path, 125 | ...params: MaybeOptionalArg["parameters"]> 126 | ): Promise["response"]> { 127 | return this.fetcher("post", this.baseUrl + path, params[0]).then((response) => 128 | this.parseResponse(response), 129 | ) as Promise["response"]>; 130 | } 131 | // 132 | 133 | // 134 | /** 135 | * Generic request method with full type-safety for any endpoint 136 | */ 137 | request< 138 | TMethod extends keyof EndpointByMethod, 139 | TPath extends keyof EndpointByMethod[TMethod], 140 | TEndpoint extends EndpointByMethod[TMethod][TPath], 141 | >( 142 | method: TMethod, 143 | path: TPath, 144 | ...params: MaybeOptionalArg["parameters"]> 145 | ): Promise< 146 | Omit & { 147 | /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/json) */ 148 | json: () => Promise; 149 | } 150 | > { 151 | return this.fetcher(method, this.baseUrl + (path as string), params[0] as EndpointParameters); 152 | } 153 | // 154 | } 155 | 156 | export function createApiClient(fetcher: Fetcher, baseUrl?: string) { 157 | return new ApiClient(fetcher).setBaseUrl(baseUrl ?? ""); 158 | } 159 | 160 | /** 161 | Example usage: 162 | const api = createApiClient((method, url, params) => 163 | fetch(url, { method, body: JSON.stringify(params) }).then((res) => res.json()), 164 | ); 165 | api.get("/users").then((users) => console.log(users)); 166 | api.post("/users", { body: { name: "John" } }).then((user) => console.log(user)); 167 | api.put("/users/:id", { path: { id: 1 }, body: { name: "John" } }).then((user) => console.log(user)); 168 | */ 169 | 170 | // value === "GET").required(), 6 | path: y.mixed((value): value is "/users" => value === "/users").required(), 7 | requestFormat: y.mixed((value): value is "json" => value === "json").required(), 8 | parameters: y.mixed((value): value is never => false).required(), 9 | response: y.array(y.string().required()), 10 | }; 11 | 12 | export type post_Very_very_very_very_very_very_very_very_very_very_long = 13 | typeof post_Very_very_very_very_very_very_very_very_very_very_long; 14 | export const post_Very_very_very_very_very_very_very_very_very_very_long = { 15 | method: y.mixed((value): value is "POST" => value === "POST").required(), 16 | path: y.mixed((value): value is "/users" => value === "/users").required(), 17 | requestFormat: y.mixed((value): value is "json" => value === "json").required(), 18 | parameters: y.object({ 19 | body: y.object({ 20 | username: y.string().required().optional(), 21 | }), 22 | }), 23 | response: y.mixed((value): value is any => true).required() as y.MixedSchema, 24 | }; 25 | 26 | // 27 | export const EndpointByMethod = { 28 | get: { 29 | "/users": get_Get_users, 30 | }, 31 | post: { 32 | "/users": post_Very_very_very_very_very_very_very_very_very_very_long, 33 | }, 34 | }; 35 | export type EndpointByMethod = typeof EndpointByMethod; 36 | // 37 | 38 | // 39 | export type GetEndpoints = EndpointByMethod["get"]; 40 | export type PostEndpoints = EndpointByMethod["post"]; 41 | // 42 | 43 | // 44 | export type EndpointParameters = { 45 | body?: unknown; 46 | query?: Record; 47 | header?: Record; 48 | path?: Record; 49 | }; 50 | 51 | export type MutationMethod = "post" | "put" | "patch" | "delete"; 52 | export type Method = "get" | "head" | "options" | MutationMethod; 53 | 54 | type RequestFormat = "json" | "form-data" | "form-url" | "binary" | "text"; 55 | 56 | export type DefaultEndpoint = { 57 | parameters?: EndpointParameters | undefined; 58 | response: unknown; 59 | }; 60 | 61 | export type Endpoint = { 62 | operationId: string; 63 | method: Method; 64 | path: string; 65 | requestFormat: RequestFormat; 66 | parameters?: TConfig["parameters"]; 67 | meta: { 68 | alias: string; 69 | hasParameters: boolean; 70 | areParametersRequired: boolean; 71 | }; 72 | response: TConfig["response"]; 73 | }; 74 | 75 | export type Fetcher = (method: Method, url: string, parameters?: EndpointParameters | undefined) => Promise; 76 | 77 | type RequiredKeys = { 78 | [P in keyof T]-?: undefined extends T[P] ? never : P; 79 | }[keyof T]; 80 | 81 | type MaybeOptionalArg = RequiredKeys extends never ? [config?: T] : [config: T]; 82 | 83 | // 84 | 85 | // 86 | export class ApiClient { 87 | baseUrl: string = ""; 88 | 89 | constructor(public fetcher: Fetcher) {} 90 | 91 | setBaseUrl(baseUrl: string) { 92 | this.baseUrl = baseUrl; 93 | return this; 94 | } 95 | 96 | parseResponse = async (response: Response): Promise => { 97 | const contentType = response.headers.get("content-type"); 98 | if (contentType?.includes("application/json")) { 99 | return response.json(); 100 | } 101 | return response.text() as unknown as T; 102 | }; 103 | 104 | // 105 | get( 106 | path: Path, 107 | ...params: MaybeOptionalArg> 108 | ): Promise> { 109 | return this.fetcher("get", this.baseUrl + path, params[0]).then((response) => 110 | this.parseResponse(response), 111 | ) as Promise>; 112 | } 113 | // 114 | 115 | // 116 | post( 117 | path: Path, 118 | ...params: MaybeOptionalArg> 119 | ): Promise> { 120 | return this.fetcher("post", this.baseUrl + path, params[0]).then((response) => 121 | this.parseResponse(response), 122 | ) as Promise>; 123 | } 124 | // 125 | 126 | // 127 | /** 128 | * Generic request method with full type-safety for any endpoint 129 | */ 130 | request< 131 | TMethod extends keyof EndpointByMethod, 132 | TPath extends keyof EndpointByMethod[TMethod], 133 | TEndpoint extends EndpointByMethod[TMethod][TPath], 134 | >( 135 | method: TMethod, 136 | path: TPath, 137 | ...params: MaybeOptionalArg> 138 | ): Promise< 139 | Omit & { 140 | /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/json) */ 141 | json: () => Promise; 142 | } 143 | > { 144 | return this.fetcher(method, this.baseUrl + (path as string), params[0] as EndpointParameters); 145 | } 146 | // 147 | } 148 | 149 | export function createApiClient(fetcher: Fetcher, baseUrl?: string) { 150 | return new ApiClient(fetcher).setBaseUrl(baseUrl ?? ""); 151 | } 152 | 153 | /** 154 | Example usage: 155 | const api = createApiClient((method, url, params) => 156 | fetch(url, { method, body: JSON.stringify(params) }).then((res) => res.json()), 157 | ); 158 | api.get("/users").then((users) => console.log(users)); 159 | api.post("/users", { body: { name: "John" } }).then((user) => console.log(user)); 160 | api.put("/users/:id", { path: { id: 1 }, body: { name: "John" } }).then((user) => console.log(user)); 161 | */ 162 | 163 | // 27 | export const EndpointByMethod = { 28 | get: { 29 | "/users": get_Get_users, 30 | }, 31 | post: { 32 | "/users": post_Very_very_very_very_very_very_very_very_very_very_long, 33 | }, 34 | }; 35 | export type EndpointByMethod = typeof EndpointByMethod; 36 | // 37 | 38 | // 39 | export type GetEndpoints = EndpointByMethod["get"]; 40 | export type PostEndpoints = EndpointByMethod["post"]; 41 | // 42 | 43 | // 44 | export type EndpointParameters = { 45 | body?: unknown; 46 | query?: Record; 47 | header?: Record; 48 | path?: Record; 49 | }; 50 | 51 | export type MutationMethod = "post" | "put" | "patch" | "delete"; 52 | export type Method = "get" | "head" | "options" | MutationMethod; 53 | 54 | type RequestFormat = "json" | "form-data" | "form-url" | "binary" | "text"; 55 | 56 | export type DefaultEndpoint = { 57 | parameters?: EndpointParameters | undefined; 58 | response: unknown; 59 | }; 60 | 61 | export type Endpoint = { 62 | operationId: string; 63 | method: Method; 64 | path: string; 65 | requestFormat: RequestFormat; 66 | parameters?: TConfig["parameters"]; 67 | meta: { 68 | alias: string; 69 | hasParameters: boolean; 70 | areParametersRequired: boolean; 71 | }; 72 | response: TConfig["response"]; 73 | }; 74 | 75 | export type Fetcher = (method: Method, url: string, parameters?: EndpointParameters | undefined) => Promise; 76 | 77 | type RequiredKeys = { 78 | [P in keyof T]-?: undefined extends T[P] ? never : P; 79 | }[keyof T]; 80 | 81 | type MaybeOptionalArg = RequiredKeys extends never ? [config?: T] : [config: T]; 82 | 83 | // 84 | 85 | // 86 | export class ApiClient { 87 | baseUrl: string = ""; 88 | 89 | constructor(public fetcher: Fetcher) {} 90 | 91 | setBaseUrl(baseUrl: string) { 92 | this.baseUrl = baseUrl; 93 | return this; 94 | } 95 | 96 | parseResponse = async (response: Response): Promise => { 97 | const contentType = response.headers.get("content-type"); 98 | if (contentType?.includes("application/json")) { 99 | return response.json(); 100 | } 101 | return response.text() as unknown as T; 102 | }; 103 | 104 | // 105 | get( 106 | path: Path, 107 | ...params: MaybeOptionalArg> 108 | ): Promise> { 109 | return this.fetcher("get", this.baseUrl + path, params[0]).then((response) => 110 | this.parseResponse(response), 111 | ) as Promise>; 112 | } 113 | // 114 | 115 | // 116 | post( 117 | path: Path, 118 | ...params: MaybeOptionalArg> 119 | ): Promise> { 120 | return this.fetcher("post", this.baseUrl + path, params[0]).then((response) => 121 | this.parseResponse(response), 122 | ) as Promise>; 123 | } 124 | // 125 | 126 | // 127 | /** 128 | * Generic request method with full type-safety for any endpoint 129 | */ 130 | request< 131 | TMethod extends keyof EndpointByMethod, 132 | TPath extends keyof EndpointByMethod[TMethod], 133 | TEndpoint extends EndpointByMethod[TMethod][TPath], 134 | >( 135 | method: TMethod, 136 | path: TPath, 137 | ...params: MaybeOptionalArg> 138 | ): Promise< 139 | Omit & { 140 | /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/json) */ 141 | json: () => Promise; 142 | } 143 | > { 144 | return this.fetcher(method, this.baseUrl + (path as string), params[0] as EndpointParameters); 145 | } 146 | // 147 | } 148 | 149 | export function createApiClient(fetcher: Fetcher, baseUrl?: string) { 150 | return new ApiClient(fetcher).setBaseUrl(baseUrl ?? ""); 151 | } 152 | 153 | /** 154 | Example usage: 155 | const api = createApiClient((method, url, params) => 156 | fetch(url, { method, body: JSON.stringify(params) }).then((res) => res.json()), 157 | ); 158 | api.get("/users").then((users) => console.log(users)); 159 | api.post("/users", { body: { name: "John" } }).then((user) => console.log(user)); 160 | api.put("/users/:id", { path: { id: 1 }, body: { name: "John" } }).then((user) => console.log(user)); 161 | */ 162 | 163 | // 3 | export type Order = Partial<{ 4 | id: number; 5 | petId: number; 6 | quantity: number; 7 | shipDate: string; 8 | status: "placed" | "approved" | "delivered"; 9 | complete: boolean; 10 | }>; 11 | export type Address = Partial<{ street: string; city: string; state: string; zip: string }>; 12 | export type Customer = Partial<{ id: number; username: string; address: Array
}>; 13 | export type Category = Partial<{ id: number; name: string }>; 14 | export type User = Partial<{ 15 | id: number; 16 | username: string; 17 | firstName: string; 18 | lastName: string; 19 | email: string; 20 | password: string; 21 | phone: string; 22 | userStatus: number; 23 | }>; 24 | export type Tag = Partial<{ id: number; name: string }>; 25 | export type Pet = { 26 | id?: number | undefined; 27 | name: string; 28 | category?: Category | undefined; 29 | photoUrls: Array; 30 | tags?: Array | undefined; 31 | status?: ("available" | "pending" | "sold") | undefined; 32 | }; 33 | export type ApiResponse = Partial<{ code: number; type: string; message: string }>; 34 | 35 | // 36 | } 37 | 38 | export namespace Endpoints { 39 | // 40 | 41 | export type put_UpdatePet = { 42 | method: "PUT"; 43 | path: "/pet"; 44 | requestFormat: "json"; 45 | parameters: { 46 | body: Schemas.Pet; 47 | }; 48 | response: Schemas.Pet; 49 | }; 50 | export type post_AddPet = { 51 | method: "POST"; 52 | path: "/pet"; 53 | requestFormat: "json"; 54 | parameters: { 55 | body: Schemas.Pet; 56 | }; 57 | response: Schemas.Pet; 58 | }; 59 | export type get_FindPetsByStatus = { 60 | method: "GET"; 61 | path: "/pet/findByStatus"; 62 | requestFormat: "json"; 63 | parameters: { 64 | query: Partial<{ status: "available" | "pending" | "sold" }>; 65 | }; 66 | response: Array; 67 | }; 68 | export type get_FindPetsByTags = { 69 | method: "GET"; 70 | path: "/pet/findByTags"; 71 | requestFormat: "json"; 72 | parameters: { 73 | query: Partial<{ tags: Array }>; 74 | }; 75 | response: Array; 76 | }; 77 | export type get_GetPetById = { 78 | method: "GET"; 79 | path: "/pet/{petId}"; 80 | requestFormat: "json"; 81 | parameters: { 82 | path: { petId: number }; 83 | }; 84 | response: Schemas.Pet; 85 | }; 86 | export type post_UpdatePetWithForm = { 87 | method: "POST"; 88 | path: "/pet/{petId}"; 89 | requestFormat: "json"; 90 | parameters: { 91 | query: Partial<{ name: string; status: string }>; 92 | path: { petId: number }; 93 | }; 94 | response: unknown; 95 | }; 96 | export type delete_DeletePet = { 97 | method: "DELETE"; 98 | path: "/pet/{petId}"; 99 | requestFormat: "json"; 100 | parameters: { 101 | path: { petId: number }; 102 | header: Partial<{ api_key: string }>; 103 | }; 104 | response: unknown; 105 | }; 106 | export type post_UploadFile = { 107 | method: "POST"; 108 | path: "/pet/{petId}/uploadImage"; 109 | requestFormat: "binary"; 110 | parameters: { 111 | query: Partial<{ additionalMetadata: string }>; 112 | path: { petId: number }; 113 | 114 | body: string; 115 | }; 116 | response: Schemas.ApiResponse; 117 | }; 118 | export type get_GetInventory = { 119 | method: "GET"; 120 | path: "/store/inventory"; 121 | requestFormat: "json"; 122 | parameters: never; 123 | response: Record; 124 | }; 125 | export type post_PlaceOrder = { 126 | method: "POST"; 127 | path: "/store/order"; 128 | requestFormat: "json"; 129 | parameters: { 130 | body: Schemas.Order; 131 | }; 132 | response: Schemas.Order; 133 | }; 134 | export type get_GetOrderById = { 135 | method: "GET"; 136 | path: "/store/order/{orderId}"; 137 | requestFormat: "json"; 138 | parameters: { 139 | path: { orderId: number }; 140 | }; 141 | response: Schemas.Order; 142 | }; 143 | export type delete_DeleteOrder = { 144 | method: "DELETE"; 145 | path: "/store/order/{orderId}"; 146 | requestFormat: "json"; 147 | parameters: { 148 | path: { orderId: number }; 149 | }; 150 | response: unknown; 151 | }; 152 | export type post_CreateUser = { 153 | method: "POST"; 154 | path: "/user"; 155 | requestFormat: "json"; 156 | parameters: { 157 | body: Schemas.User; 158 | }; 159 | response: Schemas.User; 160 | }; 161 | export type post_CreateUsersWithListInput = { 162 | method: "POST"; 163 | path: "/user/createWithList"; 164 | requestFormat: "json"; 165 | parameters: { 166 | body: Array; 167 | }; 168 | response: Schemas.User; 169 | }; 170 | export type get_LoginUser = { 171 | method: "GET"; 172 | path: "/user/login"; 173 | requestFormat: "json"; 174 | parameters: { 175 | query: Partial<{ username: string; password: string }>; 176 | }; 177 | response: string; 178 | }; 179 | export type get_LogoutUser = { 180 | method: "GET"; 181 | path: "/user/logout"; 182 | requestFormat: "json"; 183 | parameters: never; 184 | response: unknown; 185 | }; 186 | export type get_GetUserByName = { 187 | method: "GET"; 188 | path: "/user/{username}"; 189 | requestFormat: "json"; 190 | parameters: { 191 | path: { username: string }; 192 | }; 193 | response: Schemas.User; 194 | }; 195 | export type put_UpdateUser = { 196 | method: "PUT"; 197 | path: "/user/{username}"; 198 | requestFormat: "json"; 199 | parameters: { 200 | path: { username: string }; 201 | 202 | body: Schemas.User; 203 | }; 204 | response: unknown; 205 | }; 206 | export type delete_DeleteUser = { 207 | method: "DELETE"; 208 | path: "/user/{username}"; 209 | requestFormat: "json"; 210 | parameters: { 211 | path: { username: string }; 212 | }; 213 | response: unknown; 214 | }; 215 | 216 | // 217 | } 218 | 219 | // 220 | export type EndpointByMethod = { 221 | put: { 222 | "/pet": Endpoints.put_UpdatePet; 223 | "/user/{username}": Endpoints.put_UpdateUser; 224 | }; 225 | post: { 226 | "/pet": Endpoints.post_AddPet; 227 | "/pet/{petId}": Endpoints.post_UpdatePetWithForm; 228 | "/pet/{petId}/uploadImage": Endpoints.post_UploadFile; 229 | "/store/order": Endpoints.post_PlaceOrder; 230 | "/user": Endpoints.post_CreateUser; 231 | "/user/createWithList": Endpoints.post_CreateUsersWithListInput; 232 | }; 233 | get: { 234 | "/pet/findByStatus": Endpoints.get_FindPetsByStatus; 235 | "/pet/findByTags": Endpoints.get_FindPetsByTags; 236 | "/pet/{petId}": Endpoints.get_GetPetById; 237 | "/store/inventory": Endpoints.get_GetInventory; 238 | "/store/order/{orderId}": Endpoints.get_GetOrderById; 239 | "/user/login": Endpoints.get_LoginUser; 240 | "/user/logout": Endpoints.get_LogoutUser; 241 | "/user/{username}": Endpoints.get_GetUserByName; 242 | }; 243 | delete: { 244 | "/pet/{petId}": Endpoints.delete_DeletePet; 245 | "/store/order/{orderId}": Endpoints.delete_DeleteOrder; 246 | "/user/{username}": Endpoints.delete_DeleteUser; 247 | }; 248 | }; 249 | 250 | // 251 | 252 | // 253 | export type PutEndpoints = EndpointByMethod["put"]; 254 | export type PostEndpoints = EndpointByMethod["post"]; 255 | export type GetEndpoints = EndpointByMethod["get"]; 256 | export type DeleteEndpoints = EndpointByMethod["delete"]; 257 | // 258 | 259 | // 260 | export type EndpointParameters = { 261 | body?: unknown; 262 | query?: Record; 263 | header?: Record; 264 | path?: Record; 265 | }; 266 | 267 | export type MutationMethod = "post" | "put" | "patch" | "delete"; 268 | export type Method = "get" | "head" | "options" | MutationMethod; 269 | 270 | type RequestFormat = "json" | "form-data" | "form-url" | "binary" | "text"; 271 | 272 | export type DefaultEndpoint = { 273 | parameters?: EndpointParameters | undefined; 274 | response: unknown; 275 | }; 276 | 277 | export type Endpoint = { 278 | operationId: string; 279 | method: Method; 280 | path: string; 281 | requestFormat: RequestFormat; 282 | parameters?: TConfig["parameters"]; 283 | meta: { 284 | alias: string; 285 | hasParameters: boolean; 286 | areParametersRequired: boolean; 287 | }; 288 | response: TConfig["response"]; 289 | }; 290 | 291 | export type Fetcher = (method: Method, url: string, parameters?: EndpointParameters | undefined) => Promise; 292 | 293 | type RequiredKeys = { 294 | [P in keyof T]-?: undefined extends T[P] ? never : P; 295 | }[keyof T]; 296 | 297 | type MaybeOptionalArg = RequiredKeys extends never ? [config?: T] : [config: T]; 298 | 299 | // 300 | 301 | // 302 | export class ApiClient { 303 | baseUrl: string = ""; 304 | 305 | constructor(public fetcher: Fetcher) {} 306 | 307 | setBaseUrl(baseUrl: string) { 308 | this.baseUrl = baseUrl; 309 | return this; 310 | } 311 | 312 | parseResponse = async (response: Response): Promise => { 313 | const contentType = response.headers.get("content-type"); 314 | if (contentType?.includes("application/json")) { 315 | return response.json(); 316 | } 317 | return response.text() as unknown as T; 318 | }; 319 | 320 | // 321 | put( 322 | path: Path, 323 | ...params: MaybeOptionalArg 324 | ): Promise { 325 | return this.fetcher("put", this.baseUrl + path, params[0]).then((response) => 326 | this.parseResponse(response), 327 | ) as Promise; 328 | } 329 | // 330 | 331 | // 332 | post( 333 | path: Path, 334 | ...params: MaybeOptionalArg 335 | ): Promise { 336 | return this.fetcher("post", this.baseUrl + path, params[0]).then((response) => 337 | this.parseResponse(response), 338 | ) as Promise; 339 | } 340 | // 341 | 342 | // 343 | get( 344 | path: Path, 345 | ...params: MaybeOptionalArg 346 | ): Promise { 347 | return this.fetcher("get", this.baseUrl + path, params[0]).then((response) => 348 | this.parseResponse(response), 349 | ) as Promise; 350 | } 351 | // 352 | 353 | // 354 | delete( 355 | path: Path, 356 | ...params: MaybeOptionalArg 357 | ): Promise { 358 | return this.fetcher("delete", this.baseUrl + path, params[0]).then((response) => 359 | this.parseResponse(response), 360 | ) as Promise; 361 | } 362 | // 363 | 364 | // 365 | /** 366 | * Generic request method with full type-safety for any endpoint 367 | */ 368 | request< 369 | TMethod extends keyof EndpointByMethod, 370 | TPath extends keyof EndpointByMethod[TMethod], 371 | TEndpoint extends EndpointByMethod[TMethod][TPath], 372 | >( 373 | method: TMethod, 374 | path: TPath, 375 | ...params: MaybeOptionalArg 376 | ): Promise< 377 | Omit & { 378 | /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/json) */ 379 | json: () => Promise; 380 | } 381 | > { 382 | return this.fetcher(method, this.baseUrl + (path as string), params[0] as EndpointParameters); 383 | } 384 | // 385 | } 386 | 387 | export function createApiClient(fetcher: Fetcher, baseUrl?: string) { 388 | return new ApiClient(fetcher).setBaseUrl(baseUrl ?? ""); 389 | } 390 | 391 | /** 392 | Example usage: 393 | const api = createApiClient((method, url, params) => 394 | fetch(url, { method, body: JSON.stringify(params) }).then((res) => res.json()), 395 | ); 396 | api.get("/users").then((users) => console.log(users)); 397 | api.post("/users", { body: { name: "John" } }).then((user) => console.log(user)); 398 | api.put("/users/:id", { path: { id: 1 }, body: { name: "John" } }).then((user) => console.log(user)); 399 | */ 400 | 401 | // =12.20'} 65 | dev: false 66 | 67 | /valibot@0.8.0: 68 | resolution: {integrity: sha512-wQHXVkVFj6Z0R3297icGCp3UX8S66onuIg03ihJ8aVgMn8pMJvYSfmrKb+aIg7w/Aw08togrklaMSqDP2vKtIA==} 69 | dev: false 70 | 71 | /yup@1.2.0: 72 | resolution: {integrity: sha512-PPqYKSAXjpRCgLgLKVGPA33v5c/WgEx3wi6NFjIiegz90zSwyMpvTFp/uGcVnnbx6to28pgnzp/q8ih3QRjLMQ==} 73 | dependencies: 74 | property-expr: 2.0.5 75 | tiny-case: 1.0.3 76 | toposort: 2.0.2 77 | type-fest: 2.19.0 78 | dev: false 79 | 80 | /zod@3.21.4: 81 | resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==} 82 | dev: false 83 | -------------------------------------------------------------------------------- /packages/typed-openapi/tests/tanstack-query.generator.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test } from "vitest"; 2 | import SwaggerParser from "@apidevtools/swagger-parser"; 3 | import type { OpenAPIObject } from "openapi3-ts/oas31"; 4 | import { mapOpenApiEndpoints } from "../src/map-openapi-endpoints.ts"; 5 | import { generateTanstackQueryFile } from "../src/tanstack-query.generator.ts"; 6 | 7 | describe("generator", () => { 8 | test("petstore", async ({ expect }) => { 9 | const openApiDoc = (await SwaggerParser.parse("./tests/samples/petstore.yaml")) as OpenAPIObject; 10 | expect(await generateTanstackQueryFile({ 11 | ...mapOpenApiEndpoints(openApiDoc), 12 | relativeApiClientPath: "./api.client.ts" 13 | })).toMatchInlineSnapshot(` 14 | "import { queryOptions } from "@tanstack/react-query"; 15 | import type { EndpointByMethod, ApiClient } from "./api.client.ts"; 16 | 17 | type EndpointQueryKey = [ 18 | TOptions & { 19 | _id: string; 20 | _infinite?: boolean; 21 | }, 22 | ]; 23 | 24 | const createQueryKey = ( 25 | id: string, 26 | options?: TOptions, 27 | infinite?: boolean, 28 | ): [EndpointQueryKey[0]] => { 29 | const params: EndpointQueryKey[0] = { _id: id } as EndpointQueryKey[0]; 30 | if (infinite) { 31 | params._infinite = infinite; 32 | } 33 | if (options?.body) { 34 | params.body = options.body; 35 | } 36 | if (options?.header) { 37 | params.header = options.header; 38 | } 39 | if (options?.path) { 40 | params.path = options.path; 41 | } 42 | if (options?.query) { 43 | params.query = options.query; 44 | } 45 | return [params]; 46 | }; 47 | 48 | // 49 | export type PutEndpoints = EndpointByMethod["put"]; 50 | export type PostEndpoints = EndpointByMethod["post"]; 51 | export type GetEndpoints = EndpointByMethod["get"]; 52 | export type DeleteEndpoints = EndpointByMethod["delete"]; 53 | // 54 | 55 | // 56 | export type EndpointParameters = { 57 | body?: unknown; 58 | query?: Record; 59 | header?: Record; 60 | path?: Record; 61 | }; 62 | 63 | type RequiredKeys = { 64 | [P in keyof T]-?: undefined extends T[P] ? never : P; 65 | }[keyof T]; 66 | 67 | type MaybeOptionalArg = RequiredKeys extends never ? [config?: T] : [config: T]; 68 | 69 | // 70 | 71 | // 72 | export class TanstackQueryApiClient { 73 | constructor(public client: ApiClient) {} 74 | 75 | // 76 | put( 77 | path: Path, 78 | ...params: MaybeOptionalArg 79 | ) { 80 | const queryKey = createQueryKey(path, params[0]); 81 | const query = { 82 | /** type-only property if you need easy access to the endpoint params */ 83 | "~endpoint": {} as TEndpoint, 84 | queryKey, 85 | queryOptions: queryOptions({ 86 | queryFn: async ({ queryKey, signal }) => { 87 | const res = await this.client.put(path, { 88 | ...params, 89 | ...queryKey[0], 90 | signal, 91 | }); 92 | return res as TEndpoint["response"]; 93 | }, 94 | queryKey: queryKey, 95 | }), 96 | mutationOptions: { 97 | mutationKey: queryKey, 98 | mutationFn: async (localOptions: TEndpoint extends { parameters: infer Parameters } ? Parameters : never) => { 99 | const res = await this.client.put(path, { 100 | ...params, 101 | ...queryKey[0], 102 | ...localOptions, 103 | }); 104 | return res as TEndpoint["response"]; 105 | }, 106 | }, 107 | }; 108 | 109 | return query; 110 | } 111 | // 112 | 113 | // 114 | post( 115 | path: Path, 116 | ...params: MaybeOptionalArg 117 | ) { 118 | const queryKey = createQueryKey(path, params[0]); 119 | const query = { 120 | /** type-only property if you need easy access to the endpoint params */ 121 | "~endpoint": {} as TEndpoint, 122 | queryKey, 123 | queryOptions: queryOptions({ 124 | queryFn: async ({ queryKey, signal }) => { 125 | const res = await this.client.post(path, { 126 | ...params, 127 | ...queryKey[0], 128 | signal, 129 | }); 130 | return res as TEndpoint["response"]; 131 | }, 132 | queryKey: queryKey, 133 | }), 134 | mutationOptions: { 135 | mutationKey: queryKey, 136 | mutationFn: async (localOptions: TEndpoint extends { parameters: infer Parameters } ? Parameters : never) => { 137 | const res = await this.client.post(path, { 138 | ...params, 139 | ...queryKey[0], 140 | ...localOptions, 141 | }); 142 | return res as TEndpoint["response"]; 143 | }, 144 | }, 145 | }; 146 | 147 | return query; 148 | } 149 | // 150 | 151 | // 152 | get( 153 | path: Path, 154 | ...params: MaybeOptionalArg 155 | ) { 156 | const queryKey = createQueryKey(path, params[0]); 157 | const query = { 158 | /** type-only property if you need easy access to the endpoint params */ 159 | "~endpoint": {} as TEndpoint, 160 | queryKey, 161 | queryOptions: queryOptions({ 162 | queryFn: async ({ queryKey, signal }) => { 163 | const res = await this.client.get(path, { 164 | ...params, 165 | ...queryKey[0], 166 | signal, 167 | }); 168 | return res as TEndpoint["response"]; 169 | }, 170 | queryKey: queryKey, 171 | }), 172 | mutationOptions: { 173 | mutationKey: queryKey, 174 | mutationFn: async (localOptions: TEndpoint extends { parameters: infer Parameters } ? Parameters : never) => { 175 | const res = await this.client.get(path, { 176 | ...params, 177 | ...queryKey[0], 178 | ...localOptions, 179 | }); 180 | return res as TEndpoint["response"]; 181 | }, 182 | }, 183 | }; 184 | 185 | return query; 186 | } 187 | // 188 | 189 | // 190 | delete( 191 | path: Path, 192 | ...params: MaybeOptionalArg 193 | ) { 194 | const queryKey = createQueryKey(path, params[0]); 195 | const query = { 196 | /** type-only property if you need easy access to the endpoint params */ 197 | "~endpoint": {} as TEndpoint, 198 | queryKey, 199 | queryOptions: queryOptions({ 200 | queryFn: async ({ queryKey, signal }) => { 201 | const res = await this.client.delete(path, { 202 | ...params, 203 | ...queryKey[0], 204 | signal, 205 | }); 206 | return res as TEndpoint["response"]; 207 | }, 208 | queryKey: queryKey, 209 | }), 210 | mutationOptions: { 211 | mutationKey: queryKey, 212 | mutationFn: async (localOptions: TEndpoint extends { parameters: infer Parameters } ? Parameters : never) => { 213 | const res = await this.client.delete(path, { 214 | ...params, 215 | ...queryKey[0], 216 | ...localOptions, 217 | }); 218 | return res as TEndpoint["response"]; 219 | }, 220 | }, 221 | }; 222 | 223 | return query; 224 | } 225 | // 226 | 227 | // 228 | /** 229 | * Generic mutation method with full type-safety for any endpoint that doesnt require parameters to be passed initially 230 | */ 231 | mutation< 232 | TMethod extends keyof EndpointByMethod, 233 | TPath extends keyof EndpointByMethod[TMethod], 234 | TEndpoint extends EndpointByMethod[TMethod][TPath], 235 | TSelection, 236 | >( 237 | method: TMethod, 238 | path: TPath, 239 | selectFn?: ( 240 | res: Omit & { 241 | /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/json) */ 242 | json: () => Promise; 243 | }, 244 | ) => TSelection, 245 | ) { 246 | const mutationKey = [{ method, path }] as const; 247 | return { 248 | /** type-only property if you need easy access to the endpoint params */ 249 | "~endpoint": {} as TEndpoint, 250 | mutationKey: mutationKey, 251 | mutationOptions: { 252 | mutationKey: mutationKey, 253 | mutationFn: async (params: TEndpoint extends { parameters: infer Parameters } ? Parameters : never) => { 254 | const response = await this.client.request(method, path, params); 255 | const res = selectFn ? selectFn(response) : response; 256 | return res as unknown extends TSelection ? typeof response : Awaited; 257 | }, 258 | }, 259 | }; 260 | } 261 | // 262 | } 263 | " 264 | `); 265 | }); 266 | }); 267 | -------------------------------------------------------------------------------- /packages/typed-openapi/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": true 5 | }, 6 | "include": [ 7 | "src" 8 | ], 9 | "exclude": [ 10 | "node_modules", 11 | "samples" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /packages/typed-openapi/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ESNext", 5 | "module": "NodeNext", 6 | "moduleResolution": "nodenext", 7 | "lib": [ 8 | "DOM", 9 | "ES2021" 10 | ], 11 | "resolveJsonModule": true, 12 | "allowImportingTsExtensions": true 13 | }, 14 | "include": [ 15 | "src", 16 | "tests" 17 | ], 18 | "exclude": [ 19 | "node_modules", 20 | "samples" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /packages/typed-openapi/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | clean: true, 5 | entryPoints: ["src/cli.ts", "src/index.ts", "src/node.export.ts"], 6 | outDir: "dist", 7 | dts: true, 8 | format: ["esm"], 9 | }); 10 | -------------------------------------------------------------------------------- /packages/typed-openapi/vitest.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { defineConfig } from "vitest/config"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | test: { 8 | hideSkippedTests: true, 9 | snapshotFormat: { 10 | escapeString: false, 11 | }, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /packages/web/fs.shim.ts: -------------------------------------------------------------------------------- 1 | export const statSync = (path: string) => { 2 | return { 3 | isFile: () => true, 4 | }; 5 | }; 6 | 7 | export const readdirSync = (path: string) => { 8 | return []; 9 | }; 10 | 11 | export const fs = { 12 | readFileSync: (path: string) => { 13 | return ""; 14 | }, 15 | }; 16 | 17 | export default fs; 18 | -------------------------------------------------------------------------------- /packages/web/get-ts-declarations.ts: -------------------------------------------------------------------------------- 1 | import { readFile, writeFile } from "node:fs/promises"; 2 | import path from "node:path"; 3 | 4 | import { safeJSONParse } from "pastable"; 5 | import { rollup } from "rollup"; 6 | import dts from "rollup-plugin-dts"; 7 | import type { PackageJson } from "type-fest"; 8 | 9 | import { OutputRuntime } from "typed-openapi"; 10 | 11 | const getDeps = (pkg: PackageJson) => 12 | Object.keys(pkg.dependencies ?? {}).concat( 13 | // Object.keys(pkg.devDependencies ?? {}), 14 | Object.keys(pkg.peerDependencies ?? {}), 15 | ); 16 | 17 | const getPkg = async (name: string) => 18 | safeJSONParse(await readFile(`./node_modules/${name}/package.json`, "utf8")); 19 | 20 | const getTypesDeclaration = async (name: string) => { 21 | // console.log(`Parsing "${name}" package.json...`); 22 | const pkg = await getPkg(name); 23 | const types = pkg.types ?? pkg.typings ?? "index.d.ts"; 24 | const input = path.resolve("./node_modules/", name, types); 25 | if (!input) return; 26 | 27 | console.log(`Bundling "${name}"...`); 28 | const bundle = await rollup({ 29 | input, 30 | plugins: [dts({ respectExternal: true })], 31 | external: (id) => getDeps(pkg).includes(id), 32 | }); 33 | const result = await bundle.generate({}); 34 | 35 | return result.output[0].code; 36 | }; 37 | 38 | // const runtimes: OutputRuntime[] = ["zod", "arktype", "typebox", "valibot", "yup", "io-ts"]; 39 | const runtimes: OutputRuntime[] = ["yup"]; 40 | 41 | const getDeclarationFiles = async () => { 42 | const declarations = await Promise.all( 43 | runtimes.map(async (runtime) => ({ 44 | name: runtime, 45 | code: await getTypesDeclaration(runtime === "typebox" ? "@sinclair/typebox" : runtime), 46 | })), 47 | ); 48 | 49 | return declarations.filter((declaration) => Boolean(declaration.code)); 50 | }; 51 | 52 | console.log("Starting to bundle runtimes..."); 53 | const declarations = await getDeclarationFiles(); 54 | await Promise.all( 55 | declarations.map((result) => { 56 | console.log("Writing declaration file for", result.name); 57 | return writeFile(`./declarations/${result.name}.d.ts`, result.code!, "utf8"); 58 | }), 59 | ); 60 | 61 | console.log("Done!"); 62 | -------------------------------------------------------------------------------- /packages/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | typed-openapi 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /packages/web/module.shim.ts: -------------------------------------------------------------------------------- 1 | // Empty implementation for Rollup alias 2 | // eslint-disable-next-line @typescript-eslint/no-empty-function 3 | export const createRequire = () => {}; 4 | -------------------------------------------------------------------------------- /packages/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typed-openapi-web", 3 | "private": true, 4 | "type": "module", 5 | "version": "0.0.1", 6 | "scripts": { 7 | "prebuild": "panda && panda codegen && mkdir -p declarations && pnpm gen:ts-declarations", 8 | "gen:ts-declarations": "tsx ./get-ts-declarations.ts", 9 | "dev": "vite", 10 | "build": "pnpm prebuild && vite build", 11 | "preview": "vite preview", 12 | "test": "vitest" 13 | }, 14 | "dependencies": { 15 | "@ark-ui/react": "0.10.0", 16 | "@fontsource/inter": "5.0.5", 17 | "@monaco-editor/react": "4.5.1", 18 | "@pandacss/dev": "0.9.0", 19 | "@xstate/react": "3.2.2", 20 | "lz-string": "1.5.0", 21 | "monaco-editor": "0.40.0", 22 | "pastable": "2.2.0", 23 | "prettier": "2.8.4", 24 | "react": "18.2.0", 25 | "react-dom": "18.2.0", 26 | "react-icons": "4.10.1", 27 | "react-resizable-panels": "0.0.53", 28 | "ts-patch": "3.0.2", 29 | "typed-openapi": "workspace:*", 30 | "xstate": "4.38.1", 31 | "yaml": "2.3.1" 32 | }, 33 | "devDependencies": { 34 | "@esbuild-plugins/node-globals-polyfill": "0.2.3", 35 | "@pandacss/preset-base": "0.7.0", 36 | "@pandacss/preset-panda": "0.7.0", 37 | "@park-ui/presets": "0.2.0", 38 | "@sinclair/typebox": "0.30.4", 39 | "@types/node": "20.4.5", 40 | "@types/prettier": "2.7.3", 41 | "@types/react": "18.2.15", 42 | "@types/react-dom": "18.2.7", 43 | "@vitejs/plugin-react-swc": "3.3.2", 44 | "arktype": "1.0.18-alpha", 45 | "io-ts": "2.2.20", 46 | "os-browserify": "0.3.0", 47 | "path-browserify": "1.0.1", 48 | "rollup": "3.26.3", 49 | "rollup-plugin-dts": "5.3.0", 50 | "tsup": "7.1.0", 51 | "tsx": "4.19.4", 52 | "type-fest": "3.13.1", 53 | "typescript": "5.1.6", 54 | "util": "0.12.5", 55 | "valibot": "0.37.0", 56 | "vite": "4.4.4", 57 | "vite-plugin-react-click-to-component": "2.0.0", 58 | "vite-tsconfig-paths": "4.2.0", 59 | "vitest": "0.33.0", 60 | "yup": "1.2.0", 61 | "zod": "3.21.4" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /packages/web/panda.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "@pandacss/dev"; 2 | import { preset as basePreset } from "@pandacss/preset-base"; 3 | import { preset as pandaPreset } from "@pandacss/preset-panda"; 4 | import { themePreset } from "./theme/preset"; 5 | 6 | export default defineConfig({ 7 | preflight: true, 8 | include: ["./src/**/*.{tsx,jsx}", "./pages/**/*.{jsx,tsx}"], 9 | exclude: [], 10 | jsxFramework: "react", 11 | presets: [pandaPreset as any, themePreset, "@park-ui/presets"], 12 | conditions: { 13 | // next-themes 14 | dark: '.dark &, [data-theme="dark"] &', 15 | light: ".light &", 16 | // react-resizable-panels 17 | resizeHandleActive: "[data-resize-handle-active] &", 18 | panelHorizontalActive: '[data-panel-group-direction="horizontal"] &', 19 | panelVerticalActive: '[data-panel-group-direction="vertical"] &', 20 | }, 21 | utilities: { 22 | boxSize: { 23 | values: basePreset.utilities?.width?.values, 24 | transform: (value) => { 25 | return { 26 | width: value, 27 | height: value, 28 | }; 29 | }, 30 | }, 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /packages/web/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { "@pandacss/dev/postcss": {} }, 3 | }; 4 | -------------------------------------------------------------------------------- /packages/web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astahmer/typed-openapi/851ae9d0d021963d06c2a10f0e546c73194fc1e0/packages/web/public/favicon.ico -------------------------------------------------------------------------------- /packages/web/public/github-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /packages/web/src/Playground/Playground.machine.ts: -------------------------------------------------------------------------------- 1 | import type { Monaco } from "@monaco-editor/react"; 2 | import type { editor } from "monaco-editor"; 3 | import { assign, createMachine } from "xstate"; 4 | import { choose } from "xstate/lib/actions"; 5 | import { safeJSONParse } from "pastable/utils"; 6 | import { generateFile, mapOpenApiEndpoints, type OutputRuntime } from "typed-openapi"; 7 | import { parse } from "yaml"; 8 | 9 | // @ts-expect-error 10 | import { default as petstoreYaml } from "./petstore.yaml?raw"; 11 | import { UrlSaver } from "./url-saver"; 12 | import { fromPromise } from "xstate/lib/behaviors"; 13 | import { prettify } from "./format"; 14 | 15 | const urlSaver = new UrlSaver(); 16 | const initialInputList = { "petstore.yaml": urlSaver.getValue("input") || petstoreYaml }; 17 | const initialOutputList = { "petstore.client.ts": "" }; 18 | 19 | type PlaygroundContext = { 20 | monaco: Monaco | null; 21 | inputEditor: editor.IStandaloneCodeEditor | null; 22 | outputEditor: editor.IStandaloneCodeEditor | null; 23 | inputList: Record; 24 | selectedInput: string; 25 | outputList: Record; 26 | selectedOutput: string; 27 | decorations: string[]; 28 | // 29 | runtime: OutputRuntime; 30 | }; 31 | 32 | type PlaygroundEvent = 33 | | { 34 | type: "Editor Loaded"; 35 | editor: editor.IStandaloneCodeEditor; 36 | monaco: Monaco; 37 | kind: "input" | "output"; 38 | } 39 | | { type: "Select input tab"; name: string } 40 | | { type: "Select output tab"; name: string } 41 | | { type: "Update runtime"; runtime: OutputRuntime } 42 | | { type: "Update input"; value: string }; 43 | 44 | const initialContext: PlaygroundContext = { 45 | monaco: null, 46 | inputEditor: null, 47 | outputEditor: null, 48 | inputList: initialInputList, 49 | selectedInput: Object.keys(initialInputList)[0], 50 | outputList: initialOutputList, 51 | selectedOutput: Object.keys(initialOutputList)[0], 52 | decorations: [], 53 | // 54 | runtime: "none", 55 | }; 56 | 57 | // @ts-ignore 58 | globalThis.__dirname = "/"; 59 | 60 | export const playgroundMachine = createMachine( 61 | { 62 | predictableActionArguments: true, 63 | preserveActionOrder: true, 64 | id: "playground", 65 | tsTypes: {} as import("./Playground.machine.typegen").Typegen0, 66 | schema: { 67 | context: {} as PlaygroundContext, 68 | events: {} as PlaygroundEvent, 69 | }, 70 | context: initialContext, 71 | initial: "loading", 72 | states: { 73 | loading: { 74 | on: { 75 | "Editor Loaded": [ 76 | { 77 | cond: "willBeReady", 78 | target: "ready", 79 | actions: ["assignEditorRef", "updateOutput"], 80 | }, 81 | { actions: "assignEditorRef" }, 82 | ], 83 | }, 84 | }, 85 | ready: { 86 | initial: "Playing", 87 | entry: ["updateInput"], 88 | states: { 89 | Playing: { 90 | on: { 91 | "Select input tab": { 92 | actions: ["selectInputTab", "updateInput"], 93 | }, 94 | "Select output tab": { actions: ["selectOutputTab"] }, 95 | "Update input": { actions: ["updateInput"] }, 96 | }, 97 | }, 98 | }, 99 | }, 100 | }, 101 | on: { 102 | "Update runtime": { actions: ["updateRuntime", "updateOutput"] }, 103 | }, 104 | }, 105 | { 106 | actions: { 107 | assignEditorRef: assign((ctx, event) => { 108 | if (event.kind === "input") { 109 | return { ...ctx, inputEditor: event.editor, monaco: event.monaco }; 110 | } 111 | 112 | return { ...ctx, outputEditor: event.editor, monaco: event.monaco }; 113 | }), 114 | selectInputTab: assign((ctx, event) => { 115 | return { ...ctx, selectedInput: event.name }; 116 | }), 117 | selectOutputTab: assign((ctx, event) => { 118 | return { ...ctx, selectedOutput: event.name }; 119 | }), 120 | updateSelectedInput: assign((ctx, event) => { 121 | if (event.type !== "Update input") return ctx; 122 | 123 | const { inputList, selectedInput } = ctx; 124 | if (inputList[selectedInput]) { 125 | inputList[selectedInput] = event.value; 126 | } 127 | return { ...ctx, inputList }; 128 | }), 129 | updateUrl(context, event, meta) { 130 | urlSaver.setValue("input", context.inputList[context.selectedInput]); 131 | }, 132 | updateInput: choose([{ actions: ["updateSelectedInput", "updateUrl", "updateOutput"] }]), 133 | updateRuntime: assign({ runtime: (_, event) => event.runtime }), 134 | updateOutput: assign((ctx, event) => { 135 | const now = new Date(); 136 | console.log("Generating..."); 137 | 138 | const input = (event.type === "Update input" ? event.value : ctx.inputList[ctx.selectedInput]) ?? ""; 139 | const openApiDoc = input.startsWith("{") ? safeJSONParse(input) : safeYAMLParse(input); 140 | console.log({ input, openApiDoc }); 141 | if (!openApiDoc) { 142 | // toasts.error("Error while parsing OpenAPI document"); 143 | return ctx; 144 | } 145 | 146 | const context = mapOpenApiEndpoints(openApiDoc); 147 | console.log(`Found ${context.endpointList.length} endpoints`); 148 | 149 | const content = prettify(generateFile({ ...context, runtime: ctx.runtime })); 150 | const outputList = { 151 | ["petstore.client.ts"]: content, 152 | }; 153 | 154 | console.log(`Done in ${new Date().getTime() - now.getTime()}ms !`); 155 | 156 | return { 157 | ...ctx, 158 | outputList, 159 | }; 160 | }), 161 | }, 162 | guards: { 163 | willBeReady: (ctx) => { 164 | return Boolean(ctx.inputEditor || ctx.outputEditor); 165 | }, 166 | }, 167 | }, 168 | ); 169 | 170 | const safeYAMLParse = (value: string): string | null => { 171 | try { 172 | return parse(value); 173 | } catch { 174 | return null; 175 | } 176 | }; 177 | -------------------------------------------------------------------------------- /packages/web/src/Playground/Playground.machine.typegen.ts: -------------------------------------------------------------------------------- 1 | // This file was automatically generated. Edits will be overwritten 2 | 3 | export interface Typegen0 { 4 | "@@xstate/typegen": true; 5 | internalEvents: { 6 | "xstate.init": { type: "xstate.init" }; 7 | }; 8 | invokeSrcNameMap: {}; 9 | missingImplementations: { 10 | actions: never; 11 | delays: never; 12 | guards: never; 13 | services: never; 14 | }; 15 | eventsCausingActions: { 16 | assignEditorRef: "Editor Loaded"; 17 | selectInputTab: "Select input tab"; 18 | selectOutputTab: "Select output tab"; 19 | updateInput: "Editor Loaded" | "Select input tab" | "Update input"; 20 | updateOutput: "Editor Loaded" | "Select input tab" | "Update input" | "Update runtime"; 21 | updateRuntime: "Update runtime"; 22 | updateSelectedInput: "Editor Loaded" | "Select input tab" | "Update input"; 23 | updateUrl: "Editor Loaded" | "Select input tab" | "Update input"; 24 | }; 25 | eventsCausingDelays: {}; 26 | eventsCausingGuards: { 27 | willBeReady: "Editor Loaded"; 28 | }; 29 | eventsCausingServices: {}; 30 | matchesStates: "loading" | "ready" | "ready.Playing" | { ready?: "Playing" }; 31 | tags: never; 32 | } 33 | -------------------------------------------------------------------------------- /packages/web/src/Playground/Playground.tsx: -------------------------------------------------------------------------------- 1 | import Editor from "@monaco-editor/react"; 2 | import { useActor } from "@xstate/react"; 3 | import { Panel, PanelGroup } from "react-resizable-panels"; 4 | import { css } from "panda/css"; 5 | import { Flex, styled } from "panda/jsx"; 6 | import { usePlaygroundContext } from "./PlaygroundMachineProvider"; 7 | import { ResizeHandle } from "./ResizeHandle"; 8 | import { useTheme } from "../vite-themes/provider"; 9 | 10 | // @ts-ignore 11 | import ZodDeclaration from "../../declarations/zod.d.ts?raw"; 12 | // @ts-ignore 13 | import ArktypeDeclaration from "../../declarations/arktype.d.ts?raw"; 14 | // @ts-ignore 15 | import ValibotDeclaration from "../../declarations/valibot.d.ts?raw"; 16 | // @ts-ignore 17 | import TypeboxDeclaration from "../../declarations/typebox.d.ts?raw"; 18 | // @ts-ignore 19 | import IoTsDeclaration from "../../declarations/io-ts.d.ts?raw"; 20 | // @ts-ignore 21 | import YupDeclaration from "../../declarations/yup.d.ts?raw"; 22 | 23 | export const Playground = () => { 24 | const service = usePlaygroundContext(); 25 | const [state, send] = useActor(service); 26 | console.log(state.value, state.context); 27 | 28 | const theme = useTheme(); 29 | const colorMode = theme.resolvedTheme; 30 | 31 | return ( 32 | 33 | 34 | 35 | 36 | {Object.entries(state.context.inputList).map(([fileName]) => ( 37 | send({ type: "Select input tab", name: fileName })} 41 | fontSize="sm" 42 | fontWeight="medium" 43 | borderRadius="0" 44 | p="2" 45 | color="cyan.500" 46 | opacity={0.8} 47 | transition="color opacity 150ms ease" 48 | bg="none" 49 | cursor="pointer" 50 | borderBottom="solid 1px transparent" 51 | data-active={state.context.selectedInput === fileName ? "" : undefined} 52 | _active={{ 53 | color: "cyan.600", 54 | opacity: 1, 55 | borderBottom: "solid 1px token(colors.cyan.600, red)", 56 | }} 57 | _hover={{ color: "cyan.600" }} 58 | > 59 | {fileName} 60 | 61 | ))} 62 | 63 | 71 | { 77 | monaco.languages.typescript.typescriptDefaults.setCompilerOptions({ 78 | target: monaco.languages.typescript.ScriptTarget.Latest, 79 | allowNonTsExtensions: true, 80 | moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs, 81 | module: monaco.languages.typescript.ModuleKind.CommonJS, 82 | noEmit: true, 83 | esModuleInterop: true, 84 | jsx: monaco.languages.typescript.JsxEmit.Preserve, 85 | // reactNamespace: "React", 86 | allowJs: true, 87 | // typeRoots: ["node_modules/@types"], 88 | }); 89 | 90 | const getDtsPath = (name: string) => `file:///node_modules/${name}/index.d.ts`; 91 | 92 | monaco.languages.typescript.typescriptDefaults.addExtraLib(ZodDeclaration, getDtsPath("zod")); 93 | monaco.languages.typescript.typescriptDefaults.addExtraLib(ArktypeDeclaration, getDtsPath("arktype")); 94 | monaco.languages.typescript.typescriptDefaults.addExtraLib(ValibotDeclaration, getDtsPath("valibot")); 95 | monaco.languages.typescript.typescriptDefaults.addExtraLib( 96 | TypeboxDeclaration, 97 | getDtsPath("@sinclair/typebox"), 98 | ); 99 | monaco.languages.typescript.typescriptDefaults.addExtraLib(IoTsDeclaration, getDtsPath("io-ts")); 100 | 101 | monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({ 102 | noSemanticValidation: true, 103 | }); 104 | }} 105 | onMount={(editor, monaco) => { 106 | console.log("editor mounted", editor, monaco); 107 | send({ type: "Editor Loaded", editor, monaco, kind: "input" }); 108 | // editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { 109 | // send({ type: "Save" }); 110 | // }); 111 | }} 112 | onChange={(content) => send({ type: "Update input", value: content ?? "" })} 113 | /> 114 | 115 | 116 | 117 | 118 | 119 | {Object.entries(state.context.outputList).map(([fileName]) => ( 120 | send({ type: "Select output tab", name: fileName })} 124 | fontSize="sm" 125 | fontWeight="medium" 126 | borderRadius="0" 127 | p="2" 128 | color="cyan.500" 129 | opacity={0.8} 130 | transition="color opacity 150ms ease" 131 | bg="none" 132 | cursor="pointer" 133 | borderBottom="solid 1px transparent" 134 | data-active={state.context.selectedOutput === fileName ? "" : undefined} 135 | _active={{ 136 | color: "cyan.600", 137 | opacity: 1, 138 | borderBottom: "solid 1px token(colors.cyan.600, red)", 139 | }} 140 | _hover={{ color: "cyan.600" }} 141 | > 142 | {fileName} 143 | 144 | ))} 145 | 146 | 154 | { 161 | monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({ 162 | noSemanticValidation: true, 163 | }); 164 | }} 165 | onMount={(editor, monaco) => { 166 | send({ type: "Editor Loaded", editor, monaco, kind: "output" }); 167 | }} 168 | /> 169 | 170 | 171 | 172 | 173 | ); 174 | }; 175 | -------------------------------------------------------------------------------- /packages/web/src/Playground/PlaygroundMachineProvider.ts: -------------------------------------------------------------------------------- 1 | import { createContextWithHook } from "pastable/react"; 2 | import { InterpreterFrom } from "xstate"; 3 | import { playgroundMachine } from "./Playground.machine"; 4 | 5 | export const [PlaygroundMachineProvider, usePlaygroundContext] = 6 | createContextWithHook>("PlaygroundMachineContext"); 7 | -------------------------------------------------------------------------------- /packages/web/src/Playground/PlaygroundWithMachine.tsx: -------------------------------------------------------------------------------- 1 | import { useInterpret } from "@xstate/react"; 2 | import { ReactNode } from "react"; 3 | import { InterpreterFrom } from "xstate"; 4 | import { runIfFn } from "../run-if-fn"; 5 | import { Playground } from "./Playground"; 6 | import { playgroundMachine } from "./Playground.machine"; 7 | import { PlaygroundMachineProvider } from "./PlaygroundMachineProvider"; 8 | 9 | export const PlaygroundWithMachine = ({ 10 | children, 11 | }: { 12 | children?: ReactNode | ((service: InterpreterFrom) => ReactNode); 13 | }) => { 14 | const service = useInterpret(playgroundMachine); 15 | 16 | return ( 17 | 18 | {runIfFn(children, service)} 19 | 20 | 21 | ); 22 | }; 23 | 24 | export default PlaygroundWithMachine; 25 | -------------------------------------------------------------------------------- /packages/web/src/Playground/ResizeHandle.tsx: -------------------------------------------------------------------------------- 1 | import { PanelResizeHandle } from "react-resizable-panels"; 2 | import { css } from "panda/css"; 3 | import { styled } from "panda/jsx"; 4 | 5 | // adapted from https://github.com/bvaughn/react-resizable-panels/blob/820f48f263407b6b78feecf975a6914c417107e6/packages/react-resizable-panels-website/src/components/ResizeHandle.tsx 6 | export function ResizeHandle({ className = "", id }: { className?: string; id?: string }) { 7 | return ( 8 | 22 | 30 | 38 | 46 | 47 | 48 | ); 49 | } 50 | 51 | export type IconType = 52 | | "chevron-down" 53 | | "close" 54 | | "css" 55 | | "files" 56 | | "horizontal-collapse" 57 | | "horizontal-expand" 58 | | "html" 59 | | "markdown" 60 | | "resize-horizontal" 61 | | "resize-vertical" 62 | | "search" 63 | | "typescript"; 64 | 65 | function Icon({ className = "", type }: { className?: string; type: IconType }) { 66 | let path = ""; 67 | switch (type) { 68 | case "chevron-down": { 69 | path = "M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z"; 70 | break; 71 | } 72 | 73 | case "close": { 74 | path = 75 | "M20 6.91L17.09 4L12 9.09L6.91 4L4 6.91L9.09 12L4 17.09L6.91 20L12 14.91L17.09 20L20 17.09L14.91 12L20 6.91Z"; 76 | break; 77 | } 78 | 79 | case "css": { 80 | path = 81 | "M5,3L4.35,6.34H17.94L17.5,8.5H3.92L3.26,11.83H16.85L16.09,15.64L10.61,17.45L5.86,15.64L6.19,14H2.85L2.06,18L9.91,21L18.96,18L20.16,11.97L20.4,10.76L21.94,3H5Z"; 82 | break; 83 | } 84 | 85 | case "files": { 86 | path = 87 | "M15,7H20.5L15,1.5V7M8,0H16L22,6V18A2,2 0 0,1 20,20H8C6.89,20 6,19.1 6,18V2A2,2 0 0,1 8,0M4,4V22H20V24H4A2,2 0 0,1 2,22V4H4Z"; 88 | break; 89 | } 90 | 91 | case "horizontal-collapse": { 92 | path = "M13,20V4H15.03V20H13M10,20V4H12.03V20H10M5,8L9.03,12L5,16V13H2V11H5V8M20,16L16,12L20,8V11H23V13H20V16Z"; 93 | break; 94 | } 95 | 96 | case "horizontal-expand": { 97 | path = "M9,11H15V8L19,12L15,16V13H9V16L5,12L9,8V11M2,20V4H4V20H2M20,20V4H22V20H20Z"; 98 | break; 99 | } 100 | 101 | case "html": { 102 | path = 103 | "M12,17.56L16.07,16.43L16.62,10.33H9.38L9.2,8.3H16.8L17,6.31H7L7.56,12.32H14.45L14.22,14.9L12,15.5L9.78,14.9L9.64,13.24H7.64L7.93,16.43L12,17.56M4.07,3H19.93L18.5,19.2L12,21L5.5,19.2L4.07,3Z"; 104 | break; 105 | } 106 | 107 | case "markdown": { 108 | path = 109 | "M20.56 18H3.44C2.65 18 2 17.37 2 16.59V7.41C2 6.63 2.65 6 3.44 6H20.56C21.35 6 22 6.63 22 7.41V16.59C22 17.37 21.35 18 20.56 18M6.81 15.19V11.53L8.73 13.88L10.65 11.53V15.19H12.58V8.81H10.65L8.73 11.16L6.81 8.81H4.89V15.19H6.81M19.69 12H17.77V8.81H15.85V12H13.92L16.81 15.28L19.69 12Z"; 110 | break; 111 | } 112 | 113 | case "resize-horizontal": { 114 | path = "M18,16V13H15V22H13V2H15V11H18V8L22,12L18,16M2,12L6,16V13H9V22H11V2H9V11H6V8L2,12Z"; 115 | break; 116 | } 117 | 118 | case "resize-vertical": { 119 | path = "M8,18H11V15H2V13H22V15H13V18H16L12,22L8,18M12,2L8,6H11V9H2V11H22V9H13V6H16L12,2Z"; 120 | break; 121 | } 122 | 123 | case "search": { 124 | path = 125 | "M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z"; 126 | break; 127 | } 128 | 129 | case "typescript": { 130 | path = 131 | "M3,3H21V21H3V3M13.71,17.86C14.21,18.84 15.22,19.59 16.8,19.59C18.4,19.59 19.6,18.76 19.6,17.23C19.6,15.82 18.79,15.19 17.35,14.57L16.93,14.39C16.2,14.08 15.89,13.87 15.89,13.37C15.89,12.96 16.2,12.64 16.7,12.64C17.18,12.64 17.5,12.85 17.79,13.37L19.1,12.5C18.55,11.54 17.77,11.17 16.7,11.17C15.19,11.17 14.22,12.13 14.22,13.4C14.22,14.78 15.03,15.43 16.25,15.95L16.67,16.13C17.45,16.47 17.91,16.68 17.91,17.26C17.91,17.74 17.46,18.09 16.76,18.09C15.93,18.09 15.45,17.66 15.09,17.06L13.71,17.86M13,11.25H8V12.75H9.5V20H11.25V12.75H13V11.25Z"; 132 | break; 133 | } 134 | } 135 | 136 | return ( 137 | 138 | 139 | 140 | ); 141 | } 142 | -------------------------------------------------------------------------------- /packages/web/src/Playground/format.ts: -------------------------------------------------------------------------------- 1 | import prettier, { type Options } from "prettier"; 2 | import parserTypescript from "prettier/parser-typescript"; 3 | 4 | /** @see https://github.dev/stephenh/ts-poet/blob/5ea0dbb3c9f1f4b0ee51a54abb2d758102eda4a2/src/Code.ts#L231 */ 5 | function maybePretty(input: string, options?: Options | null): string { 6 | try { 7 | return prettier.format(input, { 8 | parser: "typescript", 9 | plugins: [parserTypescript], 10 | ...options, 11 | }); 12 | } catch (err) { 13 | console.warn("Failed to format code"); 14 | console.warn(err); 15 | return input; // assume it's invalid syntax and ignore 16 | } 17 | } 18 | 19 | export const prettify = (str: string, options?: Options | null) => 20 | maybePretty(str, { printWidth: 120, trailingComma: "all", ...options }); 21 | -------------------------------------------------------------------------------- /packages/web/src/Playground/url-saver.ts: -------------------------------------------------------------------------------- 1 | import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from 'lz-string' 2 | 3 | // adapted from https://github.dev/dsherret/ts-ast-viewer/blob/c71e238123d972bae889b3829e23b44f39d8d5c2/site/src/utils/UrlSaver.ts#L1-L29 4 | function getDecompressedStringFromUrl(name: string) { 5 | if (typeof window === 'undefined') return 6 | 7 | const search = new URLSearchParams(window.location.search) 8 | const code = (search.get(name) ?? '').trim() 9 | return decompressFromEncodedURIComponent(code) ?? '' // will be null on error 10 | } 11 | 12 | function updateUrlWithCompressedString(name: string, value: string) { 13 | if (value.length === 0) { 14 | updateUrlWithParam(name, '') 15 | } else { 16 | const compressed = compressToEncodedURIComponent(value) 17 | const url = new URL(window.location.href) 18 | url.searchParams.set(name, compressed) 19 | 20 | // completely arbitrary limit of characters, but it appears to not work anymore around that 21 | if (url.toString().length >= 14_500) { 22 | throw new Error('The compressed string is too large to be stored in the URL.') 23 | } else { 24 | updateUrlWithParam(name, compressed) 25 | } 26 | } 27 | } 28 | 29 | function updateUrlWithParam(name: string, value: string | number) { 30 | if (typeof window === 'undefined') return 31 | 32 | const url = new URL(window.location.href) 33 | url.searchParams.set(name, String(value)) 34 | window.history.replaceState(undefined, '', url) 35 | } 36 | 37 | const resetUrl = () => { 38 | if (typeof window === 'undefined') return 39 | 40 | window.history.replaceState(undefined, '', window.location.origin + window.location.pathname) 41 | } 42 | 43 | const deletingParamInUrl = (name: string) => { 44 | if (typeof window === 'undefined') return 45 | 46 | const url = new URL(window.location.href) 47 | url.searchParams.delete(name) 48 | window.history.replaceState(undefined, '', url) 49 | } 50 | 51 | export class UrlSaver { 52 | getValue(name: string) { 53 | return getDecompressedStringFromUrl(name) 54 | } 55 | 56 | setValue(name: string, value: string) { 57 | updateUrlWithCompressedString(name, value) 58 | } 59 | 60 | reset(name: string) { 61 | deletingParamInUrl(name) 62 | } 63 | 64 | resetAll() { 65 | resetUrl() 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /packages/web/src/components/button.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentPropsWithoutRef } from "react"; 2 | import { styled } from "panda/jsx"; 3 | import { button, type ButtonVariantProps } from "panda/recipes"; 4 | 5 | export type ButtonProps = ButtonVariantProps & ComponentPropsWithoutRef<"button">; 6 | export const Button = styled("button", button); 7 | -------------------------------------------------------------------------------- /packages/web/src/components/color-mode-switch.tsx: -------------------------------------------------------------------------------- 1 | import { css, cx } from "panda/css"; 2 | import { ComponentPropsWithoutRef, useEffect, useState } from "react"; 3 | import { useTheme } from "../vite-themes/provider"; 4 | import { styled } from "panda/jsx"; 5 | import { IconButton } from "./icon-button"; 6 | 7 | export const ColorModeSwitch = () => { 8 | const [mounted, setMounted] = useState(false); 9 | const theme = useTheme(); 10 | 11 | useEffect(() => { 12 | setMounted(true); 13 | }, []); 14 | 15 | const { setTheme, resolvedTheme } = theme; 16 | 17 | if (!mounted) { 18 | return null; 19 | } 20 | 21 | const isDark = resolvedTheme === "dark"; 22 | 23 | const toggleTheme = () => setTheme(isDark ? "light" : "dark"); 24 | 25 | const IconToUse = isDark ? Moon : Sun; 26 | const iconText = isDark ? "Dark" : "Light"; 27 | 28 | return ( 29 | 30 | 31 | 32 | ); 33 | }; 34 | 35 | function Moon(props: ComponentPropsWithoutRef<"svg">) { 36 | return ( 37 | 38 | 45 | 46 | ); 47 | } 48 | 49 | function Sun(props: ComponentPropsWithoutRef<"svg">) { 50 | return ( 51 | 52 | 56 | 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /packages/web/src/components/github-icon.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentPropsWithoutRef } from "react"; 2 | 3 | export const GithubIcon = (props: ComponentPropsWithoutRef<"svg">) => ( 4 | 5 | 6 | 7 | ); 8 | -------------------------------------------------------------------------------- /packages/web/src/components/icon-button.tsx: -------------------------------------------------------------------------------- 1 | import { css, cx } from "panda/css"; 2 | import { SystemStyleObject } from "panda/types"; 3 | 4 | interface IconButtonProps extends React.ButtonHTMLAttributes { 5 | children: React.ReactNode; 6 | css?: SystemStyleObject; 7 | } 8 | 9 | export function IconButton(props: IconButtonProps) { 10 | const { children, className, css: cssProp, ...rest } = props; 11 | return ( 12 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /packages/web/src/components/select-demo.tsx: -------------------------------------------------------------------------------- 1 | import { Portal } from "@ark-ui/react"; 2 | import { FiChevronDown } from "react-icons/fi"; 3 | import { Button } from "./button"; 4 | import { Select, SelectContent, SelectOption, SelectPositioner, SelectTrigger, type SelectProps } from "./select"; 5 | import { HStack } from "panda/jsx"; 6 | 7 | export const SelectRuntime = (props: SelectProps) => { 8 | return ( 9 | 50 | ); 51 | }; 52 | 53 | const SelectIcon = (props: { isOpen: boolean }) => { 54 | const iconStyles = { 55 | transform: props.isOpen ? "rotate(-180deg)" : undefined, 56 | transition: "transform 0.2s", 57 | transformOrigin: "center", 58 | fontSize: "18px", 59 | }; 60 | return ; 61 | }; 62 | -------------------------------------------------------------------------------- /packages/web/src/components/select.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | SelectPositioner as ArkSelectPositioner, 3 | type SelectPositionerProps as ArkSelectPositionerProps, 4 | } from "@ark-ui/react/select"; 5 | import { styled } from "panda/jsx"; 6 | import { select } from "panda/recipes"; 7 | 8 | export * from "@ark-ui/react/select"; 9 | 10 | export type SelectPositionerProps = ArkSelectPositionerProps & React.ComponentProps<"span">; 11 | export const SelectPositioner = styled(ArkSelectPositioner, select); 12 | -------------------------------------------------------------------------------- /packages/web/src/components/twitter-icon.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentPropsWithoutRef } from 'react' 2 | 3 | export const TwitterIcon = (props: ComponentPropsWithoutRef<'svg'>) => ( 4 | 5 | 6 | 7 | ) 8 | -------------------------------------------------------------------------------- /packages/web/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import "./styles.css"; 4 | import { Home } from "./pages/Home"; 5 | 6 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /packages/web/src/pages/Home.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, HStack, Stack } from "panda/jsx"; 2 | import { styled } from "panda/jsx"; 3 | import PlaygroundWithMachine from "../Playground/PlaygroundWithMachine"; 4 | 5 | import "../styles.css"; 6 | import "@fontsource/inter"; // Defaults to weight 400 7 | import { ThemeProvider } from "../vite-themes/provider"; 8 | import { ColorModeSwitch } from "../components/color-mode-switch"; 9 | import { GithubIcon } from "../components/github-icon"; 10 | import { IconButton } from "../components/icon-button"; 11 | import { SelectRuntime } from "../components/select-demo"; 12 | import { OutputRuntime } from "typed-openapi"; 13 | import { TwitterIcon } from "../components/twitter-icon"; 14 | 15 | export const Home = () => { 16 | return ( 17 | 18 | 26 | 27 | 28 | {(service) => ( 29 | 30 | 31 | typed-openapi 32 | 33 | 34 | 37 | service.send({ type: "Update runtime", runtime: (option?.value ?? "none") as OutputRuntime }) 38 | } 39 | /> 40 | 41 | 42 | 43 | 44 | 45 | 46 | 50 | 51 | 52 | 53 | 58 | openapi-zod-client 59 | 60 | 61 | 62 | 63 | )} 64 | 65 | 66 | 67 | 68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /packages/web/src/run-if-fn.ts: -------------------------------------------------------------------------------- 1 | export type AnyFunction = (...args: Arg[]) => ReturnValue; 2 | 3 | const isFunction = (value: unknown): value is T => typeof value === "function"; 4 | 5 | export const runIfFn = ( 6 | valueOrFn: MaybeReturnValue | ((...fnArgs: FunctionArgs[]) => MaybeReturnValue), 7 | ...args: FunctionArgs[] 8 | ) => 9 | isFunction>(valueOrFn) 10 | ? valueOrFn(...args) 11 | : (valueOrFn as unknown as MaybeReturnValue); 12 | -------------------------------------------------------------------------------- /packages/web/src/styles.css: -------------------------------------------------------------------------------- 1 | @layer reset, base, tokens, recipes, utilities; 2 | 3 | :root { 4 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", 5 | "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 6 | color: #333; 7 | font-size: 16px; 8 | min-width: 360px; 9 | } 10 | -------------------------------------------------------------------------------- /packages/web/src/vite-themes/provider.tsx: -------------------------------------------------------------------------------- 1 | /** Adapted from https://github.com/pacocoursey/next-themes/blob/a385b8d865bbb317ff73a5b6c1319ae566f7d6f1/src/index.tsx */ 2 | 3 | import { createContext, Fragment, memo, useCallback, useContext, useEffect, useMemo, useState } from "react"; 4 | import type { ThemeProviderProps, UseThemeProps } from "./vite-themes-types"; 5 | 6 | const colorSchemes = ["light", "dark"]; 7 | const MEDIA = "(prefers-color-scheme: dark)"; 8 | const isServer = typeof window === "undefined"; 9 | const ThemeContext = createContext(undefined); 10 | const defaultContext: UseThemeProps = { setTheme: (_) => {}, themes: [] }; 11 | 12 | export const useTheme = () => useContext(ThemeContext) ?? defaultContext; 13 | 14 | export const ThemeProvider: React.FC = (props) => { 15 | const context = useContext(ThemeContext); 16 | 17 | // Ignore nested context providers, just passthrough children 18 | if (context) return {props.children as any}; 19 | return ; 20 | }; 21 | 22 | const defaultThemes = ["light", "dark"]; 23 | 24 | const Theme: React.FC = ({ 25 | forcedTheme, 26 | disableTransitionOnChange = false, 27 | enableSystem = true, 28 | enableColorScheme = true, 29 | storageKey = "theme", 30 | themes = defaultThemes, 31 | defaultTheme = enableSystem ? "system" : "light", 32 | attribute = "data-theme", 33 | value, 34 | children, 35 | nonce, 36 | }) => { 37 | const [theme, setThemeState] = useState(() => getTheme(storageKey, defaultTheme)); 38 | const [resolvedTheme, setResolvedTheme] = useState(() => getTheme(storageKey)); 39 | const attrs = !value ? themes : Object.values(value); 40 | 41 | const applyTheme = useCallback((theme?: string) => { 42 | let resolved = theme; 43 | if (!resolved) return; 44 | 45 | // If theme is system, resolve it before setting theme 46 | if (theme === "system" && enableSystem) { 47 | resolved = getSystemTheme(); 48 | } 49 | 50 | const name = value ? value[resolved] : resolved; 51 | const enable = disableTransitionOnChange ? disableAnimation() : null; 52 | const d = document.documentElement; 53 | 54 | if (attribute === "class") { 55 | d.classList.remove(...attrs); 56 | 57 | if (name) d.classList.add(name); 58 | } else { 59 | if (name) { 60 | d.setAttribute(attribute, name); 61 | } else { 62 | d.removeAttribute(attribute); 63 | } 64 | } 65 | 66 | if (enableColorScheme) { 67 | const fallback = colorSchemes.includes(defaultTheme) ? defaultTheme : null; 68 | const colorScheme = colorSchemes.includes(resolved) ? resolved : fallback; 69 | // @ts-ignore 70 | d.style.colorScheme = colorScheme; 71 | } 72 | 73 | enable?.(); 74 | }, []); 75 | 76 | const setTheme = useCallback( 77 | (theme: string) => { 78 | setThemeState(theme); 79 | 80 | // Save to storage 81 | try { 82 | localStorage.setItem(storageKey, theme); 83 | } catch (e) { 84 | // Unsupported 85 | } 86 | }, 87 | [forcedTheme], 88 | ); 89 | 90 | const handleMediaQuery = useCallback( 91 | (e: MediaQueryListEvent | MediaQueryList) => { 92 | const resolved = getSystemTheme(e); 93 | setResolvedTheme(resolved); 94 | 95 | if (theme === "system" && enableSystem && !forcedTheme) { 96 | applyTheme("system"); 97 | } 98 | }, 99 | [theme, forcedTheme], 100 | ); 101 | 102 | // Always listen to System preference 103 | useEffect(() => { 104 | const media = window.matchMedia(MEDIA); 105 | 106 | // Intentionally use deprecated listener methods to support iOS & old browsers 107 | media.addListener(handleMediaQuery); 108 | handleMediaQuery(media); 109 | 110 | return () => media.removeListener(handleMediaQuery); 111 | }, [handleMediaQuery]); 112 | 113 | // localStorage event handling 114 | useEffect(() => { 115 | const handleStorage = (e: StorageEvent) => { 116 | if (e.key !== storageKey) { 117 | return; 118 | } 119 | 120 | // If default theme set, use it if localstorage === null (happens on local storage manual deletion) 121 | const theme = e.newValue || defaultTheme; 122 | setTheme(theme); 123 | }; 124 | 125 | window.addEventListener("storage", handleStorage); 126 | return () => window.removeEventListener("storage", handleStorage); 127 | }, [setTheme]); 128 | 129 | // Whenever theme or forcedTheme changes, apply it 130 | useEffect(() => { 131 | applyTheme(forcedTheme ?? theme); 132 | }, [forcedTheme, theme]); 133 | 134 | const providerValue = useMemo( 135 | () => ({ 136 | theme, 137 | setTheme, 138 | forcedTheme, 139 | resolvedTheme: theme === "system" ? resolvedTheme : theme, 140 | themes: enableSystem ? [...themes, "system"] : themes, 141 | systemTheme: (enableSystem ? resolvedTheme : undefined) as "light" | "dark" | undefined, 142 | }), 143 | [theme, setTheme, forcedTheme, resolvedTheme, enableSystem, themes], 144 | ); 145 | 146 | return ( 147 | 148 | 164 | {children as any} 165 | 166 | ); 167 | }; 168 | 169 | const ThemeScript = memo( 170 | ({ 171 | forcedTheme, 172 | storageKey, 173 | attribute, 174 | enableSystem, 175 | enableColorScheme, 176 | defaultTheme, 177 | value, 178 | attrs, 179 | nonce, 180 | }: ThemeProviderProps & { attrs: string[]; defaultTheme: string }) => { 181 | const defaultSystem = defaultTheme === "system"; 182 | 183 | // Code-golfing the amount of characters in the script 184 | const optimization = (() => { 185 | if (attribute === "class") { 186 | const removeClasses = `c.remove(${attrs.map((t: string) => `'${t}'`).join(",")})`; 187 | 188 | return `var d=document.documentElement,c=d.classList;${removeClasses};`; 189 | } else { 190 | return `var d=document.documentElement,n='${attribute}',s='setAttribute';`; 191 | } 192 | })(); 193 | 194 | const fallbackColorScheme = (() => { 195 | if (!enableColorScheme) { 196 | return ""; 197 | } 198 | 199 | const fallback = colorSchemes.includes(defaultTheme) ? defaultTheme : null; 200 | 201 | if (fallback) { 202 | return `if(e==='light'||e==='dark'||!e)d.style.colorScheme=e||'${defaultTheme}'`; 203 | } else { 204 | return `if(e==='light'||e==='dark')d.style.colorScheme=e`; 205 | } 206 | })(); 207 | 208 | const updateDOM = (name: string, literal: boolean = false, setColorScheme = true) => { 209 | const resolvedName = value ? value[name] : name; 210 | const val = literal ? name + `|| ''` : `'${resolvedName}'`; 211 | let text = ""; 212 | 213 | // MUCH faster to set colorScheme alongside HTML attribute/class 214 | // as it only incurs 1 style recalculation rather than 2 215 | // This can save over 250ms of work for pages with big DOM 216 | if (enableColorScheme && setColorScheme && !literal && colorSchemes.includes(name)) { 217 | text += `d.style.colorScheme = '${name}';`; 218 | } 219 | 220 | if (attribute === "class") { 221 | if (literal || resolvedName) { 222 | text += `c.add(${val})`; 223 | } else { 224 | text += `null`; 225 | } 226 | } else { 227 | if (resolvedName) { 228 | text += `d[s](n,${val})`; 229 | } 230 | } 231 | 232 | return text; 233 | }; 234 | 235 | const scriptSrc = (() => { 236 | if (forcedTheme) { 237 | return `!function(){${optimization}${updateDOM(forcedTheme)}}()`; 238 | } 239 | 240 | if (enableSystem) { 241 | return `!function(){try{${optimization}var e=localStorage.getItem('${storageKey}');if('system'===e||(!e&&${defaultSystem})){var t='${MEDIA}',m=window.matchMedia(t);if(m.media!==t||m.matches){${updateDOM( 242 | "dark", 243 | )}}else{${updateDOM("light")}}}else if(e){${value ? `var x=${JSON.stringify(value)};` : ""}${updateDOM( 244 | value ? `x[e]` : "e", 245 | true, 246 | )}}${ 247 | !defaultSystem ? `else{` + updateDOM(defaultTheme, false, false) + "}" : "" 248 | }${fallbackColorScheme}}catch(e){}}()`; 249 | } 250 | 251 | return `!function(){try{${optimization}var e=localStorage.getItem('${storageKey}');if(e){${ 252 | value ? `var x=${JSON.stringify(value)};` : "" 253 | }${updateDOM(value ? `x[e]` : "e", true)}}else{${updateDOM( 254 | defaultTheme, 255 | false, 256 | false, 257 | )};}${fallbackColorScheme}}catch(t){}}();`; 258 | })(); 259 | 260 | return