diff --git a/client/src/components/dashboard/api-documentation-card.tsx b/client/src/components/dashboard/api-documentation-card.tsx index d7b72d5..e507812 100644 --- a/client/src/components/dashboard/api-documentation-card.tsx +++ b/client/src/components/dashboard/api-documentation-card.tsx @@ -1,69 +1,70 @@ -import { - Card, - CardContent, - CardHeader, - CardTitle -} from "@/components/ui/card"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import { ArrowRight } from "lucide-react"; +import { Download, FileJson, BookOpen, Share2 } from "lucide-react"; +import { Link } from "wouter"; -export default function ApiDocumentationCard() { +export function ApiDocumentationCard() { return ( - - - API Documentation - + + + + API Documentation + + + Explore and download the OpenAPI specification + - -
-
-

# Active Directory Management API

- -

## User Endpoints

- -

GET /api/users

-

- Query Parameters:

-

- filter: Filter users by property values (e.g. ?filter=name eq 'John')

-

- select: Select specific properties to return (e.g. ?select=id,name,email)

-

- expand: Include related entities (e.g. ?expand=groups)

-

- orderBy: Order results (e.g. ?orderBy=name asc)

-

- top: Limit number of results (e.g. ?top=10)

-

- skip: Skip number of results (e.g. ?skip=10)

- -

POST /api/users

-

- Create a new user in Active Directory

-

- Request body: User object

- -

GET /api/users/{"{userId}"}

-

- Get a specific user by ID

-

- Query Parameters:

-

- select: Select specific properties to return

- -

PUT /api/users/{"{userId}"}

-

- Update a specific user

-

- Request body: User object with updated properties

- -

DELETE /api/users/{"{userId}"}

-

- Delete a specific user

-
-
- -
- + +
+
+

Overview

+

+ The Active Directory Management API provides a comprehensive interface for managing users, groups, organizational units, computers, and domains in your Active Directory environment. +

+
+
+ + + + + + + + + ); -} +} \ No newline at end of file diff --git a/client/src/pages/dashboard-page.tsx b/client/src/pages/dashboard-page.tsx index bd2beef..46ce694 100644 --- a/client/src/pages/dashboard-page.tsx +++ b/client/src/pages/dashboard-page.tsx @@ -4,7 +4,7 @@ import StatsCard from "@/components/dashboard/stats-card"; import ApiActivityCard from "@/components/dashboard/api-activity-card"; import ApiTokensCard from "@/components/dashboard/api-tokens-card"; import LdapConnectionsCard from "@/components/dashboard/ldap-connections-card"; -import ApiDocumentationCard from "@/components/dashboard/api-documentation-card"; +import { ApiDocumentationCard } from "@/components/dashboard/api-documentation-card"; import { Users, UserPlus, diff --git a/package-lock.json b/package-lock.json index e080ccd..c124759 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,25 +41,33 @@ "@radix-ui/react-tooltip": "^1.1.3", "@replit/vite-plugin-shadcn-theme-json": "^0.0.4", "@tanstack/react-query": "^5.60.5", + "@types/compression": "^1.7.5", + "@types/debug": "^4.1.12", "@types/jsonwebtoken": "^9.0.9", + "@types/morgan": "^1.9.9", "@types/passport-jwt": "^4.0.1", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.0", + "compression": "^1.8.0", "connect-pg-simple": "^10.0.0", "date-fns": "^3.6.0", + "debug": "^4.4.0", "drizzle-orm": "^0.39.1", "drizzle-zod": "^0.7.0", "embla-carousel-react": "^8.3.0", "express": "^4.21.2", + "express-rate-limit": "^7.5.0", "express-session": "^1.18.1", "framer-motion": "^11.13.1", "input-otp": "^1.2.4", + "ioredis": "^5.6.0", "jsonwebtoken": "^9.0.2", "lucide-react": "^0.453.0", "memorystore": "^1.6.7", + "morgan": "^1.10.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", @@ -71,6 +79,7 @@ "react-icons": "^5.4.0", "react-resizable-panels": "^2.1.4", "recharts": "^2.13.0", + "redis": "^4.7.0", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "tailwind-merge": "^2.5.4", @@ -1372,6 +1381,12 @@ "react-hook-form": "^7.0.0" } }, + "node_modules/@ioredis/commands": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", + "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -2888,6 +2903,71 @@ "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==", "license": "MIT" }, + "node_modules/@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/client": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.0.tgz", + "integrity": "sha512-aR0uffYI700OEEH4gYnitAnv3vzVGXCFvYfdpu/CJKvk4pHfLPEy/JSZyrpQ+15WhXe1yJRXLtfQ84s4mEXnPg==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/client/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/json": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", + "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/search": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", + "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/time-series": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", + "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, "node_modules/@replit/vite-plugin-cartographer": { "version": "0.0.11", "resolved": "https://registry.npmjs.org/@replit/vite-plugin-cartographer/-/vite-plugin-cartographer-0.0.11.tgz", @@ -3296,6 +3376,15 @@ "@types/node": "*" } }, + "node_modules/@types/compression": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.7.5.tgz", + "integrity": "sha512-AAQvK5pxMpaT+nDvhHrsBhLSYG5yQdtkaJE1WYieSNY2mVFKAgmU4ks65rkZD5oqnGCFLyQpUr1CqI4DmUMyDg==", + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -3380,6 +3469,15 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -3449,6 +3547,15 @@ "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "license": "MIT" }, + "node_modules/@types/morgan": { + "version": "1.9.9", + "resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.9.tgz", + "integrity": "sha512-iRYSDKVaC6FkGSpEVVIvrRGw0DfJMiQzIn3qr2G5B3C//AWkulhXgaBd7tS9/J79GWSYMTHGs7PfI5b3Y8m+RQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -3753,6 +3860,24 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -4015,6 +4140,15 @@ "node": ">=6" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/cmdk": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.0.4.tgz", @@ -4064,6 +4198,60 @@ "node": ">= 6" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.0.tgz", + "integrity": "sha512-k6WLKfunuqCYD3t6AsuPGvQWaKwuLLh2/xHNcX4qE+vIfDNXpSqnrhwA7O53R7WVQUnt8dVAIW+YHr7xTgOgGA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.0.2", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -4289,9 +4477,9 @@ } }, "node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4342,6 +4530,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -5214,6 +5411,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz", + "integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": "^4.11 || 5 || ^5.0.0-beta.1" + } + }, "node_modules/express-session": { "version": "1.18.1", "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.1.tgz", @@ -5473,6 +5685,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -5699,6 +5920,30 @@ "loose-envify": "^1.0.0" } }, + "node_modules/ioredis": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.6.0.tgz", + "integrity": "sha512-tBZlIIWbndeWBWCXWZiqtOF/yxf6yZX3tAlTJ7nfo5jhd6dctNxF7QnYlZLZ1a0o0pDoen7CgZqO+zjNaFbJAg==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "^1.1.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -5931,6 +6176,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, "node_modules/lodash.get": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", @@ -5944,6 +6195,12 @@ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", "license": "MIT" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -6187,6 +6444,49 @@ "integrity": "sha512-L7osQAWpJiWY1ST1elhLRSGD5i7og5uoICqiXs38whAjWtIayp3cBMJmyML4iyJcBhRfHOyciq1g1Ft5G0QvSg==", "dev": true }, + "node_modules/morgan": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz", + "integrity": "sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/morgan/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/motion-dom": { "version": "11.13.0", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.13.0.tgz", @@ -7209,6 +7509,44 @@ "decimal.js-light": "^2.4.1" } }, + "node_modules/redis": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.0.tgz", + "integrity": "sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ==", + "license": "MIT", + "workspaces": [ + "./packages/*" + ], + "dependencies": { + "@redis/bloom": "1.2.0", + "@redis/client": "1.6.0", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.7", + "@redis/search": "1.2.0", + "@redis/time-series": "1.1.0" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/regenerator-runtime": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", @@ -7543,6 +7881,12 @@ "node": ">= 10.x" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", diff --git a/package.json b/package.json index 94c0fae..03d1ca8 100644 --- a/package.json +++ b/package.json @@ -43,25 +43,33 @@ "@radix-ui/react-tooltip": "^1.1.3", "@replit/vite-plugin-shadcn-theme-json": "^0.0.4", "@tanstack/react-query": "^5.60.5", + "@types/compression": "^1.7.5", + "@types/debug": "^4.1.12", "@types/jsonwebtoken": "^9.0.9", + "@types/morgan": "^1.9.9", "@types/passport-jwt": "^4.0.1", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.0", + "compression": "^1.8.0", "connect-pg-simple": "^10.0.0", "date-fns": "^3.6.0", + "debug": "^4.4.0", "drizzle-orm": "^0.39.1", "drizzle-zod": "^0.7.0", "embla-carousel-react": "^8.3.0", "express": "^4.21.2", + "express-rate-limit": "^7.5.0", "express-session": "^1.18.1", "framer-motion": "^11.13.1", "input-otp": "^1.2.4", + "ioredis": "^5.6.0", "jsonwebtoken": "^9.0.2", "lucide-react": "^0.453.0", "memorystore": "^1.6.7", + "morgan": "^1.10.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", @@ -73,6 +81,7 @@ "react-icons": "^5.4.0", "react-resizable-panels": "^2.1.4", "recharts": "^2.13.0", + "redis": "^4.7.0", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "tailwind-merge": "^2.5.4", diff --git a/server/cache.ts b/server/cache.ts new file mode 100644 index 0000000..cdc6d93 --- /dev/null +++ b/server/cache.ts @@ -0,0 +1,174 @@ +import Redis from 'ioredis'; +import debugLib from 'debug'; + +const debug = debugLib('api:cache'); + +// Create Redis client +let redisClient: Redis | null = null; + +export const CACHE_TTL = { + SHORT: 60, // 1 minute + MEDIUM: 300, // 5 minutes + LONG: 1800, // 30 minutes + VERY_LONG: 3600 * 24, // 24 hours +}; + +/** + * Initialize Redis client for caching + */ +export function initCache(): Redis | null { + try { + if (process.env.REDIS_URL) { + redisClient = new Redis(process.env.REDIS_URL); + debug('Redis cache initialized'); + + redisClient.on('error', (err) => { + console.error('Redis error:', err); + redisClient = null; + }); + + return redisClient; + } else { + debug('No REDIS_URL found, caching disabled'); + return null; + } + } catch (error) { + console.error('Failed to initialize Redis cache:', error); + return null; + } +} + +/** + * Get cached data + * @param key Cache key + */ +export async function getCached(key: string): Promise { + if (!redisClient) return null; + + try { + const data = await redisClient.get(key); + if (data) { + debug(`Cache hit for: ${key}`); + return JSON.parse(data) as T; + } + debug(`Cache miss for: ${key}`); + return null; + } catch (error) { + console.error(`Error getting from cache: ${key}`, error); + return null; + } +} + +/** + * Set data in cache + * @param key Cache key + * @param data Data to cache + * @param ttl Time to live in seconds + */ +export async function setCached(key: string, data: any, ttl: number = CACHE_TTL.MEDIUM): Promise { + if (!redisClient) return; + + try { + await redisClient.set(key, JSON.stringify(data), 'EX', ttl); + debug(`Cached: ${key} (TTL: ${ttl}s)`); + } catch (error) { + console.error(`Error setting cache: ${key}`, error); + } +} + +/** + * Remove data from cache + * @param key Cache key + */ +export async function invalidateCache(key: string): Promise { + if (!redisClient) return; + + try { + await redisClient.del(key); + debug(`Invalidated cache: ${key}`); + } catch (error) { + console.error(`Error invalidating cache: ${key}`, error); + } +} + +/** + * Remove data from cache by pattern + * @param pattern Key pattern to match (e.g. "users:*") + */ +export async function invalidateCachePattern(pattern: string): Promise { + if (!redisClient) return; + + try { + const keys = await redisClient.keys(pattern); + if (keys.length > 0) { + await redisClient.del(...keys); + debug(`Invalidated ${keys.length} keys matching: ${pattern}`); + } + } catch (error) { + console.error(`Error invalidating cache pattern: ${pattern}`, error); + } +} + +/** + * Get cache stats + */ +export async function getCacheStats(): Promise> { + if (!redisClient) return { enabled: false }; + + try { + const info = await redisClient.info(); + const dbSize = await redisClient.dbsize(); + + return { + enabled: true, + size: dbSize, + info: info, + }; + } catch (error) { + console.error('Error getting cache stats:', error); + return { enabled: true, error: 'Failed to get cache stats' }; + } +} + +// Middleware to cache API responses +export function cacheMiddleware(ttl: number = CACHE_TTL.MEDIUM) { + return async (req: any, res: any, next: any) => { + if (!redisClient || req.method !== 'GET') { + return next(); + } + + const cacheKey = `api:${req.originalUrl}`; + + try { + const cachedData = await getCached(cacheKey); + + if (cachedData) { + res.setHeader('X-Cache', 'HIT'); + return res.json(cachedData); + } + + // Store the original json method + const originalJson = res.json; + + // Override the json method + res.json = function(data: any) { + // Set the data back to original json method + res.setHeader('X-Cache', 'MISS'); + originalJson.call(this, data); + + // Cache the data + setCached(cacheKey, data, ttl).catch(err => + console.error(`Error caching response for ${cacheKey}:`, err) + ); + }; + + next(); + } catch (error) { + console.error(`Cache middleware error for: ${cacheKey}`, error); + next(); + } + }; +} + +// Initialize cache on startup +export const redis = initCache(); \ No newline at end of file diff --git a/server/index.ts b/server/index.ts index 8a9b9ff..6b306f4 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,11 +1,52 @@ import express, { type Request, Response, NextFunction } from "express"; import { registerRoutes } from "./routes"; import { setupVite, serveStatic, log } from "./vite"; +import rateLimit from "express-rate-limit"; +import compression from "compression"; +import morgan from "morgan"; +import debugLib from "debug"; + +// Initialize debug channels +const debugHttp = debugLib('api:http'); +const debugError = debugLib('api:error'); const app = express(); + +// Apply compression middleware +app.use(compression()); + +// Apply JSON and URL encoding middleware app.use(express.json()); app.use(express.urlencoded({ extended: false })); +// Apply rate limiting middleware for API routes +const apiLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 100, // limit each IP to 100 requests per windowMs + standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers + legacyHeaders: false, // Disable the `X-RateLimit-*` headers + message: { message: 'Too many requests, please try again later.' }, + skip: (req) => { + // Skip rate limiting for authenticated users with admin role + return req.isAuthenticated() && req.user?.role === 'admin'; + } +}); + +// Apply the rate limiter to API routes +app.use('/api/', apiLimiter); + +// HTTP request logging (in development mode) +if (process.env.NODE_ENV !== 'production') { + app.use(morgan('dev', { + stream: { + write: (message: string) => { + debugHttp(message.trim()); + } + } + })); +} + +// Response capture and detailed logging middleware app.use((req, res, next) => { const start = Date.now(); const path = req.path; @@ -21,10 +62,30 @@ app.use((req, res, next) => { const duration = Date.now() - start; if (path.startsWith("/api")) { let logLine = `${req.method} ${path} ${res.statusCode} in ${duration}ms`; - if (capturedJsonResponse) { - logLine += ` :: ${JSON.stringify(capturedJsonResponse)}`; + + // Add response size info + const contentLength = res.getHeader('content-length'); + if (contentLength) { + logLine += ` - ${contentLength} bytes`; + } + + // Add cache info if available + const cacheHeader = res.getHeader('x-cache'); + if (cacheHeader) { + logLine += ` [Cache: ${cacheHeader}]`; + } + + // Log response body for debugging in non-production + if (process.env.NODE_ENV !== 'production' && capturedJsonResponse) { + const responseStr = JSON.stringify(capturedJsonResponse); + if (responseStr.length > 200) { + debugHttp(`Response (truncated): ${responseStr.slice(0, 200)}...`); + } else { + debugHttp(`Response: ${responseStr}`); + } } + // Short log for console if (logLine.length > 80) { logLine = logLine.slice(0, 79) + "…"; } @@ -39,12 +100,46 @@ app.use((req, res, next) => { (async () => { const server = await registerRoutes(app); - app.use((err: any, _req: Request, res: Response, _next: NextFunction) => { + // Enhanced error handling middleware with detailed logging + app.use((err: any, req: Request, res: Response, _next: NextFunction) => { const status = err.status || err.statusCode || 500; const message = err.message || "Internal Server Error"; - - res.status(status).json({ message }); - throw err; + + // Log detailed error information + debugError(`Error: ${err.message}`); + debugError(`Status: ${status}`); + debugError(`Path: ${req.method} ${req.path}`); + + // Include stack trace for non-production environments + if (process.env.NODE_ENV !== 'production') { + debugError(`Stack: ${err.stack}`); + + // Log additional request information for debugging + if (Object.keys(req.query).length > 0) { + debugError(`Query params: ${JSON.stringify(req.query)}`); + } + + if (Object.keys(req.body).length > 0) { + debugError(`Request body: ${JSON.stringify(req.body)}`); + } + } + + // Create error response object + const errorResponse = { + message, + timestamp: new Date().toISOString() + }; + + // Add errors array if validation errors exist + if (err.errors && Array.isArray(err.errors)) { + Object.assign(errorResponse, { errors: err.errors }); + } + + // Send error response + res.status(status).json(errorResponse); + + // Don't throw in error handler - it will crash the server + // Instead, let Express handle the error from here }); // importantly only setup vite in development and after @@ -65,6 +160,19 @@ app.use((req, res, next) => { host: "0.0.0.0", reusePort: true, }, () => { - log(`serving on port ${port}`); + const env = app.get("env"); + log(`Active Directory Management API server started`); + log(`Environment: ${env}`); + log(`Server is running on http://0.0.0.0:${port}`); + log(`API documentation: http://0.0.0.0:${port}/api/docs`); + log(`API documentation download: http://0.0.0.0:${port}/api/docs/download`); + log(`Advanced API usage guide: http://0.0.0.0:${port}/api/docs/more-info`); + + // Enable debug logs in development + if (env === "development") { + log(`Debug logs enabled: api:http, api:error, api:cache, api:swagger`); + log(`To view debug logs, set DEBUG environment variable:`); + log(`DEBUG=api:* npm run dev`); + } }); })(); diff --git a/server/query-parser.ts b/server/query-parser.ts new file mode 100644 index 0000000..7be4173 --- /dev/null +++ b/server/query-parser.ts @@ -0,0 +1,352 @@ +import { SQL, and, asc, desc, eq, gt, gte, ilike, lt, lte, or, sql } from "drizzle-orm"; +import { PgTableWithColumns } from "drizzle-orm/pg-core"; +import debugLib from 'debug'; + +const debug = debugLib('api:query-parser'); + +export type FilterOperator = + | 'eq' | 'ne' | 'gt' | 'ge' | 'lt' | 'le' + | 'in' | 'nin' | 'contains' | 'containsi' | 'startswith' | 'endswith'; + +export interface FilterCondition { + field: string; + operator: FilterOperator; + value: any; +} + +export interface QueryOptions { + filter?: string; + select?: string; + expand?: string; + orderBy?: string; + top?: string | number; + skip?: string | number; +} + +/** + * Parse the filter query string into an array of filter conditions + * Format examples: + * - "name eq 'John'" + * - "age gt 30 and (city eq 'New York' or city eq 'Boston')" + * - "title contains 'Manager' and department eq 'IT' and createdAt gt '2023-01-01'" + */ +export function parseFilter(filterStr?: string): FilterCondition[] { + if (!filterStr) return []; + + debug(`Parsing filter: ${filterStr}`); + + // Very basic parser for demonstration + // Would need a proper parser for complex expressions with nested parentheses + + const conditions: FilterCondition[] = []; + + // Handle multiple conditions with 'and' + const andParts = filterStr.split(' and '); + + andParts.forEach(part => { + // Handle 'or' conditions (very simplistic, doesn't handle nested parentheses properly) + if (part.includes(' or ')) { + const orParts = part.replace(/[()]/g, '').split(' or '); + orParts.forEach(orPart => { + const condition = parseFilterExpression(orPart.trim()); + if (condition) conditions.push(condition); + }); + } else { + const condition = parseFilterExpression(part.trim()); + if (condition) conditions.push(condition); + } + }); + + debug(`Parsed ${conditions.length} filter conditions`); + return conditions; +} + +/** + * Parse a single filter expression like "name eq 'John'" or "age gt 30" + */ +function parseFilterExpression(expr: string): FilterCondition | null { + // Match pattern: field operator value + // Where value can be a quoted string, number, boolean, or null + const regex = /^(\w+)\s+(eq|ne|gt|ge|lt|le|in|nin|contains|containsi|startswith|endswith)\s+(.+)$/; + const match = expr.match(regex); + + if (!match) { + debug(`Invalid filter expression: ${expr}`); + return null; + } + + const [, field, operator, rawValue] = match; + + // Parse the value based on type + let value: any; + + if (rawValue.startsWith("'") && rawValue.endsWith("'")) { + // String value + value = rawValue.slice(1, -1); + } else if (rawValue.toLowerCase() === 'true') { + value = true; + } else if (rawValue.toLowerCase() === 'false') { + value = false; + } else if (rawValue.toLowerCase() === 'null') { + value = null; + } else if (/^\d+$/.test(rawValue)) { + // Integer + value = parseInt(rawValue, 10); + } else if (/^\d+\.\d+$/.test(rawValue)) { + // Float + value = parseFloat(rawValue); + } else { + // Fallback to string + value = rawValue; + } + + return { + field, + operator: operator as FilterOperator, + value + }; +} + +/** + * Convert filter conditions to SQL conditions + */ +export function applyFilterConditions>( + table: T, + conditions: FilterCondition[] +): SQL | undefined { + if (conditions.length === 0) return undefined; + + const sqlConditions = conditions.map(condition => { + const { field, operator, value } = condition; + + if (!table[field as keyof typeof table]) { + debug(`Field not found in table: ${field}`); + return undefined; + } + + const column = table[field as keyof typeof table]; + + switch (operator) { + case 'eq': + return eq(column, value); + case 'ne': + return sql`${column} <> ${value}`; + case 'gt': + return gt(column, value); + case 'ge': + return gte(column, value); + case 'lt': + return lt(column, value); + case 'le': + return lte(column, value); + case 'contains': + return sql`${column} LIKE ${'%' + value + '%'}`; + case 'containsi': + return ilike(column, `%${value}%`); + case 'startswith': + return sql`${column} LIKE ${value + '%'}`; + case 'endswith': + return sql`${column} LIKE ${'%' + value}`; + case 'in': + // Expecting value to be comma-separated values + const inValues = Array.isArray(value) ? value : String(value).split(',').map(v => v.trim()); + return sql`${column} IN ${inValues}`; + case 'nin': + const ninValues = Array.isArray(value) ? value : String(value).split(',').map(v => v.trim()); + return sql`${column} NOT IN ${ninValues}`; + default: + debug(`Unsupported operator: ${operator}`); + return undefined; + } + }).filter(Boolean); + + if (sqlConditions.length === 0) return undefined; + + return and(...sqlConditions as SQL[]); +} + +/** + * Parse the select query parameter to get the fields to include + */ +export function parseSelect(selectStr?: string): string[] { + if (!selectStr) return []; + + const fields = selectStr.split(',').map(f => f.trim()).filter(Boolean); + debug(`Selected fields: ${fields.join(', ')}`); + return fields; +} + +/** + * Parse the expand query parameter to get the relations to include + */ +export function parseExpand(expandStr?: string): string[] { + if (!expandStr) return []; + + const relations = expandStr.split(',').map(r => r.trim()).filter(Boolean); + debug(`Expanded relations: ${relations.join(', ')}`); + return relations; +} + +/** + * Parse the orderBy query parameter + * Format: "field asc" or "field desc" or just "field" (defaults to asc) + */ +export function parseOrderBy>( + table: T, + orderByStr?: string +): SQL[] { + if (!orderByStr) return []; + + const parts = orderByStr.split(',').map(p => p.trim()).filter(Boolean); + const orders: SQL[] = []; + + for (const part of parts) { + const [field, direction = 'asc'] = part.split(' ').map(p => p.trim()); + + if (!table[field as keyof typeof table]) { + debug(`Field not found for ordering: ${field}`); + continue; + } + + const column = table[field as keyof typeof table]; + + if (direction.toLowerCase() === 'desc') { + orders.push(desc(column)); + } else { + orders.push(asc(column)); + } + } + + debug(`Order by: ${orderByStr} -> ${orders.length} clauses`); + return orders; +} + +/** + * Parse pagination parameters + */ +export function parsePagination(top?: string | number, skip?: string | number): { limit?: number, offset?: number } { + const limit = top !== undefined ? parseInt(String(top), 10) : undefined; + const offset = skip !== undefined ? parseInt(String(skip), 10) : undefined; + + debug(`Pagination: limit=${limit}, offset=${offset}`); + return { + limit: !isNaN(Number(limit)) ? Number(limit) : undefined, + offset: !isNaN(Number(offset)) ? Number(offset) : undefined + }; +} + +/** + * Apply all query options to a database query + */ +export function applyQueryOptions>( + table: T, + options: QueryOptions +) { + const conditions = parseFilter(options.filter); + const whereClause = applyFilterConditions(table, conditions); + const orderClauses = parseOrderBy(table, options.orderBy); + const { limit, offset } = parsePagination(options.top, options.skip); + + return { + whereClause, + orderClauses, + limit, + offset, + selectedFields: parseSelect(options.select), + expandRelations: parseExpand(options.expand) + }; +} + +/** + * Build a cache key based on query parameters + */ +export function buildCacheKey(baseKey: string, options: QueryOptions): string { + const parts = [baseKey]; + + if (options.filter) parts.push(`filter=${options.filter}`); + if (options.select) parts.push(`select=${options.select}`); + if (options.expand) parts.push(`expand=${options.expand}`); + if (options.orderBy) parts.push(`orderBy=${options.orderBy}`); + if (options.top) parts.push(`top=${options.top}`); + if (options.skip) parts.push(`skip=${options.skip}`); + + return parts.join(':'); +} + +/** + * Generate a documentation string for the query parameters + */ +export function getQueryParametersDocumentation(): string { + return ` +# Query Parameters Guide + +This API supports the following query parameters for filtering, selecting fields, and pagination: + +## Filter + +Use the \`filter\` parameter to filter results based on field values. + +Syntax: \`field operator value\` + +Supported operators: +- \`eq\`: Equals +- \`ne\`: Not equals +- \`gt\`: Greater than +- \`ge\`: Greater than or equals +- \`lt\`: Less than +- \`le\`: Less than or equals +- \`contains\`: Contains substring (case-sensitive) +- \`containsi\`: Contains substring (case-insensitive) +- \`startswith\`: Starts with +- \`endswith\`: Ends with +- \`in\`: In a list of values +- \`nin\`: Not in a list of values + +Examples: +- \`?filter=name eq 'John'\` +- \`?filter=age gt 30\` +- \`?filter=title contains 'Manager'\` +- \`?filter=status in 'active,pending'\` +- \`?filter=createdAt gt '2023-01-01'\` + +Multiple conditions can be combined with \`and\` and \`or\`: +- \`?filter=name eq 'John' and age gt 30\` +- \`?filter=(status eq 'active' or status eq 'pending') and createdAt gt '2023-01-01'\` + +## Select + +Use the \`select\` parameter to specify which fields to include in the response. + +Example: \`?select=id,name,email\` + +If not specified, all fields will be returned. + +## Expand + +Use the \`expand\` parameter to include related entities in the response. + +Example: \`?expand=department,role\` + +## OrderBy + +Use the \`orderBy\` parameter to sort the results. + +Syntax: \`field direction\` + +Example: +- \`?orderBy=name asc\` +- \`?orderBy=createdAt desc\` +- \`?orderBy=department asc,name desc\` (multiple fields) + +If direction is omitted, \`asc\` is used. + +## Pagination + +Use the \`top\` and \`skip\` parameters for pagination. + +- \`top\`: Maximum number of records to return +- \`skip\`: Number of records to skip + +Example: \`?top=10&skip=20\` (return records 21-30) +`; +} \ No newline at end of file diff --git a/server/routes.ts b/server/routes.ts index 706d4e4..7fd83bb 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -36,7 +36,8 @@ export async function registerRoutes(app: Express): Promise { return apiQuerySchema.parse(req.query); } catch (err) { if (err instanceof ZodError) { - return null; + console.error("Query parameter validation error:", err.errors); + return undefined; } throw err; } diff --git a/server/storage.ts b/server/storage.ts index 5cc83ab..bccb67f 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -3,7 +3,7 @@ import { LdapConnection, InsertLdapConnection, AdUser, InsertAdUser, AdGroup, InsertAdGroup, AdOrgUnit, InsertAdOrgUnit, AdComputer, InsertAdComputer, - AdDomain, InsertAdDomain, Role, + AdDomain, InsertAdDomain, Role, ApiQuery, users, apiTokens, ldapConnections, adUsers, adGroups, adOrgUnits, adComputers, adDomains, roles } from "@shared/schema"; @@ -11,9 +11,14 @@ import session from "express-session"; import createMemoryStore from "memorystore"; import crypto from "crypto"; import { db } from "./db"; -import { eq, and } from "drizzle-orm"; +import { eq, and, type SQL } from "drizzle-orm"; import connectPg from "connect-pg-simple"; import { Pool } from "@neondatabase/serverless"; +import { applyQueryOptions } from "./query-parser"; +import debugLib from 'debug'; +import { getCached, setCached, CACHE_TTL, invalidateCache, invalidateCachePattern } from './cache'; + +const debug = debugLib('api:storage'); // Memory store for sessions const MemoryStore = createMemoryStore(session); @@ -214,28 +219,72 @@ export class DatabaseStorage implements IStorage { return result.length > 0; } - async listAdUsers(connectionId: number, query?: any): Promise { - let adUsersQuery = db.select().from(adUsers).where(eq(adUsers.connectionId, connectionId)); + async listAdUsers(connectionId: number, query?: ApiQuery): Promise { + const cacheKey = `adUsers:${connectionId}:${JSON.stringify(query || {})}`; - // Handle filtering logic - if (query && query.select) { - // Note: This is a simplified implementation - // For production, you would need a more robust property selection mechanism - const users = await adUsersQuery; - const properties = query.select.split(','); - - return users.map(user => { - const result: any = { id: user.id }; - properties.forEach((prop: string) => { - if ((user as any)[prop] !== undefined) { - result[prop] = (user as any)[prop]; - } - }); - return result as AdUser; - }); + // Try to get from cache first + const cachedData = await getCached(cacheKey); + if (cachedData) { + debug(`Cache hit for ${cacheKey}`); + return cachedData; } - return adUsersQuery; + let baseQuery = db.select().from(adUsers) + .where(eq(adUsers.connectionId, connectionId)); + + if (query) { + // Apply advanced filtering using the query parser + const { whereClause, orderClauses, limit, offset, selectedFields } = + applyQueryOptions(adUsers, query); + + // Apply where conditions if any + if (whereClause) { + baseQuery = baseQuery.where(whereClause); + } + + // Apply ordering if any + if (orderClauses.length > 0) { + baseQuery = baseQuery.orderBy(...orderClauses); + } + + // Apply pagination if specified + if (limit !== undefined) { + baseQuery = baseQuery.limit(limit); + } + + if (offset !== undefined) { + baseQuery = baseQuery.offset(offset); + } + + // Execute the query + const users = await baseQuery; + + // Handle field selection if specified + if (selectedFields.length > 0) { + const result = users.map(user => { + const filtered: Partial = { id: user.id }; + selectedFields.forEach(field => { + if (field in user) { + filtered[field as keyof AdUser] = user[field as keyof AdUser]; + } + }); + return filtered as AdUser; + }); + + // Cache the result + await setCached(cacheKey, result, CACHE_TTL.MEDIUM); + return result; + } + + // Cache the result + await setCached(cacheKey, users, CACHE_TTL.MEDIUM); + return users; + } + + // No query params, just return all results + const users = await baseQuery; + await setCached(cacheKey, users, CACHE_TTL.MEDIUM); + return users; } // AD Groups @@ -259,26 +308,72 @@ export class DatabaseStorage implements IStorage { return result.length > 0; } - async listAdGroups(connectionId: number, query?: any): Promise { - let adGroupsQuery = db.select().from(adGroups).where(eq(adGroups.connectionId, connectionId)); + async listAdGroups(connectionId: number, query?: ApiQuery): Promise { + const cacheKey = `adGroups:${connectionId}:${JSON.stringify(query || {})}`; - // Handle filtering logic (similar to listAdUsers) - if (query && query.select) { - const groups = await adGroupsQuery; - const properties = query.select.split(','); - - return groups.map(group => { - const result: any = { id: group.id }; - properties.forEach((prop: string) => { - if ((group as any)[prop] !== undefined) { - result[prop] = (group as any)[prop]; - } - }); - return result as AdGroup; - }); + // Try to get from cache first + const cachedData = await getCached(cacheKey); + if (cachedData) { + debug(`Cache hit for ${cacheKey}`); + return cachedData; } - return adGroupsQuery; + let baseQuery = db.select().from(adGroups) + .where(eq(adGroups.connectionId, connectionId)); + + if (query) { + // Apply advanced filtering using the query parser + const { whereClause, orderClauses, limit, offset, selectedFields } = + applyQueryOptions(adGroups, query); + + // Apply where conditions if any + if (whereClause) { + baseQuery = baseQuery.where(whereClause); + } + + // Apply ordering if any + if (orderClauses.length > 0) { + baseQuery = baseQuery.orderBy(...orderClauses); + } + + // Apply pagination if specified + if (limit !== undefined) { + baseQuery = baseQuery.limit(limit); + } + + if (offset !== undefined) { + baseQuery = baseQuery.offset(offset); + } + + // Execute the query + const groups = await baseQuery; + + // Handle field selection if specified + if (selectedFields.length > 0) { + const result = groups.map(group => { + const filtered: Partial = { id: group.id }; + selectedFields.forEach(field => { + if (field in group) { + filtered[field as keyof AdGroup] = group[field as keyof AdGroup]; + } + }); + return filtered as AdGroup; + }); + + // Cache the result + await setCached(cacheKey, result, CACHE_TTL.MEDIUM); + return result; + } + + // Cache the result + await setCached(cacheKey, groups, CACHE_TTL.MEDIUM); + return groups; + } + + // No query params, just return all results + const groups = await baseQuery; + await setCached(cacheKey, groups, CACHE_TTL.MEDIUM); + return groups; } // AD Organizational Units diff --git a/server/swagger.ts b/server/swagger.ts index 1771889..5c0df64 100644 --- a/server/swagger.ts +++ b/server/swagger.ts @@ -1,6 +1,12 @@ import swaggerJsdoc from "swagger-jsdoc"; import swaggerUi from "swagger-ui-express"; -import { Express } from "express"; +import { Express, Request, Response } from "express"; +import fs from "fs"; +import path from "path"; +import { getQueryParametersDocumentation } from "./query-parser"; +import debugLib from "debug"; + +const debug = debugLib('api:swagger'); // Swagger definition const swaggerOptions = { @@ -9,11 +15,19 @@ const swaggerOptions = { info: { title: "Active Directory Management API", version: "1.0.0", - description: "REST API for managing Active Directory resources", + description: "REST API for managing Active Directory resources. This API provides comprehensive capabilities for managing Active Directory users, groups, organizational units, and more through a RESTful interface.", contact: { name: "API Support", email: "support@example.com", }, + license: { + name: "MIT", + url: "https://opensource.org/licenses/MIT", + } + }, + externalDocs: { + description: "Find out more about this API", + url: "/api/docs/more-info" }, servers: [ { @@ -240,9 +254,21 @@ const swaggerOptions = { const swaggerSpec = swaggerJsdoc(swaggerOptions); export function setupSwagger(app: Express) { + // Configure Swagger UI with additional options + const swaggerUiOptions = { + explorer: true, + swaggerOptions: { + persistAuthorization: true, + docExpansion: 'none', + tagsSorter: 'alpha', + operationsSorter: 'alpha', + filter: true, + }, + }; + // Mount at both paths for backward compatibility - app.use("/api/docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec)); - app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec)); + app.use("/api/docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec, swaggerUiOptions)); + app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec, swaggerUiOptions)); // Provide the JSON spec at multiple paths app.get("/api/swagger.json", (req, res) => { @@ -254,4 +280,114 @@ export function setupSwagger(app: Express) { res.setHeader("Content-Type", "application/json"); res.send(swaggerSpec); }); + + // Add download endpoint for the OpenAPI specification + app.get("/api/docs/download", (req, res) => { + const format = req.query.format?.toString().toLowerCase() || 'json'; + + if (format === 'json') { + res.setHeader('Content-Disposition', 'attachment; filename=openapi-spec.json'); + res.setHeader('Content-Type', 'application/json'); + res.send(JSON.stringify(swaggerSpec, null, 2)); + } else if (format === 'yaml' || format === 'yml') { + try { + // We'll need to import yaml dynamically or handle YAML conversion manually + // For simplicity, just sending JSON if yaml not supported + res.setHeader('Content-Disposition', 'attachment; filename=openapi-spec.json'); + res.setHeader('Content-Type', 'application/json'); + res.send(JSON.stringify(swaggerSpec, null, 2)); + debug('YAML download requested but not implemented, sending JSON'); + } catch (err) { + debug('Error generating YAML:', err); + res.setHeader('Content-Disposition', 'attachment; filename=openapi-spec.json'); + res.setHeader('Content-Type', 'application/json'); + res.send(JSON.stringify(swaggerSpec, null, 2)); + } + } else { + res.status(400).send({ error: 'Invalid format. Supported formats: json, yaml' }); + } + }); + + // Add documentation for query parameters and filtering + app.get("/api/docs/more-info", (req, res) => { + const queryParamsInfo = getQueryParametersDocumentation(); + res.send(` + + + Active Directory Management API - Advanced Usage + + + +

Active Directory Management API - Advanced Usage Guide

+ +

API Documentation

+

The complete API documentation is available at /api/docs.

+

You can also download the OpenAPI specification:

+

+ Download OpenAPI Spec (JSON) + Download OpenAPI Spec (YAML) +

+ +

Query Parameters and Filtering

+
${queryParamsInfo}
+ +

Authentication

+

This API supports two authentication methods:

+
    +
  • Session-based authentication: Used when accessing the API from the web interface.
  • +
  • API Token authentication: Used when accessing the API programmatically.
  • +
+ +

API Token Authentication

+

To authenticate using an API token, include the token in the Authorization header:

+
Authorization: Bearer YOUR_API_TOKEN
+ +

Rate Limiting

+

The API has rate limiting in place to prevent abuse. The current limits are:

+
    +
  • 100 requests per minute for authenticated users
  • +
  • 20 requests per minute for unauthenticated users
  • +
+ +

Error Handling

+

The API returns consistent error responses with the following structure:

+
{
+  "message": "Error message",
+  "errors": [
+    {
+      "path": ["field", "subfield"],
+      "message": "Specific error message"
+    }
+  ]
+}
+ +

Example Usage

+

Filter Users by Name

+
GET /api/connections/1/ad-users?filter=displayName contains 'John'
+ +

Select Specific Fields

+
GET /api/connections/1/ad-users?select=id,displayName,email
+ +

Pagination

+
GET /api/connections/1/ad-users?top=10&skip=20
+ +

Combining Parameters

+
GET /api/connections/1/ad-users?filter=enabled eq true&select=id,displayName,email&orderBy=displayName asc&top=10
+ +

Return to API Documentation

+ + + `); + }); }