diff --git a/.env b/.env new file mode 100644 index 0000000..ec636f2 --- /dev/null +++ b/.env @@ -0,0 +1,4 @@ +CRUD_BASE_URL=http://localhost +CRUD_US_PORT=4000 +CRUD_DB_HOST=localhost +CRUD_DB_PORT=4500 diff --git a/package.json b/package.json new file mode 100644 index 0000000..9f0bcf2 --- /dev/null +++ b/package.json @@ -0,0 +1,42 @@ +{ + "name": "nodejs-crud-api", + "version": "1.0.0", + "description": "Implementation of simple CRUD API using in-memory database", + "main": "src/index.ts", + "engines": { + "node": ">=18.0.0" + }, + "scripts": { + "start:dev": "npm exec nodemon src/single_mode.ts", + "start:prod": "npx webpack --mode production", + "start:multi": "npm exec nodemon src/index.ts" + }, + "author": "docroot", + "license": "ISC", + "dependencies": { + "dotenv": "^16.3.1", + "ts-node": "^10.9.1", + "uuid": "^9.0.0" + }, + "devDependencies": { + "@types/uuid": "^9.0.2", + "eslint": "^8.44.0", + "nodemon": "^1.19.4", + "ts-loader": "^9.4.4", + "ts-node-dev": "^2.0.0", + "typescript": "^5.1.6", + "webpack": "^5.88.1", + "webpack-cli": "^5.1.4" + }, + "compilerOptions": { + "outDir": "./dist", + "module": "es6", + "target": "es5", + "jsx": "react-jsx", + "lib": [ + "es6", + "dom" + ], + "sourceMap": true + } +} \ No newline at end of file diff --git a/src/db_service.ts b/src/db_service.ts new file mode 100644 index 0000000..50960de --- /dev/null +++ b/src/db_service.ts @@ -0,0 +1,235 @@ +import http, { IncomingMessage, ServerResponse } from 'http'; +import { parse } from 'url'; +import * as uuid from 'uuid'; +import { User } from './entities/User' + + +export class DbService { + private users: User[]; + private dbPort: string; + + constructor() { + this.users = []; + this.dbPort = process.env['CRUD_DB_PORT'] ? process.env['CRUD_DB_PORT'] : '4100'; + } + + createUser(user: User) { + this.users.push(user); + } + + getUsers() { + return this.users; + } + + getUserById(id: string) { + return this.users.find((user) => user.id === id); + } + + updateUser(id: string, updatedUser: User) { + const index = this.users.findIndex((user) => user.id === id); + if (index !== -1) { + this.users[index] = updatedUser; + return this.users[index]; + } + return false; + } + + deleteUser(id: string) { + const index = this.users.findIndex((user) => user.id === id); + if (index !== -1) { + this.users.splice(index, 1); + return true; + } + return false; + } + + start() { + const server = http.createServer((req: IncomingMessage, res: ServerResponse) => { + const { method, url } = req; + + const parsedUrl = parse(url || '', true); + const path = parsedUrl.pathname || ''; + + let requestBody = ''; + + req.on('data', (chunk) => { + requestBody += chunk; + }); + + req.on('end', () => { + let response; + res.setHeader('Content-Type', 'application/json'); + + if (method === 'POST' && (path === '/api' || path === '')) { + try { + res.statusCode = 200; + const request = JSON.parse(requestBody); + // console.log(request); + if (request.cmd) { + const cmd = request.cmd; + // console.log(`CMD is [${cmd}]`); + const data = request.data; + switch (cmd) { + case 'USERS': + response = JSON.stringify(this.getUsers()); + break; + case 'USER': + if (!data.id) { + res.statusCode = 400; + response = 'No user ID'; + throw new Error(response); + } + if (!uuid.validate(data.id)) { + res.statusCode = 400; + response = 'User ID is not valid'; + throw new Error(response); + } + // console.log(`User ID: [${data.id}]`); + const user = this.getUserById(data.id); + if (user) { + response = JSON.stringify(user); + // console.log(user); + } + else { + res.statusCode = 404; + response = 'User not found'; + throw new Error(response); + } + break; + case 'ADD': + // console.log('ADD' + data); + if (!data.username || data.username === '' || !data.age) { + res.statusCode = 400; + response = 'User data is not valid'; + throw new Error(response); + } + else { + const hobbies = Array.isArray(data.hobbies) ? data.hobbies : []; + const user = new User(uuid.v4(), data.username, data.age, hobbies); + this.createUser(user); + response = JSON.stringify(user); + res.statusCode = 201; + } + break; + case 'UPDATE': + // console.log('UPDATE' + data); + if (!data.id || !data.username || data.username === '' || !data.age) { + res.statusCode = 400; + response = 'User data is not valid'; + throw new Error(response); + } + if (!uuid.validate(data.id)) { + res.statusCode = 400; + response = 'User ID is not valid'; + throw new Error(response); + } + else { + const hobbies = Array.isArray(data.hobbies) ? data.hobbies : []; + const user = new User(data.id, data.username, data.age, hobbies); + const result = this.updateUser(data.id, user); + if (result) { + response = JSON.stringify(result); + } + else { + res.statusCode = 404; + response = 'User not found'; + throw new Error(response); + } + } + break; + case 'DELETE': + // console.log('DELETE' + data); + if (!data.id) { + res.statusCode = 400; + response = 'No user ID'; + throw new Error(response); + } + if (!uuid.validate(data.id)) { + res.statusCode = 400; + response = 'User ID is not valid'; + throw new Error(response); + } + // console.log(`User ID: [${data.id}]`); + const result = this.deleteUser(data.id); + if (result) { + res.statusCode = 204; + response = 'DELETED'; + } + else { + res.statusCode = 404; + response = 'User not found'; + throw new Error(response); + } + break; + default: + throw new Error(`Unknown command ${cmd}`); + } + } + } + catch (error) { + // console.log("Incorrect request"); + // console.log(error); + // console.log(response); + if (res.statusCode === 200) res.statusCode = 400; + } + // response = userService.getUsers(); + // res.statusCode = 200; + } else { + response = { error: 'Not found' }; + res.statusCode = 404; + } + + res.end(response); + }); + }); + + + server.listen(this.dbPort, () => { + console.log(`DB service is running on port ${this.dbPort}`); + }); + } + + + createTestUsers() { + const json = [{ + id: uuid.v4(), + username: 'John', + age: 25, + hobbies: ['Ski', 'Videogames'], + }, + { + id: 'f8561522-0681-41b4-979b-3b1ef3ae09de',//uuid.v4(), + username: 'Emma', + age: 27, + hobbies: ['Writing', 'Gardening'], + }, + { + id: uuid.v4(), + username: 'Ben', + age: 52, + hobbies: ['Reading', 'Gardening'], + } + ]; + + json.forEach((element) => { + const user: User = User.fromJson(element); + this.createUser(user); + }); + } +} + + +// process.on('SIGTERM', () => { +// process.exit(0); +// }); + +// process.on('exit', () => { +// server.closeAllConnections(); +// console.log("DB service done its work."); +// }); + + +// process.on('error', (error) => { +// console.log(error); +// process.exit(0); +// }); diff --git a/src/entities/User.ts b/src/entities/User.ts new file mode 100644 index 0000000..18e3109 --- /dev/null +++ b/src/entities/User.ts @@ -0,0 +1,18 @@ +export class User { + id: string; + username: string; + age: number; + hobbies: string[]; + + constructor(uuid: string, username: string, age: number, hobbies: string[]) { + this.id = uuid; + this.username = username; + this.age = age; + this.hobbies = hobbies; + } + + static fromJson(json: any): User { + const { id, username, age, hobbies } = json; + return new User(id, username, age, hobbies); + } +}; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..23b7576 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,83 @@ +import cluster, { Worker } from 'cluster'; +import http, { IncomingMessage, ServerResponse } from 'http'; +import { availableParallelism } from 'node:os'; +import process from 'process'; +import child_process, { spawn, ChildProcess } from 'child_process'; +import dotenv from 'dotenv'; +import { DbService } from "./db_service"; +import { UserService } from "./user_service"; + +dotenv.config(); + +// Object.keys(process.env).forEach((key) => { +// if (key.startsWith('CRUD')) +// console.log(`${key}=[${process.env[key]}]`); +// }); + +const numCPUs = availableParallelism() - 1; +let activeChildProcesses = 0; +const masterPort = process.env['CRUD_US_PORT'] ? process.env['CRUD_US_PORT'] : '4000'; +let currentWorkerPort = parseInt(masterPort) + 1; + +if (cluster.isPrimary) { + console.log(`Primary ${process.pid} is running`); + + const dbService = new DbService(); + // dbService.createTestUsers(); + dbService.start(); + + + for (let i = 0; i < numCPUs; i++) { + cluster.fork({ 'CRUD_US_PORT': currentWorkerPort }); + currentWorkerPort++; + } + + cluster.on('exit', (worker: Worker, code: number, signal: string) => { + console.log(`worker ${worker.process.pid} exited with code ${code} and signal ${signal}`); + }); + + cluster.on('listening', (worker, address) => { + console.log( + `A worker is now connected to ${address.address}:${address.port}`); + }); + + const loadBalancer = http.createServer((req: IncomingMessage, res: ServerResponse) => { + const options = { + hostname: 'localhost', + port: 4001, + path: req.url, + method: req.method, + headers: req.headers + }; + + const outgoingReq = http.request(options, (outgoingRes) => { + + if (outgoingRes && outgoingRes.statusCode) { + res.writeHead(outgoingRes.statusCode, outgoingRes.headers); + outgoingRes.pipe(res); + } + else { + res.statusCode = 500; + res.end('Error: Could not resend request'); + } + }); + + outgoingReq.on('error', (error) => { + console.error(error); + res.statusCode = 500; + res.end('Error: Could not resend request'); + }); + + req.pipe(outgoingReq); + }).listen(masterPort); + +} else if (cluster.isWorker) { + + console.log(`Cur port: [${process.env.CRUD_US_PORT}]`); + if (cluster.worker) console.log(`Worker ID: [${cluster.worker.id}]`); + + const userService = new UserService(); + userService.start(); + + console.log(`Worker ${process.pid} started`); +} diff --git a/src/single_mode.ts b/src/single_mode.ts new file mode 100644 index 0000000..b574450 --- /dev/null +++ b/src/single_mode.ts @@ -0,0 +1,17 @@ +import { DbService } from "./db_service"; +import { UserService } from "./user_service"; +import dotenv from 'dotenv'; + +dotenv.config(); + +// Object.keys(process.env).forEach((key) => { +// if (key.startsWith('CRUD')) +// console.log(`${key}=[${process.env[key]}]`); +// }); + +const dbService = new DbService(); +//dbService.createTestUsers(); +dbService.start(); + +const userService = new UserService(); +userService.start(); diff --git a/src/user_service.ts b/src/user_service.ts new file mode 100644 index 0000000..98c589b --- /dev/null +++ b/src/user_service.ts @@ -0,0 +1,309 @@ +import http, { IncomingMessage, ServerResponse, RequestOptions } from 'http'; +import { parse, URL } from 'url'; +import * as uuid from 'uuid'; +import { User } from './entities/User' + + +export class UserService { + usPort: string; + dbHost: string; + dbPort: string; + db_req_options: RequestOptions; + + constructor() { + this.usPort = process.env['CRUD_US_PORT'] ? process.env['CRUD_US_PORT'] : '4000'; + this.dbHost = process.env['CRUD_DB_HOST'] ? process.env['CRUD_DB_HOST'] : 'localhost'; + this.dbPort = process.env['CRUD_DB_PORT'] ? process.env['CRUD_DB_PORT'] : '4100'; + this.db_req_options = { + hostname: this.dbHost, + port: this.dbPort, + path: '/api', + method: 'POST', + }; + } + + + async createUser(user: User) { + return new Promise((resolve, reject) => { + const req = http.request(this.db_req_options, (res) => { + let responseData = ''; + + res.on('data', (chunk) => { + responseData += chunk; + }); + + res.on('end', () => { + console.log(responseData); + if (res.statusCode === 201) { + try { + const newUser = User.fromJson(JSON.parse(responseData)); + console.log(newUser); + resolve(newUser); + } catch (error) { + reject('Incorrect user data'); + } + } + else { + reject('Incorrect user data'); + } + }); + }); + + req.on('error', (error) => { + reject(error); + }); + + req.write(JSON.stringify({ 'cmd': 'ADD', 'data': { 'username': user.username, 'age': user.age, 'hobbies': user.hobbies } })); + req.end(); + }); + } + + + async getUsers() { + return new Promise((resolve, reject) => { + const req = http.request(this.db_req_options, (res) => { + let responseData = ''; + + res.on('data', (chunk) => { + responseData += chunk; + }); + + res.on('end', () => { + console.log(responseData); + const users: User[] = []; + try { + for (const userInfo of Object.values(JSON.parse(responseData))) { + const user = User.fromJson(userInfo); + users.push(user); + console.log(user); + } + resolve(users); + } catch (error) { + reject({ code: 500, message: 'Internal server error: unable to parse data' }); + } + }); + }); + + req.on('error', (error) => { + reject({ code: 500, message: 'Internal server error' }); + }); + + req.write(JSON.stringify({ 'cmd': 'USERS', 'data': '' })); + req.end(); + }); + } + + + async getUserById(id: string) { + return new Promise((resolve, reject) => { + const req = http.request(this.db_req_options, (res) => { + let responseData = ''; + + res.on('data', (chunk) => { + responseData += chunk; + }); + + res.on('end', () => { + if (res.statusCode === 200) { + try { + const user = User.fromJson(JSON.parse(responseData)); + console.log(user); + resolve(user); + } catch (error) { + reject('Incorrect user data'); + } + } + else { + reject('Uer not found'); + } + }); + }); + + req.on('error', (error) => { + reject(error); + }); + req.write(JSON.stringify({ 'cmd': 'USER', 'data': { 'id': id } })); + req.end(); + }); + } + + + async updateUser(id: string, user: User) { + return new Promise((resolve, reject) => { + const req = http.request(this.db_req_options, (res) => { + let responseData = ''; + + res.on('data', (chunk) => { + responseData += chunk; + }); + + res.on('end', () => { + if (res.statusCode === 200) { + try { + const updUser = User.fromJson(JSON.parse(responseData)); + console.log(updUser); + resolve(updUser); + } catch (error) { + reject('Incorrect user data'); + } + } + else { + reject('Uer not found'); + } + }); + }); + + req.on('error', (error) => { + reject(error); + }); + + req.write(JSON.stringify({ 'cmd': 'UPDATE', 'data': { 'id': user.id, 'username': user.username, 'age': user.age, 'hobbies': user.hobbies } })); + req.end(); + }); + } + + + async deleteUser(id: string) { + return new Promise((resolve, reject) => { + const req = http.request(this.db_req_options, (res) => { + let responseData = ''; + + res.on('data', (chunk) => { + responseData += chunk; + }); + + res.on('end', () => { + if (res.statusCode === 204) { + console.log(responseData); + resolve('User deleted'); + } + else { + reject('User not found'); + } + }); + }); + + req.on('error', (error) => { + reject(error); + }); + + req.write(JSON.stringify({ 'cmd': 'DELETE', 'data': { 'id': id } })); + req.end(); + }); + } + + start() { + const server = http.createServer((req: IncomingMessage, res: ServerResponse) => { + const { method, url } = req; + + const parsedUrl = parse(url || '', true); + const path = parsedUrl.pathname || ''; + + let requestBody = ''; + + req.on('data', (chunk) => { + requestBody += chunk; + }); + + req.on('end', () => { + let response; + res.setHeader('Content-Type', 'application/json'); + // console.log(`PATH: [${path}]`); + + if (method === 'GET' && (path === '/api/users' || path === '/api/users/')) { + this.getUsers().then((resolve) => { + res.statusCode = 200; + response = resolve; + res.end(JSON.stringify(response)); + }).catch((reject) => { + // console.log(reject); + res.statusCode = reject.code; + res.end(reject.message); + }); + } else if (method === 'GET' && path.startsWith('/api/users/')) { + const userId = path.split('/')[3]; + if (!userId || !uuid.validate(userId)) { + response = 'User ID is not valid'; + res.statusCode = 400; + res.end(response); + } + else { + this.getUserById(userId).then((resolve) => { + response = resolve; + res.statusCode = 200; + res.end(JSON.stringify(response)); + }).catch((error) => { + response = 'User not found'; + res.statusCode = 404; + res.end(response); + }); + } + } else if (method === 'POST' && path === '/api/users') { + // console.log("POST"); + // console.log(requestBody); + const userInfo = JSON.parse(requestBody); + userInfo.id = ''; + let user = User.fromJson(userInfo); + this.createUser(user).then((resolve) => { + response = resolve; + res.statusCode = 201; + res.end(JSON.stringify(response)); + }).catch((error) => { + response = 'Unables to create user'; + res.statusCode = 400; + res.end(response); + }); + } else if (method === 'PUT' && path.startsWith('/api/users/')) { + // console.log("PUT"); + const userId = path.split('/')[3]; + if (!userId || !uuid.validate(userId)) { + response = 'User ID is not valid'; + res.statusCode = 400; + res.end(response); + } + else { + const userInfo = JSON.parse(requestBody); + userInfo.id = userId; + let user = User.fromJson(userInfo); + this.updateUser(userId, userInfo).then((resolve) => { + response = resolve; + res.statusCode = 200; + res.end(JSON.stringify(response)); + }).catch((error) => { + response = 'Unables to update user'; + res.statusCode = 404; + res.end(response); + }); + } + } else if (method === 'DELETE' && path.startsWith('/api/users/')) { + // console.log("DELETE"); + const userId = path.split('/')[3]; + if (!userId || !uuid.validate(userId)) { + response = 'User ID is not valid'; + res.statusCode = 400; + res.end(response); + } + else { + this.deleteUser(userId).then((resolve) => { + response = 'User deleted successfully'; + res.statusCode = 200; + res.end(response); + }).catch((error) => { + response = 'User not found'; + res.statusCode = 404; + res.end(response); + }); + } + } else { + response = 'Incorrect API entry point'; + res.statusCode = 404; + res.end(response); + } + }); + }); + + server.listen(this.usPort, () => { + console.log(`Users service is running on port ${this.usPort}`); + }); + } +}