refactor: zod schema

This commit is contained in:
grtsinry43 2025-10-24 06:03:28 +00:00
parent 57f964744f
commit 434da70b06
15 changed files with 2275 additions and 229 deletions

7
.env.example Normal file
View File

@ -0,0 +1,7 @@
DATABASE_URL="your-database-url"
JWT_SECRET="your-jwt-secret"
GITHUB_CLIENT_ID="your-github-client-id"
GITHUB_CLIENT_SECRET="your-github-client-secret"
FRONTEND_URL="http://localhost:5173"
UPSTASH_REDIS_URL="http://localhost:6379"
UPSTASH_REDIS_TOKEN="your-upstash-redis-token"

View File

@ -1,123 +0,0 @@
# Gemini AI Rules for Node.js with Express Projects
## 1. Persona & Expertise
You are an expert back-end developer with a deep specialization in Node.js and the Express framework. You are proficient in building robust, scalable, and secure APIs. Your expertise includes asynchronous programming, middleware, routing, error handling, and performance optimization in a Node.js environment. You are also familiar with common project structures like MVC and best practices for securing Express applications.
## 2. Project Context
This project is a back-end application or API built with Node.js and the Express framework. The focus is on creating a secure, performant, and well-structured server-side application. Assume the project uses modern JavaScript (ES6+) or TypeScript.
## 3. Coding Standards & Best Practices
### General
- **Language:** Use modern JavaScript (ES6+) or TypeScript, depending on the project's configuration.
- **Asynchronous Operations:** Always use `async/await` for asynchronous code to improve readability and error handling.
- **Dependencies:** After suggesting new npm dependencies, remind the user to run `npm install`. Regularly audit dependencies for vulnerabilities using `npm audit`.
- **Testing:** Encourage the use of a testing framework like Jest or Mocha, and a library like Supertest for testing API endpoints.
### Node.js & Express Specific
- **Security:**
- **Secrets Management:** Never hard-code secrets. Use environment variables (and a `.env` file) for all sensitive information.
- **Helmet:** Recommend and use the `helmet` middleware to set secure HTTP headers.
- **Input Sanitization:** Sanitize and validate all user input to prevent XSS and injection attacks.
- **Rate Limiting:** Suggest implementing rate limiting to protect against brute-force attacks.
- **Project Structure:**
- **Modular Design:** Organize the application into logical modules. Separate routes, controllers, services (business logic), and models (data access) into their own directories.
- **Centralized Configuration:** Keep all configuration in a dedicated file or manage it through environment variables.
- **Error Handling:**
- **Centralized Middleware:** Implement a centralized error-handling middleware function to catch and process all errors.
- **Asynchronous Errors:** Ensure all asynchronous errors in route handlers are properly caught and passed to the error-handling middleware.
- **Performance:**
- **Gzip Compression:** Use the `compression` middleware to enable gzip compression.
- **Caching:** Recommend caching strategies for frequently accessed data.
- **Clustering:** For production environments, suggest using the `cluster` module to take advantage of multi-core systems.
### Building AI Features with the Gemini SDK (`@google/generative-ai`)
You can easily integrate powerful generative AI features into your Express application using the official Google AI Gemini SDK.
**1. Installation:**
First, add the necessary packages to your project:
```bash
npm install @google/generative-ai dotenv
```
The `dotenv` package is used to manage environment variables for your API key.
**2. Secure API Key Setup:**
Never hard-code your API key. Create a `.env` file in your project's root directory and add your key:
```
# .env
GEMINI_API_KEY="YOUR_API_KEY"
```
Make sure to add `.env` to your `.gitignore` file to keep it out of version control.
**3. Create an AI-Powered API Route:**
Here is a complete example of how to add a new route to your Express app that uses the Gemini API to generate content based on a user's prompt.
**File: `index.js` (or your main server file)**
```javascript
// Load environment variables from .env file
require('dotenv').config();
const express = require('express');
const { GoogleGenerativeAI } = require('@google/generative-ai');
const app = express();
// Middleware to parse JSON request bodies
app.use(express.json());
// Check for API key on startup
if (!process.env.GEMINI_API_KEY) {
throw new Error('GEMINI_API_KEY environment variable is not set.');
}
// Initialize the Google AI client with the API key
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
// Define a POST route to handle content generation
app.post('/api/generate', async (req, res) => {
try {
const { prompt } = req.body;
if (!prompt) {
return res.status(400).json({ error: 'Prompt is required' });
}
// Use a recent, powerful model
const model = genAI.getGenerativeModel({ model: 'gemini-1.5-pro' });
const result = await model.generateContent(prompt);
const response = await result.response;
const text = response.text();
// Send the generated text back to the client
res.json({ generatedText: text });
} catch (error) {
console.error('Error calling Gemini API:', error);
res.status(500).json({ error: 'Failed to generate content' });
}
});
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log(`Server is running on http://localhost:${port}`);
});
```
**4. How to Test the Endpoint:**
You can use a tool like `curl` to test your new endpoint:
```bash
curl -X POST http://localhost:3000/api/generate \
-H "Content-Type: application/json" \
-d '{"prompt": "Write a short poem about Node.js"}'
```
This setup provides a secure and efficient way to add generative AI capabilities to your Node.js and Express backend.
## 4. Interaction Guidelines
- Assume the user is familiar with JavaScript and basic web development concepts.
- Provide clear and actionable code examples for creating routes, middleware, and controllers.
- Break down complex tasks, like setting up authentication or connecting to a database, into smaller, manageable steps.
- If a request is ambiguous, ask for clarification about the desired functionality, database choice, or project structure.
- When discussing security, provide specific middleware and techniques to address common vulnerabilities.

View File

@ -1,28 +0,0 @@
# To learn more about how to use Nix to configure your environment
# see: https://developers.google.com/idx/guides/customize-idx-env
{ pkgs, ... }: {
# Which nixpkgs channel to use.
channel = "stable-23.11"; # or "unstable"
# Use https://search.nixos.org/packages to find packages
packages = [
pkgs.nodejs_20
];
# Sets environment variables in the workspace
env = {};
idx = {
# Search for the extensions you want on https://open-vsx.org/ and use "publisher.id"
extensions = [
"google.gemini-cli-vscode-ide-companion"
];
workspace = {
# Runs when a workspace is first created with this `dev.nix` file
onCreate = {
npm-install = "npm ci --no-audit --prefer-offline --no-progress --timing";
};
# Runs when a workspace is (re)started
onStart= {
run-server = "npm run dev";
};
};
};
}

View File

@ -18,6 +18,7 @@
"@fastify/jwt": "^10.0.0",
"@fastify/swagger": "^9.5.2",
"@octokit/rest": "^19.0.13",
"@octokit/types": "^15.0.1",
"@prisma/client": "^6.18.0",
"@upstash/redis": "^1.35.6",
"axios": "^1.12.2",

1907
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

4
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,4 @@
onlyBuiltDependencies:
- '@prisma/client'
- '@prisma/engines'
- prisma

View File

@ -1,3 +1,9 @@
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient();
// Prevent creating multiple instances of PrismaClient in development
// when using hot-reloading (ts-node-dev / nodemon).
const g = global as any;
export const prisma: PrismaClient = g.__prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') g.__prisma = prisma;

View File

@ -14,6 +14,4 @@ export function verifyGithubWebhook(req: FastifyRequest, reply: FastifyReply) {
if (digest !== signature) {
return reply.status(401).send({ error: 'Invalid signature' });
}
done();
}

View File

@ -1,67 +1,78 @@
import { FastifyInstance } from 'fastify';
import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod';
import { z } from 'zod';
import { exchangeCodeForToken, getGithubUser } from '../services/github.service';
import { prisma } from '../lib/prisma';
import { createResponseSchema, successResponse, errorResponse, ErrorCode } from '../types/response';
const loginSchema = {
body: {
type: 'object',
required: ['code'],
properties: {
code: { type: 'string' },
},
},
response: {
200: {
type: 'object',
properties: {
token: { type: 'string' },
},
},
},
} as const;
// Schema for login request body
const loginBodySchema = z.object({
code: z.string().min(1, 'Authorization code is required'),
});
export async function authRoutes(app: FastifyInstance) {
// Schema for login response data
const loginDataSchema = z.object({
token: z.string(),
});
// Use the unified response format
const loginSuccessSchema = createResponseSchema(loginDataSchema);
const loginErrorSchema = createResponseSchema(z.null());
export const authRoutes: FastifyPluginAsyncZod = async (app) => {
app.post(
'/login',
{ schema: loginSchema },
{
schema: {
description: 'Login with GitHub OAuth code',
tags: ['auth'],
body: loginBodySchema,
response: {
200: loginSuccessSchema,
500: loginErrorSchema,
},
},
},
async (request, reply) => {
// The type for request.body is inferred from the schema
// ✅ Type is automatically inferred from Zod schema
const { code } = request.body;
try {
const accessToken = await exchangeCodeForToken(code);
const githubUser = await getGithubUser(accessToken);
// Convert number to string as Prisma schema expects String
let user = await prisma.user.findUnique({
where: { githubId: githubUser.id },
where: { githubId: String(githubUser.id) },
});
if (!user) {
user = await prisma.user.create({
data: {
githubId: githubUser.id, // Corrected: Use number directly
githubId: String(githubUser.id),
username: githubUser.login,
avatarUrl: githubUser.avatar_url,
},
});
}
// The payload now matches the global FastifyJWT type
// Generate JWT token
const token = app.jwt.sign(
{
sub: user.id,
username: user.username,
avatarUrl: user.avatarUrl,
accessToken, // The user's GitHub token
accessToken,
},
{ expiresIn: '1d' },
);
return { token };
return successResponse({ token });
} catch (error) {
app.log.error('Authentication Error:', error);
reply.status(500).send({ error: 'Authentication failed' });
app.log.error({ error }, 'Authentication Error');
return reply
.status(500)
.send(errorResponse(ErrorCode.AUTH_FAILED, 'Authentication failed'));
}
},
);
}
};

View File

@ -1,7 +1,8 @@
import { FastifyInstance } from 'fastify';
import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod';
import { z } from 'zod';
import { GitHubService } from '../services/github.service';
import { authMiddleware } from '../middlewares/auth.middleware';
import { createResponseSchema, successResponse } from '../types/response';
// Schema for request parameters
const repoParamsSchema = z.object({
@ -37,9 +38,13 @@ const repoListItemSchema = z.object({
}),
});
const reposListResponseSchema = z.array(repoListItemSchema);
const reposListSchema = z.array(repoListItemSchema);
export async function repoRoutes(app: FastifyInstance) {
// Use the unified response format
const reposListResponseSchema = createResponseSchema(reposListSchema);
const repoStatsResponseSchema = createResponseSchema(repoStatsSchema);
export const repoRoutes: FastifyPluginAsyncZod = async (app) => {
// All routes in this plugin require authentication
app.addHook('preHandler', authMiddleware);
@ -48,16 +53,18 @@ export async function repoRoutes(app: FastifyInstance) {
'/',
{
schema: {
description: 'Get repositories for authenticated user',
tags: ['repos'],
response: {
200: reposListResponseSchema,
},
},
},
async (request, reply) => {
async (request) => {
const { accessToken, username } = request.user!;
const githubService = new GitHubService(accessToken, username);
const repos = await githubService.getRepositories();
return repos;
return successResponse(repos);
},
);
@ -66,18 +73,21 @@ export async function repoRoutes(app: FastifyInstance) {
'/:owner/:repo/stats',
{
schema: {
description: 'Get statistics for a specific repository',
tags: ['repos'],
params: repoParamsSchema,
response: {
200: repoStatsSchema,
200: repoStatsResponseSchema,
},
},
},
async (request, reply) => {
const { owner, repo } = request.params as z.infer<typeof repoParamsSchema>;
async (request) => {
// ✅ Type is automatically inferred from Zod schema
const { owner, repo } = request.params;
const { accessToken, username } = request.user!;
const githubService = new GitHubService(accessToken, username);
const stats = await githubService.getRepositoryStats(owner, repo);
return stats;
return successResponse(stats);
},
);
}
};

View File

@ -1,21 +1,63 @@
import { FastifyInstance } from 'fastify';
import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod';
import { z } from 'zod';
import { StatsService } from '../services/stats.service';
import { authMiddleware } from '../middlewares/auth.middleware';
import { createResponseSchema, successResponse } from '../types/response';
export async function statsRoutes(app: FastifyInstance) {
// Schema for overview stats response
const overviewStatsSchema = z.object({
totalRepos: z.number(),
totalStars: z.number(),
totalForks: z.number(),
});
// Schema for activity stats response
const activityStatsSchema = z.object({
totalCommitsLast30Days: z.number(),
});
// Use the unified response format
const overviewStatsResponseSchema = createResponseSchema(overviewStatsSchema);
const activityStatsResponseSchema = createResponseSchema(activityStatsSchema);
export const statsRoutes: FastifyPluginAsyncZod = async (app) => {
app.addHook('preHandler', authMiddleware);
app.get('/overview', async (request, reply) => {
const { accessToken, username } = request.user!;
const statsService = new StatsService(accessToken, username);
const overviewStats = await statsService.getOverviewStats();
reply.send(overviewStats);
});
app.get(
'/overview',
{
schema: {
description: 'Get overview statistics for authenticated user',
tags: ['stats'],
response: {
200: overviewStatsResponseSchema,
},
},
},
async (request) => {
const { accessToken, username } = request.user!;
const statsService = new StatsService(accessToken, username);
const overviewStats = await statsService.getOverviewStats();
return successResponse(overviewStats);
},
);
app.get('/activity', async (request, reply) => {
const { accessToken, username } = request.user!;
const statsService = new StatsService(accessToken, username);
const activityStats = await statsService.getActivityStats();
reply.send(activityStats);
});
}
app.get(
'/activity',
{
schema: {
description: 'Get activity statistics for authenticated user',
tags: ['stats'],
response: {
200: activityStatsResponseSchema,
},
},
},
async (request) => {
const { accessToken, username } = request.user!;
const statsService = new StatsService(accessToken, username);
const activityStats = await statsService.getActivityStats();
return successResponse(activityStats);
},
);
};

View File

@ -1,34 +1,48 @@
import { FastifyInstance } from 'fastify';
import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod';
import { z } from 'zod';
import { verifyGithubWebhook } from '../middlewares/verify-webhook.middleware';
import { handleWebhook } from '../services/webhook.service';
import { GitHubWebhookPayload } from '../types/github';
import { createResponseSchema, successResponse, errorResponse, ErrorCode } from '../types/response';
// Define a basic schema to expect a JSON object payload.
const webhookSchema = {
body: {
type: 'object',
// Since webhook payloads vary, we allow any properties.
// In a real application, you might add more specific validation
// based on the event types you expect to handle.
additionalProperties: true,
},
} as const;
// Schema for webhook payload (flexible since payloads vary by event type)
const webhookBodySchema = z.record(z.string(), z.any());
export async function webhookRoutes(app: FastifyInstance) {
// Schema for webhook response data
const webhookDataSchema = z.object({
message: z.string(),
});
// Use the unified response format
const webhookSuccessSchema = createResponseSchema(webhookDataSchema);
const webhookErrorSchema = createResponseSchema(z.null());
export const webhookRoutes: FastifyPluginAsyncZod = async (app) => {
app.post(
'/github',
{
preHandler: verifyGithubWebhook,
schema: webhookSchema,
schema: {
description: 'Handle GitHub webhook events',
tags: ['webhooks'],
body: webhookBodySchema,
response: {
200: webhookSuccessSchema,
500: webhookErrorSchema,
},
},
},
async (request, reply) => {
try {
// request.body is now safely typed as Record<string, any>
await handleWebhook(request.body);
reply.status(200).send({ message: 'Webhook received' });
// Type cast to GitHubWebhookPayload for service function
await handleWebhook(request.body as GitHubWebhookPayload);
return successResponse({ message: 'Webhook received' });
} catch (error) {
app.log.error('Webhook processing error:', error);
reply.status(500).send({ error: 'Webhook processing failed' });
app.log.error({ error }, 'Webhook processing error');
return reply
.status(500)
.send(errorResponse(ErrorCode.INTERNAL_ERROR, 'Webhook processing failed'));
}
},
);
}
};

View File

@ -69,8 +69,60 @@ async function bootstrap() {
},
});
// --- API Documentation with RapiDoc ---
// --- Docs portal ---
// Top-level docs landing page that links to multiple renderers (ReDoc, Stoplight Elements, RapiDoc)
app.get('/docs', (req, reply) => {
reply.type('text/html').send(`
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>API Documentation</title>
<style>
body { font-family: Inter, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; margin:0; padding:40px; background:#0f172a; color:#e6eef8 }
.container { max-width:900px; margin:0 auto; }
h1 { margin:0 0 8px; font-size:28px; }
p { color:#cbd5e1 }
.cards { display:flex; gap:16px; margin-top:24px; }
.card { background:linear-gradient(180deg,#0b1220,#061226); padding:18px; border-radius:8px; flex:1; box-shadow:0 6px 18px rgba(2,6,23,0.6); }
.card a { color:#7dd3fc; text-decoration:none; font-weight:600 }
.logo { height:36px; margin-bottom:12px }
</style>
</head>
<body>
<div class="container">
<img class="logo" src="https://raw.githubusercontent.com/readmeio/presskit/master/logos/readme-logo-white.svg" alt="Docs" />
<h1>API Documentation</h1>
<p>Choose a renderer. <strong>ReDoc</strong> is visually polished; <strong>Stoplight</strong> gives a modern interactive portal similar to Knife4j.</p>
<div class="cards">
<div class="card">
<h3>ReDoc Readable & clean</h3>
<p style="color:#9fb4c9">Great for well-structured API reference. Non-interactive but very pretty.</p>
<p><a href="/docs/redoc">Open ReDoc </a></p>
</div>
<div class="card">
<h3>Stoplight Elements Interactive</h3>
<p style="color:#9fb4c9">Modern UI with playground/try-it-out support and a Knife4j-like feel.</p>
<p><a href="/docs/stoplight">Open Stoplight </a></p>
</div>
<div class="card">
<h3>RapiDoc Lightweight</h3>
<p style="color:#9fb4c9">Existing lightweight renderer included for quick inspection.</p>
<p><a href="/docs/rapidoc">Open RapiDoc </a></p>
</div>
</div>
</div>
</body>
</html>
`);
});
// --- RapiDoc route (moved from /docs to /docs/rapidoc) ---
app.get('/docs/rapidoc', (req, reply) => {
reply.type('text/html').send(`
<!doctype html>
<html>
@ -92,6 +144,92 @@ async function bootstrap() {
`);
});
// --- API Documentation with ReDoc (alternative nicer UI) ---
// ReDoc gives a clean, modern single-page OpenAPI rendering similar to Knife4j's look.
app.get('/docs/redoc', (req, reply) => {
reply.type('text/html').send(`
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>API Docs ReDoc</title>
<!-- ReDoc standalone bundle from CDN -->
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
<style>
body { margin: 0; padding: 0; }
#redoc-container { height: 100vh; }
</style>
</head>
<body>
<div id="redoc-container"></div>
<script>
// Render ReDoc into the container and point it at the generated OpenAPI JSON
Redoc.init('/docs/json', {
scrollYOffset: 50,
theme: {
colors: { primary: { main: '#2b6cb0' } },
typography: { fontSize: '14px' }
}
}, document.getElementById('redoc-container'));
</script>
</body>
</html>
`);
});
// --- Stoplight Elements (interactive, Knife4j-like feel) ---
// Uses Stoplight Elements Web Components from CDN to render a modern interactive portal.
app.get('/docs/stoplight', (req, reply) => {
reply.type('text/html').send(`
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>API Docs Stoplight</title>
<link rel="stylesheet" href="https://unpkg.com/@stoplight/elements/styles.min.css" />
<!-- Load Stoplight Elements as an ES module; add ?module for ESM entry on unpkg -->
<script type="module" src="https://unpkg.com/@stoplight/elements/web-components.min.js?module"></script>
<style>
body { margin:0; font-family: Inter, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; }
header { padding:12px 20px; background:#0b1220; color:#e6eef8; display:flex; align-items:center; gap:12px }
header h1 { margin:0; font-size:16px }
main { height: calc(100vh - 56px); }
</style>
</head>
<body>
<header>
<img src="https://raw.githubusercontent.com/stoplightio/elements/main/docs/images/logo.svg" alt="stoplight" style="height:28px"/>
<h1>Interactive API Docs</h1>
</header>
<main>
<!-- elements-api is the web component that renders an API description URL -->
<elements-api api-description-url="/docs/json" router="hash"></elements-api>
<noscript>
<div style="padding:20px;color:#374151;background:#f8fafc;border-radius:6px;margin:16px">JavaScript is required to render interactive docs. You can view the OpenAPI JSON directly: <a href="/docs/json">/docs/json</a></div>
</noscript>
<script>
// If the web component failed to register (CDN blocked), show a fallback notice after a short timeout
setTimeout(() => {
if (!customElements.get('elements-api')) {
const fallback = document.createElement('div');
fallback.style.padding = '16px';
fallback.style.background = '#fff3cd';
fallback.style.color = '#6b4226';
fallback.style.borderRadius = '6px';
fallback.style.margin = '16px';
fallback.innerHTML = 'Interactive docs failed to load from CDN. You can view the OpenAPI JSON directly: <a href="/docs/json">/docs/json</a>';
document.querySelector('main').appendChild(fallback);
}
}, 1200);
</script>
</main>
</body>
</html>
`);
});
app.get('/docs/json', (req, reply) => {
reply.send(app.swagger());
});

View File

@ -11,6 +11,10 @@ export class CacheService {
}
async set(key: string, value: any, ttl?: number) {
return await redis.set(key, value, { ex: ttl });
// Only pass ex option if ttl is defined
if (ttl !== undefined) {
return await redis.set(key, value, { ex: ttl });
}
return await redis.set(key, value);
}
}

55
src/types/response.ts Normal file
View File

@ -0,0 +1,55 @@
import { z } from 'zod';
/**
* Standard API Response Format
* - code: 0 for success, non-zero for errors
* - msg: empty for success, error message for failures
* - data: response payload
*/
// Generic response schema creator
export function createResponseSchema<T extends z.ZodTypeAny>(dataSchema: T) {
return z.object({
code: z.number(),
msg: z.string(),
data: dataSchema,
});
}
// Success response with data
export function successResponse<T>(data: T) {
return {
code: 0,
msg: '',
data,
};
}
// Error response
export function errorResponse(code: number, msg: string) {
return {
code,
msg,
data: null,
};
}
// Common error codes
export const ErrorCode = {
SUCCESS: 0,
INVALID_PARAMS: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
INTERNAL_ERROR: 500,
AUTH_FAILED: 1001,
GITHUB_API_ERROR: 1002,
WEBHOOK_VERIFICATION_FAILED: 1003,
} as const;
// Error response schema
export const errorResponseSchema = z.object({
code: z.number(),
msg: z.string(),
data: z.null(),
});