From e9d703ec7c9ee4117c178e6000be5e8c117a58b3 Mon Sep 17 00:00:00 2001 From: fiatcode Date: Mon, 20 Oct 2025 08:18:02 +0700 Subject: [PATCH] add docker --- .dockerignore | 17 +++ .env.example | 1 - Dockerfile | 39 +++++++ README.md | 20 ---- compose.yaml | 29 ++++++ src/data/{db.ts => quote.ts} | 0 src/index.ts | 9 +- src/routes.ts | 2 +- testLoad.js | 195 +++++++++++++++++++++++++++++++++++ testRateLimit.js | 29 ++++++ 10 files changed, 316 insertions(+), 25 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 compose.yaml rename src/data/{db.ts => quote.ts} (100%) create mode 100644 testLoad.js create mode 100644 testRateLimit.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..215a231 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +node_modules +npm-debug.log +.env +.git +.gitignore +README.md +.vscode +.idea +*.md +.DS_Store +dist +coverage +.github +*.test.js +testLoad.js +testRateLimit.js +quotes.db \ No newline at end of file diff --git a/.env.example b/.env.example index 90bb771..254108e 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,2 @@ NODE_ENV=development -PORT=8080 UNSPLASH_ACCESS_KEY=your_unsplash_access_key_here diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d92d77a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +FROM node:22-alpine + +# Set working directory +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install ALL dependencies (including devDependencies for build) +RUN npm ci + +# Copy source code +COPY . . + +# Build TypeScript +RUN npm run build + +# Remove devDependencies after build to reduce image size +RUN npm prune --production + +# Create a non-root user for security +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nodejs -u 1001 + +# Change ownership +RUN chown -R nodejs:nodejs /app + +# Switch to non-root user +USER nodejs + +# Expose port +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD wget --quiet --tries=1 --spider http://localhost:8080/health || exit 1 + +# Start the application +CMD ["node", "dist/index.js"] \ No newline at end of file diff --git a/README.md b/README.md index d67e27e..f90a91f 100644 --- a/README.md +++ b/README.md @@ -41,26 +41,6 @@ npm run format - ESLint - Prettier -## Project Structure - -``` -kuwot-api-js/ -├── src/ -│ ├── index.ts # Entry point -│ ├── routes.ts # API routes -│ ├── types.d.ts # Type definitions -│ └── data/ -│ ├── db.ts # Database logic -│ └── unsplash.ts # Unsplash API integration -├── .env.example # Example environment variables -├── .gitignore # Git ignore rules -├── package.json # Project metadata and scripts -├── tsconfig.json # TypeScript configuration -└── .vscode/ - ├── launch.json # Debug configuration - └── extensions.json # Recommended extensions -``` - ## Contributing Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..5a30780 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,29 @@ +services: + quote-api: + build: . + restart: unless-stopped + ports: + - '8080:8080' + env_file: + - .env + volumes: + - ./quotes.db:/app/quotes.db:ro + healthcheck: + test: + [ + 'CMD', + 'wget', + '--quiet', + '--tries=1', + '--spider', + 'http://localhost:8080/health', + ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + logging: + driver: 'json-file' + options: + max-size: '10m' + max-file: '3' diff --git a/src/data/db.ts b/src/data/quote.ts similarity index 100% rename from src/data/db.ts rename to src/data/quote.ts diff --git a/src/index.ts b/src/index.ts index da60cd9..3b5489e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,10 @@ import { const app = express(); +app.get('/health', (req, res) => { + res.json({ status: 'ok' }); +}); + const hourlyLimiter = rateLimit({ windowMs: 60 * 60 * 1000, max: 1000, @@ -35,7 +39,6 @@ app.get('/quotes/:id', onGetQuoteById); app.get('/translations', onGetTranslations); app.get('/images', onGetImages); -const port = process.env.PORT ? Number(process.env.PORT) : 8080; -app.listen(port, () => { - console.log(`Server is running on http://localhost:${port}`); +app.listen(8080, () => { + console.log(`Server is running on http://localhost:8080`); }); diff --git a/src/routes.ts b/src/routes.ts index 0b7a803..b9d1c90 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -1,5 +1,5 @@ import type { Request, Response } from 'express'; -import { getQuote, getRandomQuote, listTranslations } from './data/db.js'; +import { getQuote, getRandomQuote, listTranslations } from './data/quote.js'; import { listRandomImages } from './data/unsplash.js'; function onGetRandomQuote(_req: Request, res: Response): void { diff --git a/testLoad.js b/testLoad.js new file mode 100644 index 0000000..9210771 --- /dev/null +++ b/testLoad.js @@ -0,0 +1,195 @@ +// Stress test script for Quote API +// Usage: node stress-test.js + +const ENDPOINTS = [ + 'http://localhost:8080/quotes/random', + 'http://localhost:8080/translations', +]; + +// Test configurations +const TESTS = [ + { name: 'Light Load', concurrent: 10, duration: 10 }, + { name: 'Medium Load', concurrent: 50, duration: 10 }, + { name: 'Heavy Load', concurrent: 100, duration: 10 }, + { name: 'Extreme Load', concurrent: 200, duration: 10 }, +]; + +class StressTest { + constructor(endpoint, concurrent, duration) { + this.endpoint = endpoint; + this.concurrent = concurrent; + this.duration = duration; + this.results = { + total: 0, + success: 0, + failed: 0, + rateLimited: 0, + totalTime: 0, + responseTimes: [], + }; + } + + async makeRequest() { + const start = Date.now(); + try { + const response = await fetch(this.endpoint); + const elapsed = Date.now() - start; + + this.results.total++; + this.results.totalTime += elapsed; + this.results.responseTimes.push(elapsed); + + if (response.status === 429) { + this.results.rateLimited++; + } else if (response.ok) { + this.results.success++; + } else { + this.results.failed++; + } + } catch (error) { + this.results.total++; + this.results.failed++; + } + } + + async runWorker(stopTime) { + while (Date.now() < stopTime) { + await this.makeRequest(); + } + } + + async run() { + console.log( + `\n🚀 Starting test: ${this.concurrent} concurrent requests for ${this.duration}s` + ); + console.log(` Endpoint: ${this.endpoint}`); + + const startTime = Date.now(); + const stopTime = startTime + this.duration * 1000; + + // Launch concurrent workers + const workers = []; + for (let i = 0; i < this.concurrent; i++) { + workers.push(this.runWorker(stopTime)); + } + + await Promise.all(workers); + + const actualDuration = (Date.now() - startTime) / 1000; + return this.calculateStats(actualDuration); + } + + calculateStats(duration) { + const { total, success, failed, rateLimited, responseTimes } = this.results; + + // Sort response times for percentile calculations + responseTimes.sort((a, b) => a - b); + + const getPercentile = (p) => { + const index = Math.ceil((p / 100) * responseTimes.length) - 1; + return responseTimes[index] || 0; + }; + + return { + requestsPerSecond: (total / duration).toFixed(2), + totalRequests: total, + successful: success, + failed: failed, + rateLimited: rateLimited, + successRate: ((success / total) * 100).toFixed(2) + '%', + avgResponseTime: (this.results.totalTime / total).toFixed(2) + 'ms', + minResponseTime: Math.min(...responseTimes) + 'ms', + maxResponseTime: Math.max(...responseTimes) + 'ms', + p50: getPercentile(50) + 'ms', + p95: getPercentile(95) + 'ms', + p99: getPercentile(99) + 'ms', + }; + } + + printResults(stats) { + console.log('\n📊 Results:'); + console.log(` Requests/sec: ${stats.requestsPerSecond}`); + console.log(` Total requests: ${stats.totalRequests}`); + console.log(` Successful: ${stats.successful}`); + console.log(` Failed: ${stats.failed}`); + console.log(` Rate limited: ${stats.rateLimited}`); + console.log(` Success rate: ${stats.successRate}`); + console.log(`\n⏱️ Response Times:`); + console.log(` Average: ${stats.avgResponseTime}`); + console.log(` Min: ${stats.minResponseTime}`); + console.log(` Max: ${stats.maxResponseTime}`); + console.log(` 50th percentile: ${stats.p50}`); + console.log(` 95th percentile: ${stats.p95}`); + console.log(` 99th percentile: ${stats.p99}`); + } +} + +async function runAllTests() { + console.log('═══════════════════════════════════════════'); + console.log(' API STRESS TEST'); + console.log('═══════════════════════════════════════════'); + + const endpoint = ENDPOINTS[0]; // Test random quotes endpoint + const allResults = []; + + for (const testConfig of TESTS) { + const test = new StressTest( + endpoint, + testConfig.concurrent, + testConfig.duration + ); + + console.log(`\n\n▶️ ${testConfig.name.toUpperCase()}`); + const stats = await test.run(); + test.printResults(stats); + + allResults.push({ + name: testConfig.name, + ...stats, + }); + + // Cool down period between tests + if (testConfig !== TESTS[TESTS.length - 1]) { + console.log('\n\n⏳ Cooling down for 5 seconds...'); + await new Promise((resolve) => setTimeout(resolve, 5000)); + } + } + + // Print summary + console.log('\n\n═══════════════════════════════════════════'); + console.log(' SUMMARY'); + console.log('═══════════════════════════════════════════\n'); + + console.log('Test Name | Req/s | Success Rate | Avg Response'); + console.log('-------------------|--------|--------------|-------------'); + allResults.forEach((result) => { + const name = result.name.padEnd(18); + const rps = result.requestsPerSecond.padEnd(6); + const successRate = result.successRate.padEnd(12); + const avgTime = result.avgResponseTime.padEnd(12); + console.log(`${name} | ${rps} | ${successRate} | ${avgTime}`); + }); + + console.log('\n✅ All tests completed!'); +} + +// Check if endpoint is reachable before starting +async function checkEndpoint() { + try { + const response = await fetch(ENDPOINTS[0]); + if (!response.ok && response.status !== 429) { + console.error('❌ Server not responding correctly. Is it running?'); + process.exit(1); + } + } catch (error) { + console.error('❌ Cannot reach server. Please start your server first.'); + console.error(` URL: ${ENDPOINTS[0]}`); + process.exit(1); + } +} + +// Run the tests +(async () => { + await checkEndpoint(); + await runAllTests(); +})(); diff --git a/testRateLimit.js b/testRateLimit.js new file mode 100644 index 0000000..c80bfc1 --- /dev/null +++ b/testRateLimit.js @@ -0,0 +1,29 @@ +async function testRateLimit() { + const url = 'http://localhost:8080/quotes/random'; + + console.log('Testing burst limiter (50 requests in 10 seconds)...\n'); + + for (let i = 1; i <= 60; i++) { + try { + const response = await fetch(url); + const status = response.status; + const remaining = response.headers.get('RateLimit-Remaining'); + const limit = response.headers.get('RateLimit-Limit'); + + if (status === 429) { + console.log(`❌ Request ${i}: RATE LIMITED!`); + const data = await response.text(); + console.log(` Message: ${data}\n`); + } else { + console.log(`✓ Request ${i}: OK (${remaining}/${limit} remaining)`); + } + } catch (error) { + console.log(`❌ Request ${i}: Error - ${error.message}`); + } + + // Small delay to see output clearly + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +testRateLimit();