├── .editorconfig ├── .github └── workflows │ ├── ci.yaml │ └── publish.yaml ├── .gitignore ├── .husky └── pre-commit ├── .lintstagedrc.mjs ├── .tool-versions ├── .vscode └── settings.json ├── README.md ├── TODO.md ├── eslint.config.mjs ├── examples └── start-server.ts ├── package.json ├── pnpm-lock.yaml ├── prettier.config.cjs ├── src ├── ajv.ts ├── autowired-security │ ├── hook-handler-builder.ts │ ├── index.ts │ └── types │ │ ├── fastify-ext.ts │ │ ├── handlers.ts │ │ ├── index.ts │ │ └── security-schemes.ts ├── constants.ts ├── errors.ts ├── extensions.ts ├── index.ts ├── oas31-types.ts ├── operation-helpers.ts ├── options.ts ├── path-converter.ts ├── plugin.ts ├── schemas.ts ├── spec-transforms │ ├── canonicalize.ts │ ├── find.ts │ ├── fixup.ts │ ├── index.ts │ └── oas-helpers.ts ├── test │ ├── autowired-security.spec.ts │ ├── bugfix-006.spec.ts │ ├── path-conversion.spec.ts │ ├── plugin.spec.ts │ ├── security-inheritance.spec.ts │ ├── spec-transforms.spec.ts │ └── typebox-ext.ts ├── ui │ └── rapidoc.ts └── util.ts ├── tsconfig.base.json ├── tsconfig.json └── vitest.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | charset = utf-8 2 | end_of_line = lf 3 | root = true 4 | 5 | [*.{json,yaml,yml,js,mjs,cjs,ts,jsx,tsx,md,rb}] 6 | indent_size = 2 7 | indent_style = space 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | tags-ignore: 8 | - '**' 9 | pull_request: 10 | branches: 11 | - '**' 12 | 13 | jobs: 14 | build: 15 | strategy: 16 | matrix: 17 | os: [ubuntu-latest] 18 | node-version: [20.x, 22.x] 19 | runs-on: ${{ matrix.os }} 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - name: install pnpm 28 | run: npm install -g pnpm 29 | - run: pnpm install --frozen-lockfile 30 | - run: pnpm build 31 | - run: pnpm test 32 | if: matrix.os == 'ubuntu-latest' 33 | - run: pnpm lint 34 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | build: 10 | strategy: 11 | matrix: 12 | os: [ubuntu-latest] 13 | node-version: [20.x, 22.x] 14 | runs-on: ${{ matrix.os }} 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v1 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | - name: install pnpm 23 | run: npm install -g pnpm 24 | - run: pnpm install --frozen-lockfile 25 | - run: pnpm build 26 | if: matrix.os == 'ubuntu-latest' 27 | - run: pnpm test 28 | if: matrix.os == 'ubuntu-latest' 29 | - run: npm test 30 | 31 | publish-npm: 32 | needs: build 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v2 36 | - uses: actions/setup-node@v1 37 | with: 38 | node-version: 20 39 | registry-url: https://registry.npmjs.org/ 40 | - name: install pnpm 41 | run: npm install -g pnpm 42 | 43 | - name: Set VERSION variable from tag 44 | run: echo "VERSION=${GITHUB_REF/refs\/tags\/v/}" >> $GITHUB_ENV 45 | - name: Print version 46 | run: echo $VERSION 47 | - name: Verify commit exists in origin/main 48 | run: | 49 | git fetch --no-tags --prune --depth=1 origin +refs/heads/*:refs/remotes/origin/* 50 | git branch --remote --contains | grep origin/main 51 | - name: Verify that the version exists in package.json 52 | run: 'sudo apt-get install -y jq && [[ "$(jq -r ".version" package.json)" == "$VERSION" ]]' 53 | 54 | - run: pnpm install --frozen-lockfile 55 | - run: pnpm build 56 | - run: npm publish --access=public 57 | env: 58 | NODE_AUTH_TOKEN: "${{secrets.NPM_AUTH_TOKEN}}" 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | build/ 4 | tmp/ 5 | coverage/ 6 | 7 | *.tmp 8 | *.tsbuildinfo 9 | 10 | ~* 11 | *~ -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | echo "- pre-commit hooks..." 4 | npx lint-staged 5 | 6 | npm run build 7 | npm run test -------------------------------------------------------------------------------- /.lintstagedrc.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | "*.ts": ["eslint --fix"], 3 | }; 4 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 20.10.0 2 | yarn 1.22.19 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.preferences.importModuleSpecifier": "relative", 3 | "typescript.preferences.importModuleSpecifierEnding": "js", 4 | "typescript.preferences.quoteStyle": "double", 5 | "typescript.tsdk": "node_modules/typescript/lib", 6 | 7 | "editor.codeActionsOnSave": { 8 | "source.fixAll.eslint": "explicit" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `@eropple/fastify-openapi3` # 2 | _Because I just can't stop making OpenAPI libraries, I guess._ 3 | 4 | [![NPM version](https://img.shields.io/npm/v/@eropple/fastify-openapi3)](https://www.npmjs.com/package/@eropple/fastify-openapi3) [![CI](https://github.com/eropple/fastify-openapi3/actions/workflows/ci.yaml/badge.svg)](https://github.com/eropple/fastify-openapi3/actions/workflows/ci.yaml) 5 | 6 | ## What is it? ## 7 | This is a library to help you generate [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0)-compliant (or 3.0.3 if you do a little work on your own) specs from your [Fastify](https://www.fastify.io/) app. Others exist, but to my mind don't scratch the itch that the best OAS tooling does: making it faster and easier to create correct specs and valid API clients from those specs. Because of my [own](https://github.com/modern-project/modern-ruby) [background](https://github.com/eropple/nestjs-openapi3) in building OpenAPI libraries, and my growing appreciation for Fastify, I decided to take a crack at it. 8 | 9 | This library presupposes that you use [`@sinclair/typebox`](https://github.com/sinclairzx81/typebox) to define the JSON schema used in your requests, and from that JSON Schema derives types. (Ergonomics for non-TypeScript users is specifically out-of-scope.) It will walk all your routes, determine your schema, and extract and deduplicate those schemas to present a relatively clean and easy-to-use OpenAPI document. It'll then also serve JSON and YAML versions of your specification, as well as host an interactive API explorer with try-it-out features courtesy of [Rapidoc](https://mrin9.github.io/RapiDoc/) or [Scalar](https://scalar.com). 10 | 11 | **Fair warning:** This library is in Early Access(tm) and while the functionality that's here does work, there's some functionality that _doesn't_ exist. The stuff that stands out to me personally can be found in [TODO.md](https://github.com/eropple/fastify-openapi3/blob/main/TODO.md), including a short list of things this plugin _won't_ do. 12 | 13 | ## Usage ## 14 | 15 | First, install it, etc. etc.: 16 | 17 | ```bash 18 | npm install @eropple/fastify-openapi3 19 | pnpm add @eropple/fastify-openapi3 20 | yarn add @eropple/fastify-openapi3 21 | ``` 22 | 23 | Once you've installed it--well, you'd best go do some things to set it up, huh? There's a manual test (originally added to smoke out issues with Rapidoc serving) in [`examples/start-server.ts`], which can also be directly invoked from the repository with `npm run demo`. Below are the important bits from that demo: 24 | 25 | ```ts 26 | import Fastify, { FastifyInstance } from 'fastify'; 27 | import { Static, Type } from '@sinclair/typebox'; 28 | 29 | import OAS3Plugin, { OAS3PluginOptions, schemaType, oas3PluginAjv } from '../src/index.js'; 30 | ``` 31 | 32 | Your imports. (Obviously, in your project, the last import will be from `"@eropple/fastify-openapi3"`.) 33 | 34 | ```ts 35 | const fastifyOpts: FastifyServerOptions = { 36 | logger: { level: 'error' }, 37 | ajv: { 38 | plugins: [oas3PluginAjv], 39 | } 40 | } 41 | 42 | const fastify = Fastify(fastifyOpts); 43 | await fastify.register(OAS3Plugin, { ...pluginOpts }); 44 | ``` 45 | 46 | Register the OAS3 plugin. This plugin uses the Fastify logger and can be pretty chatty on `debug`, so bear that in mind. `pluginOpts` is visible in that file for an example, but it's also commented exhaustively for your IntellSensing pleasure while you're writing it. 47 | 48 | ```ts 49 | const PingResponse = schemaType('PingResponse', Type.Object({ pong: Type.Boolean() })); 50 | type PingResponse = Static; 51 | ``` 52 | 53 | Your schema. `schemaType` takes a string as a name, which _must be unique for your entire project_, as well as a `@sinclair/typebox` `Type` (which you can then use as a TypeScript type by doing `Static`, it's awesome). This is now a `TaggedSchema`, which can be used anywhere a normal JSON Schema object can be used within Fastify and will handle validation as you would expect. 54 | 55 | If you use a `TaggedSchema` within another schema, the OAS3 plugin is smart enough to extract it into its own OpenAPI `#/components/schemas/YourTypeHere` entry, so your generated clients will also only have the minimal set of model classes, etc. to worry about. Ditto having them in arrays and so on. I've tried to make this as simple to deal with as possible; if it acts in ways you don't expect, _please file an issue_. 56 | 57 | And now let's make a route: 58 | 59 | ```ts 60 | await fastify.register(async (fastify: FastifyInstance) => { 61 | fastify.route<{ Reply: PingResponse }>({ 62 | url: '/ping', 63 | method: 'GET', 64 | schema: { 65 | response: { 66 | 200: PingResponse, 67 | }, 68 | }, 69 | oas: { 70 | operationId: 'pingPingPingAndDefinitelyNotPong', 71 | summary: "a ping to the server", 72 | description: "This ping to the server lets you know that it has not been eaten by a grue.", 73 | deprecated: false, 74 | tags: ['meta'], 75 | }, 76 | handler: async (req, reply) => { 77 | return { pong: true }; 78 | } 79 | }); 80 | }, { prefix: '/api' }); 81 | ``` 82 | 83 | You don't have to put yours inside a prefixed route, but I like to, so, well, there you go. 84 | 85 | If you do a `npm run demo`, you'll get a UI that looks like the following: 86 | 87 | ![a docs screenshot](https://i.imgur.com/iOPApmq.png) 88 | 89 | And there you go. 90 | 91 | ## Contributing ## 92 | Issues and PRs welcome! Constructive criticism on how to improve the library would be awesome, even as I use it in my own stuff and figure out where to go from there, too. 93 | 94 | **Before you start in on a PR, however**, please do me a solid and drop an issue so we can discuss the approach. Thanks! 95 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODOs # 2 | There are a lot of smaller TODOs throughout the codebase. Here's a brief list of 3 | larger considerations that, due to a lack of need, haven't yet been handled. 4 | Feel free to jump in. 5 | 6 | ## Integration with Fastify v4 Type Providers ## 7 | **Expected difficulty:** moderate 8 | 9 | Fastify v4 is on the way and includes type providers that make it easier to, in 10 | a generic fashion, specify validators for request bodies, responses, etc. that 11 | also act as inferrable generics. I have not yet looked at v4, but we do internally 12 | use `@sinclair/typebox` for everything and so I don't foresee this being a huge 13 | lift--just somewhat tedious to get right. 14 | 15 | ## Top-Level `Servers` Block ## 16 | **Expected difficulty:** low 17 | 18 | Right now, if you generate an OAS client from a spec generated from this client, 19 | you need to pass the base URL. Which is silly. However, there's some weird 20 | interplay between the prefix assigned to this plugin, the routes declared in the 21 | same scope, and the base URL of the server. Just needs somebody to think about 22 | it. 23 | 24 | ## Links Objects ## 25 | **Expected difficulty:** low 26 | 27 | I don't use [link objects](https://spec.openapis.org/oas/v3.1.0#link-object). If 28 | you do, I'm definitely interested in discussing both how we can implement it and 29 | how we can make writing them pleasant. 30 | 31 | ## Example Objects ## 32 | **Expected difficulty:** moderate 33 | 34 | Some example objects are free with the way that schemas work, but responses, etc. 35 | need some thought. 36 | 37 | ## Header Response Schema ## 38 | **Expected difficulty:** development low, design moderate/hard 39 | 40 | Right now, `@eropple/fastify-openapi3` is unaware of headers in responses 41 | because I haven't needed them (and also they don't really plug into the way 42 | Fastify does things too well). Suggestions welcome! 43 | 44 | ## Alternate Content Types ## 45 | **Expected difficulty:** moderate 46 | 47 | `@eropple/fastify-openapi3` expects that every request body and every response 48 | is `application/json`. There are two bits to this: 49 | 50 | - Some APIs, for good reasons, use `vnd` extensions to media types to indicate 51 | API versions. 52 | - Some folks want to send things that are not JSON. 53 | 54 | The former is just changing the schema alias in the plugin (it's a constant, so 55 | it's not a snap-your-fingers fix) but the latter requires additional thinking. 56 | And OpenAPI isn't great at that anyway. So some insight from an invested party 57 | would be awesome. 58 | 59 | In the interim, you _can_ bail out by using `oas.custom` and do whatever you 60 | want to do, but that's a little awkward. 61 | 62 | # Things We Won't Support # 63 | - server blocks at path/operation level 64 | - using `$id` for schema identification 65 | - any particular ergonomics for folks not using TypeScript. Use TypeScript. 66 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { fileURLToPath } from "node:url"; 3 | 4 | import { fixupPluginRules } from "@eslint/compat"; 5 | import { FlatCompat } from "@eslint/eslintrc"; 6 | import js from "@eslint/js"; 7 | import typescriptEslint from "@typescript-eslint/eslint-plugin"; 8 | import tsParser from "@typescript-eslint/parser"; 9 | import prettier from "eslint-plugin-prettier/recommended"; 10 | import globals from "globals"; 11 | 12 | const __filename = fileURLToPath(import.meta.url); 13 | const __dirname = path.dirname(__filename); 14 | const compat = new FlatCompat({ 15 | baseDirectory: __dirname, 16 | recommendedConfig: js.configs.recommended, 17 | allConfig: js.configs.all, 18 | }); 19 | 20 | function legacyPlugin(name, alias = name) { 21 | const plugin = compat.plugins(name)[0]?.plugins?.[alias]; 22 | 23 | if (!plugin) { 24 | throw new Error(`Unable to resolve plugin ${name} and/or alias ${alias}`); 25 | } 26 | 27 | return fixupPluginRules(plugin); 28 | } 29 | 30 | export default [ 31 | ...compat.extends( 32 | "eslint:recommended", 33 | "plugin:@typescript-eslint/recommended" 34 | ), 35 | { 36 | // eslint v9 requires this to be in a separate config option for ??reasons?? 37 | ignores: ["dist/**/*"], 38 | }, 39 | prettier, 40 | { 41 | plugins: { 42 | "@typescript-eslint": typescriptEslint, 43 | "import": legacyPlugin("eslint-plugin-import", "import"), 44 | }, 45 | 46 | languageOptions: { 47 | globals: { 48 | ...globals.node, 49 | }, 50 | 51 | parser: tsParser, 52 | ecmaVersion: 12, 53 | sourceType: "module", 54 | }, 55 | 56 | rules: { 57 | "quotes": [ 58 | 2, 59 | "double", 60 | { 61 | avoidEscape: true, 62 | allowTemplateLiterals: true, 63 | }, 64 | ], 65 | 66 | "@typescript-eslint/no-unused-vars": "off", 67 | "@typescript-eslint/consistent-type-imports": [ 68 | "error", 69 | { 70 | fixStyle: "inline-type-imports", 71 | }, 72 | ], 73 | 74 | "import/order": [ 75 | "error", 76 | { 77 | "groups": [ 78 | "builtin", 79 | "external", 80 | "internal", 81 | "parent", 82 | "sibling", 83 | "index", 84 | ], 85 | "newlines-between": "always", 86 | "alphabetize": { order: "asc" }, 87 | }, 88 | ], 89 | "import/first": "error", 90 | "import/extensions": ["error", "ignorePackages"], 91 | }, 92 | }, 93 | ]; 94 | -------------------------------------------------------------------------------- /examples/start-server.ts: -------------------------------------------------------------------------------- 1 | import { type Static, Type } from "@sinclair/typebox"; 2 | import Fastify, { 3 | type FastifyInstance, 4 | type FastifyServerOptions, 5 | } from "fastify"; 6 | 7 | import OAS3Plugin, { 8 | oas3PluginAjv, 9 | type OAS3PluginOptions, 10 | schemaType, 11 | } from "../src/index.js"; 12 | 13 | const QwopModel = schemaType( 14 | "QwopRequestBody", 15 | Type.Object({ qwop: Type.Number() }) 16 | ); 17 | type QwopModel = Static; 18 | const PingResponse = schemaType( 19 | "PingResponse", 20 | Type.Object({ pong: Type.Boolean() }) 21 | ); 22 | type PingResponse = Static; 23 | 24 | const pluginOpts: OAS3PluginOptions = { 25 | openapiInfo: { 26 | title: "Test Document", 27 | version: "0.1.0", 28 | }, 29 | publish: { 30 | ui: "scalar", 31 | scalarExtraOptions: { 32 | theme: "solarized", 33 | }, 34 | json: true, 35 | yaml: true, 36 | }, 37 | }; 38 | 39 | console.log(pluginOpts); 40 | 41 | const run = async () => { 42 | const fastifyOpts: FastifyServerOptions = { 43 | logger: { level: "error" }, 44 | ajv: { 45 | plugins: [oas3PluginAjv], 46 | }, 47 | }; 48 | 49 | const fastify = Fastify(fastifyOpts); 50 | await fastify.register(OAS3Plugin, { ...pluginOpts }); 51 | 52 | // we do this inside a prefixed scope to smoke out prefix append errors 53 | await fastify.register( 54 | async (fastify: FastifyInstance) => { 55 | fastify.route<{ Reply: PingResponse }>({ 56 | url: "/ping", 57 | method: "GET", 58 | schema: { 59 | response: { 60 | 200: PingResponse, 61 | }, 62 | }, 63 | oas: { 64 | operationId: "pingPingPingAndDefinitelyNotPong", 65 | summary: "a ping to the server", 66 | description: 67 | "This ping to the server lets you know that it has not been eaten by a grue.", 68 | deprecated: false, 69 | tags: ["meta"], 70 | }, 71 | handler: async (req, reply) => { 72 | return { pong: true }; 73 | }, 74 | }); 75 | 76 | fastify.route<{ Body: QwopModel; Reply: PingResponse }>({ 77 | url: "/qwop", 78 | method: "POST", 79 | schema: { 80 | querystring: Type.Object({ 81 | value: Type.Number({ minimum: 0, maximum: 1000 }), 82 | verbose: Type.Optional(Type.Boolean()), 83 | }), 84 | body: QwopModel, 85 | response: { 86 | 201: PingResponse, 87 | }, 88 | }, 89 | oas: {}, 90 | handler: async (req, reply) => { 91 | return { pong: true }; 92 | }, 93 | }); 94 | }, 95 | { prefix: "/api" } 96 | ); 97 | 98 | // const port = Math.floor(Math.random() * 10000) + 10000; 99 | const port = 48484; 100 | 101 | console.log(`Test server going up at http://localhost:${port}.`); 102 | 103 | console.log(`JSON: http://localhost:${port}/openapi.json`); 104 | console.log(`YAML: http://localhost:${port}/openapi.yaml`); 105 | console.log(`UI: http://localhost:${port}/docs`); 106 | 107 | fastify.listen({ 108 | port, 109 | }); 110 | }; 111 | 112 | run(); 113 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@eropple/fastify-openapi3", 3 | "version": "0.10.0", 4 | "author": "Ed Ropple", 5 | "license": "MIT", 6 | "type": "module", 7 | "exports": "./dist/index.js", 8 | "types": "./dist/index.d.ts", 9 | "files": [ 10 | "*", 11 | "src/**/*", 12 | "test/**/*", 13 | "dist/**/*", 14 | "examples/**/*" 15 | ], 16 | "scripts": { 17 | "build": "tsc", 18 | "watch": "yarn run -s build --watch", 19 | "test": "vitest run", 20 | "lint": "eslint", 21 | "demo": "node --loader ts-node/esm ./examples/start-server.ts", 22 | "prepare": "husky" 23 | }, 24 | "dependencies": { 25 | "@seriousme/openapi-schema-validator": "1.7.1", 26 | "change-case": "^4.1.2", 27 | "fastify-plugin": "^5", 28 | "js-yaml": "^4.1.0", 29 | "openapi-ts": "^0.3.4", 30 | "openapi3-ts": "^2.0.2", 31 | "utility-types": "^3.11.0" 32 | }, 33 | "peerDependencies": { 34 | "@scalar/fastify-api-reference": "^1.25.7", 35 | "@sinclair/typebox": ">=0.32.9", 36 | "ajv": "^8", 37 | "fastify": "^5" 38 | }, 39 | "devDependencies": { 40 | "@eslint/compat": "^1.1.1", 41 | "@fastify/cookie": "^11.0.2", 42 | "@fastify/formbody": "^8.0.2", 43 | "@sinclair/typebox": "^0.32.9", 44 | "@types/js-yaml": "^4.0.5", 45 | "@types/node": "^20.1.2", 46 | "@typescript-eslint/eslint-plugin": "^8.8.1", 47 | "@typescript-eslint/parser": "^8.8.1", 48 | "@vitest/coverage-v8": "^2.1.2", 49 | "ajv": "^8.17.1", 50 | "eslint": "^9.12.0", 51 | "eslint-config-prettier": "^9.1.0", 52 | "eslint-plugin-import": "^2.29.1", 53 | "eslint-plugin-prettier": "^5.2.1", 54 | "fastify": "^5", 55 | "globals": "^15.10.0", 56 | "husky": "^9.1.5", 57 | "lint-staged": "^15.2.9", 58 | "ts-node": "^10.9.1", 59 | "typescript": "^5.5.2", 60 | "vitest": "^2.1.2" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 80, 3 | tabWidth: 2, 4 | 5 | semi: true, 6 | singleQuote: false, 7 | quoteProps: "consistent", 8 | 9 | trailingComma: "es5", 10 | bracketSpacing: true, 11 | 12 | arrowParens: "always", 13 | 14 | requirePragma: false, 15 | insertPragma: false, 16 | 17 | proseWrap: "preserve", 18 | embeddedLanguageFormatting: "off", 19 | 20 | endOfLine: "lf", 21 | }; 22 | -------------------------------------------------------------------------------- /src/ajv.ts: -------------------------------------------------------------------------------- 1 | import type Ajv from "ajv"; 2 | 3 | export function oas3PluginAjv(ajv: Ajv) { 4 | ajv.addKeyword({ 5 | keyword: "x-fastify-schemaName", 6 | }); 7 | 8 | return ajv; 9 | } 10 | -------------------------------------------------------------------------------- /src/autowired-security/hook-handler-builder.ts: -------------------------------------------------------------------------------- 1 | import { type FastifyBaseLogger } from "fastify"; 2 | import { 3 | type onRequestAsyncHookHandler, 4 | type onRequestMetaHookHandler, 5 | } from "fastify/types/hooks.js"; 6 | 7 | import { 8 | OAS3PluginError, 9 | OAS3RequestBadRequestError, 10 | OAS3RequestForbiddenError, 11 | OAS3RequestUnauthorizedError, 12 | } from "../errors.js"; 13 | 14 | import { 15 | type HandlerRetval, 16 | type WrappedHandler, 17 | buildApiKeyHandler, 18 | buildHttpBasicHandler, 19 | buildHttpBearerHandler, 20 | } from "./types/handlers.js"; 21 | import { 22 | type OAS3RouteSecuritySchemeSpec, 23 | type OAS3AutowireSecurityOptions, 24 | type OAS3AutowireRequestFailedHandler, 25 | } from "./types/index.js"; 26 | 27 | type AndedHandlers = Array<[string, WrappedHandler]>; 28 | type OrredHandlers = Array; 29 | 30 | export function buildSecurityHookHandler( 31 | rLog: FastifyBaseLogger, 32 | security: Array, 33 | options: OAS3AutowireSecurityOptions 34 | ): onRequestMetaHookHandler { 35 | // `security` is an array of objects. the keys of the sub-object are security scheme names. 36 | // the values of the sub-object are arrays of security scopes. until we implement OIDC/OAuth2, 37 | // we'll ignore the scopes; we just need to loop up the security scheme name in the 38 | // `securitySchemes` object. 39 | // 40 | // schemes in the same object are "and"ed together. all separate objects are "or"ed together. 41 | 42 | const orHandlers: OrredHandlers = []; 43 | 44 | for (const andedSchemes of security) { 45 | const andedHandlers: AndedHandlers = []; 46 | for (const [name, _scopes] of Object.entries(andedSchemes)) { 47 | const scheme = options.securitySchemes[name]; 48 | 49 | if (!scheme) { 50 | rLog.warn( 51 | { securitySchemeName: name }, 52 | "Unrecognized security scheme." 53 | ); 54 | if (!options.allowUnrecognizedSecurity) { 55 | throw new OAS3PluginError(`Security scheme "${name}" not defined.`); 56 | } else { 57 | rLog.warn( 58 | "Ignoring unrecognized security scheme; it is on you to implement it." 59 | ); 60 | continue; 61 | } 62 | } 63 | 64 | let handler: WrappedHandler; 65 | switch (scheme.type) { 66 | case "apiKey": 67 | handler = buildApiKeyHandler(scheme); 68 | break; 69 | case "http": 70 | switch (scheme.scheme) { 71 | case "basic": 72 | handler = buildHttpBasicHandler(scheme); 73 | break; 74 | case "bearer": 75 | handler = buildHttpBearerHandler(scheme); 76 | break; 77 | default: 78 | // @ts-expect-error JS catch 79 | throw new Error(`Unsupported HTTP scheme: ${scheme.scheme}`); 80 | } 81 | break; 82 | default: 83 | // @ts-expect-error JS catch 84 | throw new Error(`Unsupported security scheme: ${scheme.type}`); 85 | } 86 | 87 | andedHandlers.push([name, handler]); 88 | } 89 | 90 | orHandlers.push(andedHandlers); 91 | } 92 | 93 | return buildSecurityHandlerFunction(rLog, orHandlers, options); 94 | } 95 | 96 | const defaultFailHandler: OAS3AutowireRequestFailedHandler = ( 97 | result, 98 | request, 99 | reply 100 | ) => { 101 | if (result.code === 401) { 102 | reply.code(401).send({ error: "Unauthorized" }); 103 | } else if (result.code === 403) { 104 | reply.code(403).send({ error: "Forbidden" }); 105 | } else { 106 | request.log.error( 107 | { handlerRetval: result }, 108 | "Out-of-domain value from security handlers." 109 | ); 110 | reply.code(500).send({ error: "Internal server error" }); 111 | } 112 | }; 113 | 114 | function buildSecurityHandlerFunction( 115 | rLog: FastifyBaseLogger, 116 | orredHandlers: OrredHandlers, 117 | options: OAS3AutowireSecurityOptions 118 | ): onRequestAsyncHookHandler { 119 | const failHandler: OAS3AutowireRequestFailedHandler = 120 | options.onRequestFailed ?? defaultFailHandler; 121 | 122 | // this function needs to loop over the "or" handlers. this yields the "and" 123 | // handlers, as per the OpenAPI spec. If any "and" handlers fail, we can 124 | // short-circuit return that layer. If all "and" handlers of a single group 125 | // succeed, we can short-circuit return the whole function. 126 | // 127 | // we need to collect all of them, however, because "forbidden" trumps "unauthorized" 128 | // and we want to return the most clear error to the client. 129 | 130 | return async (request, reply) => { 131 | const andRetvals: Array = []; 132 | let orSucceeded = false; 133 | 134 | let handlerGroupIndex = 0; 135 | // Loop over "or" handlers (array of "and" handler groups) 136 | for (const andedHandlers of orredHandlers) { 137 | const gLog = request.log.child({ handlerGroupIndex }); 138 | gLog.debug("Checking security handler group."); 139 | handlerGroupIndex++; 140 | 141 | let allSucceeded = true; 142 | 143 | let handlerIndex = 0; 144 | // Loop over "and" handlers within the current "or" group 145 | for (const [name, handler] of andedHandlers) { 146 | const hLog = gLog.child({ handlerIndex }); 147 | hLog.debug("Checking security handler in group."); 148 | handlerIndex++; 149 | 150 | try { 151 | hLog.debug("Calling security handler."); 152 | const result = await handler(request); 153 | 154 | hLog.debug({ handlerRetval: result }, "Security handler returned."); 155 | 156 | if (!result.ok) { 157 | hLog.debug( 158 | { 159 | handlerIndex, 160 | handlerGroupIndex, 161 | securitySchemeName: name, 162 | }, 163 | "Security scheme denied request." 164 | ); 165 | andRetvals.push(result); 166 | allSucceeded = false; 167 | break; 168 | } 169 | } catch (err) { 170 | hLog.error( 171 | { err }, 172 | `Security handler '${name}' threw an error: ${err}` 173 | ); 174 | allSucceeded = false; 175 | break; 176 | } 177 | } 178 | 179 | // If all handlers in this "and" group succeeded, allow the request 180 | if (allSucceeded) { 181 | orSucceeded = true; 182 | break; 183 | } 184 | } 185 | 186 | if (orSucceeded) { 187 | request.log.debug("At least one set of security handlers succeeded."); 188 | return; 189 | } else { 190 | request.log.debug("All security handlers failed for route."); 191 | const isForbidden = andRetvals.some( 192 | (r) => r.ok === false && r.code === 403 193 | ); 194 | 195 | return failHandler( 196 | { ok: false, code: isForbidden ? 403 : 401 }, 197 | request, 198 | reply 199 | ); 200 | } 201 | }; 202 | } 203 | -------------------------------------------------------------------------------- /src/autowired-security/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type onRequestHookHandler, 3 | type FastifyBaseLogger, 4 | type RouteOptions, 5 | type onRequestAsyncHookHandler, 6 | type FastifyRequest, 7 | } from "fastify"; 8 | import { type onRequestMetaHookHandler } from "fastify/types/hooks.js"; 9 | import { type OpenApiBuilder } from "openapi3-ts"; 10 | 11 | import { OAS3PluginError } from "../errors.js"; 12 | 13 | import { buildSecurityHookHandler } from "./hook-handler-builder.js"; 14 | import { 15 | type WrappedHandler, 16 | type HandlerRetval, 17 | buildApiKeyHandler, 18 | buildHttpBasicHandler, 19 | buildHttpBearerHandler, 20 | } from "./types/handlers.js"; 21 | import { 22 | type OAS3RouteSecuritySchemeSpec, 23 | type OAS3AutowireSecurityOptions, 24 | type OAS3PluginSecurityScheme, 25 | } from "./types/index.js"; 26 | 27 | export * from "./types/index.js"; 28 | 29 | export function validateOptions( 30 | logger: FastifyBaseLogger, 31 | options: OAS3AutowireSecurityOptions | undefined 32 | ) { 33 | if (!options || options.disabled) { 34 | logger.info("OAS plugin autowire is disabled."); 35 | return; 36 | } 37 | 38 | for (const [name, scheme] of Object.entries(options.securitySchemes)) { 39 | // TODO: consider supporting "openIdConnect" and "oauth2" 40 | // This is low-priority for me as I think these affordances don't work 41 | // very well in the only place they really matter (try-it-out docs), and 42 | // you can use HTTP bearer instead. PRs welcome. 43 | 44 | // @ts-expect-error this is basically a JS check 45 | if (scheme.type === "oauth2" || scheme.type === "openIdConnect") { 46 | // @ts-expect-error still a JS check 47 | const msg = `Security scheme type "${scheme.type}" is not supported. Consider using "bearer" or "apiKey" instead.`; 48 | 49 | if (options.allowUnrecognizedSecurity) { 50 | logger.warn({ securitySchemeName: name }, msg + " Ignoring."); 51 | } else { 52 | throw new OAS3PluginError(msg); 53 | } 54 | } 55 | 56 | // TODO: support non-"header" locations for "apiKey" 57 | // I just don't need it, so I haven't done it. PRs welcome. 58 | if ( 59 | scheme.type === "apiKey" && 60 | scheme.in !== "header" && 61 | scheme.in !== "cookie" 62 | ) { 63 | const msg = `Security scheme type "${scheme.type}" requires "in" to be "header" or "cookie".`; 64 | } 65 | } 66 | } 67 | 68 | /** 69 | * Attaches all known security schemes to the OAS document. 70 | */ 71 | export function attachSecuritySchemesToDocument( 72 | logger: FastifyBaseLogger, 73 | doc: OpenApiBuilder, 74 | options: OAS3AutowireSecurityOptions 75 | ) { 76 | for (const [name, scheme] of Object.entries(options.securitySchemes)) { 77 | const sanitized: Omit & { fn?: unknown } = { 78 | ...scheme, 79 | }; 80 | delete sanitized.fn; 81 | delete sanitized.passNullIfNoneProvided; 82 | 83 | logger.debug(`Attaching security scheme: ${name} (type: ${scheme.type}).`); 84 | doc.addSecurityScheme(name, sanitized); 85 | } 86 | } 87 | 88 | /** 89 | * Investigates a provided route and attaches an `onRequest` handler that evaluates 90 | * the given security scheme(s) for the route according to the OAS specification. 91 | */ 92 | export function attachSecurityToRoute( 93 | rLog: FastifyBaseLogger, 94 | route: RouteOptions, 95 | options: OAS3AutowireSecurityOptions, 96 | hookCache: Record 97 | ) { 98 | if (options.disabled) { 99 | rLog.trace("Autowire disabled; skipping."); 100 | return; 101 | } 102 | 103 | const routeSecurity = route.oas?.security; 104 | 105 | // If security is explicitly set to empty array, skip all security 106 | if (Array.isArray(routeSecurity) && routeSecurity.length === 0) { 107 | rLog.debug("Route security explicitly disabled; skipping."); 108 | return; 109 | } 110 | 111 | // Use route security if defined, otherwise fall back to root security 112 | let security = routeSecurity ?? options.rootSecurity; 113 | 114 | // If no security defined at either level 115 | if (!security) { 116 | if (!options.allowEmptySecurityWithNoRoot) { 117 | throw new OAS3PluginError( 118 | `Route ${route.method} ${route.url} has no security defined, and rootSecurity is not defined. If this is intentional, set \`allowEmptySecurityWithNoRoot\` to true.` 119 | ); 120 | } 121 | rLog.debug("No security defined at any level; skipping."); 122 | return; 123 | } 124 | 125 | // Normalize to array format 126 | if (!Array.isArray(security)) { 127 | security = [security]; 128 | } 129 | 130 | // Create and cache the hook handler 131 | const cacheKey = JSON.stringify(security); 132 | let hookHandler = hookCache[cacheKey]; 133 | if (!hookHandler) { 134 | hookHandler = buildSecurityHookHandler(rLog, security, options); 135 | hookCache[cacheKey] = hookHandler; 136 | } 137 | 138 | // Add the security hook 139 | const existingRouteRequestHooks = route.onRequest; 140 | const newRouteRequestHooks: Array = [hookHandler]; 141 | if (Array.isArray(existingRouteRequestHooks)) { 142 | newRouteRequestHooks.push( 143 | ...existingRouteRequestHooks.filter((f) => f !== hookHandler) 144 | ); 145 | } 146 | 147 | rLog.debug( 148 | { 149 | routeHookCount: newRouteRequestHooks.length, 150 | securitySchemes: security, 151 | }, 152 | "Adding security hook to route." 153 | ); 154 | 155 | route.onRequest = newRouteRequestHooks; 156 | } 157 | -------------------------------------------------------------------------------- /src/autowired-security/types/fastify-ext.ts: -------------------------------------------------------------------------------- 1 | import { type FastifyRequest } from "fastify"; 2 | 3 | export type FastifyRequestWithCookies = FastifyRequest & { 4 | cookies?: { 5 | [key: string]: string; 6 | }; 7 | }; 8 | -------------------------------------------------------------------------------- /src/autowired-security/types/handlers.ts: -------------------------------------------------------------------------------- 1 | import { type FastifyRequest } from "fastify"; 2 | 3 | import { decodeBasicAuthHeader } from "../../util.js"; 4 | 5 | import { 6 | type ApiKeySecurityScheme, 7 | type BasicAuthSecurityScheme, 8 | type BearerSecurityScheme, 9 | } from "./security-schemes.js"; 10 | 11 | /** 12 | * The returned value from a security handler. 13 | */ 14 | export type HandlerRetval = { ok: true } | { ok: false; code: 401 | 403 }; 15 | export const HandlerRetvalReason = Object.freeze({ 16 | UNAUTHORIZED: 401, 17 | FORBIDDEN: 403, 18 | }); 19 | 20 | /** 21 | * A wrapped handler to be used as a Fastify onRequest hook. 22 | */ 23 | export type WrappedHandler = ( 24 | request: FastifyRequest 25 | ) => HandlerRetval | Promise; 26 | 27 | /** 28 | * ----- API Key Handler Builder ----- 29 | */ 30 | export function buildApiKeyHandler( 31 | scheme: ApiKeySecurityScheme 32 | ): WrappedHandler { 33 | const schemeName = scheme.name.toLowerCase(); 34 | 35 | return (request: FastifyRequest) => { 36 | request.log.trace("Entering API key handler."); 37 | try { 38 | let value: string | undefined; 39 | switch (scheme.in) { 40 | case "header": { 41 | const headers = request.headers[schemeName]; 42 | value = Array.isArray(headers) ? headers[0] : headers; 43 | break; 44 | } 45 | case "cookie": { 46 | const cookies = request.cookies; // May be undefined if cookie plugin is not registered. 47 | value = cookies ? cookies[schemeName] : undefined; 48 | break; 49 | } 50 | default: 51 | throw new Error(`Unsupported API key location: ${scheme.in}`); 52 | } 53 | 54 | if (value === undefined || value === null) { 55 | if (scheme.passNullIfNoneProvided) { 56 | return scheme.fn(null, request); 57 | } else { 58 | return { ok: false, code: 401 }; 59 | } 60 | } 61 | return scheme.fn(value, request); 62 | } catch (err) { 63 | request.log.warn({ err }, "Uncaught error in API key handler."); 64 | return { ok: false, code: 401 }; 65 | } 66 | }; 67 | } 68 | 69 | /** 70 | * ----- HTTP Basic Handler Builder ----- 71 | */ 72 | export function buildHttpBasicHandler( 73 | scheme: BasicAuthSecurityScheme 74 | ): WrappedHandler { 75 | return (request) => { 76 | try { 77 | const headers = request.headers.authorization; 78 | const header = Array.isArray(headers) ? headers[0] : headers; 79 | 80 | // If no Authorization header exists 81 | if (!header) { 82 | if (scheme.passNullIfNoneProvided) { 83 | return scheme.fn(null, request); 84 | } else { 85 | return { ok: false, code: 401 }; 86 | } 87 | } 88 | 89 | // At this point we have a header, so try to decode it 90 | const credentials = decodeBasicAuthHeader(header); 91 | if (!credentials) { 92 | // Malformed header - always return 401 93 | return { ok: false, code: 401 }; 94 | } 95 | 96 | // Valid header format, let handler validate the credentials 97 | return scheme.fn(credentials, request); 98 | } catch (err) { 99 | request.log.warn({ err }, "Uncaught error in HTTP basic auth handler."); 100 | return { ok: false, code: 401 }; 101 | } 102 | }; 103 | } 104 | 105 | /** 106 | * ----- HTTP Bearer Handler Builder ----- 107 | */ 108 | export function buildHttpBearerHandler( 109 | scheme: BearerSecurityScheme 110 | ): WrappedHandler { 111 | return (request: FastifyRequest) => { 112 | try { 113 | const headers = request.headers.authorization; 114 | const header = Array.isArray(headers) ? headers[0] : headers; 115 | if (!header || !header.startsWith("Bearer ")) { 116 | if (scheme.passNullIfNoneProvided) { 117 | return scheme.fn(null, request); 118 | } else { 119 | return { ok: false, code: 401 }; 120 | } 121 | } 122 | const token = header.slice(7); 123 | return scheme.fn(token, request); 124 | } catch (err) { 125 | request.log.warn({ err }, "Uncaught error in HTTP bearer handler."); 126 | return { ok: false, code: 401 }; 127 | } 128 | }; 129 | } 130 | -------------------------------------------------------------------------------- /src/autowired-security/types/index.ts: -------------------------------------------------------------------------------- 1 | import { type FastifyReply, type FastifyRequest } from "fastify"; 2 | 3 | import { type HandlerRetval } from "./handlers.js"; 4 | import { type OAS3PluginSecurityScheme } from "./security-schemes.js"; 5 | 6 | export * from "./security-schemes.js"; 7 | 8 | export type OAS3RouteSecuritySchemeSpec = Record>; 9 | 10 | export type OAS3AutowireRequestFailedHandler = ( 11 | result: HandlerRetval & { ok: false }, 12 | request: FastifyRequest, 13 | reply: FastifyReply 14 | ) => void | Promise; 15 | 16 | export interface OAS3AutowireSecurityOptions { 17 | /** 18 | * If `true`, disables the automatic mapping of security schemes to 19 | * security interceptors. Defaults to `false`. 20 | */ 21 | disabled?: boolean; 22 | 23 | /** 24 | * if `false`, will fail during startup if routes use security schemes that aren't 25 | * provided explicit security handlers. Defaults to `false`. 26 | * 27 | * This library **will** attempt to evaluate ones it recognizes still, so handling 28 | * the ones it doesn't is up to you. 29 | */ 30 | allowUnrecognizedSecurity?: boolean; 31 | 32 | /** 33 | * if `false`, will fail during startup if routes don't specify security schemes 34 | * and `rootSecurity` is unset. Defaults to `true`. 35 | */ 36 | allowEmptySecurityWithNoRoot?: boolean; 37 | 38 | /** 39 | * The set of OAS securitySchemes to use for ALL routes that do not have their 40 | * own `security`. These WILL be applied to routes that are `omit`ted. 41 | */ 42 | rootSecurity?: 43 | | OAS3RouteSecuritySchemeSpec 44 | | Array; 45 | 46 | /** 47 | * The set of security schemes that should be invoked, and how. These values, 48 | * modulo 49 | */ 50 | securitySchemes: Record; 51 | 52 | /** 53 | * Invoked when a request either comes back Unauthorized or Forbidden. 54 | * Defaults to a basic JSON response. 55 | */ 56 | onRequestFailed?: OAS3AutowireRequestFailedHandler; 57 | } 58 | -------------------------------------------------------------------------------- /src/autowired-security/types/security-schemes.ts: -------------------------------------------------------------------------------- 1 | import { type FastifyRequest } from "fastify"; 2 | 3 | import { type HandlerRetval } from "./handlers.js"; 4 | 5 | /** 6 | * Primary handler type aliases. 7 | */ 8 | export type ApiKeyHandlerFn = ( 9 | value: string, 10 | request: FastifyRequest 11 | ) => HandlerRetval | Promise; 12 | export type HttpBasicHandlerFn = ( 13 | credentials: { username: string; password: string }, 14 | request: FastifyRequest 15 | ) => HandlerRetval | Promise; 16 | export type HttpBearerFn = ApiKeyHandlerFn; 17 | 18 | /** 19 | * Secondary (nullable) handler type aliases. 20 | */ 21 | export type NullableApiKeyHandlerFn = ( 22 | value: string | null, 23 | request: FastifyRequest 24 | ) => HandlerRetval | Promise; 25 | export type NullableHttpBasicHandlerFn = ( 26 | credentials: { username: string; password: string } | null, 27 | request: FastifyRequest 28 | ) => HandlerRetval | Promise; 29 | export type NullableHttpBearerFn = NullableApiKeyHandlerFn; 30 | 31 | /** 32 | * ----- API Key Security Scheme ----- 33 | */ 34 | export type ApiKeySecuritySchemeBase = { 35 | type: "apiKey"; 36 | name: string; 37 | description?: string; 38 | in: "header" | "query" | "cookie"; 39 | }; 40 | 41 | export type ApiKeySecuritySchemeStrict = ApiKeySecuritySchemeBase & { 42 | passNullIfNoneProvided?: false; 43 | fn: ApiKeyHandlerFn; 44 | }; 45 | 46 | export type ApiKeySecuritySchemeNullable = ApiKeySecuritySchemeBase & { 47 | passNullIfNoneProvided: true; 48 | fn: NullableApiKeyHandlerFn; 49 | }; 50 | 51 | export type ApiKeySecurityScheme = 52 | | ApiKeySecuritySchemeStrict 53 | | ApiKeySecuritySchemeNullable; 54 | 55 | /** 56 | * ----- HTTP Basic Security Scheme ----- 57 | */ 58 | export type BasicAuthSecuritySchemeBase = { 59 | type: "http"; 60 | scheme: "basic"; 61 | description?: string; 62 | }; 63 | 64 | export type BasicAuthSecuritySchemeStrict = BasicAuthSecuritySchemeBase & { 65 | passNullIfNoneProvided?: false; 66 | fn: HttpBasicHandlerFn; 67 | }; 68 | 69 | export type BasicAuthSecuritySchemeNullable = BasicAuthSecuritySchemeBase & { 70 | passNullIfNoneProvided: true; 71 | fn: NullableHttpBasicHandlerFn; 72 | }; 73 | 74 | export type BasicAuthSecurityScheme = 75 | | BasicAuthSecuritySchemeStrict 76 | | BasicAuthSecuritySchemeNullable; 77 | 78 | /** 79 | * ----- HTTP Bearer Security Scheme ----- 80 | */ 81 | export type BearerSecuritySchemeBase = { 82 | type: "http"; 83 | scheme: "bearer"; 84 | description?: string; 85 | }; 86 | 87 | export type BearerSecuritySchemeStrict = BearerSecuritySchemeBase & { 88 | passNullIfNoneProvided?: false; 89 | fn: HttpBearerFn; 90 | }; 91 | 92 | export type BearerSecuritySchemeNullable = BearerSecuritySchemeBase & { 93 | passNullIfNoneProvided: true; 94 | fn: NullableHttpBearerFn; 95 | }; 96 | 97 | export type BearerSecurityScheme = 98 | | BearerSecuritySchemeStrict 99 | | BearerSecuritySchemeNullable; 100 | 101 | /** 102 | * Exported union type for all supported schemes. 103 | */ 104 | export type OAS3PluginSecurityScheme = 105 | | ApiKeySecurityScheme 106 | | BasicAuthSecurityScheme 107 | | BearerSecurityScheme; 108 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const SCHEMA_NAME_PROPERTY = "x-fastify-schemaName" as const; 2 | 3 | export const APPLICATION_JSON = "application/json" as const; 4 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Base class for errors found during startup. 3 | */ 4 | export class OAS3PluginError extends Error {} 5 | 6 | export class OAS3PluginOptionsError extends OAS3PluginError { 7 | constructor(msg: string) { 8 | super(`Problem with OAS3 plugin config: ${msg}`); 9 | } 10 | } 11 | 12 | export class OAS3SpecValidationError extends OAS3PluginError { 13 | constructor() { 14 | super("Failed to validate OpenAPI specification. Check logs for errors."); 15 | } 16 | } 17 | 18 | /** 19 | * Base class for errors discovered when handling requests. 20 | */ 21 | export class OAS3RequestError extends Error { 22 | constructor( 23 | message: string, 24 | readonly statusCode: number 25 | ) { 26 | super(message); 27 | } 28 | } 29 | 30 | export class OAS3RequestBadRequestError extends OAS3RequestError { 31 | constructor(message: string = "Bad Request") { 32 | super(message, 400); 33 | } 34 | } 35 | 36 | export class OAS3RequestUnauthorizedError extends OAS3RequestError { 37 | constructor(message: string = "Unauthorized") { 38 | super(message, 401); 39 | } 40 | } 41 | 42 | export class OAS3RequestForbiddenError extends OAS3RequestError { 43 | constructor(message: string = "Forbidden") { 44 | super(message, 403); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/extensions.ts: -------------------------------------------------------------------------------- 1 | import "fastify"; 2 | import "openapi3-ts"; 3 | import "@sinclair/typebox"; 4 | 5 | import { type TSchema } from "@sinclair/typebox"; 6 | import { type FastifySchema } from "fastify"; 7 | import type { OpenAPIObject } from "openapi3-ts"; 8 | 9 | import { type HandlerRetval } from "./autowired-security/types/handlers.js"; 10 | import type { OAS3ResponseTable, OAS3RouteOptions } from "./options.js"; 11 | import type { TaggedSchema } from "./schemas.js"; 12 | 13 | /* eslint-disable @typescript-eslint/no-empty-object-type */ 14 | 15 | export type OAS3SecurityEvaluation = { 16 | result: HandlerRetval; 17 | }; 18 | 19 | declare module "fastify" { 20 | interface FastifyInstance { 21 | readonly openapiDocument: Readonly; 22 | } 23 | 24 | interface FastifyRequest { 25 | oasSecurity?: OAS3SecurityEvaluation; 26 | } 27 | 28 | interface FastifyReply {} 29 | 30 | interface RouteOptions { 31 | oas?: OAS3RouteOptions; 32 | 33 | schema?: FastifySchema & { 34 | body?: TSchema & TaggedSchema; 35 | response?: OAS3ResponseTable; 36 | }; 37 | } 38 | 39 | interface RouteShorthandOptions { 40 | oas?: OAS3RouteOptions; 41 | } 42 | } 43 | 44 | declare module "openapi3-ts" { 45 | interface SchemaObject extends Partial {} 46 | } 47 | 48 | declare module "@sinclair/typebox" { 49 | interface CustomOptions extends Partial {} 50 | } 51 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import "./extensions.js"; 2 | import { oas3Plugin } from "./plugin.js"; 3 | 4 | export default oas3Plugin; 5 | export { OAS3PluginOptions } from "./plugin.js"; 6 | 7 | export type * as OAS31 from "./oas31-types.js"; 8 | 9 | export { schemaType, TaggedSchema } from "./schemas.js"; 10 | 11 | export * from "./ajv.js"; 12 | 13 | export * from "./autowired-security/index.js"; 14 | -------------------------------------------------------------------------------- /src/oas31-types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseParameterObject, 3 | CallbackObject, 4 | CallbacksObject, 5 | ComponentsObject, 6 | ContactObject, 7 | ContentObject, 8 | DiscriminatorObject, 9 | EncodingObject, 10 | EncodingPropertyObject, 11 | ExampleObject, 12 | ExamplesObject, 13 | ExternalDocumentationObject, 14 | HeaderObject, 15 | HeadersObject, 16 | ISpecificationExtension, 17 | InfoObject, 18 | LicenseObject, 19 | LinkObject, 20 | LinkParametersObject, 21 | LinksObject, 22 | MediaTypeObject, 23 | OAuthFlowObject, 24 | OAuthFlowsObject, 25 | OpenAPIObject, 26 | OperationObject, 27 | ParameterObject, 28 | ParameterLocation, 29 | ParameterStyle, 30 | PathItemObject, 31 | PathObject, 32 | PathsObject, 33 | ReferenceObject, 34 | RequestBodyObject, 35 | ResponseObject, 36 | ResponsesObject, 37 | SchemaObject, 38 | SchemasObject, 39 | ScopesObject, 40 | SecurityRequirementObject, 41 | SecuritySchemeObject, 42 | SecuritySchemeType, 43 | ServerObject, 44 | ServerVariableObject, 45 | TagObject, 46 | XmlObject, 47 | } from "openapi3-ts"; 48 | 49 | export { 50 | BaseParameterObject, 51 | CallbackObject, 52 | CallbacksObject, 53 | ComponentsObject, 54 | ContactObject, 55 | ContentObject, 56 | DiscriminatorObject, 57 | EncodingObject, 58 | EncodingPropertyObject, 59 | ExampleObject, 60 | ExamplesObject, 61 | ExternalDocumentationObject, 62 | HeaderObject, 63 | HeadersObject, 64 | ISpecificationExtension, 65 | InfoObject, 66 | LicenseObject, 67 | LinkObject, 68 | LinkParametersObject, 69 | LinksObject, 70 | MediaTypeObject, 71 | OAuthFlowObject, 72 | OAuthFlowsObject, 73 | OpenAPIObject, 74 | OperationObject, 75 | ParameterObject, 76 | ParameterLocation, 77 | ParameterStyle, 78 | PathItemObject, 79 | PathObject, 80 | PathsObject, 81 | ReferenceObject, 82 | RequestBodyObject, 83 | ResponseObject, 84 | ResponsesObject, 85 | SchemaObject, 86 | SchemasObject, 87 | ScopesObject, 88 | SecurityRequirementObject, 89 | SecuritySchemeObject, 90 | SecuritySchemeType, 91 | ServerObject, 92 | ServerVariableObject, 93 | TagObject, 94 | XmlObject, 95 | }; 96 | -------------------------------------------------------------------------------- /src/operation-helpers.ts: -------------------------------------------------------------------------------- 1 | import { camelCase } from "change-case"; 2 | import { type RouteOptions } from "fastify"; 3 | 4 | export type OperationIdFn = (route: RouteOptions) => string; 5 | 6 | // TODO: this is a typescript crime 7 | // I know that `RouteOptions` has `prefix`. Why does TypeScript not? 8 | export const defaultOperationIdFn: OperationIdFn = (route) => 9 | camelCase( 10 | route.url 11 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 12 | .replace(new RegExp(`^${(route as any).prefix}`), "") 13 | .replace("/", " ") + 14 | " " + 15 | route.method 16 | ); 17 | -------------------------------------------------------------------------------- /src/options.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { type RouteOptions } from "fastify"; 3 | import { 4 | type CallbacksObject, 5 | type ExternalDocumentationObject, 6 | type InfoObject, 7 | type OpenApiBuilder, 8 | type OperationObject, 9 | type ParameterStyle, 10 | type PathItemObject, 11 | } from "openapi3-ts"; 12 | 13 | import { 14 | type OAS3RouteSecuritySchemeSpec, 15 | type OAS3AutowireSecurityOptions, 16 | } from "./autowired-security/index.js"; 17 | import { type OperationIdFn } from "./operation-helpers.js"; 18 | 19 | export type OASBuilderFn = (oas: OpenApiBuilder) => void; 20 | export type PathItemFn = (pathItem: PathItemObject) => void; 21 | 22 | export interface OAS3PluginPublishOptions { 23 | /** 24 | * If `'rapidoc'`, serves a Rapidoc UI at `uiPath`. 25 | * 26 | * If `'scalar'`, serves a Scalar UI at `uiPath`. 27 | * 28 | * If `null`, does not serve any explorer UI at all. 29 | * 30 | * Defaults to `'rapidoc'`. This WILL change in the future. 31 | */ 32 | ui?: "rapidoc" | "scalar" | null; 33 | 34 | /** 35 | * The path for the explorer UI. Defaults to `/docs`. 36 | */ 37 | uiPath?: string; 38 | 39 | /** 40 | * Additional options to pass to the Scalar UI. Obviously doesn't 41 | * do anything if `ui` is not `'scalar'`. 42 | */ 43 | scalarExtraOptions?: Record; 44 | 45 | /** 46 | * Serves a JSON version of your OpenAPI specification. If a string 47 | * is passed, that will be the path of the JSON file (otherwise, it 48 | * defaults to `openapi.json`). 49 | * 50 | * To skip, pass `false`. 51 | */ 52 | json?: boolean | string; 53 | /** 54 | * Serves a YAML version of your OpenAPI specification. If a string 55 | * is passed, that will be the path of the YAML file (otherwise, it 56 | * defaults to `openapi.yaml`). 57 | * 58 | * To skip, pass `false`. 59 | */ 60 | yaml?: boolean | string; 61 | } 62 | 63 | export type OperationBuildFn = ( 64 | route: RouteOptions, 65 | operation: OperationObject 66 | ) => void; 67 | 68 | export interface OAS3PluginOptions { 69 | /** 70 | * The base OpenAPI document information. Will be added verbatim 71 | * to the OpenAPI document. 72 | */ 73 | openapiInfo: InfoObject; 74 | 75 | /** 76 | * If set to true, will throw an error if started with an invalid OpenAPI3 77 | * document. 78 | */ 79 | exitOnInvalidDocument?: boolean; 80 | 81 | /** 82 | * Invoked during plugin construction, before any routes are walked. 83 | */ 84 | preParse?: OASBuilderFn; 85 | 86 | /** 87 | * Invoked just before server startup, when all routes have been established. 88 | */ 89 | postParse?: OASBuilderFn; 90 | 91 | /** 92 | * Invoked after an operation has been built but before 93 | * it is added to the OAS document. Typebox schemas should 94 | * still be object forms here, not `$ref`s. 95 | */ 96 | postOperationBuild?: OperationBuildFn; 97 | 98 | /** 99 | * Controls automatically wiring up security schemes to `onRequest` hooks for 100 | * routes that specify `securityScheme`s. 101 | * 102 | * If this is unset, NO wireup will be done. You can still assign `securityScheme`s 103 | * to routes, but you'll need to hande security scheme implementation yourself. 104 | */ 105 | autowiredSecurity?: OAS3AutowireSecurityOptions; 106 | 107 | /** 108 | * Determines how the OpenAPI specification will be parsed and specified for 109 | * this package. 110 | */ 111 | publish?: OAS3PluginPublishOptions; 112 | 113 | /** 114 | * If `false` (the default), the plugin will not include operations where 115 | * `oas` is undefined. This can be useful in cases where plugins add routes 116 | * that you'd like to avoid including, but cannot be practically placed 117 | * outside of the current scope. 118 | */ 119 | includeUnconfiguredOperations?: boolean; 120 | 121 | /** 122 | * If you don't provide an operationId on a route, this function will be 123 | * invoked to try to figure it out. It's probably not great. Be explicit! 124 | */ 125 | operationIdNameFn?: OperationIdFn; 126 | } 127 | 128 | export type OAS3ResponseTable = { 129 | [k: string]: T; 130 | }; 131 | 132 | export type OAS3RouteResponseFields = { 133 | description?: string; 134 | contentType?: string; 135 | schemaOverride?: any; 136 | }; 137 | 138 | export type OAS3RequestBodyInfo = { 139 | description?: string; 140 | contentType?: string; 141 | schemaOverride?: any; 142 | }; 143 | 144 | export type OAS3QueryParamExtras = { 145 | deprecated?: boolean; 146 | description?: string; 147 | example?: any; 148 | allowEmptyValue?: boolean; 149 | allowReserved?: boolean; 150 | style?: ParameterStyle; 151 | explode?: boolean; 152 | schemaOverride?: any; 153 | }; 154 | export type OAS3PathParamExtras = { 155 | description?: string; 156 | example?: any; 157 | schemaOverride?: any; 158 | }; 159 | 160 | export interface OAS3RouteOptions { 161 | operationId?: string; 162 | tags?: string[]; 163 | summary?: string; 164 | description?: string; 165 | deprecated?: boolean; 166 | externalDocs?: ExternalDocumentationObject; 167 | callbacks?: CallbacksObject; 168 | 169 | /** 170 | * The set of OAS securitySchemes to use for this route. 171 | * **NOTE:** security schemes **will** still be processed when `omit: true`. 172 | */ 173 | security?: OAS3RouteSecuritySchemeSpec | Array; 174 | 175 | /** 176 | * If true, no data about this route will be collated for the OpenAPI document. 177 | * **NOTE:** security schemes **will** still be processed! 178 | */ 179 | omit?: boolean; 180 | 181 | /** 182 | * Any fields set here will be merged into the OpenAPI operation object. 183 | */ 184 | custom?: Record; 185 | 186 | body?: OAS3RequestBodyInfo; 187 | 188 | querystring?: Record; 189 | params?: Record; 190 | 191 | responses?: OAS3ResponseTable; 192 | } 193 | -------------------------------------------------------------------------------- /src/path-converter.ts: -------------------------------------------------------------------------------- 1 | type ConversionResult = { 2 | url: string; 3 | paramPatterns: Record; 4 | }; 5 | 6 | export function convertFastifyToOpenAPIPath( 7 | fastifyPath: string 8 | ): ConversionResult { 9 | let url = fastifyPath; 10 | const paramPatterns: Record = {}; 11 | 12 | // Replace :paramName(regex) patterns with OpenAPI style parameters 13 | url = url.replace(/:(\w+)(\([^)]+\))?/g, (match, paramName, regex) => { 14 | if (regex) { 15 | // Remove the parentheses from the regex 16 | paramPatterns[paramName] = regex.slice(1, -1); 17 | } 18 | return `{${paramName}}`; 19 | }); 20 | 21 | return { url, paramPatterns }; 22 | } 23 | -------------------------------------------------------------------------------- /src/plugin.ts: -------------------------------------------------------------------------------- 1 | import OpenAPISchemaValidator from "@seriousme/openapi-schema-validator"; 2 | import { TypeGuard } from "@sinclair/typebox"; 3 | import { type RouteOptions } from "fastify"; 4 | import { type onRequestMetaHookHandler } from "fastify/types/hooks.js"; 5 | import { fastifyPlugin } from "fastify-plugin"; 6 | import * as YAML from "js-yaml"; 7 | import { 8 | OpenApiBuilder, 9 | type SecurityRequirementObject, 10 | type OperationObject, 11 | type PathItemObject, 12 | } from "openapi3-ts"; 13 | 14 | import "./extensions.js"; 15 | import { 16 | attachSecuritySchemesToDocument, 17 | attachSecurityToRoute, 18 | } from "./autowired-security/index.js"; 19 | import { APPLICATION_JSON } from "./constants.js"; 20 | import { OAS3PluginOptionsError, OAS3SpecValidationError } from "./errors.js"; 21 | import { defaultOperationIdFn } from "./operation-helpers.js"; 22 | import { 23 | type OAS3PluginOptions, 24 | type OAS3PluginPublishOptions, 25 | } from "./options.js"; 26 | import { convertFastifyToOpenAPIPath } from "./path-converter.js"; 27 | import { canonicalizeAnnotatedSchemas } from "./spec-transforms/index.js"; 28 | import { rapidocSkeleton } from "./ui/rapidoc.js"; 29 | import { findMissingEntries } from "./util.js"; 30 | 31 | // TODO: switch this to openapi-types; it's slightly more rigorous, but has some gremlins 32 | export * as OAS31 from "openapi3-ts"; 33 | export { OAS3PluginOptions } from "./options.js"; 34 | 35 | const validator = new OpenAPISchemaValidator(); 36 | 37 | export const oas3Plugin = fastifyPlugin( 38 | async (fastify, options) => { 39 | const pLog = fastify.log.child({ plugin: "OAS3Plugin" }); 40 | 41 | if (!options.openapiInfo) { 42 | throw new OAS3PluginOptionsError("options.openapiInfo is required."); 43 | } 44 | 45 | pLog.debug({ options }, "Initializing OAS3 plugin."); 46 | 47 | const publish: OAS3PluginPublishOptions = options.publish ?? { 48 | ui: "rapidoc", 49 | json: true, 50 | yaml: true, 51 | }; 52 | 53 | const uiPath = publish.uiPath ?? "/docs"; 54 | 55 | const isSkippablePath = (path: string) => { 56 | return ( 57 | path.startsWith(uiPath) || 58 | path === "/openapi.json" || 59 | path === "/openapi.yaml" 60 | ); 61 | }; 62 | 63 | const operationIdNameFn = options.operationIdNameFn ?? defaultOperationIdFn; 64 | 65 | // we append routes to this, rather than doing transforms, to allow 66 | // other plugins to (potentially) modify them before we do all our filthy 67 | // object-munging business during `onReady`. 68 | const routes: Array = []; 69 | fastify.addHook("onRoute", async (route) => { 70 | const hookCache: Record = {}; 71 | 72 | const rLog = pLog.child({ 73 | route: { url: route.url, method: route.method }, 74 | }); 75 | 76 | if (isSkippablePath(route.url)) { 77 | rLog.debug("Skipping OpenAPI route."); 78 | return; 79 | } 80 | 81 | if (route?.oas?.omit !== true) { 82 | routes.push(route); 83 | } 84 | 85 | if (options.autowiredSecurity && !options.autowiredSecurity.disabled) { 86 | rLog.debug("Attaching security to route."); 87 | attachSecurityToRoute( 88 | rLog, 89 | route, 90 | options.autowiredSecurity, 91 | hookCache 92 | ); 93 | } 94 | }); 95 | 96 | const postBuildDebounce: Record = {}; 97 | fastify.addHook("onReady", async () => { 98 | try { 99 | pLog.debug("OAS3 onReady hit."); 100 | let documentSecurity: SecurityRequirementObject[] | undefined; 101 | if (options.autowiredSecurity?.rootSecurity) { 102 | documentSecurity = Array.isArray( 103 | options.autowiredSecurity.rootSecurity 104 | ) 105 | ? options.autowiredSecurity.rootSecurity 106 | : [options.autowiredSecurity.rootSecurity]; 107 | } 108 | 109 | let builder = new OpenApiBuilder({ 110 | openapi: "3.1.0", 111 | info: options.openapiInfo, 112 | paths: {}, 113 | security: documentSecurity, 114 | }); 115 | 116 | // handy place for stuff like security schemas and the like 117 | if (options.preParse) { 118 | pLog.debug("Calling preParse."); 119 | options.preParse(builder); 120 | } 121 | 122 | let doc = builder.rootDoc; 123 | 124 | for (const route of routes) { 125 | const rLog = pLog.child({ 126 | route: { url: route.url, method: route.method }, 127 | }); 128 | 129 | if (isSkippablePath(route.url)) { 130 | rLog.debug("Skipping UI route."); 131 | continue; 132 | } 133 | 134 | const oasConvertedUrl = convertFastifyToOpenAPIPath(route.url); 135 | rLog.info( 136 | { oasUrl: oasConvertedUrl.url }, 137 | "Building operation for route." 138 | ); 139 | 140 | const oas = route.oas; 141 | if (!oas && options.includeUnconfiguredOperations) { 142 | rLog.debug("Route has no OAS config; skipping."); 143 | continue; 144 | } 145 | 146 | if (oas?.omit) { 147 | rLog.debug("Route has omit = true; skipping."); 148 | } 149 | const operation: OperationObject = { 150 | operationId: oas?.operationId ?? operationIdNameFn(route), 151 | summary: oas?.summary ?? route.url, 152 | description: 153 | oas?.description ?? "No operation description specified.", 154 | deprecated: oas?.deprecated, 155 | tags: oas?.tags, 156 | responses: {}, 157 | }; 158 | 159 | // Only add security to operation when explicitly set (including empty array to disable) 160 | if (oas?.security !== undefined) { 161 | const securities = Array.isArray(oas.security) 162 | ? oas.security 163 | : [oas.security]; 164 | operation.security = securities; 165 | } 166 | 167 | // and now do some inference to build our operation object... 168 | if (route.schema) { 169 | rLog.debug("Beginning to build operation object from schema."); 170 | const { body, params, querystring, response } = route.schema; 171 | 172 | if (body || oas?.body) { 173 | rLog.debug("Adding request body to operation."); 174 | if (!TypeGuard.IsSchema(body)) { 175 | rLog.warn( 176 | "Route has a request body that is not a schema. Skipping." 177 | ); 178 | } else { 179 | const oasRequestBody = oas?.body; 180 | const requestBodyContentType = 181 | oasRequestBody?.contentType ?? APPLICATION_JSON; 182 | operation.requestBody = { 183 | description: 184 | oas?.body?.description ?? 185 | "No request body description specified.", 186 | content: { 187 | [requestBodyContentType]: { 188 | schema: oas?.body?.schemaOverride ?? body, 189 | }, 190 | }, 191 | }; 192 | } 193 | } 194 | 195 | if (querystring) { 196 | rLog.debug("Adding query string to operation."); 197 | 198 | if (!TypeGuard.IsObject(querystring)) { 199 | rLog.warn( 200 | "Route has a querystring that is not a schema. Skipping." 201 | ); 202 | } else { 203 | operation.parameters = operation.parameters ?? []; 204 | 205 | if (querystring.additionalProperties) { 206 | rLog.warn( 207 | "Route's querystring has additionalProperties. This will be ignored." 208 | ); 209 | } 210 | 211 | const routeQsExtras = route.oas?.querystring ?? {}; 212 | const qsEntries = Object.entries(querystring.properties ?? {}); 213 | 214 | const unmatchedExtras = findMissingEntries( 215 | routeQsExtras, 216 | qsEntries 217 | ); 218 | if (unmatchedExtras.length > 0) { 219 | rLog.warn( 220 | { unmatchedExtras }, 221 | `Route's querystring has extra properties. These will be ignored: ${unmatchedExtras.join(", ")}` 222 | ); 223 | } 224 | 225 | for (const [qsKey, qsValue] of qsEntries) { 226 | const qsExtra = routeQsExtras[qsKey] ?? {}; 227 | 228 | operation.parameters.push({ 229 | name: qsKey, 230 | in: "query", 231 | deprecated: qsExtra.deprecated, 232 | description: 233 | qsExtra.description ?? 234 | qsValue.description ?? 235 | "No querystring parameter description specified.", 236 | example: qsExtra.example ?? qsValue.example, 237 | required: querystring.required?.includes(qsKey) ?? false, 238 | schema: qsExtra.schemaOverride ?? qsValue, 239 | style: qsExtra.style, 240 | allowEmptyValue: qsExtra.allowEmptyValue, 241 | allowReserved: qsExtra.allowReserved, 242 | explode: qsExtra.explode, 243 | }); 244 | } 245 | } 246 | } 247 | 248 | if (params) { 249 | rLog.debug("Adding params to operation."); 250 | 251 | if (!TypeGuard.IsObject(params)) { 252 | rLog.warn("Route has a params that is not a schema. Skipping."); 253 | } else { 254 | operation.parameters = operation.parameters ?? []; 255 | 256 | if (params.additionalProperties) { 257 | rLog.warn( 258 | "Route's params has additionalProperties. This will be ignored." 259 | ); 260 | } 261 | 262 | const routeParamsExtras = route.oas?.params ?? {}; 263 | const paramsEntries = Object.entries(params.properties ?? {}); 264 | 265 | const unmatchedExtras = findMissingEntries( 266 | routeParamsExtras, 267 | paramsEntries 268 | ); 269 | if (unmatchedExtras.length > 0) { 270 | rLog.warn( 271 | { unmatchedExtras }, 272 | `Route's params has extra properties. These will be ignored: ${unmatchedExtras.join(", ")}` 273 | ); 274 | } 275 | 276 | for (const [paramKey, paramValue] of paramsEntries) { 277 | const paramExtra = routeParamsExtras[paramKey] ?? {}; 278 | 279 | if (!params.required?.includes(paramKey)) { 280 | rLog.warn( 281 | { paramKey }, 282 | `Route's param is marked as not required. This will be ignored.` 283 | ); 284 | } 285 | 286 | operation.parameters.push({ 287 | name: paramKey, 288 | in: "path", 289 | description: 290 | paramExtra.description ?? 291 | paramValue.description ?? 292 | "No path parameter description specified.", 293 | required: true, 294 | example: paramExtra.example ?? paramValue.example, 295 | schema: paramExtra.schemaOverride ?? paramValue, 296 | }); 297 | } 298 | } 299 | } 300 | 301 | if (response) { 302 | // TODO: expand this to use full fastify multi-response-type support, if desired (I don't, PRs welcome) 303 | for (const responseCode of Object.keys(response)) { 304 | if ( 305 | responseCode !== "default" && 306 | !/^[1-5][0-9]{2}/.test(responseCode.toString()) 307 | ) { 308 | rLog.warn( 309 | `Route has a response schema of code '${responseCode}', which OAS3Plugin does not support.` 310 | ); 311 | continue; 312 | } 313 | 314 | const oasResponses = oas?.responses?.[responseCode]; 315 | const responseContentType = 316 | oasResponses?.contentType ?? APPLICATION_JSON; 317 | operation.responses[responseCode] = { 318 | description: 319 | oasResponses?.description ?? 320 | "No response description specified.", 321 | content: { 322 | [responseContentType]: { 323 | schema: 324 | oasResponses?.schemaOverride ?? response[responseCode], 325 | }, 326 | }, 327 | }; 328 | } 329 | } 330 | } 331 | 332 | if (options.postOperationBuild) { 333 | options.postOperationBuild(route, operation); 334 | } 335 | 336 | /// ...and now we wedge it into doc.paths 337 | const oasUrl = oasConvertedUrl.url; 338 | const p: PathItemObject = doc.paths[oasUrl] ?? {}; 339 | 340 | doc.paths[oasUrl] = p; 341 | // TODO: is this right? who actually uses 'all' for API reqs? 342 | [route.method] 343 | .flat() 344 | .forEach((method) => (p[method.toLowerCase()] = operation)); 345 | } 346 | 347 | // and now let's normalize out all our schema, hold onto your butts 348 | doc = await canonicalizeAnnotatedSchemas(doc); 349 | doc.components = doc.components ?? {}; 350 | doc.components.securitySchemes = doc.components.securitySchemes ?? {}; 351 | 352 | builder = OpenApiBuilder.create(doc); 353 | 354 | // we need to attach our security schemes to the doc 355 | if (options.autowiredSecurity && !options.autowiredSecurity.disabled) { 356 | attachSecuritySchemesToDocument( 357 | pLog, 358 | builder, 359 | options.autowiredSecurity 360 | ); 361 | } 362 | 363 | // and some wrap-up before we consider the builder-y bits done 364 | if (options.postParse) { 365 | pLog.debug("Calling postParse."); 366 | options.postParse(builder); 367 | } 368 | 369 | doc = builder.rootDoc; 370 | 371 | const result = await validator.validate(doc); 372 | if (!result.valid) { 373 | if (options.exitOnInvalidDocument) { 374 | pLog.error( 375 | { openApiErrors: result.errors, doc }, 376 | "Errors in OpenAPI validation." 377 | ); 378 | throw new OAS3SpecValidationError(); 379 | } 380 | 381 | pLog.warn( 382 | { openApiErrors: result.errors }, 383 | "Errors in OpenAPI validation." 384 | ); 385 | } 386 | 387 | pLog.debug("Assigning completed OAS document to FastifyInstance."); 388 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 389 | (fastify as any).openapiDocument = doc; 390 | } catch (err) { 391 | pLog.error({ err }, "Error during plugin instantiation."); 392 | throw err; 393 | } 394 | }); 395 | 396 | if (publish.json) { 397 | const path = 398 | typeof publish.json === "string" ? publish.json : "openapi.json"; 399 | let jsonContent: string | null = null; 400 | fastify.get( 401 | `/${path}`, 402 | { 403 | oas: { omit: true }, 404 | }, 405 | (req, rep) => { 406 | jsonContent = 407 | jsonContent ?? JSON.stringify(fastify.openapiDocument, null, 2); 408 | 409 | rep 410 | .code(200) 411 | .header("Content-Type", "application/json; charset=utf-8") 412 | .header("Content-Disposition", "inline") 413 | .send(jsonContent); 414 | } 415 | ); 416 | } 417 | 418 | if (publish.yaml) { 419 | const path = 420 | typeof publish.yaml === "string" ? publish.yaml : "openapi.yaml"; 421 | let yamlContent: string | null = null; 422 | fastify.get( 423 | `/${path}`, 424 | { 425 | oas: { omit: true }, 426 | }, 427 | (req, rep) => { 428 | yamlContent = yamlContent ?? YAML.dump(fastify.openapiDocument); 429 | 430 | rep 431 | .code(200) 432 | .header("Content-Type", "application/x-yaml; charset=utf-8") 433 | .header("Content-Disposition", "inline") 434 | .send(yamlContent); 435 | } 436 | ); 437 | } 438 | 439 | if (publish.ui) { 440 | switch (publish.ui) { 441 | case "rapidoc": { 442 | pLog.info("Enabling Rapidoc UI."); 443 | let rapidocContent: string | null = null; 444 | fastify.get( 445 | uiPath, 446 | { 447 | oas: { omit: true }, 448 | }, 449 | (req, rep) => { 450 | rapidocContent = 451 | rapidocContent ?? rapidocSkeleton(fastify.openapiDocument); 452 | rep 453 | .code(200) 454 | .header("Content-Type", "text/html") 455 | .send(rapidocContent); 456 | } 457 | ); 458 | break; 459 | } 460 | case "scalar": { 461 | pLog.info("Enabling Scalar UI."); 462 | const scalar = (await import("@scalar/fastify-api-reference")) 463 | .default; 464 | await fastify.register(scalar, { 465 | // TODO: tighten this up 466 | // later versions of typescript have made this more specific 467 | routePrefix: uiPath as `/${string}`, 468 | configuration: { 469 | ...(publish.scalarExtraOptions ?? {}), 470 | spec: { 471 | content: () => fastify.openapiDocument, 472 | }, 473 | }, 474 | }); 475 | } 476 | } 477 | } 478 | }, 479 | "5.x" 480 | ); 481 | -------------------------------------------------------------------------------- /src/schemas.ts: -------------------------------------------------------------------------------- 1 | import { type CustomOptions, type TSchema } from "@sinclair/typebox"; 2 | import { pascalCase } from "change-case"; 3 | 4 | import { SCHEMA_NAME_PROPERTY } from "./constants.js"; 5 | 6 | export interface TaggedSchema { 7 | /** 8 | * Added for internal use within `@eropple/fastify-openapi3`. Don't touch this if you 9 | * don't know what you're doing! 10 | * 11 | * If you want to know what you're doing: well, this is the uniqueness key 12 | * for your schemas, because using `$id` in schemas means that you, personally, will have 13 | * to do the juggling rather than being able to use TypeScript objects via Typebox. And 14 | * part of using Typebox, and specifically using `schemaType` to wrap Typebox objects, 15 | * is that `@eropple/fastify-openapi3` is smart enough to yell at you if you used different 16 | * types pretending to be the same one. 17 | * 18 | * Whatever value you pass to this will end up `PascalCased` when you see it later. 19 | * 20 | * Most use cases should never need to use this, I expect. This is exposed, however, in 21 | * case you're pulling in JSON Schema schemas from outside sources and need to do some 22 | * doctoring. 23 | */ 24 | [SCHEMA_NAME_PROPERTY]: symbol; 25 | } 26 | 27 | /** 28 | * Decorates a `@sinclair/typebox` schema with a name for use in 29 | * `@eropple/fastify-openapi3`. 30 | * 31 | * If you decorate two separate types with the same `name`, this 32 | * will explode at speed. You have been warned. I will laugh. 33 | */ 34 | export function schemaType( 35 | name: string, 36 | type: T 37 | ): T & TaggedSchema { 38 | return { ...type, [SCHEMA_NAME_PROPERTY]: Symbol(pascalCase(name)) }; 39 | } 40 | -------------------------------------------------------------------------------- /src/spec-transforms/canonicalize.ts: -------------------------------------------------------------------------------- 1 | import { SCHEMA_NAME_PROPERTY } from "../constants.js"; 2 | 3 | import { type TaggedSchemaObject } from "./oas-helpers.js"; 4 | 5 | /** 6 | * Recursive function to untangle all these schemas. 7 | * 8 | * Once a schema is visited, its symbol (NOT its string name) is put in 9 | * `seenSet`. This allows for bailing out if we get caught in a loop. 10 | * 11 | * Once a schema is canonicalized (for the first time), it should be placed 12 | * in `completed`. 13 | * @param current 14 | * @param completed 15 | * @param seenSet 16 | */ 17 | function canonicalizeSchema( 18 | current: TaggedSchemaObject, 19 | completed: Record, 20 | seenSet: Set 21 | ) { 22 | const tag = current[SCHEMA_NAME_PROPERTY]; 23 | if (!tag) { 24 | throw new Error( 25 | `Should never happen - tagged schema w/o tag? ${JSON.stringify(current)}` 26 | ); 27 | } 28 | if (!tag.description || tag.description.length < 1) { 29 | throw new Error("all schemas must be tagged with a non-empty string name"); 30 | } 31 | 32 | // must not be in the seen set or we're in a loop 33 | if (seenSet.has(tag)) { 34 | throw new Error(`Duplicate schema found with name '${tag.description}'.`); 35 | } 36 | 37 | // since now we're starting our operation, we add to the seen set 38 | seenSet.add(tag); 39 | 40 | // `completed` uses strings to catch ourselves if we create two tagged schemas 41 | // with the same human name. so let's check that. 42 | const existing = completed[tag.description]; 43 | if (existing) { 44 | if (existing[SCHEMA_NAME_PROPERTY] !== tag) { 45 | throw new Error( 46 | `Duplicate schemas found with tag '${tag.description}': 47 | 48 | ${JSON.stringify(existing)} 49 | 50 | ${JSON.stringify(current)}` 51 | ); 52 | } 53 | 54 | // it's already completed, and it's us, so we'll fall through to the end. 55 | } else { 56 | // it is NOT already completed, so let's add ourselves. 57 | 58 | completed[tag.description] = current; 59 | } 60 | 61 | // and now that we have completed our work, we remove ourselves from the seen set 62 | seenSet.delete(tag); 63 | } 64 | 65 | export function canonicalizeSchemas( 66 | schemas: Array 67 | ): Record { 68 | const canonicalizedSchemas: Record = {}; 69 | const seenSet = new Set(); 70 | 71 | schemas.forEach((s) => canonicalizeSchema(s, canonicalizedSchemas, seenSet)); 72 | return canonicalizedSchemas; 73 | } 74 | -------------------------------------------------------------------------------- /src/spec-transforms/find.ts: -------------------------------------------------------------------------------- 1 | import { TypeGuard } from "@sinclair/typebox"; 2 | import { 3 | type CallbackObject, 4 | type CallbacksObject, 5 | type OpenAPIObject, 6 | type OperationObject, 7 | type ParameterObject, 8 | type PathItemObject, 9 | type ReferenceObject, 10 | type RequestBodyObject, 11 | type ResponseObject, 12 | type ResponsesObject, 13 | type SchemaObject, 14 | } from "openapi3-ts"; 15 | import { isFalsy } from "utility-types"; 16 | 17 | import { APPLICATION_JSON } from "../constants.js"; 18 | import { 19 | isNotPrimitive, 20 | isNotReferenceObject, 21 | isTaggedSchema, 22 | isTruthy, 23 | } from "../util.js"; 24 | 25 | import { 26 | mapPathItems, 27 | operations, 28 | type TaggedSchemaObject, 29 | } from "./oas-helpers.js"; 30 | 31 | function findTaggedSchemasInSchemas( 32 | s: SchemaObject 33 | ): Array { 34 | // TODO: remove duplication 35 | // This method isn't optimal. It returns the same schemas multiple times 36 | // if they're nested. This isn't the worst thing in the world, because 37 | // they get deduplicated later and we are not dealing with large N here, 38 | // but it does add time to plugin startup. 39 | if (isFalsy(s.type) && !s.allOf && !s.anyOf && !s.oneOf) { 40 | throw new Error(`Schema object has no type: ${JSON.stringify(s)}`); 41 | } 42 | 43 | const ret = [ 44 | s.allOf ?? [], 45 | s.anyOf ?? [], 46 | s.oneOf ?? [], 47 | Object.values(s.properties ?? {}), 48 | s.additionalProperties, 49 | TypeGuard.IsArray(s) && [s.items], 50 | ] 51 | .flat() 52 | .filter(isNotPrimitive) 53 | .flatMap(findTaggedSchemasInSchemas); 54 | 55 | if (isTaggedSchema(s)) { 56 | ret.push(s); 57 | } 58 | 59 | return ret; 60 | } 61 | 62 | function findTaggedSchemasInResponse( 63 | r: ResponseObject 64 | ): Array { 65 | const ret: Array = []; 66 | 67 | const content = r?.content ?? {}; 68 | 69 | for (const [_mediaType, responseContent] of Object.entries(content)) { 70 | if (responseContent.schema && isTaggedSchema(responseContent.schema)) { 71 | ret.push(responseContent.schema); 72 | } 73 | } 74 | 75 | return ret; 76 | } 77 | 78 | function findTaggedSchemasInResponses( 79 | r: ResponsesObject 80 | ): Array { 81 | return Object.keys(r) 82 | .map((k) => r[k] as ResponseObject | ReferenceObject) 83 | .filter(isNotReferenceObject) 84 | .flatMap(findTaggedSchemasInResponse); 85 | } 86 | 87 | function findTaggedSchemasInCallbacks( 88 | c: CallbacksObject 89 | ): Array { 90 | return ( 91 | Object.keys(c) 92 | .map((k) => c[k] as CallbackObject | ReferenceObject) 93 | .filter(isNotReferenceObject) 94 | .flatMap((cb) => Object.values(cb) as Array) 95 | .flatMap((p) => operations(p)) 96 | // oh yeah, btw, this is _recursive_, ugh 97 | .flatMap((o) => findTaggedSchemasInOperation(o)) 98 | ); 99 | } 100 | 101 | function findTaggedSchemasInRequestBody( 102 | rb: RequestBodyObject 103 | ): Array { 104 | const ret: Array = []; 105 | 106 | for (const [k, v] of Object.entries(rb.content ?? {})) { 107 | const bodySchema = v.schema; 108 | 109 | if (bodySchema && isTaggedSchema(bodySchema)) { 110 | ret.push(bodySchema); 111 | } 112 | } 113 | 114 | return ret; 115 | } 116 | 117 | function findTaggedSchemasInParameter( 118 | parameter: ParameterObject 119 | ): Array { 120 | return [parameter] 121 | .filter(isNotReferenceObject) 122 | .map((param) => param.schema) 123 | .filter(isTaggedSchema); 124 | } 125 | 126 | function findTaggedSchemasInOperation( 127 | operation: OperationObject 128 | ): Array { 129 | const ret: Array = []; 130 | 131 | // any operation parameter with a tag should be collected 132 | ret.push( 133 | ...(operation.parameters ?? []) 134 | .filter(isNotReferenceObject) 135 | .flatMap(findTaggedSchemasInParameter) 136 | ); 137 | 138 | // request body (but only for JSON, everything else is outta scope) 139 | const { requestBody } = operation; 140 | if (requestBody && isNotReferenceObject(requestBody)) { 141 | ret.push(...findTaggedSchemasInRequestBody(requestBody)); 142 | } 143 | 144 | // The type for responses is a little weird, but we'll get any JSON responses from it too. 145 | ret.push(...findTaggedSchemasInResponses(operation.responses ?? {})); 146 | 147 | // I have never used an OAS callback but by jove I'm not leaving you out in the cold 148 | ret.push(...findTaggedSchemasInCallbacks(operation.callbacks ?? {})); 149 | 150 | return ret; 151 | } 152 | 153 | function findTaggedSchemasInPathItem( 154 | path: PathItemObject 155 | ): Array { 156 | const ret = operations(path).flatMap(findTaggedSchemasInOperation); 157 | 158 | if (path.parameters) { 159 | ret.push( 160 | ...path.parameters 161 | .filter(isNotReferenceObject) 162 | .flatMap(findTaggedSchemasInParameter) 163 | ); 164 | } 165 | 166 | return ret; 167 | } 168 | 169 | export function findTaggedSchemas( 170 | oas: OpenAPIObject 171 | ): Array { 172 | oas.components = oas.components ?? {}; 173 | oas.components.schemas = oas.components.schemas ?? {}; 174 | oas.components.callbacks = oas.components.callbacks ?? {}; 175 | oas.components.requestBodies = oas.components.requestBodies ?? {}; 176 | oas.components.parameters = oas.components.parameters ?? {}; 177 | oas.components.responses = oas.components.responses ?? {}; 178 | 179 | const rootSchemas: Array = [ 180 | ...Object.values(oas.components.schemas).flatMap( 181 | findTaggedSchemasInSchemas 182 | ), 183 | ]; 184 | 185 | // walk all paths 186 | rootSchemas.push(...mapPathItems(oas, findTaggedSchemasInPathItem).flat()); 187 | 188 | // now let's handle our other components 189 | rootSchemas.push( 190 | ...Object.values(oas.components.callbacks).flatMap( 191 | findTaggedSchemasInCallbacks 192 | ), 193 | ...Object.values(oas.components.requestBodies) 194 | .filter(isNotReferenceObject) 195 | .flatMap(findTaggedSchemasInRequestBody), 196 | ...Object.values(oas.components.responses) 197 | .filter(isNotReferenceObject) 198 | .flatMap(findTaggedSchemasInResponse), 199 | ...Object.values(oas.components.parameters) 200 | .filter(isNotReferenceObject) 201 | .flatMap(findTaggedSchemasInParameter) 202 | ); 203 | 204 | return rootSchemas.flatMap(findTaggedSchemasInSchemas); 205 | } 206 | -------------------------------------------------------------------------------- /src/spec-transforms/fixup.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type CallbackObject, 3 | isSchemaObject, 4 | type OpenAPIObject, 5 | type PathItemObject, 6 | type ReferenceObject, 7 | type ResponseObject, 8 | type SchemaObject, 9 | } from "openapi3-ts"; 10 | 11 | import { APPLICATION_JSON, SCHEMA_NAME_PROPERTY } from "../constants.js"; 12 | import { type TaggedSchema } from "../schemas.js"; 13 | import { isNotReferenceObject, isTaggedSchema } from "../util.js"; 14 | 15 | import { 16 | mapPathItems, 17 | type MaybeSchemaHolder, 18 | operations, 19 | } from "./oas-helpers.js"; 20 | 21 | function refFromTaggedSchema(s: TaggedSchema): ReferenceObject { 22 | return { 23 | $ref: `#/components/schemas/${s[SCHEMA_NAME_PROPERTY].description}`, 24 | }; 25 | } 26 | 27 | function fixupSchemaHolder(s: MaybeSchemaHolder): void { 28 | if (s.schema) { 29 | if (isSchemaObject(s.schema)) { 30 | fixupReferencesInSchema(s.schema); 31 | } 32 | 33 | if (isTaggedSchema(s.schema)) { 34 | s.schema = refFromTaggedSchema(s.schema); 35 | } 36 | } 37 | } 38 | 39 | function fixupReferencesInSchema(s: SchemaObject) { 40 | if (s.allOf) { 41 | s.allOf = s.allOf.map((s2) => { 42 | if (isSchemaObject(s2)) { 43 | fixupReferencesInSchema(s2); 44 | } 45 | 46 | return isTaggedSchema(s2) ? refFromTaggedSchema(s2) : s2; 47 | }); 48 | } 49 | 50 | if (s.anyOf) { 51 | s.anyOf = s.anyOf.map((s2) => { 52 | if (isSchemaObject(s2)) { 53 | fixupReferencesInSchema(s2); 54 | } 55 | return isTaggedSchema(s2) ? refFromTaggedSchema(s2) : s2; 56 | }); 57 | } 58 | 59 | if ( 60 | typeof s.additionalProperties === "object" && 61 | isSchemaObject(s.additionalProperties) 62 | ) { 63 | fixupReferencesInSchema(s.additionalProperties); 64 | 65 | if (isTaggedSchema(s.additionalProperties)) { 66 | s.additionalProperties = refFromTaggedSchema(s.additionalProperties); 67 | } 68 | } 69 | 70 | if (typeof s.items === "object" && isSchemaObject(s.items)) { 71 | fixupReferencesInSchema(s.items); 72 | 73 | if (isTaggedSchema(s.items)) { 74 | s.items = refFromTaggedSchema(s.items); 75 | } 76 | } 77 | 78 | if (s.properties) { 79 | for (const propKey in s.properties) { 80 | const s2 = s.properties[propKey]; 81 | if (isSchemaObject(s2)) { 82 | fixupReferencesInSchema(s2); 83 | } 84 | s.properties[propKey] = isTaggedSchema(s2) ? refFromTaggedSchema(s2) : s2; 85 | } 86 | } 87 | } 88 | 89 | function fixupPathItems(p: PathItemObject) { 90 | p?.parameters?.filter(isNotReferenceObject)?.forEach(fixupSchemaHolder); 91 | 92 | operations(p).forEach((oper) => { 93 | oper?.parameters?.filter(isNotReferenceObject)?.forEach(fixupSchemaHolder); 94 | 95 | if (oper.requestBody && isNotReferenceObject(oper.requestBody)) { 96 | const content = oper.requestBody.content ?? {}; 97 | for (const [_mediaType, responseContent] of Object.entries(content)) { 98 | if (responseContent) { 99 | fixupSchemaHolder(responseContent); 100 | } 101 | } 102 | } 103 | 104 | const r = oper.responses; 105 | if (r) { 106 | Object.keys(r) 107 | .map((k) => r[k] as ResponseObject | ReferenceObject) 108 | .filter(isNotReferenceObject) 109 | .forEach((resp) => { 110 | const content = resp?.content ?? {}; 111 | 112 | for (const [_mediaType, responseContent] of Object.entries( 113 | content ?? {} 114 | )) { 115 | if (responseContent) { 116 | fixupSchemaHolder(responseContent); 117 | } 118 | } 119 | }); 120 | } 121 | 122 | const c = oper.callbacks ?? {}; 123 | Object.keys(c) 124 | .map((k) => c[k] as CallbackObject | ReferenceObject) 125 | .filter(isNotReferenceObject) 126 | .forEach((cbs) => 127 | // again with the gross reentrancy 128 | (Object.values(cbs) as Array).forEach(fixupPathItems) 129 | ); 130 | }); 131 | } 132 | 133 | export function fixupSpecSchemaRefs(oas: OpenAPIObject): void { 134 | // first, top level schemas 135 | Object.values(oas.components!.schemas!) 136 | .filter(isSchemaObject) 137 | .forEach(fixupReferencesInSchema); 138 | 139 | // then, everything else 140 | mapPathItems(oas, fixupPathItems); 141 | } 142 | -------------------------------------------------------------------------------- /src/spec-transforms/index.ts: -------------------------------------------------------------------------------- 1 | import { type OpenAPIObject } from "openapi3-ts"; 2 | 3 | import { isTaggedSchema } from "../util.js"; 4 | 5 | import { canonicalizeSchemas } from "./canonicalize.js"; 6 | import { findTaggedSchemas } from "./find.js"; 7 | import { fixupSpecSchemaRefs } from "./fixup.js"; 8 | 9 | /** 10 | * Functions that represent a transformation to be applied to a specification 11 | * after the plugin has generated it. 12 | * 13 | * These may be destructive (and then return the `oas` object 14 | * passed in) or pass an entirely new schema object, at your discretion. 15 | */ 16 | export type OASTransformFunction = ( 17 | oas: OpenAPIObject 18 | ) => OpenAPIObject | Promise; 19 | 20 | /** 21 | * Walks the OAS spec to find the plugin's extension tag on all schemas that it can reach 22 | * and extracts them to the top level `components`. This is recursive, so it does have a 23 | * theoretical stack depth, and also it has rudimentary cycle detection (and doesn't know 24 | * how to deal with them, but then again neither do many OAS generators--PRs welcome!). 25 | * 26 | * This _will_ explode, and loudly, if it finds two schemas with the same annotated name 27 | * that do not match. 28 | */ 29 | export const canonicalizeAnnotatedSchemas: OASTransformFunction = (oas) => { 30 | oas.components = oas.components ?? {}; 31 | oas.components.schemas = oas.components.schemas ?? {}; 32 | oas.components.callbacks = oas.components.callbacks ?? {}; 33 | oas.components.requestBodies = oas.components.requestBodies ?? {}; 34 | oas.components.parameters = oas.components.parameters ?? {}; 35 | oas.components.responses = oas.components.responses ?? {}; 36 | 37 | // step 1: find all places where a tagged schema can be hiding out. 38 | const rootSchemas = findTaggedSchemas(oas).filter(isTaggedSchema); 39 | 40 | // step 2: build the canonical schema map from all those tagged schemas. this 41 | // will end up in our top-level `#/components/schemas`. 42 | const canonicalized = canonicalizeSchemas(rootSchemas); 43 | oas.components.schemas = { 44 | ...oas.components.schemas, 45 | ...canonicalized, 46 | }; 47 | 48 | // step 3: walk the doc again, this time touching everything that _isn't_ a 49 | // top level schema. if we find a tagged schema, replace it with a 50 | // JSON reference to the entry in `#/components`. 51 | fixupSpecSchemaRefs(oas); 52 | 53 | // step 4: clean up all the "not-JSON" stuff so users aren't confused later when 54 | // they see a "JSON specification" with symbols, etc. in it. 55 | oas = JSON.parse(JSON.stringify(oas)); 56 | 57 | return oas; 58 | }; 59 | -------------------------------------------------------------------------------- /src/spec-transforms/oas-helpers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type OpenAPIObject, 3 | type OperationObject, 4 | type PathItemObject, 5 | type ReferenceObject, 6 | type SchemaObject, 7 | } from "openapi3-ts"; 8 | 9 | import { type TaggedSchema } from "../schemas.js"; 10 | import { isNotReferenceObject, isTruthy } from "../util.js"; 11 | 12 | export type TaggedSchemaObject = SchemaObject & TaggedSchema; 13 | export type MaybeSchemaHolder = { schema?: SchemaObject | ReferenceObject }; 14 | export type SchemaHolder = { schema: SchemaObject | ReferenceObject }; 15 | 16 | export function operations(path: PathItemObject): Array { 17 | return [ 18 | path.get, 19 | path.put, 20 | path.post, 21 | path.delete, 22 | path.options, 23 | path.head, 24 | path.patch, 25 | path.trace, 26 | ].filter(isTruthy); 27 | } 28 | 29 | export function mapPathItems( 30 | oas: OpenAPIObject, 31 | fn: (path: PathItemObject) => T 32 | ): Array { 33 | return Object.values(oas.paths || {}) 34 | .filter(isNotReferenceObject) 35 | .flatMap(fn); 36 | } 37 | -------------------------------------------------------------------------------- /src/test/autowired-security.spec.ts: -------------------------------------------------------------------------------- 1 | import { fastifyCookie } from "@fastify/cookie"; 2 | import { Type } from "@sinclair/typebox"; 3 | import Fastify, { 4 | type FastifyInstance, 5 | type FastifyServerOptions, 6 | } from "fastify"; 7 | import { describe, expect, test } from "vitest"; 8 | 9 | import { oas3PluginAjv } from "../ajv.js"; 10 | import { type OAS3AutowireSecurityOptions } from "../autowired-security/index.js"; 11 | import { type OAS3PluginOptions } from "../options.js"; 12 | import { oas3Plugin } from "../plugin.js"; 13 | 14 | const fastifyOpts: FastifyServerOptions = { 15 | logger: { level: "error" }, 16 | ajv: { 17 | customOptions: { 18 | coerceTypes: true, 19 | }, 20 | plugins: [oas3PluginAjv], 21 | }, 22 | }; 23 | const pluginOpts: OAS3PluginOptions = { 24 | openapiInfo: { 25 | title: "test", 26 | version: "0.1.0", 27 | }, 28 | }; 29 | const autowiredOpts: OAS3AutowireSecurityOptions = { 30 | disabled: false, 31 | securitySchemes: {}, 32 | }; 33 | 34 | describe("autowired security", () => { 35 | describe("doc generation", () => { 36 | test("attaching security schemes to document works", async () => { 37 | const fastify = Fastify(fastifyOpts); 38 | await fastify.register(oas3Plugin, { 39 | ...pluginOpts, 40 | autowiredSecurity: { 41 | ...autowiredOpts, 42 | securitySchemes: { 43 | TestScheme: { 44 | type: "apiKey", 45 | in: "header", 46 | name: "X-Test-Key", 47 | fn: () => ({ ok: true }), 48 | }, 49 | }, 50 | }, 51 | }); 52 | 53 | await fastify.ready(); 54 | 55 | const jsonDoc = JSON.parse( 56 | ( 57 | await fastify.inject({ 58 | method: "GET", 59 | path: "/openapi.json", 60 | }) 61 | ).body 62 | ); 63 | 64 | expect(jsonDoc.components.securitySchemes).toMatchObject({ 65 | TestScheme: { 66 | type: "apiKey", 67 | in: "header", 68 | name: "X-Test-Key", 69 | }, 70 | }); 71 | expect(jsonDoc.components.securitySchemes.TestScheme.fn).toBeFalsy(); 72 | }); 73 | }); 74 | 75 | describe("configuration checks", () => { 76 | test("does not yell at unrecognized schemes if disabled", async () => { 77 | const fastify = Fastify(fastifyOpts); 78 | await fastify.register(oas3Plugin, { 79 | ...pluginOpts, 80 | autowiredSecurity: { 81 | ...autowiredOpts, 82 | disabled: true, 83 | }, 84 | }); 85 | 86 | fastify.get( 87 | "/boop", 88 | { 89 | oas: { 90 | security: { 91 | ANonsenseItem: [], 92 | }, 93 | }, 94 | }, 95 | async (request, reply) => { 96 | return "hi"; 97 | } 98 | ); 99 | 100 | expect(async () => fastify.ready()).not.toThrow(); 101 | }); 102 | 103 | test("allows unrecognized schemes when configured to", async () => { 104 | const fastify = Fastify(fastifyOpts); 105 | await fastify.register(oas3Plugin, { 106 | ...pluginOpts, 107 | autowiredSecurity: { 108 | ...autowiredOpts, 109 | disabled: false, 110 | allowUnrecognizedSecurity: true, 111 | }, 112 | }); 113 | 114 | fastify.get( 115 | "/boop", 116 | { 117 | oas: { 118 | security: { 119 | ANonsenseItem: [], 120 | }, 121 | }, 122 | }, 123 | async (request, reply) => { 124 | return "hi"; 125 | } 126 | ); 127 | 128 | expect(async () => fastify.ready()).not.toThrow(); 129 | 130 | const jsonDoc = JSON.parse( 131 | ( 132 | await fastify.inject({ 133 | method: "GET", 134 | path: "/openapi.json", 135 | }) 136 | ).body 137 | ); 138 | 139 | expect(jsonDoc.paths["/boop"].get.security).toMatchObject([ 140 | { ANonsenseItem: [] }, 141 | ]); 142 | }); 143 | 144 | test("includes security clauses even when autowired is off", async () => { 145 | const fastify = Fastify(fastifyOpts); 146 | await fastify.register(oas3Plugin, { 147 | ...pluginOpts, 148 | autowiredSecurity: undefined, 149 | }); 150 | 151 | fastify.get( 152 | "/boop", 153 | { 154 | oas: { 155 | security: { 156 | ANonsenseItem: [], 157 | }, 158 | }, 159 | }, 160 | async (request, reply) => { 161 | return "hi"; 162 | } 163 | ); 164 | 165 | expect(async () => fastify.ready()).not.toThrow(); 166 | 167 | const jsonDoc = JSON.parse( 168 | ( 169 | await fastify.inject({ 170 | method: "GET", 171 | path: "/openapi.json", 172 | }) 173 | ).body 174 | ); 175 | 176 | expect(jsonDoc.paths["/boop"].get.security).toMatchObject([ 177 | { ANonsenseItem: [] }, 178 | ]); 179 | }); 180 | 181 | // TODO: fix test 182 | // this fails because it throws in onRoute, which blows up the server (good!) 183 | // but can't be caught in the test (bad!) 184 | // test("by default, requires a route-level security if no root security", async () => { 185 | // const fastify = Fastify(fastifyOpts); 186 | // await fastify.register(oas3Plugin, { 187 | // ...pluginOpts, 188 | // autowiredSecurity: { 189 | // ...autowiredOpts, 190 | // }, 191 | // }); 192 | 193 | // try { 194 | // fastify.get( 195 | // "/boop", 196 | // { 197 | // schema: { 198 | // response: { 199 | // 200: Type.Object({}), 200 | // }, 201 | // }, 202 | // oas: {}, 203 | // }, 204 | // async (request, reply) => { 205 | // return "hi"; 206 | // } 207 | // ); 208 | 209 | // throw new Error("onRoute failed to throw"); 210 | // } catch (err) { 211 | // expect(err).toBeInstanceOf(Error); 212 | // expect((err as Error).message).toContain( 213 | // "has no security defined, and rootSecurity is not defined" 214 | // ); 215 | // } 216 | // }); 217 | 218 | test("allows no route-level handlers when root security is not set when options permits", async () => { 219 | const fastify = Fastify(fastifyOpts); 220 | await fastify.register(oas3Plugin, { 221 | ...pluginOpts, 222 | autowiredSecurity: { 223 | ...autowiredOpts, 224 | allowEmptySecurityWithNoRoot: true, 225 | }, 226 | }); 227 | 228 | fastify.get( 229 | "/boop", 230 | { 231 | schema: { 232 | response: { 233 | 200: Type.Object({}), 234 | }, 235 | }, 236 | oas: {}, 237 | }, 238 | async (request, reply) => { 239 | return "hi"; 240 | } 241 | ); 242 | 243 | expect(async () => fastify.ready()).not.toThrow(); 244 | }); 245 | }); 246 | 247 | describe("autowired handlers", () => { 248 | describe("API key handlers", () => { 249 | test("empty security allows anything", async () => { 250 | const fastify = Fastify(fastifyOpts); 251 | await fastify.register(oas3Plugin, { 252 | ...pluginOpts, 253 | autowiredSecurity: { 254 | ...autowiredOpts, 255 | securitySchemes: { 256 | MyApiKey: { 257 | type: "apiKey", 258 | in: "header", 259 | name: "X-My-Key", 260 | fn: () => ({ ok: true }), 261 | }, 262 | }, 263 | }, 264 | }); 265 | 266 | fastify.get( 267 | "/boop", 268 | { 269 | schema: { 270 | response: { 271 | 200: Type.Object({}), 272 | }, 273 | }, 274 | oas: { 275 | security: [], 276 | }, 277 | }, 278 | async (request, reply) => { 279 | return "hello"; 280 | } 281 | ); 282 | 283 | await fastify.ready(); 284 | 285 | const response = await fastify.inject({ 286 | method: "GET", 287 | path: "/boop", 288 | }); 289 | 290 | expect(response.statusCode).toBe(200); 291 | }); 292 | 293 | test("basic API key handler works", async () => { 294 | const fastify = Fastify(fastifyOpts); 295 | await fastify.register(oas3Plugin, { 296 | ...pluginOpts, 297 | autowiredSecurity: { 298 | ...autowiredOpts, 299 | securitySchemes: { 300 | MyApiKey: { 301 | type: "apiKey", 302 | in: "header", 303 | name: "X-My-Key", 304 | fn: (header, request) => { 305 | return header === "test" 306 | ? { ok: true } 307 | : { ok: false, code: 401 }; 308 | }, 309 | }, 310 | }, 311 | }, 312 | }); 313 | 314 | fastify.get( 315 | "/boop", 316 | { 317 | schema: { 318 | response: { 319 | 200: Type.Object({}), 320 | }, 321 | }, 322 | oas: { 323 | security: { 324 | MyApiKey: [], 325 | }, 326 | }, 327 | }, 328 | async (request, reply) => { 329 | return "hello"; 330 | } 331 | ); 332 | 333 | await fastify.ready(); 334 | 335 | const jsonDoc = JSON.parse( 336 | ( 337 | await fastify.inject({ 338 | method: "GET", 339 | path: "/openapi.json", 340 | }) 341 | ).body 342 | ); 343 | 344 | expect(jsonDoc.components.securitySchemes).toMatchObject({ 345 | MyApiKey: { 346 | type: "apiKey", 347 | in: "header", 348 | name: "X-My-Key", 349 | }, 350 | }); 351 | expect(jsonDoc.paths["/boop"].get.security).toMatchObject([ 352 | { MyApiKey: [] }, 353 | ]); 354 | 355 | const response = await fastify.inject({ 356 | method: "GET", 357 | path: "/boop", 358 | headers: { 359 | "X-My-Key": "test", 360 | }, 361 | }); 362 | 363 | expect(response.statusCode).toBe(200); 364 | 365 | const response2 = await fastify.inject({ 366 | method: "GET", 367 | path: "/boop", 368 | headers: { 369 | "X-My-Key": "not-test", 370 | }, 371 | }); 372 | 373 | expect(response2.statusCode).toBe(401); 374 | }); 375 | 376 | test("http basic handler works", async () => { 377 | const fastify = Fastify(fastifyOpts); 378 | await fastify.register(oas3Plugin, { 379 | ...pluginOpts, 380 | autowiredSecurity: { 381 | ...autowiredOpts, 382 | securitySchemes: { 383 | MyHttpBasic: { 384 | type: "http", 385 | scheme: "basic", 386 | fn: (credentials, request) => { 387 | return credentials.username === "test" && 388 | credentials.password === "pwd" 389 | ? { ok: true } 390 | : { ok: false, code: 401 }; 391 | }, 392 | }, 393 | }, 394 | }, 395 | }); 396 | 397 | fastify.get( 398 | "/boop", 399 | { 400 | schema: { 401 | response: { 402 | 200: Type.Object({}), 403 | }, 404 | }, 405 | oas: { 406 | security: { 407 | MyHttpBasic: [], 408 | }, 409 | }, 410 | }, 411 | async (request, reply) => { 412 | return "hello"; 413 | } 414 | ); 415 | 416 | await fastify.ready(); 417 | 418 | const encodedGood = Buffer.from("test:pwd").toString("base64"); 419 | const encodedBad = Buffer.from("test:not-pwd").toString("base64"); 420 | 421 | const response = await fastify.inject({ 422 | method: "GET", 423 | path: "/boop", 424 | headers: { 425 | Authorization: "Basic " + encodedGood, 426 | }, 427 | }); 428 | 429 | expect(response.statusCode).toBe(200); 430 | 431 | const response2 = await fastify.inject({ 432 | method: "GET", 433 | path: "/boop", 434 | headers: { 435 | Authorization: "Basic " + encodedBad, 436 | }, 437 | }); 438 | 439 | expect(response2.statusCode).toBe(401); 440 | }); 441 | }); 442 | 443 | describe("cookie-based security", () => { 444 | test("basic cookie handler works", async () => { 445 | const fastify = Fastify(fastifyOpts); 446 | await fastify.register(fastifyCookie); 447 | await fastify.register(oas3Plugin, { 448 | ...pluginOpts, 449 | autowiredSecurity: { 450 | ...autowiredOpts, 451 | securitySchemes: { 452 | MyCookie: { 453 | type: "apiKey", 454 | in: "cookie", 455 | name: "session", 456 | fn: (cookieValue, request) => { 457 | return cookieValue === "valid-session" 458 | ? { ok: true } 459 | : { ok: false, code: 401 }; 460 | }, 461 | }, 462 | }, 463 | }, 464 | }); 465 | 466 | fastify.get( 467 | "/protected", 468 | { 469 | schema: { 470 | response: { 471 | 200: Type.Object({}), 472 | }, 473 | }, 474 | oas: { 475 | security: { 476 | MyCookie: [], 477 | }, 478 | }, 479 | }, 480 | async () => "hello" 481 | ); 482 | 483 | await fastify.ready(); 484 | 485 | // Test valid cookie 486 | const response = await fastify.inject({ 487 | method: "GET", 488 | path: "/protected", 489 | cookies: { 490 | session: "valid-session", 491 | }, 492 | }); 493 | expect(response.statusCode).toBe(200); 494 | 495 | // Test invalid cookie 496 | const response2 = await fastify.inject({ 497 | method: "GET", 498 | path: "/protected", 499 | cookies: { 500 | session: "invalid-session", 501 | }, 502 | }); 503 | expect(response2.statusCode).toBe(401); 504 | 505 | // Test missing cookie 506 | const response3 = await fastify.inject({ 507 | method: "GET", 508 | path: "/protected", 509 | }); 510 | expect(response3.statusCode).toBe(401); 511 | }); 512 | 513 | test("cookie handler fails gracefully without cookie plugin", async () => { 514 | const fastify = Fastify(fastifyOpts); 515 | // Deliberately NOT registering cookie plugin 516 | await fastify.register(oas3Plugin, { 517 | ...pluginOpts, 518 | autowiredSecurity: { 519 | ...autowiredOpts, 520 | securitySchemes: { 521 | MyCookie: { 522 | type: "apiKey", 523 | in: "cookie", 524 | name: "session", 525 | fn: () => ({ ok: true }), 526 | }, 527 | }, 528 | }, 529 | }); 530 | 531 | fastify.get( 532 | "/protected", 533 | { 534 | oas: { 535 | security: { 536 | MyCookie: [], 537 | }, 538 | }, 539 | }, 540 | async () => "hello" 541 | ); 542 | 543 | await fastify.ready(); 544 | 545 | const response = await fastify.inject({ 546 | method: "GET", 547 | path: "/protected", 548 | cookies: { 549 | session: "any-value", 550 | }, 551 | }); 552 | expect(response.statusCode).toBe(401); 553 | }); 554 | }); 555 | 556 | test("http bearer", async () => { 557 | const fastify = Fastify(fastifyOpts); 558 | await fastify.register(oas3Plugin, { 559 | ...pluginOpts, 560 | autowiredSecurity: { 561 | ...autowiredOpts, 562 | securitySchemes: { 563 | MyHttpBearer: { 564 | type: "http", 565 | scheme: "bearer", 566 | fn: (token, request) => { 567 | return token === "test" 568 | ? { ok: true } 569 | : { ok: false, code: 401 }; 570 | }, 571 | }, 572 | }, 573 | }, 574 | }); 575 | 576 | fastify.get( 577 | "/boop", 578 | { 579 | schema: { 580 | response: { 581 | 200: Type.Object({}), 582 | }, 583 | }, 584 | oas: { 585 | security: { 586 | MyHttpBearer: [], 587 | }, 588 | }, 589 | }, 590 | async (request, reply) => { 591 | return "hello"; 592 | } 593 | ); 594 | 595 | await fastify.ready(); 596 | 597 | const response = await fastify.inject({ 598 | method: "GET", 599 | path: "/boop", 600 | headers: { 601 | Authorization: "Bearer test", 602 | }, 603 | }); 604 | 605 | expect(response.statusCode).toBe(200); 606 | 607 | const response2 = await fastify.inject({ 608 | method: "GET", 609 | path: "/boop", 610 | headers: { 611 | Authorization: "Bearer not-test", 612 | }, 613 | }); 614 | 615 | expect(response2.statusCode).toBe(401); 616 | }); 617 | }); 618 | 619 | test("OR security handlers work (separate array entries)", async () => { 620 | const fastify = Fastify(fastifyOpts); 621 | await fastify.register(oas3Plugin, { 622 | ...pluginOpts, 623 | autowiredSecurity: { 624 | ...autowiredOpts, 625 | securitySchemes: { 626 | MyApiKey: { 627 | type: "apiKey", 628 | in: "header", 629 | name: "X-Test-Key", 630 | fn: (key, request) => { 631 | return key === "test" ? { ok: true } : { ok: false, code: 401 }; 632 | }, 633 | }, 634 | MySecondKey: { 635 | type: "apiKey", 636 | in: "header", 637 | name: "X-Test-Second-Key", 638 | fn: (key, request) => { 639 | return key === "test" ? { ok: true } : { ok: false, code: 401 }; 640 | }, 641 | }, 642 | }, 643 | }, 644 | }); 645 | 646 | fastify.get( 647 | "/boop", 648 | { 649 | schema: { 650 | response: { 651 | 200: Type.Object({}), 652 | }, 653 | }, 654 | oas: { 655 | security: [{ MyApiKey: [] }, { MySecondKey: [] }], 656 | }, 657 | }, 658 | async (request, reply) => { 659 | return "hello"; 660 | } 661 | ); 662 | 663 | await fastify.ready(); 664 | 665 | const response = await fastify.inject({ 666 | method: "GET", 667 | path: "/boop", 668 | headers: { 669 | "X-Test-Key": "test", 670 | }, 671 | }); 672 | 673 | expect(response.statusCode).toBe(200); 674 | 675 | const response2 = await fastify.inject({ 676 | method: "GET", 677 | path: "/boop", 678 | headers: { 679 | "X-Test-Second-Key": "test", 680 | }, 681 | }); 682 | 683 | expect(response2.statusCode).toBe(200); 684 | 685 | const response3 = await fastify.inject({ 686 | method: "GET", 687 | path: "/boop", 688 | headers: { 689 | "X-Test-Key": "not-test", 690 | }, 691 | }); 692 | 693 | expect(response3.statusCode).toBe(401); 694 | }); 695 | 696 | test("AND handler (multiple keys in same object)", async () => { 697 | const fastify = Fastify(fastifyOpts); 698 | await fastify.register(oas3Plugin, { 699 | ...pluginOpts, 700 | autowiredSecurity: { 701 | ...autowiredOpts, 702 | securitySchemes: { 703 | MyApiKey: { 704 | type: "apiKey", 705 | in: "header", 706 | name: "X-Test-Key", 707 | fn: (key, request) => { 708 | return key === "test" ? { ok: true } : { ok: false, code: 401 }; 709 | }, 710 | }, 711 | MySecondKey: { 712 | type: "apiKey", 713 | in: "header", 714 | name: "X-Test-Second-Key", 715 | fn: (key, request) => { 716 | return key === "test2" ? { ok: true } : { ok: false, code: 401 }; 717 | }, 718 | }, 719 | }, 720 | }, 721 | }); 722 | 723 | fastify.get( 724 | "/boop", 725 | { 726 | schema: { 727 | response: { 728 | 200: Type.Object({}), 729 | }, 730 | }, 731 | oas: { 732 | security: { 733 | MyApiKey: [], 734 | MySecondKey: [], 735 | }, 736 | }, 737 | }, 738 | async (request, reply) => { 739 | return "hello"; 740 | } 741 | ); 742 | 743 | await fastify.ready(); 744 | 745 | const response = await fastify.inject({ 746 | method: "GET", 747 | path: "/boop", 748 | headers: { 749 | "X-Test-Key": "test", 750 | "X-Test-Second-Key": "test2", 751 | }, 752 | }); 753 | 754 | expect(response.statusCode).toBe(200); 755 | 756 | const response2 = await fastify.inject({ 757 | method: "GET", 758 | path: "/boop", 759 | headers: { 760 | "X-Test-Key": "test", 761 | "X-Test-Second-Key": "not-test2", 762 | }, 763 | }); 764 | 765 | expect(response2.statusCode).toBe(401); 766 | 767 | const response3 = await fastify.inject({ 768 | method: "GET", 769 | path: "/boop", 770 | headers: { 771 | "X-Test-Key": "not-test", 772 | "X-Test-Second-Key": "test2", 773 | }, 774 | }); 775 | 776 | expect(response3.statusCode).toBe(401); 777 | 778 | const response4 = await fastify.inject({ 779 | method: "GET", 780 | path: "/boop", 781 | headers: { 782 | "X-Test-Key": "test", 783 | }, 784 | }); 785 | 786 | expect(response4.statusCode).toBe(401); 787 | 788 | const response5 = await fastify.inject({ 789 | method: "GET", 790 | path: "/boop", 791 | headers: { 792 | "X-Test-Second-Key": "test2", 793 | }, 794 | }); 795 | 796 | expect(response5.statusCode).toBe(401); 797 | }); 798 | 799 | test("root security should apply if route-specific doesn't exist", async () => { 800 | const fastify = Fastify(fastifyOpts); 801 | await fastify.register(oas3Plugin, { 802 | ...pluginOpts, 803 | autowiredSecurity: { 804 | ...autowiredOpts, 805 | rootSecurity: { MyApiKey: [] }, 806 | securitySchemes: { 807 | MyApiKey: { 808 | type: "apiKey", 809 | in: "header", 810 | name: "X-Test-Key", 811 | fn: (key, request) => { 812 | return key === "test" ? { ok: true } : { ok: false, code: 401 }; 813 | }, 814 | }, 815 | }, 816 | }, 817 | }); 818 | 819 | fastify.get( 820 | "/boop", 821 | { 822 | schema: { 823 | response: { 824 | 200: Type.Object({}), 825 | }, 826 | }, 827 | oas: {}, 828 | }, 829 | async (request, reply) => { 830 | return "hello"; 831 | } 832 | ); 833 | 834 | await fastify.ready(); 835 | 836 | const response = await fastify.inject({ 837 | method: "GET", 838 | path: "/boop", 839 | headers: { 840 | "X-Test-Key": "test", 841 | }, 842 | }); 843 | 844 | expect(response.statusCode).toBe(200); 845 | }); 846 | 847 | test("route-specific security should replace root security", async () => { 848 | const fastify = Fastify(fastifyOpts); 849 | await fastify.register(oas3Plugin, { 850 | ...pluginOpts, 851 | autowiredSecurity: { 852 | ...autowiredOpts, 853 | rootSecurity: { MyApiKey: [] }, 854 | securitySchemes: { 855 | MyApiKey: { 856 | type: "apiKey", 857 | in: "header", 858 | name: "X-Test-Key", 859 | fn: (key, request) => { 860 | return key === "test" ? { ok: true } : { ok: false, code: 401 }; 861 | }, 862 | }, 863 | MySecondKey: { 864 | type: "apiKey", 865 | in: "header", 866 | name: "X-Test-Second-Key", 867 | fn: (key, request) => { 868 | return key === "test2" ? { ok: true } : { ok: false, code: 401 }; 869 | }, 870 | }, 871 | }, 872 | }, 873 | }); 874 | 875 | fastify.get( 876 | "/boop", 877 | { 878 | schema: { 879 | response: { 880 | 200: Type.Object({}), 881 | }, 882 | }, 883 | oas: { 884 | security: { 885 | MySecondKey: [], 886 | }, 887 | }, 888 | }, 889 | async (request, reply) => { 890 | return "hello"; 891 | } 892 | ); 893 | 894 | await fastify.ready(); 895 | 896 | const response = await fastify.inject({ 897 | method: "GET", 898 | path: "/boop", 899 | headers: { 900 | "X-Test-Second-Key": "test2", 901 | }, 902 | }); 903 | 904 | expect(response.statusCode).toBe(200); 905 | 906 | const response2 = await fastify.inject({ 907 | method: "GET", 908 | path: "/boop", 909 | headers: { 910 | "X-Test-Key": "test", 911 | }, 912 | }); 913 | 914 | expect(response2.statusCode).toBe(401); 915 | 916 | const response3 = await fastify.inject({ 917 | method: "GET", 918 | path: "/boop", 919 | headers: {}, 920 | }); 921 | 922 | expect(response3.statusCode).toBe(401); 923 | }); 924 | 925 | describe("passNullIfNotFound variants", () => { 926 | describe("API Key (header)", () => { 927 | test("strict variant returns 401 when header missing", async () => { 928 | const fastify = Fastify(fastifyOpts); 929 | await fastify.register(oas3Plugin, { 930 | ...pluginOpts, 931 | autowiredSecurity: { 932 | ...autowiredOpts, 933 | securitySchemes: { 934 | StrictApiKey: { 935 | type: "apiKey", 936 | in: "header", 937 | name: "X-Test-Key", 938 | passNullIfNoneProvided: false, 939 | fn: (value) => 940 | value === "test" ? { ok: true } : { ok: false, code: 401 }, 941 | }, 942 | }, 943 | }, 944 | }); 945 | 946 | fastify.get( 947 | "/test", 948 | { 949 | schema: { response: { 200: Type.Object({}) } }, 950 | oas: { security: { StrictApiKey: [] } }, 951 | }, 952 | async () => "ok" 953 | ); 954 | 955 | await fastify.ready(); 956 | 957 | const resNoHeader = await fastify.inject({ 958 | method: "GET", 959 | path: "/test", 960 | }); 961 | expect(resNoHeader.statusCode).toBe(401); 962 | 963 | const resValid = await fastify.inject({ 964 | method: "GET", 965 | path: "/test", 966 | headers: { "X-Test-Key": "test" }, 967 | }); 968 | expect(resValid.statusCode).toBe(200); 969 | }); 970 | 971 | test("nullable variant passes null to handler when header missing", async () => { 972 | const fastify = Fastify(fastifyOpts); 973 | await fastify.register(oas3Plugin, { 974 | ...pluginOpts, 975 | autowiredSecurity: { 976 | ...autowiredOpts, 977 | securitySchemes: { 978 | NullableApiKey: { 979 | type: "apiKey", 980 | in: "header", 981 | name: "X-Test-Key", 982 | passNullIfNoneProvided: true, 983 | fn: (value) => 984 | value === null || value === "test" 985 | ? { ok: true } 986 | : { ok: false, code: 401 }, 987 | }, 988 | }, 989 | }, 990 | }); 991 | 992 | fastify.get( 993 | "/test", 994 | { 995 | schema: { response: { 200: Type.Object({}) } }, 996 | oas: { security: { NullableApiKey: [] } }, 997 | }, 998 | async () => "ok" 999 | ); 1000 | 1001 | await fastify.ready(); 1002 | 1003 | const resNoHeader = await fastify.inject({ 1004 | method: "GET", 1005 | path: "/test", 1006 | }); 1007 | expect(resNoHeader.statusCode).toBe(200); 1008 | 1009 | const resValid = await fastify.inject({ 1010 | method: "GET", 1011 | path: "/test", 1012 | headers: { "X-Test-Key": "test" }, 1013 | }); 1014 | expect(resValid.statusCode).toBe(200); 1015 | 1016 | const resInvalid = await fastify.inject({ 1017 | method: "GET", 1018 | path: "/test", 1019 | headers: { "X-Test-Key": "wrong" }, 1020 | }); 1021 | expect(resInvalid.statusCode).toBe(401); 1022 | }); 1023 | }); 1024 | 1025 | describe("API Key (cookie)", () => { 1026 | test("strict variant returns 401 when cookie missing", async () => { 1027 | const fastify = Fastify(fastifyOpts); 1028 | await fastify.register(fastifyCookie); 1029 | await fastify.register(oas3Plugin, { 1030 | ...pluginOpts, 1031 | autowiredSecurity: { 1032 | ...autowiredOpts, 1033 | securitySchemes: { 1034 | StrictCookie: { 1035 | type: "apiKey", 1036 | in: "cookie", 1037 | name: "session", 1038 | passNullIfNoneProvided: false, 1039 | fn: (value) => 1040 | value === "test" ? { ok: true } : { ok: false, code: 401 }, 1041 | }, 1042 | }, 1043 | }, 1044 | }); 1045 | 1046 | fastify.get( 1047 | "/test", 1048 | { 1049 | schema: { response: { 200: Type.Object({}) } }, 1050 | oas: { security: { StrictCookie: [] } }, 1051 | }, 1052 | async () => "ok" 1053 | ); 1054 | 1055 | await fastify.ready(); 1056 | 1057 | const resNoCookie = await fastify.inject({ 1058 | method: "GET", 1059 | path: "/test", 1060 | }); 1061 | expect(resNoCookie.statusCode).toBe(401); 1062 | 1063 | const resValid = await fastify.inject({ 1064 | method: "GET", 1065 | path: "/test", 1066 | cookies: { session: "test" }, 1067 | }); 1068 | expect(resValid.statusCode).toBe(200); 1069 | }); 1070 | 1071 | test("nullable variant passes null to handler when cookie missing", async () => { 1072 | const fastify = Fastify(fastifyOpts); 1073 | await fastify.register(fastifyCookie); 1074 | await fastify.register(oas3Plugin, { 1075 | ...pluginOpts, 1076 | autowiredSecurity: { 1077 | ...autowiredOpts, 1078 | securitySchemes: { 1079 | NullableCookie: { 1080 | type: "apiKey", 1081 | in: "cookie", 1082 | name: "session", 1083 | passNullIfNoneProvided: true, 1084 | fn: (value) => 1085 | value === null || value === "test" 1086 | ? { ok: true } 1087 | : { ok: false, code: 401 }, 1088 | }, 1089 | }, 1090 | }, 1091 | }); 1092 | 1093 | fastify.get( 1094 | "/test", 1095 | { 1096 | schema: { response: { 200: Type.Object({}) } }, 1097 | oas: { security: { NullableCookie: [] } }, 1098 | }, 1099 | async () => "ok" 1100 | ); 1101 | 1102 | await fastify.ready(); 1103 | 1104 | const resNoCookie = await fastify.inject({ 1105 | method: "GET", 1106 | path: "/test", 1107 | }); 1108 | expect(resNoCookie.statusCode).toBe(200); 1109 | 1110 | const resValid = await fastify.inject({ 1111 | method: "GET", 1112 | path: "/test", 1113 | cookies: { session: "test" }, 1114 | }); 1115 | expect(resValid.statusCode).toBe(200); 1116 | 1117 | const resInvalid = await fastify.inject({ 1118 | method: "GET", 1119 | path: "/test", 1120 | cookies: { session: "wrong" }, 1121 | }); 1122 | expect(resInvalid.statusCode).toBe(401); 1123 | }); 1124 | }); 1125 | 1126 | describe("HTTP Basic Auth", () => { 1127 | test("strict variant returns 401 when auth header missing", async () => { 1128 | const fastify = Fastify(fastifyOpts); 1129 | await fastify.register(oas3Plugin, { 1130 | ...pluginOpts, 1131 | autowiredSecurity: { 1132 | ...autowiredOpts, 1133 | securitySchemes: { 1134 | StrictBasic: { 1135 | type: "http", 1136 | scheme: "basic", 1137 | passNullIfNoneProvided: false, 1138 | fn: (creds) => 1139 | creds.username === "user" && creds.password === "pass" 1140 | ? { ok: true } 1141 | : { ok: false, code: 401 }, 1142 | }, 1143 | }, 1144 | }, 1145 | }); 1146 | 1147 | fastify.get( 1148 | "/test", 1149 | { 1150 | schema: { response: { 200: Type.Object({}) } }, 1151 | oas: { security: { StrictBasic: [] } }, 1152 | }, 1153 | async () => "ok" 1154 | ); 1155 | 1156 | await fastify.ready(); 1157 | 1158 | const resNoAuth = await fastify.inject({ 1159 | method: "GET", 1160 | path: "/test", 1161 | }); 1162 | expect(resNoAuth.statusCode).toBe(401); 1163 | 1164 | const resValid = await fastify.inject({ 1165 | method: "GET", 1166 | path: "/test", 1167 | headers: { 1168 | Authorization: 1169 | "Basic " + Buffer.from("user:pass").toString("base64"), 1170 | }, 1171 | }); 1172 | expect(resValid.statusCode).toBe(200); 1173 | }); 1174 | 1175 | test("nullable variant passes null to handler when auth header missing", async () => { 1176 | const fastify = Fastify(fastifyOpts); 1177 | await fastify.register(oas3Plugin, { 1178 | ...pluginOpts, 1179 | autowiredSecurity: { 1180 | ...autowiredOpts, 1181 | securitySchemes: { 1182 | NullableBasic: { 1183 | type: "http", 1184 | scheme: "basic", 1185 | passNullIfNoneProvided: true, 1186 | fn: (creds) => 1187 | creds === null || 1188 | (creds.username === "user" && creds.password === "pass") 1189 | ? { ok: true } 1190 | : { ok: false, code: 401 }, 1191 | }, 1192 | }, 1193 | }, 1194 | }); 1195 | 1196 | fastify.get( 1197 | "/test", 1198 | { 1199 | schema: { response: { 200: Type.Object({}) } }, 1200 | oas: { security: { NullableBasic: [] } }, 1201 | }, 1202 | async () => "ok" 1203 | ); 1204 | 1205 | await fastify.ready(); 1206 | 1207 | const resNoAuth = await fastify.inject({ 1208 | method: "GET", 1209 | path: "/test", 1210 | }); 1211 | expect(resNoAuth.statusCode).toBe(200); 1212 | 1213 | const resValid = await fastify.inject({ 1214 | method: "GET", 1215 | path: "/test", 1216 | headers: { 1217 | Authorization: 1218 | "Basic " + Buffer.from("user:pass").toString("base64"), 1219 | }, 1220 | }); 1221 | expect(resValid.statusCode).toBe(200); 1222 | 1223 | const resInvalid = await fastify.inject({ 1224 | method: "GET", 1225 | path: "/test", 1226 | headers: { 1227 | Authorization: 1228 | "Basic " + Buffer.from("wrong:wrong").toString("base64"), 1229 | }, 1230 | }); 1231 | expect(resInvalid.statusCode).toBe(401); 1232 | }); 1233 | }); 1234 | 1235 | describe("HTTP Bearer Auth", () => { 1236 | test("strict variant returns 401 when auth header missing", async () => { 1237 | const fastify = Fastify(fastifyOpts); 1238 | await fastify.register(oas3Plugin, { 1239 | ...pluginOpts, 1240 | autowiredSecurity: { 1241 | ...autowiredOpts, 1242 | securitySchemes: { 1243 | StrictBearer: { 1244 | type: "http", 1245 | scheme: "bearer", 1246 | passNullIfNoneProvided: false, 1247 | fn: (token) => 1248 | token === "valid-token" 1249 | ? { ok: true } 1250 | : { ok: false, code: 401 }, 1251 | }, 1252 | }, 1253 | }, 1254 | }); 1255 | 1256 | fastify.get( 1257 | "/test", 1258 | { 1259 | schema: { response: { 200: Type.Object({}) } }, 1260 | oas: { security: { StrictBearer: [] } }, 1261 | }, 1262 | async () => "ok" 1263 | ); 1264 | 1265 | await fastify.ready(); 1266 | 1267 | const resNoAuth = await fastify.inject({ 1268 | method: "GET", 1269 | path: "/test", 1270 | }); 1271 | expect(resNoAuth.statusCode).toBe(401); 1272 | 1273 | const resValid = await fastify.inject({ 1274 | method: "GET", 1275 | path: "/test", 1276 | headers: { Authorization: "Bearer valid-token" }, 1277 | }); 1278 | expect(resValid.statusCode).toBe(200); 1279 | }); 1280 | 1281 | test("nullable variant passes null to handler when auth header missing", async () => { 1282 | const fastify = Fastify(fastifyOpts); 1283 | await fastify.register(oas3Plugin, { 1284 | ...pluginOpts, 1285 | autowiredSecurity: { 1286 | ...autowiredOpts, 1287 | securitySchemes: { 1288 | NullableBearer: { 1289 | type: "http", 1290 | scheme: "bearer", 1291 | passNullIfNoneProvided: true, 1292 | fn: (token) => 1293 | token === null || token === "valid-token" 1294 | ? { ok: true } 1295 | : { ok: false, code: 401 }, 1296 | }, 1297 | }, 1298 | }, 1299 | }); 1300 | 1301 | fastify.get( 1302 | "/test", 1303 | { 1304 | schema: { response: { 200: Type.Object({}) } }, 1305 | oas: { security: { NullableBearer: [] } }, 1306 | }, 1307 | async () => "ok" 1308 | ); 1309 | 1310 | await fastify.ready(); 1311 | 1312 | const resNoAuth = await fastify.inject({ 1313 | method: "GET", 1314 | path: "/test", 1315 | }); 1316 | expect(resNoAuth.statusCode).toBe(200); 1317 | 1318 | const resValid = await fastify.inject({ 1319 | method: "GET", 1320 | path: "/test", 1321 | headers: { Authorization: "Bearer valid-token" }, 1322 | }); 1323 | expect(resValid.statusCode).toBe(200); 1324 | 1325 | const resInvalid = await fastify.inject({ 1326 | method: "GET", 1327 | path: "/test", 1328 | headers: { Authorization: "Bearer wrong-token" }, 1329 | }); 1330 | expect(resInvalid.statusCode).toBe(401); 1331 | }); 1332 | }); 1333 | }); 1334 | }); 1335 | -------------------------------------------------------------------------------- /src/test/bugfix-006.spec.ts: -------------------------------------------------------------------------------- 1 | import "../extensions.js"; 2 | import { type Static, Type } from "@sinclair/typebox"; 3 | import Fastify, { type FastifyInstance } from "fastify"; 4 | import { type SchemaObject } from "openapi3-ts"; 5 | import { describe, expect, test } from "vitest"; 6 | 7 | import { APPLICATION_JSON } from "../constants.js"; 8 | import { oas3Plugin, type OAS3PluginOptions } from "../plugin.js"; 9 | import { schemaType } from "../schemas.js"; 10 | 11 | const pluginOpts: OAS3PluginOptions = { 12 | openapiInfo: { 13 | title: "test", 14 | version: "0.1.0", 15 | }, 16 | }; 17 | 18 | describe("bug 006", () => { 19 | test("supports a response type with a nested object", async () => { 20 | const TestResponseInner = schemaType( 21 | "TestResponseInner", 22 | Type.Object({ foo: Type.String() }) 23 | ); 24 | type TestResponseInner = Static; 25 | 26 | const TestResponse = schemaType( 27 | "TestResponse", 28 | Type.Object({ bar: TestResponseInner }) 29 | ); 30 | type TestResponse = Static; 31 | 32 | const fastify = Fastify({ logger: { level: "error" } }); 33 | await fastify.register(oas3Plugin, { ...pluginOpts }); 34 | 35 | const ret = { 36 | bar: { 37 | foo: "baz", 38 | }, 39 | }; 40 | 41 | await fastify.register(async (fastify: FastifyInstance) => { 42 | fastify.route<{ Reply: TestResponse }>({ 43 | url: "/nested", 44 | method: "GET", 45 | schema: { 46 | response: { 47 | 200: TestResponse, 48 | }, 49 | }, 50 | oas: {}, 51 | handler: () => ret, 52 | }); 53 | }); 54 | await fastify.ready(); 55 | 56 | const oas = fastify.openapiDocument; 57 | const op = oas.paths?.["/nested"]?.get; 58 | 59 | expect(oas.components?.schemas?.TestResponse).toBeTruthy(); 60 | expect(oas.components?.schemas?.TestResponseInner).toBeTruthy(); 61 | expect( 62 | (oas.components?.schemas?.TestResponse as SchemaObject)?.properties?.bar 63 | ?.$ref 64 | ).toEqual("#/components/schemas/TestResponseInner"); 65 | expect(op?.responses?.["200"]?.content?.[APPLICATION_JSON]?.schema).toEqual( 66 | { $ref: "#/components/schemas/TestResponse" } 67 | ); 68 | 69 | const response = await fastify.inject({ 70 | method: "GET", 71 | url: "/nested", 72 | }); 73 | const responseBody = response.json(); 74 | 75 | expect(response.statusCode).toEqual(200); 76 | expect(responseBody).toMatchObject(ret); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /src/test/path-conversion.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | 3 | import { convertFastifyToOpenAPIPath } from "../path-converter.js"; // Assume the function is in this file 4 | 5 | describe("convertFastifyToOpenAPIPath", () => { 6 | // Test simple static paths 7 | test("should not modify paths without parameters", () => { 8 | expect(convertFastifyToOpenAPIPath("/users")).toEqual({ 9 | url: "/users", 10 | paramPatterns: {}, 11 | }); 12 | expect(convertFastifyToOpenAPIPath("/api/v1/products")).toEqual({ 13 | url: "/api/v1/products", 14 | paramPatterns: {}, 15 | }); 16 | }); 17 | 18 | // Test paths with simple parameters 19 | test("should convert simple path parameters", () => { 20 | expect(convertFastifyToOpenAPIPath("/users/:userId")).toEqual({ 21 | url: "/users/{userId}", 22 | paramPatterns: {}, 23 | }); 24 | expect( 25 | convertFastifyToOpenAPIPath( 26 | "/api/v1/products/:productId/reviews/:reviewId" 27 | ) 28 | ).toEqual({ 29 | url: "/api/v1/products/{productId}/reviews/{reviewId}", 30 | paramPatterns: {}, 31 | }); 32 | }); 33 | 34 | // Test paths with regex patterns 35 | test("should convert regex patterns to OpenAPI style parameters", () => { 36 | expect( 37 | convertFastifyToOpenAPIPath("/user/:action(profile|settings)") 38 | ).toEqual({ 39 | url: "/user/{action}", 40 | paramPatterns: { action: "profile|settings" }, 41 | }); 42 | expect( 43 | convertFastifyToOpenAPIPath("/api/v:version(\\d+)/users/:userId") 44 | ).toEqual({ 45 | url: "/api/v{version}/users/{userId}", 46 | paramPatterns: { version: "\\d+" }, 47 | }); 48 | }); 49 | 50 | // Test paths with multiple regex patterns 51 | test("should handle multiple regex patterns in a single path", () => { 52 | expect( 53 | convertFastifyToOpenAPIPath( 54 | "/api/:apiVersion(v1|v2)/:resource/:id(\\d+)-:status([a-z]+)" 55 | ) 56 | ).toEqual({ 57 | url: "/api/{apiVersion}/{resource}/{id}-{status}", 58 | paramPatterns: { 59 | apiVersion: "v1|v2", 60 | id: "\\d+", 61 | status: "[a-z]+", 62 | }, 63 | }); 64 | }); 65 | 66 | // Test paths with regex patterns and simple parameters 67 | test("should handle a mix of regex patterns and simple parameters", () => { 68 | expect( 69 | convertFastifyToOpenAPIPath("/:resource/:id(\\d+)-:status/:category") 70 | ).toEqual({ 71 | url: "/{resource}/{id}-{status}/{category}", 72 | paramPatterns: { id: "\\d+" }, 73 | }); 74 | }); 75 | 76 | // Test edge cases 77 | test("should handle edge cases", () => { 78 | // Empty path 79 | expect(convertFastifyToOpenAPIPath("")).toEqual({ 80 | url: "", 81 | paramPatterns: {}, 82 | }); 83 | 84 | // Path with only a parameter 85 | expect(convertFastifyToOpenAPIPath(":param")).toEqual({ 86 | url: "{param}", 87 | paramPatterns: {}, 88 | }); 89 | 90 | // Path with a regex at the end 91 | expect(convertFastifyToOpenAPIPath("/users/:id(\\d+)")).toEqual({ 92 | url: "/users/{id}", 93 | paramPatterns: { id: "\\d+" }, 94 | }); 95 | 96 | // Path with a regex at the start 97 | expect( 98 | convertFastifyToOpenAPIPath(":userType(admin|user)/:role/dashboard") 99 | ).toEqual({ 100 | url: "{userType}/{role}/dashboard", 101 | paramPatterns: { userType: "admin|user" }, 102 | }); 103 | }); 104 | 105 | // Test paths with special characters in regex 106 | test("should handle paths with special characters in regex", () => { 107 | expect( 108 | convertFastifyToOpenAPIPath("/user/:username([a-zA-Z0-9_-]+)") 109 | ).toEqual({ 110 | url: "/user/{username}", 111 | paramPatterns: { username: "[a-zA-Z0-9_-]+" }, 112 | }); 113 | expect(convertFastifyToOpenAPIPath("/file/:filename(\\.[a-z]+)")).toEqual({ 114 | url: "/file/{filename}", 115 | paramPatterns: { filename: "\\.[a-z]+" }, 116 | }); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /src/test/plugin.spec.ts: -------------------------------------------------------------------------------- 1 | import "../extensions.js"; 2 | 3 | import { inspect } from "util"; 4 | 5 | import { fastifyFormbody } from "@fastify/formbody"; 6 | import { type Static, Type } from "@sinclair/typebox"; 7 | import Fastify, { 8 | type FastifyInstance, 9 | type FastifyServerOptions, 10 | } from "fastify"; 11 | import jsYaml from "js-yaml"; 12 | import { describe, expect, test } from "vitest"; 13 | 14 | import { APPLICATION_JSON } from "../constants.js"; 15 | import { oas3PluginAjv, schemaType } from "../index.js"; 16 | import { oas3Plugin, type OAS3PluginOptions } from "../plugin.js"; 17 | 18 | const fastifyOpts: FastifyServerOptions = { 19 | logger: { level: "error" }, 20 | ajv: { 21 | customOptions: { 22 | coerceTypes: true, 23 | }, 24 | plugins: [oas3PluginAjv], 25 | }, 26 | }; 27 | const pluginOpts: OAS3PluginOptions = { 28 | openapiInfo: { 29 | title: "test", 30 | version: "0.1.0", 31 | }, 32 | }; 33 | 34 | const PingResponse = schemaType( 35 | "PingResponse", 36 | Type.Object({ pong: Type.Boolean() }) 37 | ); 38 | type PingResponse = Static; 39 | 40 | const QwopModel = schemaType( 41 | "QwopRequestBody", 42 | Type.Object({ qwop: Type.Number() }) 43 | ); 44 | type QwopModel = Static; 45 | 46 | describe("plugin", () => { 47 | test("can add a base GET route", async () => { 48 | const fastify = Fastify(fastifyOpts); 49 | await fastify.register(oas3Plugin, { ...pluginOpts }); 50 | 51 | // we do this inside a prefixed scope to smoke out prefix append errors 52 | await fastify.register( 53 | async (fastify: FastifyInstance) => { 54 | // TODO: once fastify 4.x hits and type providers are a thing, this should be refactored 55 | fastify.route<{ Reply: PingResponse }>({ 56 | url: "/ping", 57 | method: "GET", 58 | schema: { 59 | response: { 60 | 200: PingResponse, 61 | }, 62 | }, 63 | oas: {}, 64 | handler: async (req, reply) => { 65 | return { pong: true }; 66 | }, 67 | }); 68 | }, 69 | { prefix: "/api" } 70 | ); 71 | await fastify.ready(); 72 | 73 | const oas = fastify.openapiDocument; 74 | const op = oas.paths?.["/api/ping"]?.get; 75 | 76 | expect(oas.components?.schemas?.PingResponse).toBeTruthy(); 77 | expect(op?.operationId).toEqual("pingGet"); 78 | expect(op?.responses?.["200"]?.content?.[APPLICATION_JSON]?.schema).toEqual( 79 | { $ref: "#/components/schemas/PingResponse" } 80 | ); 81 | }); 82 | 83 | test("will error on an invalid spec", async () => { 84 | const fastify = Fastify({ ...fastifyOpts, logger: { level: "silent" } }); 85 | await fastify.register(oas3Plugin, { 86 | // this WILL cause a failure to sput out logging values. 87 | ...pluginOpts, 88 | postParse: (oas) => { 89 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 90 | (oas.rootDoc.openapi as any) = 42; 91 | }, 92 | exitOnInvalidDocument: true, 93 | }); 94 | 95 | try { 96 | await fastify.ready(); 97 | expect("this should have failed").toEqual(false); 98 | } catch (err) { 99 | /* this is ok */ 100 | } 101 | }); 102 | 103 | test("will serve an OAS json doc and YAML doc", async () => { 104 | const fastify = Fastify(fastifyOpts); 105 | await fastify.register(oas3Plugin, { ...pluginOpts }); 106 | 107 | // we do this inside a prefixed scope to smoke out prefix append errors 108 | await fastify.register( 109 | async (fastify: FastifyInstance) => { 110 | fastify.get("/ping", { 111 | schema: { 112 | response: { 113 | 200: PingResponse, 114 | }, 115 | }, 116 | oas: {}, 117 | handler: async (req, reply) => { 118 | return { pong: true }; 119 | }, 120 | }); 121 | }, 122 | { prefix: "/api" } 123 | ); 124 | await fastify.ready(); 125 | 126 | const jsonResponse = await fastify.inject({ 127 | method: "GET", 128 | path: "/openapi.json", 129 | }); 130 | 131 | const jsonDoc = JSON.parse(jsonResponse.body); 132 | expect(jsonDoc).toMatchObject(fastify.openapiDocument); 133 | 134 | const yamlResponse = await fastify.inject({ 135 | method: "GET", 136 | path: "/openapi.yaml", 137 | }); 138 | 139 | const yamlDoc = jsYaml.load(yamlResponse.body); 140 | expect(yamlDoc).toMatchObject(fastify.openapiDocument); 141 | }); 142 | 143 | test("correctly represents responses in OAS documents", async () => { 144 | const fastify = Fastify(fastifyOpts); 145 | await fastify.register(oas3Plugin, { ...pluginOpts }); 146 | 147 | await fastify.register( 148 | async (fastify: FastifyInstance) => { 149 | fastify.get("/ping", { 150 | schema: { 151 | response: { 152 | 200: PingResponse, 153 | }, 154 | }, 155 | oas: {}, 156 | handler: async (req, reply) => { 157 | return { pong: true }; 158 | }, 159 | }); 160 | }, 161 | { prefix: "/api" } 162 | ); 163 | 164 | await fastify.ready(); 165 | 166 | const jsonResponse = await fastify.inject({ 167 | method: "GET", 168 | path: "/openapi.json", 169 | }); 170 | 171 | const jsonDoc = JSON.parse(jsonResponse.body); 172 | 173 | const operation = jsonDoc.paths?.["/api/ping"]?.get; 174 | const response = operation?.responses?.["200"]; 175 | expect(response).toMatchObject({ 176 | description: "No response description specified.", 177 | content: { 178 | [APPLICATION_JSON]: { 179 | schema: { $ref: "#/components/schemas/PingResponse" }, 180 | }, 181 | }, 182 | }); 183 | 184 | const pingResponse = jsonDoc.components?.schemas?.PingResponse; 185 | 186 | expect(pingResponse).toBeDefined(); 187 | expect(pingResponse).toMatchObject({ 188 | type: "object", 189 | properties: { pong: { type: "boolean" } }, 190 | required: ["pong"], 191 | }); 192 | }); 193 | 194 | test("correctly represents non-JSON responses in OAS documents", async () => { 195 | const fastify = Fastify(fastifyOpts); 196 | await fastify.register(oas3Plugin, { ...pluginOpts }); 197 | 198 | await fastify.register( 199 | async (fastify: FastifyInstance) => { 200 | fastify.get("/ping", { 201 | schema: { 202 | response: { 203 | 200: PingResponse, 204 | }, 205 | }, 206 | oas: { 207 | responses: { 208 | 200: { 209 | description: "No response description specified.", 210 | contentType: "application/x-www-form-urlencoded", 211 | }, 212 | }, 213 | }, 214 | handler: async (req, reply) => { 215 | reply.header("Content-Type", "application/x-www-form-urlencoded"); 216 | return "pong=true"; 217 | }, 218 | }); 219 | }, 220 | { prefix: "/api" } 221 | ); 222 | 223 | await fastify.ready(); 224 | 225 | const jsonResponse = await fastify.inject({ 226 | method: "GET", 227 | path: "/openapi.json", 228 | }); 229 | 230 | const jsonDoc = JSON.parse(jsonResponse.body); 231 | 232 | const operation = jsonDoc.paths?.["/api/ping"]?.get; 233 | const response = operation?.responses?.["200"]; 234 | expect(response).toMatchObject({ 235 | description: "No response description specified.", 236 | content: { 237 | "application/x-www-form-urlencoded": { 238 | schema: { $ref: "#/components/schemas/PingResponse" }, 239 | }, 240 | }, 241 | }); 242 | 243 | const pingResponse = jsonDoc.components?.schemas?.PingResponse; 244 | 245 | expect(pingResponse).toBeDefined(); 246 | expect(pingResponse).toMatchObject({ 247 | type: "object", 248 | properties: { pong: { type: "boolean" } }, 249 | required: ["pong"], 250 | }); 251 | 252 | // and now inject the request 253 | 254 | const response2 = await fastify.inject({ 255 | method: "GET", 256 | path: "/api/ping", 257 | }); 258 | 259 | expect(response2.headers["content-type"]).toEqual( 260 | "application/x-www-form-urlencoded" 261 | ); 262 | expect(response2.body).toEqual("pong=true"); 263 | }); 264 | 265 | test("correctly represents request bodies in OAS documents", async () => { 266 | const fastify = Fastify(fastifyOpts); 267 | await fastify.register(oas3Plugin, { ...pluginOpts }); 268 | 269 | await fastify.register( 270 | async (fastify: FastifyInstance) => { 271 | fastify.post("/qwop", { 272 | schema: { 273 | body: QwopModel, 274 | response: { 275 | 200: PingResponse, 276 | }, 277 | }, 278 | oas: {}, 279 | handler: async (req, reply) => { 280 | return { pong: true }; 281 | }, 282 | }); 283 | }, 284 | { prefix: "/api" } 285 | ); 286 | await fastify.ready(); 287 | 288 | const jsonResponse = await fastify.inject({ 289 | method: "GET", 290 | path: "/openapi.json", 291 | }); 292 | 293 | const jsonDoc = JSON.parse(jsonResponse.body); 294 | const operation = jsonDoc.paths?.["/api/qwop"]?.post; 295 | 296 | const requestBody = operation?.requestBody; 297 | expect(requestBody).toMatchObject({ 298 | content: { 299 | [APPLICATION_JSON]: { 300 | schema: { $ref: "#/components/schemas/QwopRequestBody" }, 301 | }, 302 | }, 303 | }); 304 | 305 | const qwopRequestBody = jsonDoc.components?.schemas?.QwopRequestBody; 306 | expect(qwopRequestBody).toMatchObject({ 307 | type: "object", 308 | properties: { qwop: { type: "number" } }, 309 | required: ["qwop"], 310 | }); 311 | }); 312 | 313 | test("correctly represents request bodies with custom content type in OAS documents", async () => { 314 | const fastify = Fastify(fastifyOpts); 315 | await fastify.register(oas3Plugin, { ...pluginOpts }); 316 | 317 | await fastify.register( 318 | async (fastify: FastifyInstance) => { 319 | fastify.post("/qwop", { 320 | schema: { 321 | body: QwopModel, 322 | response: { 323 | 200: PingResponse, 324 | }, 325 | }, 326 | oas: { 327 | body: { 328 | contentType: "application/x-www-form-urlencoded", 329 | }, 330 | }, 331 | handler: async (req, reply) => { 332 | return { pong: true }; 333 | }, 334 | }); 335 | }, 336 | { prefix: "/api" } 337 | ); 338 | await fastify.ready(); 339 | 340 | const jsonResponse = await fastify.inject({ 341 | method: "GET", 342 | path: "/openapi.json", 343 | }); 344 | 345 | const jsonDoc = JSON.parse(jsonResponse.body); 346 | const operation = jsonDoc.paths?.["/api/qwop"]?.post; 347 | 348 | const requestBody = operation?.requestBody; 349 | expect(requestBody).toMatchObject({ 350 | content: { 351 | "application/x-www-form-urlencoded": { 352 | schema: { $ref: "#/components/schemas/QwopRequestBody" }, 353 | }, 354 | }, 355 | }); 356 | }); 357 | 358 | test("handles form-encoded request bodies correctly (testing non-JSON request bodies)", async () => { 359 | const fastify = Fastify(fastifyOpts); 360 | await fastify.register(oas3Plugin, { ...pluginOpts }); 361 | 362 | await fastify.register( 363 | async (fastify: FastifyInstance) => { 364 | fastify.register(fastifyFormbody); 365 | fastify.post("/qwop", { 366 | schema: { 367 | body: QwopModel, 368 | response: { 369 | 200: PingResponse, 370 | }, 371 | }, 372 | oas: { 373 | body: { 374 | contentType: "application/x-www-form-urlencoded", 375 | }, 376 | }, 377 | handler: async (req, reply) => { 378 | const body = req.body as QwopModel; 379 | return { pong: body.qwop === 42 }; 380 | }, 381 | }); 382 | }, 383 | { prefix: "/api" } 384 | ); 385 | await fastify.ready(); 386 | 387 | const response = await fastify.inject({ 388 | method: "POST", 389 | path: "/api/qwop", 390 | headers: { 391 | "content-type": "application/x-www-form-urlencoded", 392 | }, 393 | payload: "qwop=42", 394 | }); 395 | 396 | expect(response.statusCode).toBe(200); 397 | expect(JSON.parse(response.body)).toEqual({ pong: true }); 398 | }); 399 | 400 | test("fires postPathItemBuild on each route", async () => { 401 | const fastify = Fastify(fastifyOpts); 402 | const routeDetails: Set = new Set(); 403 | 404 | await fastify.register(oas3Plugin, { 405 | ...pluginOpts, 406 | postOperationBuild: (route, pathItem) => { 407 | routeDetails.add(route.url); 408 | }, 409 | }); 410 | 411 | await fastify.register(async (fastify: FastifyInstance) => { 412 | fastify.get("/boop", { 413 | schema: { 414 | querystring: Type.Object({ 415 | boopIndex: Type.Number({ description: "Boop index." }), 416 | verbose: Type.Optional(Type.Boolean()), 417 | }), 418 | response: { 419 | 200: PingResponse, 420 | }, 421 | }, 422 | oas: { 423 | querystring: { 424 | verbose: { deprecated: true }, 425 | }, 426 | }, 427 | handler: async (req, reply) => { 428 | return { pong: true }; 429 | }, 430 | }); 431 | 432 | fastify.get("/boop2", { 433 | schema: { 434 | querystring: Type.Object({ 435 | boopIndex: Type.Number({ description: "Boop index." }), 436 | verbose: Type.Optional(Type.Boolean()), 437 | }), 438 | response: { 439 | 200: PingResponse, 440 | }, 441 | }, 442 | oas: { 443 | querystring: { 444 | verbose: { deprecated: true }, 445 | }, 446 | }, 447 | handler: async (req, reply) => { 448 | return { pong: true }; 449 | }, 450 | }); 451 | }); 452 | 453 | await fastify.ready(); 454 | 455 | expect(routeDetails).toEqual(new Set(["/boop", "/boop2"])); 456 | }); 457 | 458 | test("correctly represents query parameters in OAS documents", async () => { 459 | const fastify = Fastify(fastifyOpts); 460 | await fastify.register(oas3Plugin, { ...pluginOpts }); 461 | 462 | await fastify.register(async (fastify: FastifyInstance) => { 463 | fastify.get("/boop", { 464 | schema: { 465 | querystring: Type.Object({ 466 | boopIndex: Type.Number({ description: "Boop index." }), 467 | verbose: Type.Optional(Type.Boolean()), 468 | }), 469 | response: { 470 | 200: PingResponse, 471 | }, 472 | }, 473 | oas: { 474 | querystring: { 475 | verbose: { deprecated: true }, 476 | }, 477 | }, 478 | handler: async (req, reply) => { 479 | return { pong: true }; 480 | }, 481 | }); 482 | }); 483 | await fastify.ready(); 484 | 485 | const jsonResponse = await fastify.inject({ 486 | method: "GET", 487 | path: "/openapi.json", 488 | }); 489 | 490 | const jsonDoc = JSON.parse(jsonResponse.body); 491 | const operation = jsonDoc.paths?.["/boop"]?.get; 492 | 493 | const parameters = operation?.parameters; 494 | expect(parameters).toMatchObject([ 495 | { 496 | in: "query", 497 | name: "boopIndex", 498 | description: "Boop index.", 499 | schema: { type: "number" }, 500 | required: true, 501 | }, 502 | { 503 | in: "query", 504 | name: "verbose", 505 | schema: { type: "boolean" }, 506 | required: false, 507 | deprecated: true, 508 | }, 509 | ]); 510 | }); 511 | 512 | test("correctly represents path parameters in OAS documents", async () => { 513 | const fastify = Fastify(fastifyOpts); 514 | await fastify.register(oas3Plugin, { ...pluginOpts }); 515 | 516 | await fastify.register(async (fastify: FastifyInstance) => { 517 | fastify.get("/clank/:primary/:secondary", { 518 | schema: { 519 | params: Type.Object({ 520 | primary: Type.String(), 521 | secondary: Type.Number(), 522 | }), 523 | response: { 524 | 200: PingResponse, 525 | }, 526 | }, 527 | oas: {}, 528 | handler: async (req, reply) => { 529 | return { pong: true }; 530 | }, 531 | }); 532 | }); 533 | await fastify.ready(); 534 | 535 | const jsonResponse = await fastify.inject({ 536 | method: "GET", 537 | path: "/openapi.json", 538 | }); 539 | 540 | const jsonDoc = JSON.parse(jsonResponse.body); 541 | const operation = jsonDoc.paths?.["/clank/{primary}/{secondary}"]?.get; 542 | 543 | const parameters = operation?.parameters; 544 | // remember: `required` is implied (and forced true) for path params 545 | expect(parameters).toMatchObject([ 546 | { 547 | in: "path", 548 | name: "primary", 549 | schema: { type: "string" }, 550 | required: true, 551 | }, 552 | { 553 | in: "path", 554 | name: "secondary", 555 | schema: { type: "number" }, 556 | required: true, 557 | }, 558 | ]); 559 | }); 560 | }); 561 | -------------------------------------------------------------------------------- /src/test/security-inheritance.spec.ts: -------------------------------------------------------------------------------- 1 | import { inspect } from "util"; 2 | 3 | import { Type } from "@sinclair/typebox"; 4 | import Fastify, { 5 | type FastifyInstance, 6 | type FastifyServerOptions, 7 | } from "fastify"; 8 | import { describe, expect, test } from "vitest"; 9 | 10 | import { oas3PluginAjv } from "../ajv.js"; 11 | import { type OAS3AutowireSecurityOptions } from "../autowired-security/index.js"; 12 | import { type OAS3PluginOptions } from "../options.js"; 13 | import { oas3Plugin } from "../plugin.js"; 14 | 15 | const fastifyOpts: FastifyServerOptions = { 16 | logger: { level: "error" }, 17 | ajv: { 18 | customOptions: { 19 | coerceTypes: true, 20 | }, 21 | plugins: [oas3PluginAjv], 22 | }, 23 | }; 24 | const pluginOpts: OAS3PluginOptions = { 25 | openapiInfo: { 26 | title: "test", 27 | version: "0.1.0", 28 | }, 29 | }; 30 | const autowiredOpts: OAS3AutowireSecurityOptions = { 31 | disabled: false, 32 | securitySchemes: {}, 33 | }; 34 | 35 | test("operation with security but no root security works", async () => { 36 | const fastify = Fastify(fastifyOpts); 37 | await fastify.register(oas3Plugin, { 38 | ...pluginOpts, 39 | autowiredSecurity: { 40 | securitySchemes: { 41 | TestApiKey: { 42 | type: "apiKey", 43 | in: "header", 44 | name: "X-Test-Key", 45 | fn: (key) => 46 | key === "valid" ? { ok: true } : { ok: false, code: 401 }, 47 | }, 48 | }, 49 | }, 50 | }); 51 | 52 | fastify.get( 53 | "/test", 54 | { 55 | oas: { 56 | security: { TestApiKey: [] }, 57 | }, 58 | }, 59 | async () => "ok" 60 | ); 61 | 62 | await fastify.ready(); 63 | 64 | // Get and parse OAS doc 65 | const docResponse = await fastify.inject({ 66 | method: "GET", 67 | path: "/openapi.json", 68 | }); 69 | const docBody = docResponse.body; 70 | const parsedDoc = JSON.parse(docBody); 71 | 72 | // Verify OAS doc 73 | expect(parsedDoc.paths["/test"].get.security).toEqual([{ TestApiKey: [] }]); 74 | 75 | // Verify interceptor behavior 76 | const validReq = await fastify.inject({ 77 | method: "GET", 78 | path: "/test", 79 | headers: { "X-Test-Key": "valid" }, 80 | }); 81 | expect(validReq.statusCode).toBe(200); 82 | 83 | const invalidReq = await fastify.inject({ 84 | method: "GET", 85 | path: "/test", 86 | }); 87 | expect(invalidReq.statusCode).toBe(401); 88 | }); 89 | 90 | test("operation inherits root security when none specified", async () => { 91 | const fastify = Fastify(fastifyOpts); 92 | await fastify.register(oas3Plugin, { 93 | ...pluginOpts, 94 | autowiredSecurity: { 95 | rootSecurity: { RootApiKey: [] }, 96 | securitySchemes: { 97 | RootApiKey: { 98 | type: "apiKey", 99 | in: "header", 100 | name: "X-Root-Key", 101 | fn: (key) => 102 | key === "valid" ? { ok: true } : { ok: false, code: 401 }, 103 | }, 104 | }, 105 | }, 106 | }); 107 | 108 | fastify.get( 109 | "/test", 110 | { 111 | oas: {}, // No security specified 112 | }, 113 | async () => "ok" 114 | ); 115 | 116 | await fastify.ready(); 117 | 118 | // Get and parse OAS doc 119 | const docResponse = await fastify.inject({ 120 | method: "GET", 121 | path: "/openapi.json", 122 | }); 123 | const docBody = docResponse.body; 124 | const parsedDoc = JSON.parse(docBody); 125 | 126 | // Verify OAS doc 127 | expect(parsedDoc.security).toEqual([{ RootApiKey: [] }]); 128 | expect(parsedDoc.paths["/test"].get.security).toBeUndefined(); 129 | 130 | // Verify interceptor 131 | const validReq = await fastify.inject({ 132 | method: "GET", 133 | path: "/test", 134 | headers: { "X-Root-Key": "valid" }, 135 | }); 136 | expect(validReq.statusCode).toBe(200); 137 | }); 138 | 139 | test("operation can disable inherited root security", async () => { 140 | const fastify = Fastify(fastifyOpts); 141 | await fastify.register(oas3Plugin, { 142 | ...pluginOpts, 143 | autowiredSecurity: { 144 | rootSecurity: { RootApiKey: [] }, 145 | securitySchemes: { 146 | RootApiKey: { 147 | type: "apiKey", 148 | in: "header", 149 | name: "X-Root-Key", 150 | fn: (key) => 151 | key === "valid" ? { ok: true } : { ok: false, code: 401 }, 152 | }, 153 | }, 154 | }, 155 | }); 156 | 157 | fastify.get( 158 | "/test", 159 | { 160 | oas: { 161 | security: [], // Explicitly disable security 162 | }, 163 | }, 164 | async () => "ok" 165 | ); 166 | 167 | await fastify.ready(); 168 | 169 | // Get and parse OAS doc 170 | const docResponse = await fastify.inject({ 171 | method: "GET", 172 | path: "/openapi.json", 173 | }); 174 | const docBody = docResponse.body; 175 | const parsedDoc = JSON.parse(docBody); 176 | 177 | // Verify OAS doc 178 | expect(parsedDoc.paths["/test"].get.security).toEqual([]); 179 | 180 | // Verify no interceptor 181 | const req = await fastify.inject({ 182 | method: "GET", 183 | path: "/test", 184 | }); 185 | expect(req.statusCode).toBe(200); 186 | }); 187 | -------------------------------------------------------------------------------- /src/test/spec-transforms.spec.ts: -------------------------------------------------------------------------------- 1 | import { Type } from "@sinclair/typebox"; 2 | import { 3 | type OpenAPIObject, 4 | type OperationObject, 5 | type SchemaObject, 6 | } from "openapi3-ts"; 7 | import { describe, expect, test } from "vitest"; 8 | 9 | import { APPLICATION_JSON, SCHEMA_NAME_PROPERTY } from "../constants.js"; 10 | import { schemaType } from "../schemas.js"; 11 | import { canonicalizeSchemas } from "../spec-transforms/canonicalize.js"; 12 | import { findTaggedSchemas } from "../spec-transforms/find.js"; 13 | import { canonicalizeAnnotatedSchemas } from "../spec-transforms/index.js"; 14 | 15 | import { UnionOneOf } from "./typebox-ext.js"; 16 | 17 | const typeA = schemaType("MyTypeA", Type.Object({})); 18 | const typeB = schemaType( 19 | "MyTypeB", 20 | Type.Object({ 21 | foo: Type.Boolean(), 22 | }) 23 | ); 24 | const typeC = schemaType( 25 | "MyTypeC", 26 | Type.Object({ 27 | a: typeA, 28 | b: typeB, 29 | }) 30 | ); 31 | 32 | const typeWithArray = schemaType( 33 | "TypeWithArray", 34 | Type.Object({ 35 | arr: Type.Array(typeA), 36 | }) 37 | ); 38 | 39 | const oneOfType = schemaType( 40 | "OneOfType", 41 | UnionOneOf([Type.Literal("a"), Type.Literal("b")]) 42 | ); 43 | 44 | const oneOfType2 = schemaType("OneOfType2", UnionOneOf([typeA, typeB, typeC])); 45 | 46 | const baseOas: OpenAPIObject = { 47 | openapi: "3.1.0", 48 | info: { 49 | title: "a test", 50 | version: "0.0.1", 51 | }, 52 | paths: {}, 53 | components: {}, 54 | }; 55 | 56 | describe("tagged schema finder", () => { 57 | test("finds tagged schema in oas.components", () => { 58 | const oas: OpenAPIObject = { 59 | ...baseOas, 60 | components: { 61 | schemas: { 62 | MyTypeA: typeA, 63 | MyTypeB: typeB, 64 | }, 65 | }, 66 | }; 67 | 68 | expect(findTaggedSchemas(oas)).toHaveLength(2); 69 | }); 70 | 71 | test("finds tagged schema in request body", () => { 72 | const oas: OpenAPIObject = { 73 | ...baseOas, 74 | paths: { 75 | "/": { 76 | post: { 77 | requestBody: { 78 | content: { 79 | [APPLICATION_JSON]: { 80 | schema: typeA, 81 | }, 82 | }, 83 | }, 84 | responses: {}, 85 | } as OperationObject, 86 | }, 87 | }, 88 | components: { 89 | requestBodies: { 90 | abc: { 91 | content: { 92 | [APPLICATION_JSON]: { 93 | schema: typeB, 94 | }, 95 | }, 96 | }, 97 | }, 98 | }, 99 | }; 100 | 101 | expect(findTaggedSchemas(oas)).toHaveLength(2); 102 | }); 103 | 104 | test("finds tagged schema in responses", () => { 105 | const oas: OpenAPIObject = { 106 | ...baseOas, 107 | paths: { 108 | "/": { 109 | post: { 110 | responses: { 111 | default: { 112 | description: "doot", 113 | content: { 114 | [APPLICATION_JSON]: { 115 | schema: typeA, 116 | }, 117 | }, 118 | }, 119 | }, 120 | } as OperationObject, 121 | }, 122 | }, 123 | components: { 124 | responses: { 125 | "a-response": { 126 | description: "DOOT!", 127 | content: { 128 | [APPLICATION_JSON]: { 129 | schema: typeB, 130 | }, 131 | }, 132 | }, 133 | }, 134 | }, 135 | }; 136 | 137 | expect(findTaggedSchemas(oas)).toHaveLength(2); 138 | }); 139 | 140 | test("find tagged schema in parameters", () => { 141 | const oas: OpenAPIObject = { 142 | ...baseOas, 143 | paths: { 144 | "/": { 145 | get: { 146 | parameters: [ 147 | { 148 | name: "foo", 149 | in: "query", 150 | schema: typeA, 151 | }, 152 | ], 153 | responses: {}, 154 | } as OperationObject, 155 | parameters: [ 156 | { 157 | name: "bar", 158 | in: "query", 159 | schema: typeB, 160 | }, 161 | ], 162 | }, 163 | }, 164 | }; 165 | 166 | expect(findTaggedSchemas(oas)).toHaveLength(2); 167 | }); 168 | 169 | test("finds nested tagged schema in objects", () => { 170 | const oas: OpenAPIObject = { 171 | ...baseOas, 172 | components: { 173 | schemas: { 174 | MyTypeC: typeC, 175 | }, 176 | }, 177 | }; 178 | 179 | const schemaKeys = [ 180 | ...new Set([ 181 | ...findTaggedSchemas(oas).map((s) => s[SCHEMA_NAME_PROPERTY]), 182 | ]), 183 | ]; 184 | expect(schemaKeys).toHaveLength(3); 185 | }); 186 | 187 | test("finds nested tagged schema in arrays", () => { 188 | const oas: OpenAPIObject = { 189 | ...baseOas, 190 | components: { 191 | schemas: { 192 | TypeWithArray: typeWithArray, 193 | }, 194 | }, 195 | }; 196 | 197 | const schemaKeys = [ 198 | ...new Set([ 199 | ...findTaggedSchemas(oas).map((s) => s[SCHEMA_NAME_PROPERTY]), 200 | ]), 201 | ]; 202 | expect(schemaKeys).toHaveLength(2); 203 | }); 204 | 205 | test("correctly finds oneOf/anyOf schemas (literals)", () => { 206 | const oas: OpenAPIObject = { 207 | ...baseOas, 208 | components: { 209 | schemas: { 210 | OneOfType: oneOfType, 211 | }, 212 | }, 213 | }; 214 | 215 | const schemaKeys = [ 216 | ...new Set([ 217 | ...findTaggedSchemas(oas).map((s) => s[SCHEMA_NAME_PROPERTY]), 218 | ]), 219 | ]; 220 | expect(schemaKeys).toHaveLength(1); 221 | }); 222 | 223 | test("correctly finds oneOf/anyOf schemas (multiple schemas)", () => { 224 | const oas: OpenAPIObject = { 225 | ...baseOas, 226 | components: { 227 | schemas: { 228 | OneOfType2: oneOfType2, 229 | }, 230 | }, 231 | }; 232 | 233 | const schemaKeys = [ 234 | ...new Set([ 235 | ...findTaggedSchemas(oas).map((s) => s[SCHEMA_NAME_PROPERTY]), 236 | ]), 237 | ]; 238 | expect(schemaKeys).toHaveLength(4); 239 | }); 240 | 241 | // TODO: test for callback 242 | // I'm confident it works (as it duplicates request body syntax), but we should have 243 | // a test for completeness. 244 | }); 245 | 246 | describe("schema canonicalization", () => { 247 | test("canonicalizes even in nested schema", () => { 248 | const oas: OpenAPIObject = { 249 | ...baseOas, 250 | components: { 251 | schemas: { 252 | MyTypeC: typeC, 253 | }, 254 | }, 255 | }; 256 | 257 | const schemas = findTaggedSchemas(oas); 258 | const canonicalized = canonicalizeSchemas(schemas); 259 | expect(Object.values(canonicalized)).toHaveLength(3); 260 | }); 261 | 262 | // I don't feel a need for more tests here right now. This is the hard case. 263 | }); 264 | 265 | describe("schema fixup", () => { 266 | test("properly canonicalizes schema with multiple uses", () => { 267 | const oas: OpenAPIObject = { 268 | ...baseOas, 269 | paths: { 270 | "/": { 271 | get: { 272 | parameters: [ 273 | { 274 | name: "foo", 275 | in: "query", 276 | schema: typeA, 277 | }, 278 | ], 279 | responses: {}, 280 | } as OperationObject, 281 | parameters: [ 282 | { 283 | name: "bar", 284 | in: "query", 285 | schema: typeB, 286 | }, 287 | ], 288 | }, 289 | }, 290 | components: { 291 | schemas: { 292 | MyTypeC: typeC, 293 | }, 294 | }, 295 | }; 296 | 297 | canonicalizeAnnotatedSchemas(oas); 298 | 299 | expect(Object.keys(oas?.components?.schemas ?? {})).toHaveLength(3); 300 | 301 | const aParam = oas.paths["/"].get.parameters[0]; 302 | const bParam = oas.paths["/"].parameters[0]; 303 | expect(aParam).toMatchObject({ 304 | name: "foo", 305 | schema: { $ref: "#/components/schemas/MyTypeA" }, 306 | }); 307 | expect(bParam).toMatchObject({ 308 | name: "bar", 309 | schema: { $ref: "#/components/schemas/MyTypeB" }, 310 | }); 311 | expect( 312 | (oas?.components?.schemas?.["MyTypeC"] as SchemaObject)?.properties?.["a"] 313 | ).toEqual({ $ref: "#/components/schemas/MyTypeA" }); 314 | expect( 315 | (oas?.components?.schemas?.["MyTypeC"] as SchemaObject)?.properties?.["b"] 316 | ).toEqual({ $ref: "#/components/schemas/MyTypeB" }); 317 | }); 318 | }); 319 | -------------------------------------------------------------------------------- /src/test/typebox-ext.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | /*-------------------------------------------------------------------------- 3 | 4 | @sinclair/typebox/extensions 5 | 6 | The MIT License (MIT) 7 | 8 | Copyright (c) 2017-2023 Haydn Paterson (sinclair) 9 | 10 | Permission is hereby granted, free of charge, to any person obtaining a copy 11 | of this software and associated documentation files (the "Software"), to deal 12 | in the Software without restriction, including without limitation the rights 13 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | copies of the Software, and to permit persons to whom the Software is 15 | furnished to do so, subject to the following conditions: 16 | 17 | The above copyright notice and this permission notice shall be included in 18 | all copies or substantial portions of the Software. 19 | 20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 26 | THE SOFTWARE. 27 | 28 | ---------------------------------------------------------------------------*/ 29 | 30 | import { 31 | Kind, 32 | type TSchema, 33 | type SchemaOptions, 34 | type Static, 35 | TypeRegistry, 36 | } from "@sinclair/typebox"; 37 | import { Value } from "@sinclair/typebox/value"; 38 | 39 | function UnionOneOfCheck(schema: UnionOneOf, value: unknown) { 40 | return ( 41 | 1 === 42 | schema.oneOf.reduce( 43 | (acc: number, schema: any) => 44 | Value.Check(schema, value) ? acc + 1 : acc, 45 | 0 46 | ) 47 | ); 48 | } 49 | 50 | export interface UnionOneOf extends TSchema { 51 | [Kind]: "UnionOneOf"; 52 | static: { [K in keyof T]: Static }[number]; 53 | oneOf: T; 54 | } 55 | 56 | /** Creates a Union type with a `oneOf` schema representation */ 57 | export function UnionOneOf( 58 | oneOf: [...T], 59 | options: SchemaOptions = {} 60 | ) { 61 | if (!TypeRegistry.Has("UnionOneOf")) 62 | TypeRegistry.Set("UnionOneOf", UnionOneOfCheck); 63 | return { ...options, [Kind]: "UnionOneOf", oneOf } as UnionOneOf; 64 | } 65 | -------------------------------------------------------------------------------- /src/ui/rapidoc.ts: -------------------------------------------------------------------------------- 1 | import { type OpenAPIObject } from "openapi3-ts"; 2 | 3 | export function rapidocSkeleton(document: OpenAPIObject): string { 4 | // this is a _little_ bonkers because we have to avoid accidental backticks. 5 | // it's OK though. 6 | return ` 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 19 | 30 | 31 | `; 32 | } 33 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isReferenceObject, 3 | isSchemaObject, 4 | type ReferenceObject, 5 | type SchemaObject, 6 | } from "openapi3-ts"; 7 | import { 8 | type Falsy, 9 | isFalsy, 10 | isPrimitive, 11 | type Primitive, 12 | } from "utility-types"; 13 | 14 | import { SCHEMA_NAME_PROPERTY } from "./constants.js"; 15 | import { type TaggedSchema } from "./schemas.js"; 16 | 17 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 18 | export function isTruthy>( 19 | item: T | null | undefined 20 | ): item is Exclude { 21 | return !isFalsy(item); 22 | } 23 | 24 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 25 | export function isNotPrimitive(item: any): item is Exclude { 26 | return !isPrimitive(item); 27 | } 28 | 29 | export function isNotReferenceObject< 30 | T, 31 | U extends T | ReferenceObject = T | ReferenceObject, 32 | >(item: U): item is Exclude { 33 | return !isReferenceObject(item); 34 | } 35 | 36 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 37 | export function isTaggedSchema(t: any): t is SchemaObject & TaggedSchema { 38 | return isSchemaObject(t) && typeof t[SCHEMA_NAME_PROPERTY] === "symbol"; 39 | } 40 | 41 | export function findMissingEntries( 42 | mainObject: T, 43 | comparisonObject: U | null | undefined 44 | ): (keyof T)[] { 45 | return Object.keys(mainObject).filter( 46 | (key) => !(comparisonObject && key in comparisonObject) 47 | ) as (keyof T)[]; 48 | } 49 | 50 | export function decodeBasicAuthHeader( 51 | header: string 52 | ): { username: string; password: string } | null { 53 | if (!header.startsWith("Basic ")) { 54 | return null; 55 | } 56 | 57 | // Extract the base64 part 58 | const base64Credentials = header.slice(6); // Remove 'Basic ' from the string 59 | 60 | // Decode the base64 string 61 | const decodedCredentials = Buffer.from(base64Credentials, "base64").toString( 62 | "utf-8" 63 | ); 64 | 65 | // Split the decoded string into username and password 66 | const [username, password] = decodedCredentials.split(":"); 67 | 68 | // Ensure both username and password exist 69 | if (!username || !password) { 70 | return null; 71 | } 72 | 73 | return { username, password }; 74 | } 75 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Projects */ 6 | "incremental": true, /* Enable incremental compilation */ 7 | "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | "preserveWatchOutput": true, 9 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ 10 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ 11 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 12 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 13 | 14 | /* Language and Environment */ 15 | "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 16 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 17 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 18 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 19 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 20 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ 21 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 22 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ 23 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ 24 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 25 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 26 | 27 | /* Modules */ 28 | // "module": "commonjs", /* Specify what module code is generated. */ 29 | // "rootDir": "./", /* Specify the root folder within your source files. */ 30 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 34 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ 35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 37 | "resolveJsonModule": false, /* Enable importing .json files */ 38 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ 39 | 40 | /* JavaScript Support */ 41 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 42 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 43 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ 44 | 45 | /* Emit */ 46 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 47 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 48 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 49 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 50 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ 51 | // "outDir": "./", /* Specify an output folder for all emitted files. */ 52 | // "removeComments": true, /* Disable emitting comments. */ 53 | // "noEmit": true, /* Disable emitting files from a compilation. */ 54 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 55 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ 56 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 57 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 58 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 59 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 60 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 61 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 62 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 63 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ 64 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ 65 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 66 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ 67 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 68 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 69 | 70 | /* Interop Constraints */ 71 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 72 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 73 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */ 74 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 75 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 76 | 77 | /* Type Checking */ 78 | "strict": true, /* Enable all strict type-checking options. */ 79 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 80 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 81 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 82 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ 83 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 84 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ 85 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ 86 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 87 | // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ 88 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ 89 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 90 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 91 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 92 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 93 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 94 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ 95 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 96 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 97 | 98 | /* Completeness */ 99 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 100 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./dist", 6 | "target": "ES2017", 7 | "lib": [ 8 | "ESNext", 9 | ], 10 | "moduleResolution": "node", 11 | "paths": { 12 | "@/*": ["./src/*"], 13 | }, 14 | "module": "esnext", 15 | }, 16 | "include": [ 17 | "./src/*.ts", 18 | "./src/**/*.ts" 19 | ], 20 | "typedocOptions": { 21 | "entryPoints": [ 22 | "src/plugin.ts", 23 | "src/options.ts", 24 | "src/operation-helpers.ts", 25 | ], 26 | "entryPointStrategy": "expand", 27 | "out": "docs" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: false, 6 | 7 | include: ["src/test/*.spec.ts"], 8 | 9 | coverage: { 10 | enabled: false, 11 | reporter: ["json", "text", "html"], 12 | cleanOnRerun: true, 13 | extension: ["*.ts"], 14 | reportsDirectory: "../coverage", 15 | 16 | include: ["src/**/*.ts"], // Include all source files for coverage 17 | }, 18 | }, 19 | }); 20 | --------------------------------------------------------------------------------