mirror of
https://github.com/freedbygrace/ActiveDirectoryManager.git
synced 2026-08-09 02:11:57 +00:00
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:
+174
@@ -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
@@ -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`);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user