diff --git a/index.js b/index.js new file mode 100644 index 0000000..e2cb778 --- /dev/null +++ b/index.js @@ -0,0 +1,13 @@ +import server from "./src/server.js"; + +const PORT = process.env.PORT || 3000; +const HOST = process.env.HOST || "127.0.0.1"; + +server.listen(PORT, HOST, () => { + console.log(`Todo API running at http://${HOST}:${PORT}/`); + console.log("Available endpoints:"); + console.log(" GET /todos - Get all todos"); + console.log(" POST /todos - Create todo"); + console.log(" PUT /todos/:id - Update todo"); + console.log(" DELETE /todos/:id - Delete todo"); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..79c1257 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "nodeapi-template", + "version": "1.0.0", + "description": "Basic node.js API template", + "main": "index.js", + "scripts": { + "start": "node index.js", + "dev": "node --watch index.js" + }, + "type": "module" +} diff --git a/src/controllers/todoController.js b/src/controllers/todoController.js new file mode 100644 index 0000000..51b2cf2 --- /dev/null +++ b/src/controllers/todoController.js @@ -0,0 +1,111 @@ +import { sendJSON, parseBody } from "../utils/helpers.js"; +import { + getTodos, + addTodo, + updateTodoById, + deleteTodoById, +} from "../data/todos.js"; + +/** + * @typedef {Object} Todo + * @property {number} id - Todo unique identifier + * @property {string} text - Todo text content + * @property {boolean} completed - Todo completion status + */ + +/** + * @typedef {Object} CreateTodoRequest + * @property {string} text - Todo text content + */ + +/** + * @typedef {Object} UpdateTodoRequest + * @property {string} [text] - Todo text content + * @property {boolean} [completed] - Todo completion status + */ + +/** + * @typedef {Object} RequestWithParams + * @property {Object} params - Request parameters + * @property {string} params.id - Todo ID from URL + */ + +/** + * Get all todos + * @param {import('http').IncomingMessage} req - HTTP request object + * @param {import('http').ServerResponse} res - HTTP response object + * @returns {void} + */ +export const getAllTodos = (req, res) => { + /** @type {Todo[]} */ + const todos = getTodos(); + sendJSON(res, 200, todos); +}; + +/** + * Create a new todo + * @param {import('http').IncomingMessage} req - HTTP request object + * @param {import('http').ServerResponse} res - HTTP response object + * @returns {void} + */ +export const createTodo = (req, res) => { + parseBody(req, (error, data) => { + if (error) { + return sendJSON(res, 400, { error: "Invalid JSON" }); + } + + /** @type {CreateTodoRequest} */ + const todoData = data; + + if (!todoData.text) { + return sendJSON(res, 400, { error: "Text is required" }); + } + + /** @type {Todo} */ + const newTodo = addTodo(todoData.text); + sendJSON(res, 201, newTodo); + }); +}; + +/** + * Update an existing todo + * @param {import('http').IncomingMessage & RequestWithParams} req - HTTP request object with params + * @param {import('http').ServerResponse} res - HTTP response object + * @returns {void} + */ +export const updateTodo = (req, res) => { + parseBody(req, (error, data) => { + if (error) { + return sendJSON(res, 400, { error: "Invalid JSON" }); + } + + /** @type {UpdateTodoRequest} */ + const updateData = data; + + /** @type {Todo | null} */ + const updatedTodo = updateTodoById(req.params.id, updateData); + + if (updatedTodo) { + sendJSON(res, 200, updatedTodo); + } else { + sendJSON(res, 404, { error: "Todo not found" }); + } + }); +}; + +/** + * Delete a todo + * @param {import('http').IncomingMessage & RequestWithParams} req - HTTP request object with params + * @param {import('http').ServerResponse} res - HTTP response object + * @returns {void} + */ +export const deleteTodo = (req, res) => { + /** @type {boolean} */ + const deleted = deleteTodoById(req.params.id); + + if (deleted) { + sendJSON(res, 200, { message: "Todo deleted" }); + } else { + sendJSON(res, 404, { error: "Todo not found" }); + } +}; diff --git a/src/data/todos.js b/src/data/todos.js new file mode 100644 index 0000000..f6e7ca7 --- /dev/null +++ b/src/data/todos.js @@ -0,0 +1,72 @@ +/** + * @typedef {Object} Todo + * @property {number} id - Todo unique identifier + * @property {string} text - Todo text content + * @property {boolean} completed - Todo completion status + */ + +/** + * @typedef {Object} TodoUpdate + * @property {string} [text] - Todo text content + * @property {boolean} [completed] - Todo completion status + */ + +/** + * In-memory storage for todos + * @type {Todo[]} + */ +let todos = [ + { id: 1, text: "Learn Node.js", completed: false }, + { id: 2, text: "Build an API", completed: true }, +]; + +/** + * Get all todos + * @returns {Todo[]} Array of all todos + */ +export const getTodos = () => todos; + +/** + * Add a new todo + * @param {string} text - Todo text content + * @returns {Todo} The newly created todo + */ +export const addTodo = (text) => { + /** @type {Todo} */ + const newTodo = { + id: todos.length > 0 ? Math.max(...todos.map((t) => t.id)) + 1 : 1, + text, + completed: false, + }; + todos.push(newTodo); + return newTodo; +}; + +/** + * Update a todo by ID + * @param {string} id - Todo ID to update + * @param {TodoUpdate} updates - Properties to update + * @returns {Todo | null} Updated todo or null if not found + */ +export const updateTodoById = (id, updates) => { + /** @type {number} */ + const todoIndex = todos.findIndex((todo) => todo.id === parseInt(id)); + if (todoIndex === -1) return null; + + todos[todoIndex] = { ...todos[todoIndex], ...updates }; + return todos[todoIndex]; +}; + +/** + * Delete a todo by ID + * @param {string} id - Todo ID to delete + * @returns {boolean} True if deleted, false if not found + */ +export const deleteTodoById = (id) => { + /** @type {number} */ + const todoIndex = todos.findIndex((todo) => todo.id === parseInt(id)); + if (todoIndex === -1) return false; + + todos.splice(todoIndex, 1); + return true; +}; diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..b43ac72 --- /dev/null +++ b/src/server.js @@ -0,0 +1,83 @@ +import http from "node:http"; +import url from "node:url"; +import { sendJSON, setCorsHeaders } from "./utils/helpers.js"; +import { + getAllTodos, + createTodo, + updateTodo, + deleteTodo, +} from "./controllers/todoController.js"; + +/** + * @typedef {Object} ApiEndpoints + * @property {string} "GET /todos" - Get all todos + * @property {string} "POST /todos" - Create todo + * @property {string} "PUT /todos/:id" - Update todo + * @property {string} "DELETE /todos/:id" - Delete todo + */ + +/** + * @typedef {Object} RootResponse + * @property {string} message - API name + * @property {string} version - API version + * @property {ApiEndpoints} endpoints - Available endpoints + */ + +/** + * @typedef {Object} ErrorResponse + * @property {string} error - Error message + */ + +/** + * HTTP server that handles Todo API requests + * @type {http.Server} + */ +const server = http.createServer((req, res) => { + /** @type {url.UrlWithParsedQuery} */ + const parsedUrl = url.parse(req.url, true); + /** @type {string} */ + const path = parsedUrl.pathname; + /** @type {string} */ + const method = req.method; + + setCorsHeaders(res); + + if (method === "OPTIONS") { + res.statusCode = 200; + res.end(); + return; + } + + // Routes + if (path === "/" && method === "GET") { + // Root route + sendJSON(res, 200, { + message: "Todo API", + version: "1.0.0", + endpoints: { + "GET /todos": "Get all todos", + "POST /todos": "Create todo", + "PUT /todos/:id": "Update todo", + "DELETE /todos/:id": "Delete todo", + }, + }); + } else if (path === "/todos" && method === "GET") { + getAllTodos(req, res); + } else if (path === "/todos" && method === "POST") { + createTodo(req, res); + } else if (path.match(/^\/todos\/\d+$/) && method === "PUT") { + /** @type {string} */ + const id = path.split("/")[2]; + req.params = { id }; + updateTodo(req, res); + } else if (path.match(/^\/todos\/\d+$/) && method === "DELETE") { + /** @type {string} */ + const id = path.split("/")[2]; + req.params = { id }; + deleteTodo(req, res); + } else { + sendJSON(res, 404, { error: "Route not found" }); + } +}); + +export default server; diff --git a/src/utils/helpers.js b/src/utils/helpers.js new file mode 100644 index 0000000..801a383 --- /dev/null +++ b/src/utils/helpers.js @@ -0,0 +1,53 @@ +/** + * @callback ParseBodyCallback + * @param {Error | null} error - Parse error if any + * @param {Object} data - Parsed JSON data + * @returns {void} + */ + +/** + * Set CORS headers on response + * @param {import('http').ServerResponse} res - HTTP response object + * @returns {void} + */ +export const setCorsHeaders = (res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); +}; + +/** + * Send JSON response + * @param {import('http').ServerResponse} res - HTTP response object + * @param {number} statusCode - HTTP status code + * @param {Object | Array} data - Data to send as JSON + * @returns {void} + */ +export const sendJSON = (res, statusCode, data) => { + res.statusCode = statusCode; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(data)); +}; + +/** + * Parse request body as JSON + * @param {import('http').IncomingMessage} req - HTTP request object + * @param {ParseBodyCallback} callback - Callback function with parsed data or error + * @returns {void} + */ +export const parseBody = (req, callback) => { + /** @type {string} */ + let body = ""; + req.on("data", (chunk) => { + body += chunk.toString(); + }); + req.on("end", () => { + try { + /** @type {Object} */ + const data = body ? JSON.parse(body) : {}; + callback(null, data); + } catch (error) { + callback(error); + } + }); +};