From 2065af41f993434a873578171985b96d1eeb94f7 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Mon, 29 Jun 2026 01:52:30 +0000 Subject: [PATCH] feat(server): add swagger setup for public api documentation Introduces Swagger and OpenAPI documentation generation for the backend API. Configures bearer authentication globally while allowing explicit bypasses for public endpoints. Exports nested DTO classes across several modules so the NestJS Swagger plugin can correctly reflect their schema definitions. Closes #399 --- .gitignore | 1 + docs/DEVELOPMENT.md | 32 +- pnpm-lock.yaml | 287 ++++++------------ server/.env.example | 4 + server/.prettierignore | 1 + server/README.md | 13 + server/eslint.config.mjs | 2 +- server/nest-cli.json | 12 +- server/package.json | 1 + .../src/common/decorators/public.decorator.ts | 5 +- server/src/config/config.test.ts | 8 + server/src/config/config.ts | 1 + server/src/config/env.validation.test.ts | 20 ++ server/src/config/env.validation.ts | 32 +- server/src/main.ts | 8 + .../dto/create-oidc-provider.dto.ts | 4 +- .../dto/update-oidc-config.dto.ts | 4 +- .../dto/update-oidc-provider.dto.ts | 4 +- .../dto/update-config.dto.ts | 16 +- .../collection/dto/reorder-collections.dto.ts | 2 +- .../dto/koreader-progress.dto.test.ts | 12 +- .../koreader/dto/koreader-progress.dto.ts | 2 +- .../modules/koreader/koreader.controller.ts | 4 +- .../library/dto/reorder-libraries.dto.ts | 2 +- .../dto/update-global-preferences.dto.ts | 2 +- .../dto/create-migration-profile.dto.ts | 4 +- .../dto/validate-path-mappings.dto.ts | 2 +- .../dto/reorder-smart-scopes.dto.ts | 2 +- server/src/swagger.test.ts | 32 ++ server/src/swagger.ts | 88 ++++++ server/tsconfig.build.json | 2 +- 31 files changed, 352 insertions(+), 257 deletions(-) create mode 100644 server/.prettierignore create mode 100644 server/src/swagger.test.ts create mode 100644 server/src/swagger.ts diff --git a/.gitignore b/.gitignore index db31a2892..38b545990 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ local/ # Build artifacts coverage/ *.tsbuildinfo +server/src/metadata.ts # Test results (generated artifacts - uploaded to CI, not committed) test-results/ diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 863221462..3d1cd04be 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -183,6 +183,21 @@ This starts the server, client, and types watcher concurrently with hot reload. If PostgreSQL is not running, start it first with `pnpm run db:up`. +### API Docs + +Generated OpenAPI docs are disabled by default. To enable them for local development, set `SWAGGER_ENABLED=true` in `server/.env` or prefix the dev command: + +```bash +SWAGGER_ENABLED=true pnpm dev +``` + +When enabled, open: + +| Docs | URL | +| ------------ | ----------------------------------- | +| Swagger UI | http://localhost:3000/api/docs | +| OpenAPI JSON | http://localhost:3000/api/docs-json | +
Running workspaces separately @@ -209,14 +224,15 @@ pnpm --filter @bookorbit/types dev These are the variables you are most likely to need during development. See `server/.env.example` for the full list with comments. -| Variable | Purpose | Default (dev) | -| --------------- | ---------------------------------- | --------------------------------------------------------- | -| `DATABASE_URL` | PostgreSQL connection string | `postgres://bookorbit:bookorbit@localhost:5432/bookorbit` | -| `PORT` | Server listen port | `3000` | -| `NODE_ENV` | Runtime mode | `development` | -| `JWT_SECRET` | Signing key for auth tokens | `change-me-in-production` | -| `APP_DATA_PATH` | Storage for covers, avatars, cache | `../local/data` | -| `APP_URL` | Base URL for email links | `http://localhost:5173` | +| Variable | Purpose | Default (dev) | +| ----------------- | ---------------------------------- | --------------------------------------------------------- | +| `DATABASE_URL` | PostgreSQL connection string | `postgres://bookorbit:bookorbit@localhost:5432/bookorbit` | +| `PORT` | Server listen port | `3000` | +| `NODE_ENV` | Runtime mode | `development` | +| `JWT_SECRET` | Signing key for auth tokens | `change-me-in-production` | +| `APP_DATA_PATH` | Storage for covers, avatars, cache | `../local/data` | +| `APP_URL` | Base URL for email links | `http://localhost:5173` | +| `SWAGGER_ENABLED` | Serve Swagger UI and OpenAPI JSON | `false` | --- diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b258743f..02e39a38d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -276,6 +276,9 @@ importers: "@nestjs/schedule": specifier: ^6.1.3 version: 6.1.3(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27) + "@nestjs/swagger": + specifier: ^11.4.4 + version: 11.4.4(@fastify/static@9.1.3)(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2) "@nestjs/terminus": specifier: ^11.1.1 version: 11.1.1(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -3865,6 +3868,12 @@ packages: } engines: { node: ">=8" } + "@microsoft/tsdoc@0.16.0": + resolution: + { + integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==, + } + "@napi-rs/nice-android-arm-eabi@1.1.1": resolution: { @@ -4111,6 +4120,22 @@ packages: peerDependencies: "@nestjs/common": ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + "@nestjs/mapped-types@2.1.1": + resolution: + { + integrity: sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==, + } + peerDependencies: + "@nestjs/common": ^10.0.0 || ^11.0.0 + class-transformer: ^0.4.0 || ^0.5.0 + class-validator: ^0.13.0 || ^0.14.0 || ^0.15.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + "@nestjs/passport@11.0.5": resolution: { @@ -4167,6 +4192,26 @@ packages: prettier: optional: true + "@nestjs/swagger@11.4.4": + resolution: + { + integrity: sha512-VaIo1ruV2G7b+f2zPzkBSUNy9a/WQ9sg8TLKhWlrTfg4O6U10M/PA7Xi6XMXadOVhwOqoesijba8jH3i/3adrA==, + } + peerDependencies: + "@fastify/static": ^8.0.0 || ^9.0.0 + "@nestjs/common": ^11.0.1 + "@nestjs/core": ^11.0.1 + class-transformer: "*" + class-validator: "*" + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + "@fastify/static": + optional: true + class-transformer: + optional: true + class-validator: + optional: true + "@nestjs/terminus@11.1.1": resolution: { @@ -4571,136 +4616,6 @@ packages: integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==, } - "@parcel/watcher-android-arm64@2.5.6": - resolution: - { - integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==, - } - engines: { node: ">= 10.0.0" } - cpu: [arm64] - os: [android] - - "@parcel/watcher-darwin-arm64@2.5.6": - resolution: - { - integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==, - } - engines: { node: ">= 10.0.0" } - cpu: [arm64] - os: [darwin] - - "@parcel/watcher-darwin-x64@2.5.6": - resolution: - { - integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==, - } - engines: { node: ">= 10.0.0" } - cpu: [x64] - os: [darwin] - - "@parcel/watcher-freebsd-x64@2.5.6": - resolution: - { - integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==, - } - engines: { node: ">= 10.0.0" } - cpu: [x64] - os: [freebsd] - - "@parcel/watcher-linux-arm-glibc@2.5.6": - resolution: - { - integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==, - } - engines: { node: ">= 10.0.0" } - cpu: [arm] - os: [linux] - libc: [glibc] - - "@parcel/watcher-linux-arm-musl@2.5.6": - resolution: - { - integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==, - } - engines: { node: ">= 10.0.0" } - cpu: [arm] - os: [linux] - libc: [musl] - - "@parcel/watcher-linux-arm64-glibc@2.5.6": - resolution: - { - integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==, - } - engines: { node: ">= 10.0.0" } - cpu: [arm64] - os: [linux] - libc: [glibc] - - "@parcel/watcher-linux-arm64-musl@2.5.6": - resolution: - { - integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==, - } - engines: { node: ">= 10.0.0" } - cpu: [arm64] - os: [linux] - libc: [musl] - - "@parcel/watcher-linux-x64-glibc@2.5.6": - resolution: - { - integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==, - } - engines: { node: ">= 10.0.0" } - cpu: [x64] - os: [linux] - libc: [glibc] - - "@parcel/watcher-linux-x64-musl@2.5.6": - resolution: - { - integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==, - } - engines: { node: ">= 10.0.0" } - cpu: [x64] - os: [linux] - libc: [musl] - - "@parcel/watcher-win32-arm64@2.5.6": - resolution: - { - integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==, - } - engines: { node: ">= 10.0.0" } - cpu: [arm64] - os: [win32] - - "@parcel/watcher-win32-ia32@2.5.6": - resolution: - { - integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==, - } - engines: { node: ">= 10.0.0" } - cpu: [ia32] - os: [win32] - - "@parcel/watcher-win32-x64@2.5.6": - resolution: - { - integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==, - } - engines: { node: ">= 10.0.0" } - cpu: [x64] - os: [win32] - - "@parcel/watcher@2.5.6": - resolution: - { - integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==, - } - engines: { node: ">= 10.0.0" } - "@pdf-lib/standard-fonts@1.0.0": resolution: { @@ -5189,6 +5104,12 @@ packages: cpu: [x64] os: [win32] + "@scarf/scarf@1.4.0": + resolution: + { + integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==, + } + "@sec-ant/readable-stream@0.4.1": resolution: { @@ -11095,12 +11016,6 @@ packages: integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==, } - node-addon-api@7.1.1: - resolution: - { - integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==, - } - node-emoji@1.11.0: resolution: { @@ -13192,6 +13107,12 @@ packages: } engines: { node: ">=18" } + swagger-ui-dist@5.32.6: + resolution: + { + integrity: sha512-75ttZNaYCLoFPnozPZcTUU6mS3wKT8l7WLjU5zJSHFeJa23i5vtnze6IiCl4jDMPeQTXVXIgovq4M11NNfQvSA==, + } + symbol-observable@4.0.0: resolution: { @@ -16966,6 +16887,8 @@ snapshots: "@lukeed/ms@2.0.2": {} + "@microsoft/tsdoc@0.16.0": {} + "@napi-rs/nice-android-arm-eabi@1.1.1": optional: true @@ -17126,6 +17049,14 @@ snapshots: "@types/jsonwebtoken": 9.0.10 jsonwebtoken: 9.0.3 + "@nestjs/mapped-types@2.1.1(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)": + dependencies: + "@nestjs/common": 11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.15.1 + "@nestjs/passport@11.0.5(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)": dependencies: "@nestjs/common": 11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -17192,6 +17123,22 @@ snapshots: transitivePeerDependencies: - chokidar + "@nestjs/swagger@11.4.4(@fastify/static@9.1.3)(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)": + dependencies: + "@microsoft/tsdoc": 0.16.0 + "@nestjs/common": 11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + "@nestjs/core": 11.1.27(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) + "@nestjs/mapped-types": 2.1.1(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2) + js-yaml: 4.1.1 + lodash: 4.18.1 + path-to-regexp: 8.4.2 + reflect-metadata: 0.2.2 + swagger-ui-dist: 5.32.6 + optionalDependencies: + "@fastify/static": 9.1.3 + class-transformer: 0.5.1 + class-validator: 0.15.1 + "@nestjs/terminus@11.1.1(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)": dependencies: "@nestjs/common": 11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -17367,66 +17314,6 @@ snapshots: dependencies: "@noble/hashes": 1.8.0 - "@parcel/watcher-android-arm64@2.5.6": - optional: true - - "@parcel/watcher-darwin-arm64@2.5.6": - optional: true - - "@parcel/watcher-darwin-x64@2.5.6": - optional: true - - "@parcel/watcher-freebsd-x64@2.5.6": - optional: true - - "@parcel/watcher-linux-arm-glibc@2.5.6": - optional: true - - "@parcel/watcher-linux-arm-musl@2.5.6": - optional: true - - "@parcel/watcher-linux-arm64-glibc@2.5.6": - optional: true - - "@parcel/watcher-linux-arm64-musl@2.5.6": - optional: true - - "@parcel/watcher-linux-x64-glibc@2.5.6": - optional: true - - "@parcel/watcher-linux-x64-musl@2.5.6": - optional: true - - "@parcel/watcher-win32-arm64@2.5.6": - optional: true - - "@parcel/watcher-win32-ia32@2.5.6": - optional: true - - "@parcel/watcher-win32-x64@2.5.6": - optional: true - - "@parcel/watcher@2.5.6": - dependencies: - detect-libc: 2.1.2 - is-glob: 4.0.3 - node-addon-api: 7.1.1 - picomatch: 4.0.4 - optionalDependencies: - "@parcel/watcher-android-arm64": 2.5.6 - "@parcel/watcher-darwin-arm64": 2.5.6 - "@parcel/watcher-darwin-x64": 2.5.6 - "@parcel/watcher-freebsd-x64": 2.5.6 - "@parcel/watcher-linux-arm-glibc": 2.5.6 - "@parcel/watcher-linux-arm-musl": 2.5.6 - "@parcel/watcher-linux-arm64-glibc": 2.5.6 - "@parcel/watcher-linux-arm64-musl": 2.5.6 - "@parcel/watcher-linux-x64-glibc": 2.5.6 - "@parcel/watcher-linux-x64-musl": 2.5.6 - "@parcel/watcher-win32-arm64": 2.5.6 - "@parcel/watcher-win32-ia32": 2.5.6 - "@parcel/watcher-win32-x64": 2.5.6 - "@pdf-lib/standard-fonts@1.0.0": dependencies: pako: 1.0.11 @@ -17629,6 +17516,8 @@ snapshots: "@rollup/rollup-win32-x64-msvc@4.62.2": optional: true + "@scarf/scarf@1.4.0": {} + "@sec-ant/readable-stream@0.4.1": {} "@semantic-release/commit-analyzer@13.0.1(semantic-release@25.0.5(typescript@6.0.3))": @@ -21225,8 +21114,6 @@ snapshots: node-abort-controller@3.1.1: {} - node-addon-api@7.1.1: {} - node-emoji@1.11.0: dependencies: lodash: 4.18.1 @@ -22592,6 +22479,10 @@ snapshots: transitivePeerDependencies: - "@typescript-eslint/types" + swagger-ui-dist@5.32.6: + dependencies: + "@scarf/scarf": 1.4.0 + symbol-observable@4.0.0: {} symbol-tree@3.2.4: {} diff --git a/server/.env.example b/server/.env.example index 0a6d7441a..d01465b0b 100644 --- a/server/.env.example +++ b/server/.env.example @@ -5,6 +5,10 @@ DATABASE_URL=postgres://bookorbit:bookorbit@localhost:5432/bookorbit PORT=3000 NODE_ENV=development +# API docs (optional) +# Set to true to serve Swagger UI at /api/docs and OpenAPI JSON at /api/docs-json. +# SWAGGER_ENABLED=false + # Auth JWT_SECRET=change-me-in-production JWT_EXPIRES_IN=15m diff --git a/server/.prettierignore b/server/.prettierignore new file mode 100644 index 000000000..1f7fd1a72 --- /dev/null +++ b/server/.prettierignore @@ -0,0 +1 @@ +src/metadata.ts diff --git a/server/README.md b/server/README.md index 41645a95e..f4201dda3 100644 --- a/server/README.md +++ b/server/README.md @@ -10,6 +10,19 @@ Server only: `pnpm start:dev` (from this directory). The server loads environment variables from `.env` in this directory. See `.env.example` for all available options. +## API docs + +Generated OpenAPI docs are disabled by default. Enable them with `SWAGGER_ENABLED=true`: + +```bash +SWAGGER_ENABLED=true pnpm start:dev +``` + +Then open: + +- Swagger UI: http://localhost:3000/api/docs +- OpenAPI JSON: http://localhost:3000/api/docs-json + ## Module structure Each feature is a self-contained NestJS module under `src/modules/`: diff --git a/server/eslint.config.mjs b/server/eslint.config.mjs index c8163be9d..bf88496d4 100644 --- a/server/eslint.config.mjs +++ b/server/eslint.config.mjs @@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'; export default tseslint.config( { - ignores: ['eslint.config.mjs', 'dist/**', 'coverage/**'], + ignores: ['eslint.config.mjs', 'dist/**', 'coverage/**', 'src/metadata.ts'], }, eslint.configs.recommended, ...tseslint.configs.recommendedTypeChecked, diff --git a/server/nest-cli.json b/server/nest-cli.json index c9ff8240e..88730e3af 100644 --- a/server/nest-cli.json +++ b/server/nest-cli.json @@ -4,6 +4,16 @@ "sourceRoot": "src", "compilerOptions": { "deleteOutDir": true, - "builder": "swc" + "builder": "swc", + "typeCheck": true, + "plugins": [ + { + "name": "@nestjs/swagger", + "options": { + "classValidatorShim": true, + "dtoFileNameSuffix": [".dto.ts"] + } + } + ] } } diff --git a/server/package.json b/server/package.json index 74faae7e0..68ce4b0d7 100644 --- a/server/package.json +++ b/server/package.json @@ -64,6 +64,7 @@ "@nestjs/platform-fastify": "^11.1.27", "@nestjs/platform-socket.io": "^11.1.27", "@nestjs/schedule": "^6.1.3", + "@nestjs/swagger": "^11.4.4", "@nestjs/terminus": "^11.1.1", "@nestjs/throttler": "^6.5.0", "@nestjs/websockets": "^11.1.27", diff --git a/server/src/common/decorators/public.decorator.ts b/server/src/common/decorators/public.decorator.ts index b3845e122..cef2f44ff 100644 --- a/server/src/common/decorators/public.decorator.ts +++ b/server/src/common/decorators/public.decorator.ts @@ -1,4 +1,5 @@ -import { SetMetadata } from '@nestjs/common'; +import { applyDecorators, SetMetadata } from '@nestjs/common'; +import { ApiExtension } from '@nestjs/swagger'; export const IS_PUBLIC_KEY = 'isPublic'; -export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); +export const Public = () => applyDecorators(SetMetadata(IS_PUBLIC_KEY, true), ApiExtension('x-public', true)); diff --git a/server/src/config/config.test.ts b/server/src/config/config.test.ts index f5271fd77..c90c73135 100644 --- a/server/src/config/config.test.ts +++ b/server/src/config/config.test.ts @@ -10,7 +10,9 @@ function resetEnv(): void { delete process.env.APP_URL; delete process.env.APP_VERSION; delete process.env.OIDC_ALLOW_LOCAL_ISSUERS; + delete process.env.SWAGGER_ENABLED; delete process.env.KOBO_CLOUDSCRAPER_PYTHON; + delete process.env.KOREADER_PLUGIN_PATH; delete process.env.DATABASE_URL; delete process.env.JWT_SECRET; delete process.env.JWT_EXPIRES_IN; @@ -46,7 +48,9 @@ describe('config', () => { githubReleasesRepo: 'bookorbit/bookorbit', githubReleasesToken: undefined, oidcAllowLocalIssuers: false, + swaggerEnabled: false, koboCloudscraperPython: undefined, + koreaderPluginSourcePath: undefined, }); }); @@ -55,7 +59,9 @@ describe('config', () => { process.env.APP_URL = 'https://bookorbit.local'; process.env.APP_VERSION = 'v2.3.4'; process.env.OIDC_ALLOW_LOCAL_ISSUERS = 'true'; + process.env.SWAGGER_ENABLED = 'true'; process.env.KOBO_CLOUDSCRAPER_PYTHON = '/opt/bookorbit-python/bin/python'; + process.env.KOREADER_PLUGIN_PATH = '/opt/koreader/bookorbit.koplugin'; process.env.GITHUB_RELEASES_REPO = 'acme/app'; process.env.GITHUB_RELEASES_TOKEN = 'ghp_example'; @@ -66,7 +72,9 @@ describe('config', () => { githubReleasesRepo: 'acme/app', githubReleasesToken: 'ghp_example', oidcAllowLocalIssuers: true, + swaggerEnabled: true, koboCloudscraperPython: '/opt/bookorbit-python/bin/python', + koreaderPluginSourcePath: '/opt/koreader/bookorbit.koplugin', }); }); diff --git a/server/src/config/config.ts b/server/src/config/config.ts index a624eded1..4ad5bc6c4 100644 --- a/server/src/config/config.ts +++ b/server/src/config/config.ts @@ -8,6 +8,7 @@ export const appConfig = registerAs('app', () => ({ githubReleasesRepo: process.env.GITHUB_RELEASES_REPO?.trim() || 'bookorbit/bookorbit', githubReleasesToken: process.env.GITHUB_RELEASES_TOKEN?.trim() || undefined, oidcAllowLocalIssuers: parseBooleanFlag(process.env.OIDC_ALLOW_LOCAL_ISSUERS, false), + swaggerEnabled: parseBooleanFlag(process.env.SWAGGER_ENABLED, false), koboCloudscraperPython: process.env.KOBO_CLOUDSCRAPER_PYTHON?.trim() || undefined, koreaderPluginSourcePath: process.env.KOREADER_PLUGIN_PATH?.trim() || undefined, })); diff --git a/server/src/config/env.validation.test.ts b/server/src/config/env.validation.test.ts index a39436313..b7acdbb4a 100644 --- a/server/src/config/env.validation.test.ts +++ b/server/src/config/env.validation.test.ts @@ -79,6 +79,26 @@ describe('validateEnv', () => { ).toThrow('OIDC_ALLOW_LOCAL_ISSUERS must be one of true/false/1/0/yes/no/on/off'); }); + it('accepts boolean-like values for SWAGGER_ENABLED', () => { + for (const SWAGGER_ENABLED of ['true', 'false', '1', '0', 'yes', 'no', 'on', 'off']) { + expect(() => + validateEnv({ + ...BASE_ENV, + SWAGGER_ENABLED, + }), + ).not.toThrow(); + } + }); + + it('rejects invalid SWAGGER_ENABLED values', () => { + expect(() => + validateEnv({ + ...BASE_ENV, + SWAGGER_ENABLED: 'maybe', + }), + ).toThrow('SWAGGER_ENABLED must be one of true/false/1/0/yes/no/on/off'); + }); + it('accepts a custom Book Dock container path', () => { expect(() => validateEnv({ diff --git a/server/src/config/env.validation.ts b/server/src/config/env.validation.ts index e87bacb29..b604e49b8 100644 --- a/server/src/config/env.validation.ts +++ b/server/src/config/env.validation.ts @@ -1,6 +1,8 @@ import { parseIntoClientConfig } from 'pg-connection-string'; import { z } from 'zod'; +const BOOLEAN_ENV_VALUES = ['true', 'false', '1', '0', 'yes', 'no', 'on', 'off']; + function isValidPostgresConnectionString(value: string): boolean { if (!value.trim()) { return false; @@ -18,6 +20,17 @@ function isValidPostgresConnectionString(value: string): boolean { } } +function booleanEnvFlag(name: string) { + return z + .string() + .trim() + .toLowerCase() + .refine((val) => BOOLEAN_ENV_VALUES.includes(val), { + message: `${name} must be one of ${BOOLEAN_ENV_VALUES.join('/')}`, + }) + .optional(); +} + const envSchema = z.object({ NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), DATABASE_URL: z.string().refine(isValidPostgresConnectionString, 'DATABASE_URL must be a valid PostgreSQL connection string').optional(), @@ -43,22 +56,9 @@ const envSchema = z.object({ TRUST_PROXY: z.string().optional(), EMAIL_ENCRYPTION_KEY: z.string().optional(), LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).optional(), - OIDC_ALLOW_LOCAL_ISSUERS: z - .string() - .trim() - .toLowerCase() - .refine((val) => ['true', 'false', '1', '0', 'yes', 'no', 'on', 'off'].includes(val), { - message: 'OIDC_ALLOW_LOCAL_ISSUERS must be one of true/false/1/0/yes/no/on/off', - }) - .optional(), - CSP_ALLOW_CLOUDFLARE_INSIGHTS: z - .string() - .trim() - .toLowerCase() - .refine((val) => ['true', 'false', '1', '0', 'yes', 'no', 'on', 'off'].includes(val), { - message: 'CSP_ALLOW_CLOUDFLARE_INSIGHTS must be one of true/false/1/0/yes/no/on/off', - }) - .optional(), + OIDC_ALLOW_LOCAL_ISSUERS: booleanEnvFlag('OIDC_ALLOW_LOCAL_ISSUERS'), + CSP_ALLOW_CLOUDFLARE_INSIGHTS: booleanEnvFlag('CSP_ALLOW_CLOUDFLARE_INSIGHTS'), + SWAGGER_ENABLED: booleanEnvFlag('SWAGGER_ENABLED'), KOBO_CLOUDSCRAPER_PYTHON: z .string() .transform((val) => val.trim()) diff --git a/server/src/main.ts b/server/src/main.ts index 93782fb10..539554a6d 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -3,6 +3,7 @@ import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify import { ValidationPipe } from '@nestjs/common'; import { IoAdapter } from '@nestjs/platform-socket.io'; import { Logger } from 'nestjs-pino'; +import type { ConfigType } from '@nestjs/config'; import { AppModule } from './app.module'; import { GlobalExceptionFilter } from './common/filters/http-exception.filter'; import { join } from 'path'; @@ -11,6 +12,8 @@ import fastifyMultipart from '@fastify/multipart'; import fastifyStatic from '@fastify/static'; import fastifyHelmet from '@fastify/helmet'; import fastifyCompress from '@fastify/compress'; +import { appConfig } from './config/config'; +import { setupSwaggerDocs } from './swagger'; import { parseBooleanEnv, parseTrustProxy, @@ -67,6 +70,11 @@ async function bootstrap() { app.useGlobalFilters(new GlobalExceptionFilter()); + const appConfiguration = app.get>(appConfig.KEY); + if (appConfiguration.swaggerEnabled) { + await setupSwaggerDocs(app, appConfiguration); + } + await app.register(fastifyHelmet as never, { crossOriginOpenerPolicy: false, contentSecurityPolicy: { diff --git a/server/src/modules/app-settings/dto/create-oidc-provider.dto.ts b/server/src/modules/app-settings/dto/create-oidc-provider.dto.ts index 179b78c85..141cbc7c3 100644 --- a/server/src/modules/app-settings/dto/create-oidc-provider.dto.ts +++ b/server/src/modules/app-settings/dto/create-oidc-provider.dto.ts @@ -1,7 +1,7 @@ import { Type } from 'class-transformer'; import { IsArray, IsBoolean, IsOptional, IsString, IsUrl, Matches, MaxLength, MinLength, ValidateNested } from 'class-validator'; -class ClaimMappingDto { +export class ClaimMappingDto { @IsString() @MaxLength(256) username: string; @@ -19,7 +19,7 @@ class ClaimMappingDto { groups: string; } -class AutoProvisionDto { +export class AutoProvisionDto { @IsBoolean() enabled: boolean; diff --git a/server/src/modules/app-settings/dto/update-oidc-config.dto.ts b/server/src/modules/app-settings/dto/update-oidc-config.dto.ts index ecc310b60..b43a0c218 100644 --- a/server/src/modules/app-settings/dto/update-oidc-config.dto.ts +++ b/server/src/modules/app-settings/dto/update-oidc-config.dto.ts @@ -1,7 +1,7 @@ import { Type } from 'class-transformer'; import { IsArray, IsBoolean, IsOptional, IsString, IsUrl, MaxLength, ValidateNested } from 'class-validator'; -class ClaimMappingDto { +export class ClaimMappingDto { @IsString() @MaxLength(256) username: string; @@ -19,7 +19,7 @@ class ClaimMappingDto { groups: string; } -class AutoProvisionDto { +export class AutoProvisionDto { @IsBoolean() enabled: boolean; diff --git a/server/src/modules/app-settings/dto/update-oidc-provider.dto.ts b/server/src/modules/app-settings/dto/update-oidc-provider.dto.ts index 422bb4375..13ff64aab 100644 --- a/server/src/modules/app-settings/dto/update-oidc-provider.dto.ts +++ b/server/src/modules/app-settings/dto/update-oidc-provider.dto.ts @@ -1,7 +1,7 @@ import { Type } from 'class-transformer'; import { IsArray, IsBoolean, IsOptional, IsString, IsUrl, MaxLength, MinLength, ValidateNested } from 'class-validator'; -class ClaimMappingDto { +export class ClaimMappingDto { @IsString() @MaxLength(256) username: string; @@ -19,7 +19,7 @@ class ClaimMappingDto { groups: string; } -class AutoProvisionDto { +export class AutoProvisionDto { @IsBoolean() enabled: boolean; diff --git a/server/src/modules/book-metadata-fetch/dto/update-config.dto.ts b/server/src/modules/book-metadata-fetch/dto/update-config.dto.ts index 87e21c57d..0e1a4e3d3 100644 --- a/server/src/modules/book-metadata-fetch/dto/update-config.dto.ts +++ b/server/src/modules/book-metadata-fetch/dto/update-config.dto.ts @@ -10,7 +10,7 @@ import type { import { ALL_METADATA_FIELDS } from '@bookorbit/types'; -class ScoreConditionDto { +export class ScoreConditionDto { @IsDefined() @IsBoolean() enabled: boolean; @@ -22,7 +22,7 @@ class ScoreConditionDto { threshold: number; } -class MissingFieldsConditionDto { +export class MissingFieldsConditionDto { @IsDefined() @IsBoolean() enabled: boolean; @@ -33,13 +33,13 @@ class MissingFieldsConditionDto { fields: MetadataField[]; } -class NeverFetchedConditionDto { +export class NeverFetchedConditionDto { @IsDefined() @IsBoolean() enabled: boolean; } -class ConditionsDto implements BookMetadataFetchConditions { +export class ConditionsDto implements BookMetadataFetchConditions { @IsDefined() @IsObject() @ValidateNested() @@ -59,7 +59,7 @@ class ConditionsDto implements BookMetadataFetchConditions { neverFetched: NeverFetchedConditionDto; } -class ScoreConditionOverrideDto implements Partial { +export class ScoreConditionOverrideDto implements Partial { @IsOptional() @IsBoolean() enabled?: boolean; @@ -71,7 +71,7 @@ class ScoreConditionOverrideDto implements Partial { threshold?: number; } -class MissingFieldsConditionOverrideDto implements Partial { +export class MissingFieldsConditionOverrideDto implements Partial { @IsOptional() @IsBoolean() enabled?: boolean; @@ -82,13 +82,13 @@ class MissingFieldsConditionOverrideDto implements Partial { +export class NeverFetchedConditionOverrideDto implements Partial { @IsOptional() @IsBoolean() enabled?: boolean; } -class ConditionsOverrideDto implements BookMetadataFetchConditionsOverride { +export class ConditionsOverrideDto implements BookMetadataFetchConditionsOverride { @IsOptional() @IsObject() @ValidateNested() diff --git a/server/src/modules/collection/dto/reorder-collections.dto.ts b/server/src/modules/collection/dto/reorder-collections.dto.ts index 4dd8ab206..190c99694 100644 --- a/server/src/modules/collection/dto/reorder-collections.dto.ts +++ b/server/src/modules/collection/dto/reorder-collections.dto.ts @@ -1,7 +1,7 @@ import { Type } from 'class-transformer'; import { ArrayMinSize, IsArray, IsInt, Min, ValidateNested } from 'class-validator'; -class CollectionOrderItem { +export class CollectionOrderItem { @IsInt() @Min(1) id: number; diff --git a/server/src/modules/koreader/dto/koreader-progress.dto.test.ts b/server/src/modules/koreader/dto/koreader-progress.dto.test.ts index c9ee0b715..9d67dd5c7 100644 --- a/server/src/modules/koreader/dto/koreader-progress.dto.test.ts +++ b/server/src/modules/koreader/dto/koreader-progress.dto.test.ts @@ -3,13 +3,13 @@ import 'reflect-metadata'; import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; -import { SaveProgressDto } from './koreader-progress.dto'; +import { KoreaderSaveProgressDto } from './koreader-progress.dto'; const BASE = { document: 'abc123', percentage: 0.42 }; -describe('SaveProgressDto', () => { +describe('KoreaderSaveProgressDto', () => { it('accepts an xpointer string progress from reflowable documents', async () => { - const dto = plainToInstance(SaveProgressDto, { ...BASE, progress: '/body/DocFragment[8]/body/p[12]/text().0' }); + const dto = plainToInstance(KoreaderSaveProgressDto, { ...BASE, progress: '/body/DocFragment[8]/body/p[12]/text().0' }); const errors = await validate(dto); expect(errors).toHaveLength(0); @@ -17,7 +17,7 @@ describe('SaveProgressDto', () => { }); it('coerces the numeric page progress sent for paged documents into a string', async () => { - const dto = plainToInstance(SaveProgressDto, { ...BASE, progress: 42 }); + const dto = plainToInstance(KoreaderSaveProgressDto, { ...BASE, progress: 42 }); const errors = await validate(dto); expect(errors).toHaveLength(0); @@ -25,7 +25,7 @@ describe('SaveProgressDto', () => { }); it('keeps progress undefined when omitted', async () => { - const dto = plainToInstance(SaveProgressDto, { ...BASE }); + const dto = plainToInstance(KoreaderSaveProgressDto, { ...BASE }); const errors = await validate(dto); expect(errors).toHaveLength(0); @@ -33,7 +33,7 @@ describe('SaveProgressDto', () => { }); it('rejects progress values that are neither string nor number', async () => { - const dto = plainToInstance(SaveProgressDto, { ...BASE, progress: true }); + const dto = plainToInstance(KoreaderSaveProgressDto, { ...BASE, progress: true }); const errors = await validate(dto); expect(errors.length).toBeGreaterThan(0); diff --git a/server/src/modules/koreader/dto/koreader-progress.dto.ts b/server/src/modules/koreader/dto/koreader-progress.dto.ts index 3e8412bff..99b912cbb 100644 --- a/server/src/modules/koreader/dto/koreader-progress.dto.ts +++ b/server/src/modules/koreader/dto/koreader-progress.dto.ts @@ -1,7 +1,7 @@ import { Transform } from 'class-transformer'; import { IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; -export class SaveProgressDto { +export class KoreaderSaveProgressDto { @IsString() document!: string; diff --git a/server/src/modules/koreader/koreader.controller.ts b/server/src/modules/koreader/koreader.controller.ts index 1227b8b29..1f1a42327 100644 --- a/server/src/modules/koreader/koreader.controller.ts +++ b/server/src/modules/koreader/koreader.controller.ts @@ -24,7 +24,7 @@ import type { RequestUser } from '../../common/types/request-user'; import { KoreaderAuthGuard } from './koreader-auth.guard'; import { KoreaderPackageService } from './koreader-package.service'; import { KoreaderService } from './koreader.service'; -import { CreateKoreaderUserDto, DownloadPluginPackageDto, SaveProgressDto, TestConnectionDto, UpdateKoreaderUserDto } from './dto'; +import { CreateKoreaderUserDto, DownloadPluginPackageDto, KoreaderSaveProgressDto, TestConnectionDto, UpdateKoreaderUserDto } from './dto'; @Controller('koreader') export class KoreaderController { @@ -51,7 +51,7 @@ export class KoreaderController { @Public() @UseGuards(KoreaderAuthGuard) @Put('syncs/progress') - async saveProgress(@CurrentUser() user: RequestUser, @Body() dto: SaveProgressDto) { + async saveProgress(@CurrentUser() user: RequestUser, @Body() dto: KoreaderSaveProgressDto) { return this.koreaderService.saveProgress(user.id, dto); } diff --git a/server/src/modules/library/dto/reorder-libraries.dto.ts b/server/src/modules/library/dto/reorder-libraries.dto.ts index f185167dc..c94b0ee4d 100644 --- a/server/src/modules/library/dto/reorder-libraries.dto.ts +++ b/server/src/modules/library/dto/reorder-libraries.dto.ts @@ -1,7 +1,7 @@ import { Type } from 'class-transformer'; import { IsArray, IsInt, Min, ValidateNested } from 'class-validator'; -class LibraryOrderItem { +export class LibraryOrderItem { @IsInt() @Min(1) id: number; diff --git a/server/src/modules/metadata-preferences/dto/update-global-preferences.dto.ts b/server/src/modules/metadata-preferences/dto/update-global-preferences.dto.ts index 52e2bb5c5..8c671862f 100644 --- a/server/src/modules/metadata-preferences/dto/update-global-preferences.dto.ts +++ b/server/src/modules/metadata-preferences/dto/update-global-preferences.dto.ts @@ -30,7 +30,7 @@ export class FieldPreferenceDto { mergeStrategy!: MergeStrategy; } -class GenreOptionsDto { +export class GenreOptionsDto { @IsIn(GENRE_MERGE_MODES) mode!: GenreMergeMode; diff --git a/server/src/modules/migration/dto/create-migration-profile.dto.ts b/server/src/modules/migration/dto/create-migration-profile.dto.ts index 2dcb4b666..af3171fb9 100644 --- a/server/src/modules/migration/dto/create-migration-profile.dto.ts +++ b/server/src/modules/migration/dto/create-migration-profile.dto.ts @@ -1,7 +1,7 @@ import { Type } from 'class-transformer'; import { IsArray, IsInt, IsNotEmpty, IsObject, IsOptional, IsString, MaxLength, Min, ValidateNested } from 'class-validator'; -class UserMappingDto { +export class UserMappingDto { @IsString() @IsNotEmpty() @MaxLength(255) @@ -13,7 +13,7 @@ class UserMappingDto { targetUserId!: number; } -class PathMappingDto { +export class PathMappingDto { @IsString() @IsNotEmpty() @MaxLength(1024) diff --git a/server/src/modules/migration/dto/validate-path-mappings.dto.ts b/server/src/modules/migration/dto/validate-path-mappings.dto.ts index 1d40c3e0e..e0f315dde 100644 --- a/server/src/modules/migration/dto/validate-path-mappings.dto.ts +++ b/server/src/modules/migration/dto/validate-path-mappings.dto.ts @@ -1,7 +1,7 @@ import { Type } from 'class-transformer'; import { IsArray, IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min, ValidateNested } from 'class-validator'; -class PathMappingInputDto { +export class PathMappingInputDto { @IsString() @IsNotEmpty() @MaxLength(1024) diff --git a/server/src/modules/smart-scope/dto/reorder-smart-scopes.dto.ts b/server/src/modules/smart-scope/dto/reorder-smart-scopes.dto.ts index 1af93b8ce..db2c4c834 100644 --- a/server/src/modules/smart-scope/dto/reorder-smart-scopes.dto.ts +++ b/server/src/modules/smart-scope/dto/reorder-smart-scopes.dto.ts @@ -1,7 +1,7 @@ import { Type } from 'class-transformer'; import { ArrayMinSize, ArrayUnique, IsArray, IsInt, Min, ValidateNested } from 'class-validator'; -class SmartScopeOrderItem { +export class SmartScopeOrderItem { @IsInt() @Min(1) id: number; diff --git a/server/src/swagger.test.ts b/server/src/swagger.test.ts new file mode 100644 index 000000000..0f1d7008c --- /dev/null +++ b/server/src/swagger.test.ts @@ -0,0 +1,32 @@ +import type { OpenAPIObject } from '@nestjs/swagger'; + +import { normalizeSwaggerDocument } from './swagger'; + +describe('normalizeSwaggerDocument', () => { + it('uses operation-level security so public routes do not inherit bearer auth', () => { + const document = { + openapi: '3.0.0', + info: { title: 'BookOrbit API', version: 'test' }, + paths: { + '/api/v1/auth/login': { + post: { + 'x-public': true, + responses: { '200': { description: '' } }, + }, + }, + '/api/v1/books': { + get: { + responses: { '200': { description: '' } }, + }, + }, + }, + security: [{ bearer: [] }], + } as unknown as OpenAPIObject; + + const result = normalizeSwaggerDocument(document); + + expect(result.security).toBeUndefined(); + expect(result.paths['/api/v1/auth/login']?.post?.security).toEqual([]); + expect(result.paths['/api/v1/books']?.get?.security).toEqual([{ bearer: [] }]); + }); +}); diff --git a/server/src/swagger.ts b/server/src/swagger.ts new file mode 100644 index 000000000..dadfcfc2b --- /dev/null +++ b/server/src/swagger.ts @@ -0,0 +1,88 @@ +import type { ConfigType } from '@nestjs/config'; +import { DocumentBuilder, SwaggerModule, type OpenAPIObject } from '@nestjs/swagger'; +import type { NestFastifyApplication } from '@nestjs/platform-fastify'; + +import { appConfig } from './config/config'; + +const SWAGGER_METADATA_MODULE_PATH = './metadata.js'; +const PUBLIC_OPERATION_EXTENSION = 'x-public'; +const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace', 'search'] as const; + +type AppConfiguration = ConfigType; +type SwaggerPluginMetadataFactory = () => Promise>; +type SwaggerOperation = { + security?: Array>; + [PUBLIC_OPERATION_EXTENSION]?: boolean; +}; +type SwaggerPathItem = OpenAPIObject['paths'][string] & Record; + +export async function setupSwaggerDocs(app: NestFastifyApplication, config: AppConfiguration): Promise { + await loadSwaggerPluginMetadata(); + + const openApiConfig = new DocumentBuilder() + .setTitle('BookOrbit API') + .setDescription('BookOrbit server API') + .setVersion(config.version) + .addBearerAuth({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, 'bearer') + .build(); + + const documentFactory = () => normalizeSwaggerDocument(SwaggerModule.createDocument(app, openApiConfig)); + + SwaggerModule.setup('api/docs', app, documentFactory, { + customSiteTitle: 'BookOrbit API Docs', + jsonDocumentUrl: 'api/docs-json', + raw: ['json'], + swaggerOptions: { + persistAuthorization: true, + }, + }); +} + +export function normalizeSwaggerDocument(document: OpenAPIObject): OpenAPIObject { + delete document.security; + + for (const pathItem of Object.values(document.paths)) { + applyOperationSecurity(pathItem as SwaggerPathItem); + } + + return document; +} + +function applyOperationSecurity(pathItem: SwaggerPathItem): void { + for (const method of HTTP_METHODS) { + const operation = pathItem[method]; + if (!isSwaggerOperation(operation)) { + continue; + } + + operation.security = operation[PUBLIC_OPERATION_EXTENSION] === true ? [] : [{ bearer: [] }]; + } +} + +function isSwaggerOperation(value: unknown): value is SwaggerOperation { + return typeof value === 'object' && value !== null; +} + +async function loadSwaggerPluginMetadata(): Promise { + try { + const metadataModule = (await import(SWAGGER_METADATA_MODULE_PATH)) as { default?: SwaggerPluginMetadataFactory }; + if (typeof metadataModule.default === 'function') { + await SwaggerModule.loadPluginMetadata(metadataModule.default); + } + } catch (err) { + if (isMissingSwaggerMetadata(err)) { + return; + } + throw err; + } +} + +function isMissingSwaggerMetadata(err: unknown): boolean { + const code = typeof err === 'object' && err !== null && 'code' in err ? String((err as { code?: unknown }).code) : ''; + if (code !== 'ERR_MODULE_NOT_FOUND' && code !== 'MODULE_NOT_FOUND') { + return false; + } + + const message = err instanceof Error ? err.message : ''; + return message.includes('/metadata') || message.includes('\\metadata') || message.includes(SWAGGER_METADATA_MODULE_PATH); +} diff --git a/server/tsconfig.build.json b/server/tsconfig.build.json index cb63862ba..390beb6a2 100644 --- a/server/tsconfig.build.json +++ b/server/tsconfig.build.json @@ -1,4 +1,4 @@ { "extends": "./tsconfig.json", - "exclude": ["node_modules", "test", "dist", "**/*.test.ts"] + "exclude": ["node_modules", "test", "dist", "src/metadata.ts", "**/*.test.ts"] }