HTTP Methods and CRUD
Each HTTP method maps to a specific operation in RESTful APIs:
| Method | CRUD Operation | Idempotent? | Safe? | Body? |
|---|---|---|---|---|
| GET | Read | Yes | Yes | No |
| POST | Create | No | No | Yes |
| PUT | Replace/Update | Yes | No | Yes |
| PATCH | Partial Update | No | No | Yes |
| DELETE | Delete | Yes | No | Optional |
- Idempotent: Multiple identical requests produce the same result
- Safe: Request does not modify server state
Building a Complete CRUD API
Letβs build a task management API with in-memory storage that demonstrates all HTTP methods properly:
const http = require('http');
// ββ In-Memory Database ββ
let tasks = [
{ id: 1, title: 'Learn Node.js', completed: false },
{ id: 2, title: 'Build an API', completed: true },
];
let nextId = 3;
// ββ Router ββ
const routes = {
'GET /tasks': listTasks,
'GET /tasks/:id': getTask,
'POST /tasks': createTask,
'PUT /tasks/:id': updateTask,
'DELETE /tasks/:id': deleteTask,
};
// ββ Server ββ
const server = http.createServer(async (req, res) => {
// Enable JSON helpers on res
res.json = (data, status = 200) => {
const json = JSON.stringify(data);
res.writeHead(status, {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(json),
});
res.end(json);
};
try {
await handleRequest(req, res);
} catch (err) {
console.error('Unhandled error:', err);
res.json({ error: 'Internal server error' }, 500);
}
});
async function handleRequest(req, res) {
const parsedUrl = new URL(req.url, `http://${req.headers.host}`);
const pathname = parsedUrl.pathname;
const method = req.method;
// OPTIONS handler for CORS
if (method === 'OPTIONS') {
res.writeHead(204, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
'Access-Control-Allow-Headers': 'Content-Type',
});
res.end();
return;
}
// ββ Route Matching (supports :id param) ββ
for (const [routeKey, handler] of Object.entries(routes)) {
const [routeMethod, routePath] = routeKey.split(' ');
if (routeMethod !== method) continue;
const routeParts = routePath.split('/');
const pathParts = pathname.split('/');
if (routeParts.length !== pathParts.length) continue;
const params = {};
let match = true;
for (let i = 0; i < routeParts.length; i++) {
if (routeParts[i].startsWith(':')) {
params[routeParts[i].slice(1)] = pathParts[i];
} else if (routeParts[i] !== pathParts[i]) {
match = false;
break;
}
}
if (match) {
req.params = params;
req.query = parsedUrl.searchParams;
await handler(req, res);
return;
}
}
// No route matched
res.json({ error: `Route ${method} ${pathname} not found` }, 404);
}
GET β Listing Resources
async function listTasks(req, res) {
// Support query filtering
const completed = req.query.get('completed');
let result = tasks;
if (completed === 'true') {
result = tasks.filter(t => t.completed);
} else if (completed === 'false') {
result = tasks.filter(t => !t.completed);
}
// Support search query
const search = req.query.get('q');
if (search) {
const term = search.toLowerCase();
result = result.filter(t => t.title.toLowerCase().includes(term));
}
// Support pagination
const page = parseInt(req.query.get('page')) || 1;
const limit = Math.min(parseInt(req.query.get('limit')) || 10, 100);
const start = (page - 1) * limit;
const paginated = result.slice(start, start + limit);
res.json({
data: paginated,
pagination: {
page,
limit,
total: result.length,
totalPages: Math.ceil(result.length / limit),
},
});
}
Testing:
# List all tasks
curl http://localhost:3000/tasks
# Filter by completion
curl http://localhost:3000/tasks?completed=false
# Search
curl http://localhost:3000/tasks?q=learn
# Pagination
curl "http://localhost:3000/tasks?page=1&limit=5"
GET β Single Resource
async function getTask(req, res) {
const id = parseInt(req.params.id);
const task = tasks.find(t => t.id === id);
if (!task) {
return res.json({ error: 'Task not found' }, 404);
}
res.json({ data: task });
}
Testing:
curl http://localhost:3000/tasks/1
# {"data":{"id":1,"title":"Learn Node.js","completed":false}}
curl http://localhost:3000/tasks/999
# {"error":"Task not found"} (status 404)
GET Status Codes
| Scenario | Status Code |
|---|---|
| Resource found | 200 OK |
| Resource not found | 404 Not Found |
| Invalid ID format | 400 Bad Request |
POST β Creating Resources
async function createTask(req, res) {
// Read request body
const body = await readBody(req);
// Validate required fields
if (!body.title || typeof body.title !== 'string') {
return res.json({
error: 'Validation failed',
details: { title: 'Title is required and must be a string' },
}, 400);
}
// Trim and validate length
const title = body.title.trim();
if (title.length < 1 || title.length > 200) {
return res.json({
error: 'Validation failed',
details: { title: 'Title must be between 1 and 200 characters' },
}, 400);
}
// Create the resource
const task = {
id: nextId++,
title,
completed: body.completed === true, // Default to false
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
tasks.push(task);
// 201 Created β include Location header
res.writeHead(201, {
'Content-Type': 'application/json',
'Location': `/tasks/${task.id}`,
});
res.end(JSON.stringify({ data: task }));
}
// Helper: read JSON body
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
let totalSize = 0;
const maxSize = 1024 * 100; // 100KB limit
req.on('data', (chunk) => {
totalSize += chunk.length;
if (totalSize > maxSize) {
reject(new Error('Request body too large'));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => {
try {
const body = JSON.parse(Buffer.concat(chunks).toString());
resolve(body);
} catch (err) {
reject(new Error('Invalid JSON'));
}
});
req.on('error', reject);
});
}
Testing:
curl -X POST http://localhost:3000/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Write tests", "completed": false}'
# 201 Created
# {"data":{"id":3,"title":"Write tests","completed":false,"createdAt":"...","updatedAt":"..."}}
# Validation error
curl -X POST http://localhost:3000/tasks \
-H "Content-Type: application/json" \
-d '{}'
# 400 Bad Request
# {"error":"Validation failed","details":{"title":"Title is required..."}}
POST Status Codes
| Scenario | Status Code |
|---|---|
| Resource created | 201 Created |
| Invalid input | 400 Bad Request |
| Missing required fields | 422 Unprocessable Entity |
Why 201 and not 200? 201 specifically means βCreatedβ. It tells the client that a new resource was created as a result of this request. Include a
Locationheader pointing to the new resource.
PUT β Replacing/Updating Resources
PUT is idempotent β calling it multiple times should have the same effect as calling it once. It typically replaces the entire resource:
async function updateTask(req, res) {
const id = parseInt(req.params.id);
const body = await readBody(req);
const index = tasks.findIndex(t => t.id === id);
if (index === -1) {
return res.json({ error: 'Task not found' }, 404);
}
// PUT replaces the resource β require all fields
if (!body.title || typeof body.title !== 'string') {
return res.json({
error: 'Validation failed',
details: { title: 'Title is required' },
}, 400);
}
// Replace the entire resource
tasks[index] = {
id,
title: body.title.trim(),
completed: body.completed === true,
createdAt: tasks[index].createdAt, // Preserve original creation time
updatedAt: new Date().toISOString(),
};
res.json({ data: tasks[index] });
}
Testing:
# Replace entire resource
curl -X PUT http://localhost:3000/tasks/1 \
-H "Content-Type: application/json" \
-d '{"title": "Master Node.js streams", "completed": true}'
# 200 OK
# If you omit a field, it's gone (PUT replaces everything)
curl -X PUT http://localhost:3000/tasks/1 \
-H "Content-Type: application/json" \
-d '{"title": "Just title"}'
# completed defaults to false (it was replaced!)
PUT vs PATCH: PUT replaces the entire resource. If you send only
{ "title": "new" }, thecompletedfield disappears. PATCH (partial update) only modifies the fields you send. Use PATCH for partial updates; use PUT for full replacements.
PUT Status Codes
| Scenario | Status Code |
|---|---|
| Resource updated | 200 OK |
| Resource created (new ID) | 201 Created |
| Resource not found | 404 Not Found |
| Invalid data | 400 Bad Request |
DELETE β Removing Resources
async function deleteTask(req, res) {
const id = parseInt(req.params.id);
const index = tasks.findIndex(t => t.id === id);
if (index === -1) {
return res.json({ error: 'Task not found' }, 404);
}
// Remove the resource
const deleted = tasks.splice(index, 1)[0];
// Log the deletion
console.log(`Deleted task ${id}: "${deleted.title}"`);
// 200 with body, or 204 No Content
res.json({ data: deleted, message: 'Task deleted' });
}
curl -X DELETE http://localhost:3000/tasks/1
# {"data":{"id":1,"title":"Master Node.js streams","completed":true},"message":"Task deleted"}
curl -X DELETE http://localhost:3000/tasks/999
# {"error":"Task not found"} (status 404)
DELETE Status Codes
| Scenario | Status Code |
|---|---|
| Resource deleted | 200 OK (with body) or 204 No Content |
| Resource not found | 404 Not Found |
| Resource exists but canβt be deleted | 409 Conflict |
204 vs 200 for DELETE: Some APIs return 204 No Content (no body) because the resource is gone and thereβs nothing to return. Others return 200 with a confirmation body. Both are valid β choose one and be consistent.
Handling Edge Cases
Method Not Allowed
Return 405 when the route exists but the method is wrong:
if (!match) {
// Check if the path exists with a different method
const allowedMethods = findAllowedMethods(pathname);
if (allowedMethods.length > 0) {
res.writeHead(405, { 'Allow': allowedMethods.join(', ') });
res.json({ error: `Method ${method} not allowed. Allowed: ${allowedMethods.join(', ')}` });
} else {
res.json({ error: 'Not found' }, 404);
}
}
function findAllowedMethods(pathname) {
const methods = [];
for (const key of Object.keys(routes)) {
const [routeMethod, routePath] = key.split(' ');
if (routePath === pathname) methods.push(routeMethod);
}
return methods;
}
curl -X DELETE http://localhost:3000/tasks
# 405 Method Not Allowed
# Allow: GET, POST
Idempotency in Practice
// DELETE is idempotent: deleting the same resource twice
// First call: 200 OK
DELETE /tasks/1 β 200
// Second call: 404 (already gone) β still idempotent
DELETE /tasks/1 β 404
// The server state after both calls is the same
// PUT is idempotent: same PUT multiple times
PUT /tasks/1 { title: "Fixed", completed: true }
// First: 200, title = "Fixed", completed = true
// Second: 200, title = "Fixed", completed = true
// Third: 200, title = "Fixed", completed = true
// Result is identical every time
Complete Server with All Methods
const http = require('http');
const server = http.createServer(async (req, res) => {
// CORS
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
// JSON helper
res.json = (data, status = 200) => {
const json = JSON.stringify(data);
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(json);
};
// Simple router
const url = new URL(req.url, `http://${req.headers.host}`);
const path = url.pathname;
const method = req.method;
try {
if (path === '/tasks') {
if (method === 'GET') return await listTasks(req, res);
if (method === 'POST') return await createTask(req, res);
// Method not allowed
return res.json({ error: 'Method not allowed' }, 405);
}
const match = path.match(/^\/tasks\/(\d+)$/);
if (match) {
req.params = { id: parseInt(match[1]) };
if (method === 'GET') return await getTask(req, res);
if (method === 'PUT') return await updateTask(req, res);
if (method === 'DELETE') return await deleteTask(req, res);
return res.json({ error: 'Method not allowed' }, 405);
}
res.json({ error: 'Not found' }, 404);
} catch (err) {
console.error('Error:', err.message);
res.json({ error: err.message }, 400);
}
});
server.listen(3000);
Key Takeaways
- GET β read resources (safe, idempotent). Never modify state. Support filtering, pagination.
- POST β create resources (not idempotent). Return 201 Created with a Location header. Always validate input.
- PUT β replace entire resources (idempotent). Require all fields. Missing fields = removed data.
- DELETE β remove resources (idempotent). Return 200 or 204. Second call returns 404.
- Always return proper HTTP status codes β 200, 201, 204, 400, 404, 405, 422, 500
- Send Location headers on creation so clients know the new resource URL
- Validate input β check types, lengths, required fields before processing
- Handle OPTIONS for CORS preflight when serving browser clients
- Return 405 Method Not Allowed with an
Allowheader when the method is wrong but the path exists