add docker
This commit is contained in:
parent
dcb2b5ee53
commit
e9d703ec7c
10 changed files with 316 additions and 25 deletions
17
.dockerignore
Normal file
17
.dockerignore
Normal file
|
|
@ -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
|
||||||
|
|
@ -1,3 +1,2 @@
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
PORT=8080
|
|
||||||
UNSPLASH_ACCESS_KEY=your_unsplash_access_key_here
|
UNSPLASH_ACCESS_KEY=your_unsplash_access_key_here
|
||||||
|
|
|
||||||
39
Dockerfile
Normal file
39
Dockerfile
Normal file
|
|
@ -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"]
|
||||||
20
README.md
20
README.md
|
|
@ -41,26 +41,6 @@ npm run format
|
||||||
- ESLint
|
- ESLint
|
||||||
- Prettier
|
- 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
|
## Contributing
|
||||||
|
|
||||||
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
|
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
|
||||||
|
|
|
||||||
29
compose.yaml
Normal file
29
compose.yaml
Normal file
|
|
@ -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'
|
||||||
|
|
@ -10,6 +10,10 @@ import {
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({ status: 'ok' });
|
||||||
|
});
|
||||||
|
|
||||||
const hourlyLimiter = rateLimit({
|
const hourlyLimiter = rateLimit({
|
||||||
windowMs: 60 * 60 * 1000,
|
windowMs: 60 * 60 * 1000,
|
||||||
max: 1000,
|
max: 1000,
|
||||||
|
|
@ -35,7 +39,6 @@ app.get('/quotes/:id', onGetQuoteById);
|
||||||
app.get('/translations', onGetTranslations);
|
app.get('/translations', onGetTranslations);
|
||||||
app.get('/images', onGetImages);
|
app.get('/images', onGetImages);
|
||||||
|
|
||||||
const port = process.env.PORT ? Number(process.env.PORT) : 8080;
|
app.listen(8080, () => {
|
||||||
app.listen(port, () => {
|
console.log(`Server is running on http://localhost:8080`);
|
||||||
console.log(`Server is running on http://localhost:${port}`);
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Request, Response } from 'express';
|
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';
|
import { listRandomImages } from './data/unsplash.js';
|
||||||
|
|
||||||
function onGetRandomQuote(_req: Request, res: Response): void {
|
function onGetRandomQuote(_req: Request, res: Response): void {
|
||||||
|
|
|
||||||
195
testLoad.js
Normal file
195
testLoad.js
Normal file
|
|
@ -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();
|
||||||
|
})();
|
||||||
29
testRateLimit.js
Normal file
29
testRateLimit.js
Normal file
|
|
@ -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();
|
||||||
Loading…
Add table
Add a link
Reference in a new issue