Checkpoint

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 705f2157-ef97-4fbd-89e4-8c7f2ecaea90
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/7ed01c5f-a82d-405a-b728-b2e3d127c60c/30fe5a86-4e58-47d8-aa32-104aaf3a85ce.jpg
This commit is contained in:
alphaeusmote
2025-04-08 02:33:56 +00:00
10 changed files with 1335 additions and 115 deletions
@@ -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 (
<Card>
<CardHeader className="px-6 py-4 border-b flex justify-between items-center">
<CardTitle>API Documentation</CardTitle>
<Button
variant="link"
className="p-0 h-auto font-medium text-sm text-primary"
onClick={() => window.open("/api-docs", "_blank")}
>
View Full Documentation
</Button>
<Card className="shadow-lg">
<CardHeader className="bg-gradient-to-r from-slate-100 to-slate-50 dark:from-slate-800 dark:to-slate-900">
<CardTitle className="text-xl flex items-center gap-2">
<BookOpen className="w-5 h-5" /> API Documentation
</CardTitle>
<CardDescription>
Explore and download the OpenAPI specification
</CardDescription>
</CardHeader>
<CardContent className="p-4">
<div className="bg-muted rounded-md p-4 font-mono text-sm overflow-x-auto">
<div className="text-xs whitespace-pre-wrap">
<p className="font-bold mb-2"># Active Directory Management API</p>
<p className="font-bold mt-3 mb-2">## User Endpoints</p>
<p className="mb-1">GET /api/users</p>
<p className="mb-1">- Query Parameters:</p>
<p className="ml-4 mb-1">- filter: Filter users by property values (e.g. ?filter=name eq 'John')</p>
<p className="ml-4 mb-1">- select: Select specific properties to return (e.g. ?select=id,name,email)</p>
<p className="ml-4 mb-1">- expand: Include related entities (e.g. ?expand=groups)</p>
<p className="ml-4 mb-1">- orderBy: Order results (e.g. ?orderBy=name asc)</p>
<p className="ml-4 mb-1">- top: Limit number of results (e.g. ?top=10)</p>
<p className="ml-4 mb-1">- skip: Skip number of results (e.g. ?skip=10)</p>
<p className="mt-3 mb-1">POST /api/users</p>
<p className="mb-1">- Create a new user in Active Directory</p>
<p className="mb-1">- Request body: User object</p>
<p className="mt-3 mb-1">GET /api/users/{"{userId}"}</p>
<p className="mb-1">- Get a specific user by ID</p>
<p className="mb-1">- Query Parameters:</p>
<p className="ml-4 mb-1">- select: Select specific properties to return</p>
<p className="mt-3 mb-1">PUT /api/users/{"{userId}"}</p>
<p className="mb-1">- Update a specific user</p>
<p className="mb-1">- Request body: User object with updated properties</p>
<p className="mt-3 mb-1">DELETE /api/users/{"{userId}"}</p>
<p className="mb-1">- Delete a specific user</p>
</div>
</div>
<div className="mt-4 flex justify-end">
<Button
variant="link"
className="p-0 h-auto font-medium text-sm text-primary"
onClick={() => window.open("/api-docs", "_blank")}
>
Go to Swagger Documentation <ArrowRight className="ml-1 h-4 w-4" />
</Button>
<CardContent className="pt-6">
<div className="grid gap-4">
<div>
<h3 className="text-sm font-medium mb-2">Overview</h3>
<p className="text-sm text-muted-foreground">
The Active Directory Management API provides a comprehensive interface for managing users, groups, organizational units, computers, and domains in your Active Directory environment.
</p>
</div>
<div>
<h3 className="text-sm font-medium mb-2">Documentation Links</h3>
<div className="flex flex-col gap-2">
<a
href="/api/docs"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 flex items-center gap-1"
>
<BookOpen className="w-4 h-4" /> Interactive API Documentation
</a>
<a
href="/api/docs/more-info"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 flex items-center gap-1"
>
<FileJson className="w-4 h-4" /> Advanced Usage Guide
</a>
</div>
</div>
</div>
</CardContent>
<CardFooter className="flex flex-wrap gap-2">
<a href="/api/docs/download?format=json" download="ad-management-api-spec.json">
<Button variant="outline" size="sm" className="h-8">
<Download className="mr-2 h-3 w-3" />
JSON Spec
</Button>
</a>
<a href="/api/docs/download?format=yaml" download="ad-management-api-spec.yaml">
<Button variant="outline" size="sm" className="h-8">
<Download className="mr-2 h-3 w-3" />
YAML Spec
</Button>
</a>
<Button variant="outline" size="sm" className="h-8 ml-auto" asChild>
<Link to="/api-tokens">
<Share2 className="mr-2 h-3 w-3" />
Manage API Tokens
</Link>
</Button>
</CardFooter>
</Card>
);
}
}
+1 -1
View File
@@ -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,
+347 -3
View File
@@ -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",
+9
View File
@@ -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",
+174
View File
@@ -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<T>(key: string): Promise<T | null> {
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<void> {
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<void> {
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<void> {
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<Record<string, any>> {
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();
+115 -7
View File
@@ -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`);
}
});
})();
+352
View File
@@ -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<T extends PgTableWithColumns<any>>(
table: T,
conditions: FilterCondition[]
): SQL<unknown> | 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<unknown>[]);
}
/**
* 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<T extends PgTableWithColumns<any>>(
table: T,
orderByStr?: string
): SQL<unknown>[] {
if (!orderByStr) return [];
const parts = orderByStr.split(',').map(p => p.trim()).filter(Boolean);
const orders: SQL<unknown>[] = [];
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<T extends PgTableWithColumns<any>>(
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)
`;
}
+2 -1
View File
@@ -36,7 +36,8 @@ export async function registerRoutes(app: Express): Promise<Server> {
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;
}
+133 -38
View File
@@ -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<AdUser[]> {
let adUsersQuery = db.select().from(adUsers).where(eq(adUsers.connectionId, connectionId));
async listAdUsers(connectionId: number, query?: ApiQuery): Promise<AdUser[]> {
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<AdUser[]>(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<AdUser> = { 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<AdGroup[]> {
let adGroupsQuery = db.select().from(adGroups).where(eq(adGroups.connectionId, connectionId));
async listAdGroups(connectionId: number, query?: ApiQuery): Promise<AdGroup[]> {
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<AdGroup[]>(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<AdGroup> = { 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
+140 -4
View File
@@ -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(`
<html>
<head>
<title>Active Directory Management API - Advanced Usage</title>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 1200px; margin: 0 auto; padding: 20px; }
h1, h2, h3 { color: #0066cc; }
pre { background-color: #f5f5f5; padding: 10px; border-radius: 5px; overflow-x: auto; }
code { background-color: #f5f5f5; padding: 2px 4px; border-radius: 3px; }
table { border-collapse: collapse; width: 100%; margin-bottom: 20px; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f2f2f2; }
tr:nth-child(even) { background-color: #f9f9f9; }
.btn { display: inline-block; padding: 10px 15px; background-color: #0066cc; color: white; text-decoration: none; border-radius: 5px; margin: 10px 0; }
.btn:hover { background-color: #004c99; }
</style>
</head>
<body>
<h1>Active Directory Management API - Advanced Usage Guide</h1>
<h2>API Documentation</h2>
<p>The complete API documentation is available at <a href="/api/docs">/api/docs</a>.</p>
<p>You can also download the OpenAPI specification:</p>
<p>
<a href="/api/docs/download?format=json" class="btn">Download OpenAPI Spec (JSON)</a>
<a href="/api/docs/download?format=yaml" class="btn">Download OpenAPI Spec (YAML)</a>
</p>
<h2>Query Parameters and Filtering</h2>
<pre>${queryParamsInfo}</pre>
<h2>Authentication</h2>
<p>This API supports two authentication methods:</p>
<ul>
<li><strong>Session-based authentication</strong>: Used when accessing the API from the web interface.</li>
<li><strong>API Token authentication</strong>: Used when accessing the API programmatically.</li>
</ul>
<h3>API Token Authentication</h3>
<p>To authenticate using an API token, include the token in the Authorization header:</p>
<pre>Authorization: Bearer YOUR_API_TOKEN</pre>
<h2>Rate Limiting</h2>
<p>The API has rate limiting in place to prevent abuse. The current limits are:</p>
<ul>
<li>100 requests per minute for authenticated users</li>
<li>20 requests per minute for unauthenticated users</li>
</ul>
<h2>Error Handling</h2>
<p>The API returns consistent error responses with the following structure:</p>
<pre>{
"message": "Error message",
"errors": [
{
"path": ["field", "subfield"],
"message": "Specific error message"
}
]
}</pre>
<h2>Example Usage</h2>
<h3>Filter Users by Name</h3>
<pre>GET /api/connections/1/ad-users?filter=displayName contains 'John'</pre>
<h3>Select Specific Fields</h3>
<pre>GET /api/connections/1/ad-users?select=id,displayName,email</pre>
<h3>Pagination</h3>
<pre>GET /api/connections/1/ad-users?top=10&skip=20</pre>
<h3>Combining Parameters</h3>
<pre>GET /api/connections/1/ad-users?filter=enabled eq true&select=id,displayName,email&orderBy=displayName asc&top=10</pre>
<p>Return to <a href="/api/docs">API Documentation</a></p>
</body>
</html>
`);
});
}