From 6085443549bf688b71d1724949e0178ce4038337 Mon Sep 17 00:00:00 2001 From: Dmitri Date: Thu, 2 Jul 2026 13:03:05 +0200 Subject: [PATCH] added Swagger documentation --- backend/package-lock.json | 168 +- backend/package.json | 2 - backend/src/index.ts | 54 +- backend/src/openapi/document.ts | 1959 +++++++++++++++++ backend/src/types/app.ts | 14 +- backend/src/types/index.ts | 3 - backend/tests/openapi-document.test.ts | 114 + .../Constructor/ConstructorToolbar.tsx | 4 +- .../Constructor/ElementEditorPanel.tsx | 23 +- .../ElementSettings/StyleSettingsSection.tsx | 5 +- frontend/src/config.ts | 2 + .../src/context/PageNavigationContext.tsx | 3 +- frontend/src/hooks/usePreloadOrchestrator.ts | 5 +- frontend/src/hooks/useTransitionCreation.ts | 3 +- frontend/src/hooks/useTransitionPlayback.ts | 6 +- frontend/src/lib/assetCache/assetDiscovery.ts | 6 +- frontend/src/lib/canvasScale.ts | 7 +- frontend/src/lib/elementEffects.ts | 18 +- frontend/src/lib/gallerySectionStyles.ts | 6 +- frontend/src/menuAside.ts | 3 +- frontend/src/pages/projects/projects-list.tsx | 4 +- frontend/src/types/constructor.ts | 15 +- frontend/src/types/filters.ts | 8 +- frontend/src/types/offline.ts | 19 +- frontend/src/types/presentation.ts | 6 +- frontend/src/types/ui.ts | 8 +- frontend/src/types/uiControls.ts | 6 +- 27 files changed, 2133 insertions(+), 338 deletions(-) create mode 100644 backend/src/openapi/document.ts create mode 100644 backend/tests/openapi-document.test.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 011fcf8..f8c13d5 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -35,7 +35,6 @@ "pino": "^9.0.0", "pino-pretty": "^11.0.0", "sequelize": "^6.37.0", - "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.0", "uuid": "^14.0.1", "validator": "^13.15.35" @@ -58,7 +57,6 @@ "@types/passport-google-oauth2": "^0.1.10", "@types/passport-jwt": "^4.0.1", "@types/passport-microsoft": "^2.1.1", - "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", "@types/validator": "^13.15.10", "@typescript-eslint/eslint-plugin": "^8.62.1", @@ -76,50 +74,6 @@ "node": ">=24 <25" } }, - "node_modules/@apidevtools/json-schema-ref-parser": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz", - "integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==", - "license": "MIT", - "dependencies": { - "@jsdevtools/ono": "^7.1.3", - "@types/json-schema": "^7.0.6", - "call-me-maybe": "^1.0.1", - "js-yaml": "^4.1.0" - } - }, - "node_modules/@apidevtools/openapi-schemas": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", - "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/@apidevtools/swagger-methods": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", - "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", - "license": "MIT" - }, - "node_modules/@apidevtools/swagger-parser": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz", - "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==", - "license": "MIT", - "dependencies": { - "@apidevtools/json-schema-ref-parser": "^9.0.6", - "@apidevtools/openapi-schemas": "^2.0.4", - "@apidevtools/swagger-methods": "^3.0.2", - "@jsdevtools/ono": "^7.1.3", - "call-me-maybe": "^1.0.1", - "z-schema": "^5.0.1" - }, - "peerDependencies": { - "openapi-types": ">=7" - } - }, "node_modules/@aws-crypto/crc32": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", @@ -1255,12 +1209,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/@jsdevtools/ono": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", - "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", - "license": "MIT" - }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -2369,12 +2317,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "license": "MIT" - }, "node_modules/@types/json2csv": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/@types/json2csv/-/json2csv-5.0.7.tgz", @@ -2566,13 +2508,6 @@ "@types/node": "*" } }, - "node_modules/@types/swagger-jsdoc": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/@types/swagger-jsdoc/-/swagger-jsdoc-6.0.4.tgz", - "integrity": "sha512-W+Xw5epcOZrF/AooUM/PccNMSAFOKWZA5dasNyMujTwsBkU74njSJBpvCCJhHAJ95XRMzQrrW844Btu0uoetwQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/swagger-ui-express": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz", @@ -3321,6 +3256,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, "license": "Python-2.0" }, "node_modules/array-buffer-byte-length": { @@ -3640,6 +3576,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, "license": "MIT" }, "node_modules/base64-js": { @@ -3806,6 +3743,7 @@ "version": "1.1.15", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -3930,12 +3868,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/call-me-maybe": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", - "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", - "license": "MIT" - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -4039,6 +3971,7 @@ "version": "10.0.1", "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -4049,6 +3982,7 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, "license": "MIT" }, "node_modules/concat-stream": { @@ -4353,6 +4287,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, "license": "Apache-2.0", "dependencies": { "esutils": "^2.0.2" @@ -5254,6 +5189,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -5664,6 +5600,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, "license": "ISC" }, "node_modules/fsevents": { @@ -5895,26 +5832,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/glob": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", - "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -6292,6 +6209,7 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -6784,6 +6702,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, "funding": [ { "type": "github", @@ -6992,6 +6911,7 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "dev": true, "license": "MIT" }, "node_modules/lodash.isinteger": { @@ -7025,12 +6945,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.mergewith": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", - "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", - "license": "MIT" - }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", @@ -7123,6 +7037,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -7632,13 +7547,6 @@ "wrappy": "1" } }, - "node_modules/openapi-types": { - "version": "12.1.3", - "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", - "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", - "license": "MIT", - "peer": true - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -7838,6 +7746,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9251,47 +9160,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/swagger-jsdoc": { - "version": "6.2.8", - "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", - "integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==", - "license": "MIT", - "dependencies": { - "commander": "6.2.0", - "doctrine": "3.0.0", - "glob": "7.1.6", - "lodash.mergewith": "^4.6.2", - "swagger-parser": "^10.0.3", - "yaml": "2.0.0-1" - }, - "bin": { - "swagger-jsdoc": "bin/swagger-jsdoc.js" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/swagger-jsdoc/node_modules/commander": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", - "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/swagger-parser": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz", - "integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==", - "license": "MIT", - "dependencies": { - "@apidevtools/swagger-parser": "10.0.3" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/swagger-ui-dist": { "version": "5.17.14", "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.17.14.tgz", @@ -10199,15 +10067,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.0.0-1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", - "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", - "license": "ISC", - "engines": { - "node": ">= 6" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -10224,6 +10083,7 @@ "version": "5.0.6", "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.6.tgz", "integrity": "sha512-+XR1GhnWklYdfr8YaZv/iu+vY+ux7V5DS5zH1DQf6bO5ufrt/5cgNhVO5qyhsjFXvsqQb/f08DWE9b6uPscyAg==", + "dev": true, "license": "MIT", "dependencies": { "lodash.get": "^4.4.2", diff --git a/backend/package.json b/backend/package.json index 9a93b01..e207a55 100644 --- a/backend/package.json +++ b/backend/package.json @@ -55,7 +55,6 @@ "pino": "^9.0.0", "pino-pretty": "^11.0.0", "sequelize": "^6.37.0", - "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.0", "uuid": "^14.0.1", "validator": "^13.15.35" @@ -93,7 +92,6 @@ "@types/passport-google-oauth2": "^0.1.10", "@types/passport-jwt": "^4.0.1", "@types/passport-microsoft": "^2.1.1", - "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", "@types/validator": "^13.15.10", "@typescript-eslint/eslint-plugin": "^8.62.1", diff --git a/backend/src/index.ts b/backend/src/index.ts index 7bc60d8..863c674 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -4,7 +4,6 @@ import express from 'express'; import fs from 'node:fs'; import path from 'node:path'; import helmet from 'helmet'; -import swaggerJsDoc from 'swagger-jsdoc'; import * as swaggerUI from 'swagger-ui-express'; import './auth/auth.ts'; @@ -23,6 +22,7 @@ import { downloadLimiter, searchLimiter, } from './middlewares/rateLimiter.ts'; +import { createOpenApiDocument } from './openapi/document.ts'; import accessLogsRoutesModule from './routes/access_logs.ts'; import assetVariantsRoutesModule from './routes/asset_variants.ts'; import assetsRoutesModule from './routes/assets.ts'; @@ -55,9 +55,6 @@ import type { HealthResponse, MountRuntimeEntityRoute, RuntimeReadOrAuthMiddleware, - SwaggerDocumentOptions, - SwaggerHostMiddleware, - SwaggerUiModuleWithHost, } from './types/index.ts'; import { exitAfterLogging, @@ -73,7 +70,6 @@ import { } from './utils/request-context.ts'; const app = express(); -const swaggerUiWithHost: SwaggerUiModuleWithHost = swaggerUI; registerProcessErrorHandlers(); initializePermissionsMiddleware(); @@ -154,54 +150,12 @@ const getBaseUrl = (url: string | undefined): string => { return url.endsWith('/api') ? url.slice(0, -4) : url; }; -const options: SwaggerDocumentOptions = { - definition: { - openapi: '3.0.0', - info: { - version: '1.0.0', - title: 'Tour Builder Platform', - description: - 'Tour Builder Platform Online REST API for Testing and Prototyping application. You can perform all major operations with your entities - create, delete and etc.', - }, - servers: [ - { - url: getBaseUrl(process.env.NEXT_PUBLIC_BACK_API) || config.swaggerUrl, - description: 'Development server', - }, - ], - components: { - securitySchemes: { - bearerAuth: { - type: 'http', - scheme: 'bearer', - bearerFormat: 'JWT', - }, - }, - responses: { - UnauthorizedError: { - description: 'Access token is missing or invalid', - }, - }, - }, - security: [ - { - bearerAuth: [], - }, - ], - }, - apis: ['./src/routes/*.{js,ts}'], -}; - -const specs = swaggerJsDoc(options); -const swaggerHostMiddleware: SwaggerHostMiddleware = (req, _res, next) => { - swaggerUiWithHost.host = - getBaseUrl(process.env.NEXT_PUBLIC_BACK_API) || req.get('host') || ''; - next(); -}; +const specs = createOpenApiDocument({ + serverUrl: getBaseUrl(process.env.NEXT_PUBLIC_BACK_API) || config.swaggerUrl, +}); app.use( '/api-docs', - swaggerHostMiddleware, swaggerUI.serve, swaggerUI.setup(specs), ); diff --git a/backend/src/openapi/document.ts b/backend/src/openapi/document.ts new file mode 100644 index 0000000..c734d7a --- /dev/null +++ b/backend/src/openapi/document.ts @@ -0,0 +1,1959 @@ +type HttpMethod = 'get' | 'post' | 'put' | 'delete'; + +type OpenApiSchema = Record; +type OpenApiParameter = Record; +type OpenApiOperation = Record; +type OpenApiPathItem = Partial>; +type OpenApiPaths = Record; + +interface OpenApiDocumentOptions { + serverUrl: string; +} + +interface CrudResource { + path: string; + tag: string; + schema: string; + description: string; + runtimePublicRead?: boolean; +} + +interface OpenApiDocument { + openapi: string; + info: { + version: string; + title: string; + description: string; + }; + servers: Array<{ + url: string; + description: string; + }>; + tags: Array<{ + name: string; + description: string; + }>; + components: { + securitySchemes: Record; + schemas: Record; + parameters: Record; + responses: Record; + }; + paths: OpenApiPaths; +} + +const bearerSecurity = [{ bearerAuth: [] }]; +const runtimeEnvironmentValues = ['dev', 'stage', 'production']; + +const ref = (name: string): OpenApiSchema => ({ + $ref: `#/components/schemas/${name}`, +}); + +const arrayOf = (schema: OpenApiSchema): OpenApiSchema => ({ + type: 'array', + items: schema, +}); + +const nullable = (schema: OpenApiSchema): OpenApiSchema => ({ + ...schema, + nullable: true, +}); + +const uuidSchema: OpenApiSchema = { + type: 'string', + format: 'uuid', +}; + +const dateTimeSchema: OpenApiSchema = { + type: 'string', + format: 'date-time', +}; + +const idParameter: OpenApiParameter = { + in: 'path', + name: 'id', + required: true, + schema: uuidSchema, +}; + +const projectIdParameter: OpenApiParameter = { + in: 'path', + name: 'projectId', + required: true, + schema: uuidSchema, +}; + +const environmentParameter: OpenApiParameter = { + in: 'path', + name: 'environment', + required: true, + schema: { + type: 'string', + enum: runtimeEnvironmentValues, + }, +}; + +const runtimeHeaders: OpenApiParameter[] = [ + { + in: 'header', + name: 'X-Runtime-Environment', + required: false, + schema: { + type: 'string', + enum: runtimeEnvironmentValues, + }, + description: + 'Content environment used by runtime-readable endpoints. Production GET requests can be public depending on project visibility.', + }, + { + in: 'header', + name: 'X-Project-Slug', + required: false, + schema: { + type: 'string', + }, + description: + 'Project slug used by runtime public/private presentation access checks.', + }, +]; + +const listParameters: OpenApiParameter[] = [ + { + in: 'query', + name: 'limit', + required: false, + schema: { + type: 'integer', + minimum: 1, + maximum: 1000, + default: 50, + }, + }, + { + in: 'query', + name: 'page', + required: false, + schema: { + type: 'integer', + minimum: 1, + default: 1, + }, + }, + { + in: 'query', + name: 'field', + required: false, + schema: { + type: 'string', + maxLength: 128, + }, + description: 'Sortable model field. Unsupported values are ignored.', + }, + { + in: 'query', + name: 'sort', + required: false, + schema: { + type: 'string', + enum: ['ASC', 'DESC', 'asc', 'desc'], + default: 'DESC', + }, + }, + { + in: 'query', + name: 'filetype', + required: false, + schema: { + type: 'string', + enum: ['csv'], + }, + description: 'When set to csv, the route returns a CSV export attachment.', + }, +]; + +const autocompleteParameters: OpenApiParameter[] = [ + { + in: 'query', + name: 'query', + required: false, + schema: { + type: 'string', + maxLength: 255, + }, + }, + { + in: 'query', + name: 'limit', + required: false, + schema: { + type: 'integer', + minimum: 1, + maximum: 50, + default: 20, + }, + }, + { + in: 'query', + name: 'offset', + required: false, + schema: { + type: 'integer', + minimum: 0, + }, + }, +]; + +const jsonRequest = (schema: OpenApiSchema): Record => ({ + required: true, + content: { + 'application/json': { + schema, + }, + }, +}); + +const jsonResponse = ( + description: string, + schema?: OpenApiSchema, +): Record => { + const response: Record = { + description, + }; + if (schema) { + response.content = { + 'application/json': { + schema, + }, + }; + } + return response; +}; + +const booleanResponse = jsonResponse('Boolean success response', { + type: 'boolean', +}); + +const successObjectResponse = jsonResponse('Success response', ref('SuccessResponse')); + +const errorResponses = { + 400: { $ref: '#/components/responses/BadRequestError' }, + 401: { $ref: '#/components/responses/UnauthorizedError' }, + 403: { $ref: '#/components/responses/ForbiddenError' }, + 404: { $ref: '#/components/responses/NotFoundError' }, + 429: { $ref: '#/components/responses/RateLimitError' }, + 500: { $ref: '#/components/responses/ServerError' }, +}; + +const entityEnvelopeSchema = (schemaName: string): OpenApiSchema => ({ + type: 'object', + required: ['data'], + additionalProperties: false, + properties: { + id: uuidSchema, + data: ref(schemaName), + }, +}); + +const paginatedSchema = (schemaName: string): OpenApiSchema => ({ + type: 'object', + required: ['rows', 'count'], + properties: { + rows: arrayOf(ref(schemaName)), + count: { + type: 'integer', + minimum: 0, + }, + }, +}); + +const crudPaths = (resource: CrudResource): OpenApiPaths => { + const readSecurity = resource.runtimePublicRead + ? [{ bearerAuth: [] }, {}] + : bearerSecurity; + const readDescription = resource.runtimePublicRead + ? `${resource.description}. GET requests are public for allowed production runtime reads and authenticated otherwise.` + : resource.description; + const commonReadParameters = resource.runtimePublicRead + ? [...runtimeHeaders] + : []; + + return { + [resource.path]: { + post: { + tags: [resource.tag], + summary: `Create ${resource.tag} item`, + description: `Creates one ${resource.description} record through the standard entity router.`, + security: bearerSecurity, + requestBody: jsonRequest(entityEnvelopeSchema(resource.schema)), + responses: { + 200: jsonResponse('Created item', ref(resource.schema)), + ...errorResponses, + }, + }, + get: { + tags: [resource.tag], + summary: `List ${resource.tag} items`, + description: + `${readDescription}. Supports pagination, sorting, entity filters, and CSV export through filetype=csv.`, + security: readSecurity, + parameters: [...commonReadParameters, ...listParameters], + responses: { + 200: jsonResponse('Paginated list', paginatedSchema(resource.schema)), + ...errorResponses, + }, + }, + }, + [`${resource.path}/bulk-import`]: { + post: { + tags: [resource.tag], + summary: `Bulk import ${resource.tag}`, + description: + 'Passes the request to the entity service bulkImport implementation.', + security: bearerSecurity, + requestBody: { + required: false, + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: true, + }, + }, + }, + }, + responses: { + 200: booleanResponse, + ...errorResponses, + }, + }, + }, + [`${resource.path}/deleteByIds`]: { + post: { + tags: [resource.tag], + summary: `Delete multiple ${resource.tag} items`, + security: bearerSecurity, + requestBody: jsonRequest(ref('BulkDeleteRequest')), + responses: { + 200: booleanResponse, + ...errorResponses, + }, + }, + }, + [`${resource.path}/count`]: { + get: { + tags: [resource.tag], + summary: `Count ${resource.tag} items`, + description: + 'Returns count for records matching the same filters accepted by the list endpoint.', + security: readSecurity, + parameters: [...commonReadParameters, ...listParameters], + responses: { + 200: jsonResponse('Matching record count', { + type: 'integer', + minimum: 0, + }), + ...errorResponses, + }, + }, + }, + [`${resource.path}/autocomplete`]: { + get: { + tags: [resource.tag], + summary: `Autocomplete ${resource.tag}`, + security: readSecurity, + parameters: [...commonReadParameters, ...autocompleteParameters], + responses: { + 200: jsonResponse('Autocomplete rows', arrayOf(ref(resource.schema))), + ...errorResponses, + }, + }, + }, + [`${resource.path}/{id}`]: { + get: { + tags: [resource.tag], + summary: `Get ${resource.tag} item by ID`, + security: readSecurity, + parameters: [...commonReadParameters, idParameter], + responses: { + 200: jsonResponse('Selected item', ref(resource.schema)), + ...errorResponses, + }, + }, + put: { + tags: [resource.tag], + summary: `Update ${resource.tag} item`, + security: bearerSecurity, + parameters: [idParameter], + requestBody: jsonRequest(entityEnvelopeSchema(resource.schema)), + responses: { + 200: booleanResponse, + ...errorResponses, + }, + }, + delete: { + tags: [resource.tag], + summary: `Delete ${resource.tag} item`, + security: bearerSecurity, + parameters: [idParameter], + responses: { + 200: booleanResponse, + ...errorResponses, + }, + }, + }, + }; +}; + +const schemas: Record = { + Id: uuidSchema, + ErrorResponse: { + type: 'object', + properties: { + message: { type: 'string' }, + error: { type: 'string' }, + code: { type: 'string' }, + details: { + type: 'object', + additionalProperties: true, + }, + }, + additionalProperties: true, + }, + SuccessResponse: { + type: 'object', + required: ['success'], + properties: { + success: { type: 'boolean' }, + }, + }, + BulkDeleteRequest: { + type: 'object', + required: ['data'], + additionalProperties: false, + properties: { + data: arrayOf(uuidSchema), + }, + }, + AuthSigninLocalRequest: { + type: 'object', + required: ['email', 'password'], + additionalProperties: false, + properties: { + email: { type: 'string', format: 'email' }, + password: { type: 'string', format: 'password' }, + }, + }, + PasswordResetRequest: { + type: 'object', + required: ['token', 'password'], + additionalProperties: false, + properties: { + token: { type: 'string' }, + password: { type: 'string', format: 'password' }, + }, + }, + PasswordUpdateRequest: { + type: 'object', + required: ['currentPassword', 'newPassword'], + additionalProperties: false, + properties: { + currentPassword: { type: 'string', format: 'password' }, + newPassword: { type: 'string', format: 'password' }, + }, + }, + SendPasswordResetEmailRequest: { + type: 'object', + required: ['email'], + additionalProperties: false, + properties: { + email: { type: 'string', format: 'email' }, + }, + }, + ProfileUpdateRequest: { + type: 'object', + required: ['profile'], + additionalProperties: false, + properties: { + profile: { + type: 'object', + additionalProperties: true, + }, + }, + }, + VerifyEmailRequest: { + type: 'object', + required: ['token'], + additionalProperties: false, + properties: { + token: { type: 'string' }, + }, + }, + CurrentUser: { + type: 'object', + properties: { + id: uuidSchema, + email: { type: 'string', format: 'email' }, + firstName: nullable({ type: 'string' }), + lastName: nullable({ type: 'string' }), + phoneNumber: nullable({ type: 'string' }), + emailVerified: { type: 'boolean' }, + disabled: { type: 'boolean' }, + app_role: ref('Role'), + custom_permissions: arrayOf(ref('Permission')), + allowedPrivateProductionSlugs: arrayOf({ type: 'string' }), + }, + additionalProperties: true, + }, + User: { + type: 'object', + properties: { + id: uuidSchema, + firstName: nullable({ type: 'string' }), + lastName: nullable({ type: 'string' }), + phoneNumber: nullable({ type: 'string' }), + email: { type: 'string', format: 'email' }, + disabled: { type: 'boolean' }, + emailVerified: { type: 'boolean' }, + provider: { type: 'string' }, + app_role: ref('Role'), + custom_permissions: arrayOf(ref('Permission')), + allowed_private_production_project_ids: arrayOf(uuidSchema), + avatar: nullable({ + type: 'object', + additionalProperties: true, + }), + importHash: nullable({ type: 'string' }), + }, + additionalProperties: true, + }, + Role: { + type: 'object', + properties: { + id: uuidSchema, + name: { type: 'string' }, + description: nullable({ type: 'string' }), + permissions: arrayOf(ref('Permission')), + importHash: nullable({ type: 'string' }), + }, + additionalProperties: true, + }, + Permission: { + type: 'object', + properties: { + id: uuidSchema, + name: { type: 'string' }, + importHash: nullable({ type: 'string' }), + }, + additionalProperties: true, + }, + Project: { + type: 'object', + required: ['name', 'slug'], + properties: { + id: uuidSchema, + name: { type: 'string', minLength: 1, maxLength: 255 }, + slug: { type: 'string', maxLength: 255 }, + description: nullable({ type: 'string' }), + logo_url: nullable({ type: 'string', maxLength: 4096 }), + favicon_url: nullable({ type: 'string', maxLength: 4096 }), + og_image_url: nullable({ type: 'string', maxLength: 4096 }), + design_width: nullable({ type: 'integer', minimum: 1, maximum: 20000 }), + design_height: nullable({ type: 'integer', minimum: 1, maximum: 20000 }), + production_presentation_visibility: { + type: 'string', + enum: ['public', 'private'], + }, + importHash: nullable({ type: 'string' }), + }, + additionalProperties: true, + }, + ProjectMembership: { + type: 'object', + properties: { + id: uuidSchema, + project: uuidSchema, + user: uuidSchema, + role: { type: 'string', enum: ['viewer', 'editor', 'owner'] }, + can_publish: { type: 'boolean' }, + invited_at: nullable(dateTimeSchema), + accepted_at: nullable(dateTimeSchema), + importHash: nullable({ type: 'string' }), + }, + additionalProperties: true, + }, + TourPage: { + type: 'object', + required: ['name', 'slug'], + properties: { + id: uuidSchema, + project: uuidSchema, + projectId: uuidSchema, + project_id: uuidSchema, + environment: { type: 'string', enum: runtimeEnvironmentValues }, + source_key: nullable({ type: 'string', maxLength: 4096 }), + name: { type: 'string', minLength: 1, maxLength: 255 }, + slug: { type: 'string', maxLength: 255 }, + sort_order: { type: 'integer', minimum: 0 }, + background_image_url: nullable({ type: 'string', maxLength: 4096 }), + background_video_url: nullable({ type: 'string', maxLength: 4096 }), + background_embed_url: nullable({ type: 'string', maxLength: 8192 }), + background_audio_url: nullable({ type: 'string', maxLength: 4096 }), + background_audio_autoplay: { type: 'boolean' }, + background_audio_loop: { type: 'boolean' }, + background_audio_start_time: nullable({ type: 'number', minimum: 0 }), + background_audio_end_time: nullable({ type: 'number', minimum: 0 }), + background_loop: { type: 'boolean' }, + background_video_autoplay: { type: 'boolean' }, + background_video_loop: { type: 'boolean' }, + background_video_muted: { type: 'boolean' }, + background_video_start_time: nullable({ type: 'number', minimum: 0 }), + background_video_end_time: nullable({ type: 'number', minimum: 0 }), + design_width: nullable({ type: 'integer', minimum: 1, maximum: 20000 }), + design_height: nullable({ type: 'integer', minimum: 1, maximum: 20000 }), + requires_auth: { type: 'boolean' }, + ui_schema_json: { + oneOf: [ + { type: 'object', additionalProperties: true }, + { type: 'string' }, + { type: 'null' }, + ], + }, + global_ui_controls_settings_json: { + oneOf: [ + { type: 'object', additionalProperties: true }, + { type: 'string' }, + { type: 'null' }, + ], + }, + importHash: nullable({ type: 'string' }), + }, + additionalProperties: true, + }, + Asset: { + type: 'object', + properties: { + id: uuidSchema, + name: nullable({ type: 'string' }), + asset_type: { type: 'string', enum: ['image', 'video', 'audio', 'embed'] }, + type: { type: 'string', enum: ['general', 'background', 'transition'] }, + cdn_url: nullable({ type: 'string' }), + storage_key: nullable({ type: 'string' }), + mime_type: nullable({ type: 'string' }), + checksum: nullable({ type: 'string' }), + width_px: nullable({ type: 'integer' }), + height_px: nullable({ type: 'integer' }), + size_mb: nullable({ type: 'number' }), + duration_sec: nullable({ type: 'number' }), + frame_rate: nullable({ type: 'number' }), + reverse_storage_key: nullable({ type: 'string' }), + reverse_cdn_url: nullable({ type: 'string' }), + reverse_status: nullable({ type: 'string' }), + reverse_generation_requested: { type: 'boolean' }, + importHash: nullable({ type: 'string' }), + }, + additionalProperties: true, + }, + AssetVariant: { + type: 'object', + properties: { + id: uuidSchema, + asset: uuidSchema, + variant_type: { type: 'string' }, + cdn_url: nullable({ type: 'string' }), + storage_key: nullable({ type: 'string' }), + width_px: nullable({ type: 'integer' }), + height_px: nullable({ type: 'integer' }), + size_mb: nullable({ type: 'number' }), + importHash: nullable({ type: 'string' }), + }, + additionalProperties: true, + }, + ProjectAudioTrack: { + type: 'object', + properties: { + id: uuidSchema, + project: uuidSchema, + environment: { type: 'string', enum: runtimeEnvironmentValues }, + name: nullable({ type: 'string' }), + audio_url: nullable({ type: 'string' }), + storage_key: nullable({ type: 'string' }), + mime_type: nullable({ type: 'string' }), + autoplay: { type: 'boolean' }, + volume: nullable({ type: 'number' }), + sort_order: nullable({ type: 'integer' }), + loop: { type: 'boolean' }, + importHash: nullable({ type: 'string' }), + }, + additionalProperties: true, + }, + ProjectTransitionSetting: { + type: 'object', + properties: { + id: uuidSchema, + project: uuidSchema, + environment: { type: 'string', enum: runtimeEnvironmentValues }, + source_key: nullable({ type: 'string' }), + transition_type: { type: 'string' }, + duration_ms: { type: 'integer', minimum: 0 }, + easing: { type: 'string' }, + background_color: { type: 'string' }, + importHash: nullable({ type: 'string' }), + }, + additionalProperties: true, + }, + GlobalTransitionDefault: { + type: 'object', + properties: { + id: uuidSchema, + transition_type: { type: 'string' }, + background_color: { type: 'string' }, + duration_ms: { type: 'integer', minimum: 0 }, + easing: { type: 'string' }, + }, + additionalProperties: true, + }, + GlobalUiControlDefaults: { + type: 'object', + properties: { + id: uuidSchema, + settings_json: { + type: 'object', + additionalProperties: true, + }, + }, + additionalProperties: true, + }, + ProjectUiControlSettings: { + type: 'object', + properties: { + id: uuidSchema, + project: uuidSchema, + environment: { type: 'string', enum: runtimeEnvironmentValues }, + settings_json: { + type: 'object', + additionalProperties: true, + }, + importHash: nullable({ type: 'string' }), + }, + additionalProperties: true, + }, + ElementTypeDefault: { + type: 'object', + properties: { + id: uuidSchema, + element_type: { type: 'string' }, + display_name: { type: 'string' }, + sort_order: { type: 'integer' }, + settings_json: nullable({ type: 'string' }), + importHash: nullable({ type: 'string' }), + }, + additionalProperties: true, + }, + ProjectElementDefault: { + type: 'object', + properties: { + id: uuidSchema, + project: uuidSchema, + element_type: { type: 'string' }, + display_name: nullable({ type: 'string' }), + sort_order: { type: 'integer' }, + settings_json: nullable({ type: 'string' }), + element_type_default_id: nullable(uuidSchema), + version: { type: 'integer' }, + importHash: nullable({ type: 'string' }), + }, + additionalProperties: true, + }, + PublishEvent: { + type: 'object', + properties: { + id: uuidSchema, + project: uuidSchema, + actor: nullable(uuidSchema), + source_environment: { type: 'string', enum: runtimeEnvironmentValues }, + target_environment: { type: 'string', enum: runtimeEnvironmentValues }, + started_at: nullable(dateTimeSchema), + finished_at: nullable(dateTimeSchema), + status: { type: 'string', enum: ['queued', 'running', 'success', 'failed'] }, + error_message: nullable({ type: 'string' }), + pages_count: nullable({ type: 'integer' }), + assets_count: nullable({ type: 'integer' }), + importHash: nullable({ type: 'string' }), + }, + additionalProperties: true, + }, + PwaCache: { + type: 'object', + properties: { + id: uuidSchema, + project: uuidSchema, + environment: { type: 'string', enum: runtimeEnvironmentValues }, + cache_version: nullable({ type: 'string' }), + manifest_json: { + type: 'object', + additionalProperties: true, + }, + cached_assets_json: { + type: 'object', + additionalProperties: true, + }, + last_built_at: nullable(dateTimeSchema), + is_active: { type: 'boolean' }, + importHash: nullable({ type: 'string' }), + }, + additionalProperties: true, + }, + PresignedUrlRequest: { + type: 'object', + properties: { + id: uuidSchema, + project: uuidSchema, + environment: { type: 'string', enum: runtimeEnvironmentValues }, + asset_type: { type: 'string', enum: ['image', 'video', 'audio', 'embed'] }, + url: nullable({ type: 'string' }), + mime_type: nullable({ type: 'string' }), + size_mb: nullable({ type: 'number' }), + expires_at: nullable(dateTimeSchema), + storage_key: nullable({ type: 'string' }), + importHash: nullable({ type: 'string' }), + }, + additionalProperties: true, + }, + AccessLog: { + type: 'object', + properties: { + id: uuidSchema, + event_type: { type: 'string' }, + entity_type: nullable({ type: 'string' }), + entity_id: nullable({ type: 'string' }), + user_id: nullable({ type: 'string' }), + occurred_at: dateTimeSchema, + importHash: nullable({ type: 'string' }), + }, + additionalProperties: true, + }, + HealthResponse: { + type: 'object', + required: ['status', 'timestamp', 'uptime', 'environment'], + properties: { + status: { type: 'string', enum: ['ok', 'degraded'] }, + timestamp: dateTimeSchema, + uptime: { type: 'number' }, + environment: { type: 'string' }, + database: { type: 'string', enum: ['connected', 'disconnected'] }, + databaseError: { type: 'string' }, + }, + }, + FilePresignRequest: { + type: 'object', + required: ['urls'], + additionalProperties: false, + properties: { + urls: { + type: 'array', + minItems: 1, + maxItems: 50, + items: { type: 'string', minLength: 1, maxLength: 4096 }, + }, + }, + }, + FilePresignResponse: { + type: 'object', + required: ['presignedUrls'], + properties: { + presignedUrls: { + type: 'object', + additionalProperties: { type: 'string' }, + }, + }, + }, + UploadSessionInitRequest: { + type: 'object', + required: ['folder', 'filename', 'totalChunks', 'size'], + additionalProperties: false, + properties: { + folder: { type: 'string', minLength: 1, maxLength: 512 }, + filename: { type: 'string', minLength: 1, maxLength: 255 }, + totalChunks: { type: 'integer', minimum: 1, maximum: 10000 }, + size: { type: 'number', minimum: 0 }, + contentType: { type: 'string', maxLength: 255 }, + }, + }, + UploadSession: { + type: 'object', + additionalProperties: true, + properties: { + id: uuidSchema, + folder: { type: 'string' }, + filename: { type: 'string' }, + totalChunks: { type: 'integer' }, + size: { type: 'number' }, + contentType: { type: 'string' }, + uploadedChunks: arrayOf({ type: 'integer' }), + }, + }, + PublishRequest: { + type: 'object', + required: ['projectId'], + additionalProperties: false, + properties: { + projectId: uuidSchema, + title: nullable({ type: 'string', maxLength: 255 }), + description: nullable({ type: 'string', maxLength: 2000 }), + }, + }, + SaveToStageRequest: { + type: 'object', + required: ['projectId'], + additionalProperties: false, + properties: { + projectId: uuidSchema, + }, + }, + PublishResult: { + type: 'object', + additionalProperties: true, + properties: { + success: { type: 'boolean' }, + event: ref('PublishEvent'), + }, + }, + SearchRequest: { + type: 'object', + additionalProperties: false, + properties: { + query: { type: 'string' }, + entities: arrayOf({ type: 'string' }), + limit: { type: 'integer', minimum: 1, maximum: 100 }, + }, + }, + SearchResponse: { + type: 'object', + additionalProperties: true, + }, + RuntimeContextInspection: { + type: 'object', + properties: { + mode: { type: 'string' }, + projectSlug: nullable({ type: 'string' }), + headerEnvironment: { + type: 'string', + enum: runtimeEnvironmentValues, + }, + headerProjectSlug: nullable({ type: 'string' }), + }, + additionalProperties: true, + }, + RuntimePresentationAccess: { + type: 'object', + required: ['slug', 'isPrivateProductionPresentation'], + properties: { + slug: { type: 'string' }, + isPrivateProductionPresentation: { type: 'boolean' }, + }, + }, + RuntimeMeAccess: { + type: 'object', + required: ['allowedPrivateProductionSlugs'], + properties: { + allowedPrivateProductionSlugs: arrayOf({ type: 'string' }), + }, + }, + TourPageReorderRequest: { + type: 'object', + required: ['data'], + additionalProperties: false, + properties: { + data: { + type: 'object', + required: ['orderedPageIds'], + properties: { + project: uuidSchema, + projectId: uuidSchema, + environment: { type: 'string', enum: ['dev'], default: 'dev' }, + orderedPageIds: arrayOf(uuidSchema), + }, + additionalProperties: false, + }, + }, + }, + TourPageDuplicateRequest: { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + project: uuidSchema, + projectId: uuidSchema, + environment: { type: 'string', enum: ['dev'], default: 'dev' }, + name: { type: 'string', maxLength: 255 }, + slug: { type: 'string', maxLength: 255 }, + }, + additionalProperties: false, + }, + }, + additionalProperties: false, + }, + ReverseVideoStatusRequest: { + type: 'object', + required: ['storageKeys'], + additionalProperties: false, + properties: { + storageKeys: { + type: 'array', + minItems: 1, + maxItems: 200, + items: { type: 'string', minLength: 1, maxLength: 4096 }, + }, + }, + }, + ReverseVideoStatusResponse: { + type: 'object', + additionalProperties: { + type: 'object', + properties: { + exists: { type: 'boolean' }, + reverseVideoUrl: nullable({ type: 'string' }), + }, + additionalProperties: true, + }, + }, + ProjectElementDefaultDiff: { + type: 'object', + additionalProperties: true, + }, +}; + +const crudResources: CrudResource[] = [ + { + path: '/api/users', + tag: 'Users', + schema: 'User', + description: 'users', + }, + { + path: '/api/roles', + tag: 'Roles', + schema: 'Role', + description: 'roles', + }, + { + path: '/api/permissions', + tag: 'Permissions', + schema: 'Permission', + description: 'permissions', + }, + { + path: '/api/projects', + tag: 'Projects', + schema: 'Project', + description: 'projects', + runtimePublicRead: true, + }, + { + path: '/api/project_memberships', + tag: 'ProjectMemberships', + schema: 'ProjectMembership', + description: 'project memberships', + }, + { + path: '/api/tour_pages', + tag: 'TourPages', + schema: 'TourPage', + description: 'tour pages', + runtimePublicRead: true, + }, + { + path: '/api/assets', + tag: 'Assets', + schema: 'Asset', + description: 'assets', + }, + { + path: '/api/asset_variants', + tag: 'AssetVariants', + schema: 'AssetVariant', + description: 'asset variants', + }, + { + path: '/api/project_audio_tracks', + tag: 'ProjectAudioTracks', + schema: 'ProjectAudioTrack', + description: 'project audio tracks', + runtimePublicRead: true, + }, + { + path: '/api/publish_events', + tag: 'PublishEvents', + schema: 'PublishEvent', + description: 'publish events', + }, + { + path: '/api/pwa_caches', + tag: 'PwaCaches', + schema: 'PwaCache', + description: 'PWA caches', + }, + { + path: '/api/access_logs', + tag: 'AccessLogs', + schema: 'AccessLog', + description: 'access logs', + }, + { + path: '/api/presigned_url_requests', + tag: 'PresignedUrlRequests', + schema: 'PresignedUrlRequest', + description: 'presigned URL request audit rows', + }, + { + path: '/api/element-type-defaults', + tag: 'ElementTypeDefaults', + schema: 'ElementTypeDefault', + description: 'global element type defaults', + }, + { + path: '/api/ui-elements', + tag: 'ElementTypeDefaults', + schema: 'ElementTypeDefault', + description: 'backwards-compatible alias for global element type defaults', + }, + { + path: '/api/project-element-defaults', + tag: 'ProjectElementDefaults', + schema: 'ProjectElementDefault', + description: 'project element defaults', + }, +]; + +const authPaths: OpenApiPaths = { + '/api/auth/signin/local': { + post: { + tags: ['Auth'], + summary: 'Sign in with email and password', + security: [], + requestBody: jsonRequest(ref('AuthSigninLocalRequest')), + responses: { + 200: jsonResponse('JWT token', { type: 'string' }), + ...errorResponses, + }, + }, + }, + '/api/auth/me': { + get: { + tags: ['Auth'], + summary: 'Get current authenticated user', + security: bearerSecurity, + responses: { + 200: jsonResponse('Current user', ref('CurrentUser')), + ...errorResponses, + }, + }, + }, + '/api/auth/password-reset': { + put: { + tags: ['Auth'], + summary: 'Reset password using emailed token', + security: [], + requestBody: jsonRequest(ref('PasswordResetRequest')), + responses: { + 200: booleanResponse, + ...errorResponses, + }, + }, + }, + '/api/auth/password-update': { + put: { + tags: ['Auth'], + summary: 'Update password for current user', + security: bearerSecurity, + requestBody: jsonRequest(ref('PasswordUpdateRequest')), + responses: { + 200: booleanResponse, + ...errorResponses, + }, + }, + }, + '/api/auth/send-email-address-verification-email': { + post: { + tags: ['Auth'], + summary: 'Send email address verification email to current user', + security: bearerSecurity, + responses: { + 200: booleanResponse, + ...errorResponses, + }, + }, + }, + '/api/auth/send-password-reset-email': { + post: { + tags: ['Auth'], + summary: 'Send password reset email', + security: [], + requestBody: jsonRequest(ref('SendPasswordResetEmailRequest')), + responses: { + 200: booleanResponse, + ...errorResponses, + }, + }, + }, + '/api/auth/profile': { + put: { + tags: ['Auth'], + summary: 'Update current user profile', + security: bearerSecurity, + requestBody: jsonRequest(ref('ProfileUpdateRequest')), + responses: { + 200: booleanResponse, + ...errorResponses, + }, + }, + }, + '/api/auth/verify-email': { + put: { + tags: ['Auth'], + summary: 'Verify email address using token', + security: [], + requestBody: jsonRequest(ref('VerifyEmailRequest')), + responses: { + 200: booleanResponse, + ...errorResponses, + }, + }, + }, + '/api/auth/email-configured': { + get: { + tags: ['Auth'], + summary: 'Check whether outbound email is configured', + security: [], + responses: { + 200: booleanResponse, + ...errorResponses, + }, + }, + }, + '/api/auth/signin/google': { + get: { + tags: ['Auth'], + summary: 'Start Google OAuth sign-in', + security: [], + parameters: [ + { + in: 'query', + name: 'app', + required: false, + schema: { type: 'string', maxLength: 255 }, + }, + ], + responses: { + 302: { description: 'Redirect to Google OAuth' }, + ...errorResponses, + }, + }, + }, + '/api/auth/signin/google/callback': { + get: { + tags: ['Auth'], + summary: 'Google OAuth callback', + security: [], + responses: { + 302: { description: 'Redirect to frontend login with JWT token' }, + ...errorResponses, + }, + }, + }, + '/api/auth/signin/microsoft': { + get: { + tags: ['Auth'], + summary: 'Start Microsoft OAuth sign-in', + security: [], + parameters: [ + { + in: 'query', + name: 'app', + required: false, + schema: { type: 'string', maxLength: 255 }, + }, + ], + responses: { + 302: { description: 'Redirect to Microsoft OAuth' }, + ...errorResponses, + }, + }, + }, + '/api/auth/signin/microsoft/callback': { + get: { + tags: ['Auth'], + summary: 'Microsoft OAuth callback', + security: [], + responses: { + 302: { description: 'Redirect to frontend login with JWT token' }, + ...errorResponses, + }, + }, + }, +}; + +const filePaths: OpenApiPaths = { + '/api/file/download': { + get: { + tags: ['File'], + summary: 'Download a stored file', + security: [], + parameters: [ + { + in: 'query', + name: 'privateUrl', + required: false, + schema: { type: 'string' }, + }, + { + in: 'query', + name: 'publicUrl', + required: false, + schema: { type: 'string' }, + }, + ], + responses: { + 200: { + description: 'File stream', + content: { + 'application/octet-stream': { + schema: { type: 'string', format: 'binary' }, + }, + }, + }, + ...errorResponses, + }, + }, + }, + '/api/file/presign': { + post: { + tags: ['File'], + summary: 'Generate presigned URLs for stored assets', + security: [], + requestBody: jsonRequest(ref('FilePresignRequest')), + responses: { + 200: jsonResponse('Presigned URL map', ref('FilePresignResponse')), + ...errorResponses, + }, + }, + }, + '/api/file/upload/{table}/{field}': { + post: { + tags: ['File'], + summary: 'Upload a file for an entity field', + security: bearerSecurity, + parameters: [ + { + in: 'path', + name: 'table', + required: true, + schema: { type: 'string', maxLength: 128 }, + }, + { + in: 'path', + name: 'field', + required: true, + schema: { type: 'string', maxLength: 128 }, + }, + ], + requestBody: { + required: true, + content: { + 'application/octet-stream': { + schema: { type: 'string', format: 'binary' }, + }, + 'multipart/form-data': { + schema: { + type: 'object', + properties: { + file: { type: 'string', format: 'binary' }, + }, + }, + }, + }, + }, + responses: { + 200: jsonResponse('Uploaded file metadata', { + type: 'object', + additionalProperties: true, + }), + ...errorResponses, + }, + }, + }, + '/api/file/upload-sessions/init': { + post: { + tags: ['File'], + summary: 'Initialize chunked upload session', + security: bearerSecurity, + requestBody: jsonRequest(ref('UploadSessionInitRequest')), + responses: { + 200: jsonResponse('Upload session', ref('UploadSession')), + ...errorResponses, + }, + }, + }, + '/api/file/upload-sessions/{sessionId}': { + get: { + tags: ['File'], + summary: 'Get chunked upload session status', + security: bearerSecurity, + parameters: [ + { + in: 'path', + name: 'sessionId', + required: true, + schema: uuidSchema, + }, + ], + responses: { + 200: jsonResponse('Upload session', ref('UploadSession')), + ...errorResponses, + }, + }, + }, + '/api/file/upload-sessions/{sessionId}/chunks/{chunkIndex}': { + put: { + tags: ['File'], + summary: 'Upload one chunk for a chunked upload session', + security: bearerSecurity, + parameters: [ + { + in: 'path', + name: 'sessionId', + required: true, + schema: uuidSchema, + }, + { + in: 'path', + name: 'chunkIndex', + required: true, + schema: { type: 'integer', minimum: 0, maximum: 9999 }, + }, + ], + requestBody: { + required: true, + content: { + 'application/octet-stream': { + schema: { type: 'string', format: 'binary' }, + }, + }, + }, + responses: { + 200: jsonResponse('Chunk upload result', { + type: 'object', + additionalProperties: true, + }), + ...errorResponses, + }, + }, + }, + '/api/file/upload-sessions/{sessionId}/finalize': { + post: { + tags: ['File'], + summary: 'Finalize a chunked upload session', + security: bearerSecurity, + parameters: [ + { + in: 'path', + name: 'sessionId', + required: true, + schema: uuidSchema, + }, + ], + responses: { + 200: jsonResponse('Final uploaded file metadata', { + type: 'object', + additionalProperties: true, + }), + ...errorResponses, + }, + }, + }, +}; + +const customPaths: OpenApiPaths = { + '/api/health': { + get: { + tags: ['System'], + summary: 'Backend health check', + security: [], + responses: { + 200: jsonResponse('Healthy backend', ref('HealthResponse')), + 503: jsonResponse('Degraded backend', ref('HealthResponse')), + }, + }, + }, + '/api/projects/{id}/clone': { + post: { + tags: ['Projects'], + summary: 'Clone a project', + security: bearerSecurity, + parameters: [idParameter], + responses: { + 200: jsonResponse('Cloned project', ref('Project')), + ...errorResponses, + }, + }, + }, + '/api/tour_pages/reorder': { + post: { + tags: ['TourPages'], + summary: 'Reorder dev tour pages inside a project', + security: bearerSecurity, + requestBody: jsonRequest(ref('TourPageReorderRequest')), + responses: { + 200: jsonResponse('Reordered pages', arrayOf(ref('TourPage'))), + ...errorResponses, + }, + }, + }, + '/api/tour_pages/{id}/duplicate': { + post: { + tags: ['TourPages'], + summary: 'Duplicate a dev tour page', + security: bearerSecurity, + parameters: [idParameter], + requestBody: jsonRequest(ref('TourPageDuplicateRequest')), + responses: { + 200: jsonResponse('Duplicated page', ref('TourPage')), + ...errorResponses, + }, + }, + }, + '/api/tour_pages/reverse-video-status': { + post: { + tags: ['TourPages'], + summary: 'Check reverse video generation status for storage keys', + security: bearerSecurity, + requestBody: jsonRequest(ref('ReverseVideoStatusRequest')), + responses: { + 200: jsonResponse('Reverse video status map', ref('ReverseVideoStatusResponse')), + ...errorResponses, + }, + }, + }, + '/api/project-element-defaults/{id}/reset': { + post: { + tags: ['ProjectElementDefaults'], + summary: 'Reset project element default from global default', + security: bearerSecurity, + parameters: [idParameter], + responses: { + 200: jsonResponse('Reset project element default', ref('ProjectElementDefault')), + ...errorResponses, + }, + }, + }, + '/api/project-element-defaults/{id}/diff': { + get: { + tags: ['ProjectElementDefaults'], + summary: 'Get project element default diff from global default', + security: bearerSecurity, + parameters: [idParameter], + responses: { + 200: jsonResponse('Project element default diff', ref('ProjectElementDefaultDiff')), + ...errorResponses, + }, + }, + }, + '/api/global-transition-defaults': { + get: { + tags: ['GlobalTransitionDefaults'], + summary: 'Get singleton global transition defaults', + security: [], + responses: { + 200: jsonResponse('Global transition defaults', ref('GlobalTransitionDefault')), + ...errorResponses, + }, + }, + }, + '/api/global-transition-defaults/{id}': { + get: { + tags: ['GlobalTransitionDefaults'], + summary: 'Get global transition defaults by ID', + security: [], + parameters: [idParameter], + responses: { + 200: jsonResponse('Global transition defaults', ref('GlobalTransitionDefault')), + ...errorResponses, + }, + }, + put: { + tags: ['GlobalTransitionDefaults'], + summary: 'Update global transition defaults', + security: bearerSecurity, + parameters: [idParameter], + requestBody: jsonRequest(entityEnvelopeSchema('GlobalTransitionDefault')), + responses: { + 200: booleanResponse, + ...errorResponses, + }, + }, + }, + '/api/project-transition-settings': { + get: { + tags: ['ProjectTransitionSettings'], + summary: 'List project transition settings', + security: bearerSecurity, + parameters: listParameters, + responses: { + 200: jsonResponse('Paginated list', paginatedSchema('ProjectTransitionSetting')), + ...errorResponses, + }, + }, + post: { + tags: ['ProjectTransitionSettings'], + summary: 'Create project transition settings', + security: bearerSecurity, + requestBody: jsonRequest(entityEnvelopeSchema('ProjectTransitionSetting')), + responses: { + 200: jsonResponse('Created settings', ref('ProjectTransitionSetting')), + ...errorResponses, + }, + }, + }, + '/api/project-transition-settings/{id}': { + get: { + tags: ['ProjectTransitionSettings'], + summary: 'Get project transition settings by ID', + security: bearerSecurity, + parameters: [idParameter], + responses: { + 200: jsonResponse('Settings', ref('ProjectTransitionSetting')), + ...errorResponses, + }, + }, + put: { + tags: ['ProjectTransitionSettings'], + summary: 'Update project transition settings by ID', + security: bearerSecurity, + parameters: [idParameter], + requestBody: jsonRequest(entityEnvelopeSchema('ProjectTransitionSetting')), + responses: { + 200: booleanResponse, + ...errorResponses, + }, + }, + delete: { + tags: ['ProjectTransitionSettings'], + summary: 'Delete project transition settings by ID', + security: bearerSecurity, + parameters: [idParameter], + responses: { + 200: booleanResponse, + ...errorResponses, + }, + }, + }, + '/api/project-transition-settings/project/{projectId}/env/{environment}': { + get: { + tags: ['ProjectTransitionSettings'], + summary: 'Get project transition settings for project environment', + security: [{ bearerAuth: [] }, {}], + parameters: [projectIdParameter, environmentParameter, ...runtimeHeaders], + responses: { + 200: jsonResponse('Settings', ref('ProjectTransitionSetting')), + ...errorResponses, + }, + }, + put: { + tags: ['ProjectTransitionSettings'], + summary: 'Upsert project transition settings for project environment', + security: bearerSecurity, + parameters: [projectIdParameter, environmentParameter], + requestBody: jsonRequest(entityEnvelopeSchema('ProjectTransitionSetting')), + responses: { + 200: jsonResponse('Settings', ref('ProjectTransitionSetting')), + ...errorResponses, + }, + }, + delete: { + tags: ['ProjectTransitionSettings'], + summary: 'Reset project transition settings for project environment', + security: bearerSecurity, + parameters: [projectIdParameter, environmentParameter], + responses: { + 200: successObjectResponse, + ...errorResponses, + }, + }, + }, + '/api/global-ui-control-defaults': { + get: { + tags: ['GlobalUiControlDefaults'], + summary: 'Get singleton global UI-control defaults', + security: [], + responses: { + 200: jsonResponse('Global UI-control defaults', ref('GlobalUiControlDefaults')), + ...errorResponses, + }, + }, + }, + '/api/global-ui-control-defaults/{id}': { + get: { + tags: ['GlobalUiControlDefaults'], + summary: 'Get global UI-control defaults by ID', + security: [], + parameters: [idParameter], + responses: { + 200: jsonResponse('Global UI-control defaults', ref('GlobalUiControlDefaults')), + ...errorResponses, + }, + }, + put: { + tags: ['GlobalUiControlDefaults'], + summary: 'Update global UI-control defaults', + security: bearerSecurity, + parameters: [idParameter], + requestBody: jsonRequest(entityEnvelopeSchema('GlobalUiControlDefaults')), + responses: { + 200: jsonResponse('Global UI-control defaults', ref('GlobalUiControlDefaults')), + ...errorResponses, + }, + }, + }, + '/api/project-ui-control-settings': { + get: { + tags: ['ProjectUiControlSettings'], + summary: 'List project UI-control settings', + security: bearerSecurity, + parameters: listParameters, + responses: { + 200: jsonResponse('Paginated list', paginatedSchema('ProjectUiControlSettings')), + ...errorResponses, + }, + }, + }, + '/api/project-ui-control-settings/project/{projectId}/env/{environment}': { + get: { + tags: ['ProjectUiControlSettings'], + summary: 'Get project UI-control settings for project environment', + security: [{ bearerAuth: [] }, {}], + parameters: [projectIdParameter, environmentParameter, ...runtimeHeaders], + responses: { + 200: jsonResponse('Settings', ref('ProjectUiControlSettings')), + ...errorResponses, + }, + }, + put: { + tags: ['ProjectUiControlSettings'], + summary: 'Upsert project UI-control settings for project environment', + security: bearerSecurity, + parameters: [projectIdParameter, environmentParameter], + requestBody: jsonRequest(entityEnvelopeSchema('ProjectUiControlSettings')), + responses: { + 200: jsonResponse('Settings', ref('ProjectUiControlSettings')), + ...errorResponses, + }, + }, + delete: { + tags: ['ProjectUiControlSettings'], + summary: 'Reset project UI-control settings for project environment', + security: bearerSecurity, + parameters: [projectIdParameter, environmentParameter], + responses: { + 200: successObjectResponse, + ...errorResponses, + }, + }, + }, + '/api/publish': { + post: { + tags: ['Publish'], + summary: 'Publish staged content to production', + security: bearerSecurity, + requestBody: jsonRequest(ref('PublishRequest')), + responses: { + 200: jsonResponse('Publish result', ref('PublishResult')), + ...errorResponses, + }, + }, + }, + '/api/publish/save-to-stage': { + post: { + tags: ['Publish'], + summary: 'Copy dev content to stage', + security: bearerSecurity, + requestBody: jsonRequest(ref('SaveToStageRequest')), + responses: { + 200: jsonResponse('Save-to-stage result', ref('PublishResult')), + ...errorResponses, + }, + }, + }, + '/api/search': { + post: { + tags: ['Search'], + summary: 'Global permission-aware search', + security: bearerSecurity, + requestBody: jsonRequest(ref('SearchRequest')), + responses: { + 200: jsonResponse('Search results', ref('SearchResponse')), + ...errorResponses, + }, + }, + }, + '/api/runtime-context': { + get: { + tags: ['RuntimeContext'], + summary: 'Inspect request runtime context', + security: [], + parameters: runtimeHeaders, + responses: { + 200: jsonResponse('Runtime context', ref('RuntimeContextInspection')), + ...errorResponses, + }, + }, + }, + '/api/runtime-access/presentations/{slug}': { + get: { + tags: ['RuntimeAccess'], + summary: 'Check whether a production presentation is private', + security: [], + parameters: [ + { + in: 'path', + name: 'slug', + required: true, + schema: { type: 'string' }, + }, + ], + responses: { + 200: jsonResponse('Presentation access metadata', ref('RuntimePresentationAccess')), + ...errorResponses, + }, + }, + }, + '/api/runtime-access/private-production-presentations': { + get: { + tags: ['RuntimeAccess'], + summary: 'List private production presentations for user-management grants', + security: bearerSecurity, + responses: { + 200: jsonResponse('Private production presentations', arrayOf(ref('Project'))), + ...errorResponses, + }, + }, + }, + '/api/runtime-access/private-production-presentations/autocomplete': { + get: { + tags: ['RuntimeAccess'], + summary: 'Autocomplete private production presentations for user-management grants', + security: bearerSecurity, + responses: { + 200: jsonResponse('Private production presentations', arrayOf(ref('Project'))), + ...errorResponses, + }, + }, + }, + '/api/runtime-access/me': { + get: { + tags: ['RuntimeAccess'], + summary: 'Get current user runtime presentation grants', + security: bearerSecurity, + responses: { + 200: jsonResponse('Runtime grants', ref('RuntimeMeAccess')), + ...errorResponses, + }, + }, + }, +}; + +const tags = [ + 'System', + 'Auth', + 'RuntimeContext', + 'RuntimeAccess', + 'Users', + 'Roles', + 'Permissions', + 'Projects', + 'ProjectMemberships', + 'TourPages', + 'Assets', + 'AssetVariants', + 'ProjectAudioTracks', + 'ProjectTransitionSettings', + 'GlobalTransitionDefaults', + 'GlobalUiControlDefaults', + 'ProjectUiControlSettings', + 'ElementTypeDefaults', + 'ProjectElementDefaults', + 'Publish', + 'PublishEvents', + 'PwaCaches', + 'PresignedUrlRequests', + 'AccessLogs', + 'File', + 'Search', +].map((name) => ({ + name, + description: `${name} API`, +})); + +function buildCrudPaths(): OpenApiPaths { + return crudResources.reduce( + (paths, resource) => ({ + ...paths, + ...crudPaths(resource), + }), + {}, + ); +} + +function createOpenApiDocument(options: OpenApiDocumentOptions): OpenApiDocument { + return { + openapi: '3.0.0', + info: { + version: '1.0.0', + title: 'Tour Builder Platform', + description: + 'Comprehensive REST API for the Tour Builder Platform. Factory-backed entity routes are generated from the same OpenAPI helper that describes the shared CRUD router contract.', + }, + servers: [ + { + url: options.serverUrl, + description: 'Backend server', + }, + ], + tags, + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + }, + }, + schemas, + parameters: { + Id: idParameter, + ProjectId: projectIdParameter, + RuntimeEnvironment: environmentParameter, + }, + responses: { + BadRequestError: jsonResponse('Invalid request', ref('ErrorResponse')), + UnauthorizedError: jsonResponse( + 'Access token is missing or invalid', + ref('ErrorResponse'), + ), + ForbiddenError: jsonResponse('Permission denied', ref('ErrorResponse')), + NotFoundError: jsonResponse('Resource not found', ref('ErrorResponse')), + RateLimitError: jsonResponse('Rate limit exceeded', ref('ErrorResponse')), + ServerError: jsonResponse('Server error', ref('ErrorResponse')), + }, + }, + paths: { + ...buildCrudPaths(), + ...authPaths, + ...filePaths, + ...customPaths, + }, + }; +} + +export type { OpenApiDocument }; +export { createOpenApiDocument }; diff --git a/backend/src/types/app.ts b/backend/src/types/app.ts index e19f56f..c4c7cd1 100644 --- a/backend/src/types/app.ts +++ b/backend/src/types/app.ts @@ -1,15 +1,7 @@ -import type { ErrorRequestHandler, NextFunction, Request, RequestHandler, Response, Router } from 'express'; -import type swaggerJSDoc from 'swagger-jsdoc'; -import type * as swaggerUiExpress from 'swagger-ui-express'; +import type { ErrorRequestHandler, RequestHandler, Router } from 'express'; import type { CurrentUser } from './auth.ts'; -export type SwaggerDocumentOptions = swaggerJSDoc.Options; - -export type SwaggerUiModuleWithHost = typeof swaggerUiExpress & { - host?: string; -}; - export type ExpressRouter = Router; export interface HealthResponse { @@ -33,10 +25,6 @@ export interface MountRuntimeEntityRoute { export type AppErrorHandler = ErrorRequestHandler; -export interface SwaggerHostMiddleware { - (req: Request, res: Response, next: NextFunction): void; -} - export interface PublicAccessHardeningSummary { publicRolePermissions: number; publicUsersWithCustomPermissions: number; diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts index 4decfc1..cd5c75b 100644 --- a/backend/src/types/index.ts +++ b/backend/src/types/index.ts @@ -131,9 +131,6 @@ export type { PublicAccessHardeningSummary, RuntimeJwtVerifyHandler, RuntimeReadOrAuthMiddleware, - SwaggerDocumentOptions, - SwaggerHostMiddleware, - SwaggerUiModuleWithHost, } from './app.ts'; export type { EmailSendResult, diff --git a/backend/tests/openapi-document.test.ts b/backend/tests/openapi-document.test.ts new file mode 100644 index 0000000..a70b776 --- /dev/null +++ b/backend/tests/openapi-document.test.ts @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { NextFunction } from 'express'; +import { createRequest, createResponse } from 'node-mocks-http'; +import * as swaggerUI from 'swagger-ui-express'; + +import { createOpenApiDocument } from '../src/openapi/document.ts'; +import type { OpenApiDocument } from '../src/openapi/document.ts'; + +function createTestDocument(): OpenApiDocument { + return createOpenApiDocument({ + serverUrl: 'http://localhost:3000', + }); +} + +function collectRefs(value: unknown): Set { + const refs = new Set(); + const serialized = JSON.stringify(value); + const refPattern = /"\$ref"\s*:\s*"([^"]+)"/g; + + for (const match of serialized.matchAll(refPattern)) { + const ref = match[1]; + if (ref) { + refs.add(ref); + } + } + + return refs; +} + +function hasComponentRef(document: OpenApiDocument, ref: string): boolean { + const match = /^#\/components\/(schemas|responses|parameters|securitySchemes)\/(.+)$/.exec(ref); + if (!match) return false; + + const section = match[1]; + const name = match[2]; + if (!section || !name) return false; + + switch (section) { + case 'schemas': + return name in document.components.schemas; + case 'responses': + return name in document.components.responses; + case 'parameters': + return name in document.components.parameters; + case 'securitySchemes': + return name in document.components.securitySchemes; + default: + return false; + } +} + +void test('OpenAPI document exposes comprehensive route coverage', () => { + const document = createTestDocument(); + const requiredPaths = [ + '/api/health', + '/api/auth/signin/local', + '/api/users', + '/api/users/count', + '/api/users/autocomplete', + '/api/projects/{id}/clone', + '/api/file/presign', + '/api/file/upload-sessions/{sessionId}/chunks/{chunkIndex}', + '/api/runtime-access/me', + '/api/project-ui-control-settings/project/{projectId}/env/{environment}', + '/api/tour_pages/reverse-video-status', + ]; + + assert.equal(document.openapi, '3.0.0'); + + for (const path of requiredPaths) { + assert.ok(document.paths[path], `Missing OpenAPI path: ${path}`); + } +}); + +void test('OpenAPI document resolves all internal refs', () => { + const document = createTestDocument(); + const refs = collectRefs(document); + const missingRefs = [...refs].filter((ref) => !hasComponentRef(document, ref)); + + assert.equal(missingRefs.length, 0, `Missing refs: ${missingRefs.join(', ')}`); +}); + +void test('OpenAPI factory CRUD paths are generated consistently', () => { + const document = createTestDocument(); + const resourcePath = '/api/assets'; + + assert.ok(document.paths[resourcePath]?.post); + assert.ok(document.paths[resourcePath]?.get); + assert.ok(document.paths[`${resourcePath}/bulk-import`]?.post); + assert.ok(document.paths[`${resourcePath}/deleteByIds`]?.post); + assert.ok(document.paths[`${resourcePath}/count`]?.get); + assert.ok(document.paths[`${resourcePath}/autocomplete`]?.get); + assert.ok(document.paths[`${resourcePath}/{id}`]?.get); + assert.ok(document.paths[`${resourcePath}/{id}`]?.put); + assert.ok(document.paths[`${resourcePath}/{id}`]?.delete); +}); + +void test('Swagger UI setup serves documentation HTML without throwing', () => { + const document = createTestDocument(); + const req = createRequest({ url: '/api-docs/' }); + const res = createResponse(); + const handler = swaggerUI.setup(document); + let nextError: unknown = null; + const next: NextFunction = (error?: unknown) => { + nextError = error; + }; + + handler(req, res, next); + + assert.equal(nextError, null); + assert.equal(res.statusCode, 200); + assert.match(String(res._getData()), /swagger-ui/); +}); diff --git a/frontend/src/components/Constructor/ConstructorToolbar.tsx b/frontend/src/components/Constructor/ConstructorToolbar.tsx index 5af156a..e6ef153 100644 --- a/frontend/src/components/Constructor/ConstructorToolbar.tsx +++ b/frontend/src/components/Constructor/ConstructorToolbar.tsx @@ -434,9 +434,7 @@ const ConstructorToolbar = forwardRef( className='h-10 w-[86px]' label={isSaving ? 'Saving...' : 'Save'} subtitle={ - lastSavedAt - ? dataFormatter.relativeTimestamp(lastSavedAt) - : ' ' + lastSavedAt ? dataFormatter.relativeTimestamp(lastSavedAt) : ' ' } onClick={onSave} disabled={isSaving} diff --git a/frontend/src/components/Constructor/ElementEditorPanel.tsx b/frontend/src/components/Constructor/ElementEditorPanel.tsx index d6e28a3..4bb8816 100644 --- a/frontend/src/components/Constructor/ElementEditorPanel.tsx +++ b/frontend/src/components/Constructor/ElementEditorPanel.tsx @@ -642,8 +642,7 @@ export function ElementEditorPanel({ { const val = e.target.value as - | 'left' - | 'center' - | 'right' - | ''; + 'left' | 'center' | 'right' | ''; updateSelectedElement({ infoPanelTitleTextAlign: val || undefined, }); @@ -1465,10 +1461,7 @@ export function ElementEditorPanel({ } onChange={(e) => { const val = e.target.value as - | 'left' - | 'center' - | 'right' - | ''; + 'left' | 'center' | 'right' | ''; updateSelectedElement({ infoPanelTextTextAlign: val || undefined, }); @@ -1639,10 +1632,7 @@ export function ElementEditorPanel({ } onChange={(e) => { const val = e.target.value as - | 'left' - | 'center' - | 'right' - | ''; + 'left' | 'center' | 'right' | ''; updateSelectedElement({ infoPanelSpanTextAlign: val || undefined, }); @@ -2093,10 +2083,7 @@ export function ElementEditorPanel({ // Handle slide transition properties with proper prefixes if (prop === 'slideTransitionType') { const typedValue = (value || undefined) as - | 'fade' - | 'none' - | '' - | undefined; + 'fade' | 'none' | '' | undefined; if (selectedElement.type === 'gallery') { updateSelectedElement({ gallerySlideTransitionType: typedValue, diff --git a/frontend/src/components/ElementSettings/StyleSettingsSection.tsx b/frontend/src/components/ElementSettings/StyleSettingsSection.tsx index 3fecaf4..64f8d8a 100644 --- a/frontend/src/components/ElementSettings/StyleSettingsSection.tsx +++ b/frontend/src/components/ElementSettings/StyleSettingsSection.tsx @@ -148,7 +148,10 @@ const StyleSettingsSection: React.FC = ({ max='100' value={opacityToPercentInput(values.opacity)} onChange={(event) => - onChange('opacity', percentInputToOpacityValue(event.target.value)) + onChange( + 'opacity', + percentInputToOpacityValue(event.target.value), + ) } placeholder='50' /> diff --git a/frontend/src/config.ts b/frontend/src/config.ts index 0b8983f..de0bd0a 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -8,6 +8,8 @@ export const portApi = : ''; export const baseURLApi = `${hostApi}${portApi ? `:${portApi}` : ``}/api`; +export const swaggerDocsUrl = baseURLApi.replace(/\/api\/?$/, '/api-docs'); + export const localStorageDarkModeKey = 'darkMode'; export const localStorageStyleKey = 'style'; diff --git a/frontend/src/context/PageNavigationContext.tsx b/frontend/src/context/PageNavigationContext.tsx index 0dfd704..fdb9021 100644 --- a/frontend/src/context/PageNavigationContext.tsx +++ b/frontend/src/context/PageNavigationContext.tsx @@ -36,8 +36,7 @@ const PageNavigationContext = // Provider // ============================================================================ -export interface PageNavigationProviderProps - extends UsePageNavigationStateOptions { +export interface PageNavigationProviderProps extends UsePageNavigationStateOptions { children: ReactNode; } diff --git a/frontend/src/hooks/usePreloadOrchestrator.ts b/frontend/src/hooks/usePreloadOrchestrator.ts index d3f5d6c..4f49c86 100644 --- a/frontend/src/hooks/usePreloadOrchestrator.ts +++ b/frontend/src/hooks/usePreloadOrchestrator.ts @@ -48,10 +48,7 @@ interface PreloadQueueItem { } export type PreloadPhase = - | 'idle' - | 'phase1_current_page' - | 'phase2_transitions' - | 'complete'; + 'idle' | 'phase1_current_page' | 'phase2_transitions' | 'complete'; interface UsePreloadOrchestratorResult { isPreloading: boolean; diff --git a/frontend/src/hooks/useTransitionCreation.ts b/frontend/src/hooks/useTransitionCreation.ts index ce44da1..fa735be 100644 --- a/frontend/src/hooks/useTransitionCreation.ts +++ b/frontend/src/hooks/useTransitionCreation.ts @@ -47,8 +47,7 @@ export interface UseTransitionCreationOptions { } export interface UseTransitionCreationResult - extends TransitionCreationState, - TransitionCreationActions {} + extends TransitionCreationState, TransitionCreationActions {} /** * Hook to manage transition creation form state. diff --git a/frontend/src/hooks/useTransitionPlayback.ts b/frontend/src/hooks/useTransitionPlayback.ts index 01bca68..89573ec 100644 --- a/frontend/src/hooks/useTransitionPlayback.ts +++ b/frontend/src/hooks/useTransitionPlayback.ts @@ -60,11 +60,7 @@ export interface UseTransitionPlaybackOptions { } export type PlaybackPhase = - | 'idle' - | 'preparing' - | 'playing' - | 'finishing' - | 'completed'; + 'idle' | 'preparing' | 'playing' | 'finishing' | 'completed'; export interface UseTransitionPlaybackResult { phase: PlaybackPhase; diff --git a/frontend/src/lib/assetCache/assetDiscovery.ts b/frontend/src/lib/assetCache/assetDiscovery.ts index 7c1591e..f527758 100644 --- a/frontend/src/lib/assetCache/assetDiscovery.ts +++ b/frontend/src/lib/assetCache/assetDiscovery.ts @@ -404,11 +404,7 @@ export function toPreloadAssetInfo(asset: AssetToCache): PreloadAssetInfo { url: asset.originalUrl, pageId: asset.pageId, assetType: assetType as - | 'image' - | 'video' - | 'audio' - | 'transition' - | 'other', + 'image' | 'video' | 'audio' | 'transition' | 'other', priority: asset.priority, }; } diff --git a/frontend/src/lib/canvasScale.ts b/frontend/src/lib/canvasScale.ts index 1c35617..0be59cd 100644 --- a/frontend/src/lib/canvasScale.ts +++ b/frontend/src/lib/canvasScale.ts @@ -131,12 +131,7 @@ export function remToDesignPx(value: number): number { export function normalizeToCanvasUnits( value: string | number | undefined, property: - | 'width' - | 'height' - | 'fontSize' - | 'padding' - | 'borderRadius' - | 'gap', + 'width' | 'height' | 'fontSize' | 'padding' | 'borderRadius' | 'gap', designWidth: number = CANVAS_CONFIG.defaults.width, designHeight: number = CANVAS_CONFIG.defaults.height, ): string { diff --git a/frontend/src/lib/elementEffects.ts b/frontend/src/lib/elementEffects.ts index beed554..1672a17 100644 --- a/frontend/src/lib/elementEffects.ts +++ b/frontend/src/lib/elementEffects.ts @@ -266,10 +266,10 @@ export function hasHoverEffects( ): boolean { return Boolean( effects.hoverScale || - effects.hoverOpacity || - effects.hoverBackgroundColor || - effects.hoverColor || - effects.hoverBoxShadow, + effects.hoverOpacity || + effects.hoverBackgroundColor || + effects.hoverColor || + effects.hoverBoxShadow, ); } @@ -281,9 +281,9 @@ export function hasFocusEffects( ): boolean { return Boolean( effects.focusScale || - effects.focusOpacity || - effects.focusOutline || - effects.focusBoxShadow, + effects.focusOpacity || + effects.focusOutline || + effects.focusBoxShadow, ); } @@ -295,8 +295,8 @@ export function hasActiveEffects( ): boolean { return Boolean( effects.activeScale || - effects.activeOpacity || - effects.activeBackgroundColor, + effects.activeOpacity || + effects.activeBackgroundColor, ); } diff --git a/frontend/src/lib/gallerySectionStyles.ts b/frontend/src/lib/gallerySectionStyles.ts index f836dd0..5e21fc3 100644 --- a/frontend/src/lib/gallerySectionStyles.ts +++ b/frontend/src/lib/gallerySectionStyles.ts @@ -18,11 +18,7 @@ import { toCU } from './canvasScale'; * Gallery section names for styling */ export type GallerySectionName = - | 'header' - | 'title' - | 'span' - | 'card' - | 'wrapper'; + 'header' | 'title' | 'span' | 'card' | 'wrapper'; /** * Default values for gallery sections using canvas units. diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 9cce3f3..b1388de 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -1,4 +1,5 @@ import * as icon from '@mdi/js'; +import { swaggerDocsUrl } from './config'; import { MenuAsideItem } from './types/menu'; const menuAside: MenuAsideItem[] = [ @@ -31,7 +32,7 @@ const menuAside: MenuAsideItem[] = [ icon: icon.mdiAccountCircle, }, { - href: '/api-docs', + href: swaggerDocsUrl, target: '_blank', label: 'Swagger', icon: icon.mdiFileCode, diff --git a/frontend/src/pages/projects/projects-list.tsx b/frontend/src/pages/projects/projects-list.tsx index 79e381c..160869b 100644 --- a/frontend/src/pages/projects/projects-list.tsx +++ b/frontend/src/pages/projects/projects-list.tsx @@ -23,9 +23,7 @@ const ProjectsListPage = () => { const dispatch = useAppDispatch(); const projectsRaw = useAppSelector((state) => state.projects.data) as - | Project[] - | Project - | undefined; + Project[] | Project | undefined; // Handle both array (from list fetch) and single object (after edit fetch) const projects: Project[] = Array.isArray(projectsRaw) ? projectsRaw diff --git a/frontend/src/types/constructor.ts b/frontend/src/types/constructor.ts index ad0aa4c..e586468 100644 --- a/frontend/src/types/constructor.ts +++ b/frontend/src/types/constructor.ts @@ -114,10 +114,7 @@ export interface CarouselSlide { export type InfoPanelItemType = 'image' | 'video' | '360'; export type InfoPanelMediaOpenMode = 'panel' | 'fullscreen'; export type InfoPanelImageClickAction = - | 'panel' - | 'fullscreen' - | 'target_page' - | 'external_url'; + 'panel' | 'fullscreen' | 'target_page' | 'external_url'; export type InfoPanelLinkClickAction = 'target_page' | 'external_url'; /** @@ -165,12 +162,7 @@ export interface InfoPanelInfoSpan { * - 'images': Media viewer with preview + thumbnail strip */ export type InfoPanelSectionType = - | 'header' - | 'title' - | 'text' - | 'spans' - | 'cards' - | 'images'; + 'header' | 'title' | 'text' | 'spans' | 'cards' | 'images'; /** * Section instance with unique ID, settings, AND data. @@ -269,8 +261,7 @@ export function generateItemId(): string { * Extends ElementStyleProperties for CSS styling and ElementEffectProperties for effects. */ export interface BaseCanvasElement - extends ElementStyleProperties, - ElementEffectProperties { + extends ElementStyleProperties, ElementEffectProperties { id: string; type: CanvasElementType | string; label?: string; diff --git a/frontend/src/types/filters.ts b/frontend/src/types/filters.ts index f71aa9b..841e39b 100644 --- a/frontend/src/types/filters.ts +++ b/frontend/src/types/filters.ts @@ -50,13 +50,7 @@ export interface TableColumnConfig { valueOptions?: string[]; // Custom render function name renderType?: - | 'link' - | 'boolean' - | 'date' - | 'datetime' - | 'image' - | 'actions' - | 'relation'; + 'link' | 'boolean' | 'date' | 'datetime' | 'image' | 'actions' | 'relation'; // For relation columns relationField?: string; } diff --git a/frontend/src/types/offline.ts b/frontend/src/types/offline.ts index 9cf3f2d..bb3f72b 100644 --- a/frontend/src/types/offline.ts +++ b/frontend/src/types/offline.ts @@ -6,22 +6,13 @@ // Asset variant types matching backend model export type AssetVariantType = - | 'thumbnail' - | 'preview' - | 'webp' - | 'mp4_low' - | 'mp4_high' - | 'original'; + 'thumbnail' | 'preview' | 'webp' | 'mp4_low' | 'mp4_high' | 'original'; export type AssetType = 'image' | 'video' | 'audio' | 'transition' | 'other'; // Preload job status export type PreloadJobStatus = - | 'queued' - | 'downloading' - | 'completed' - | 'error' - | 'paused'; + 'queued' | 'downloading' | 'completed' | 'error' | 'paused'; // Download job for tracking individual asset downloads export interface PreloadJob { @@ -44,11 +35,7 @@ export interface PreloadJob { // Project offline status export type ProjectOfflineStatus = - | 'not_downloaded' - | 'downloading' - | 'downloaded' - | 'outdated' - | 'error'; + 'not_downloaded' | 'downloading' | 'downloaded' | 'outdated' | 'error'; // Offline project metadata export interface OfflineProject { diff --git a/frontend/src/types/presentation.ts b/frontend/src/types/presentation.ts index 227f031..cea54c1 100644 --- a/frontend/src/types/presentation.ts +++ b/frontend/src/types/presentation.ts @@ -92,11 +92,7 @@ export interface NavigableElement { * Transition phase from useTransitionPlayback */ export type TransitionPhase = - | 'idle' - | 'preparing' - | 'playing' - | 'finishing' - | 'completed'; + 'idle' | 'preparing' | 'playing' | 'finishing' | 'completed'; /** * Background transition options diff --git a/frontend/src/types/ui.ts b/frontend/src/types/ui.ts index 51e1ee6..e92e0b0 100644 --- a/frontend/src/types/ui.ts +++ b/frontend/src/types/ui.ts @@ -8,13 +8,7 @@ * Color keys for general UI elements */ export type ColorKey = - | 'white' - | 'light' - | 'contrast' - | 'success' - | 'danger' - | 'warning' - | 'info'; + 'white' | 'light' | 'contrast' | 'success' | 'danger' | 'warning' | 'info'; /** * Color keys for buttons diff --git a/frontend/src/types/uiControls.ts b/frontend/src/types/uiControls.ts index 53586e3..515a602 100644 --- a/frontend/src/types/uiControls.ts +++ b/frontend/src/types/uiControls.ts @@ -2,11 +2,7 @@ import type { BaseEntity } from './entities'; export type SystemUiControlType = 'fullscreen' | 'sound' | 'offline'; export type SystemUiControlAnchor = - | 'center' - | 'top-left' - | 'top-right' - | 'bottom-left' - | 'bottom-right'; + 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; export interface SystemUiControlSettings { enabled?: boolean;