├── .gitignore ├── Insomnia_requests.json ├── README.md ├── package-lock.json ├── package.json ├── src ├── database.js ├── middlewares │ └── json.js ├── routes.js ├── server.js └── utils │ ├── build-route-path.js │ └── extract-query-params.js └── streams ├── import-csv.js └── table-tasks.csv /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | db.json -------------------------------------------------------------------------------- /Insomnia_requests.json: -------------------------------------------------------------------------------- 1 | {"_type":"export","__export_format":4,"__export_date":"2024-03-13T02:31:04.296Z","__export_source":"insomnia.desktop.app:v8.6.1","resources":[{"_id":"req_92b34bb7f815489bad518f043a19ef52","parentId":"fld_b244aecb375e4e348e0f8bf411b5f680","modified":1710194422356,"created":1710194303668,"url":"{{ _.BASE_URL }}/{{ _.ROUTE }}/:id/complete","name":"Check Complete","description":"","method":"PATCH","body":{},"parameters":[],"headers":[{"name":"User-Agent","value":"insomnia/8.6.1"}],"authentication":{},"metaSortKey":-1710194303668,"isPrivate":false,"pathParameters":[{"name":"id","value":""}],"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_b244aecb375e4e348e0f8bf411b5f680","parentId":"wrk_b463e35ab75440e1aa0fdd1f7343d9a5","modified":1710294585545,"created":1710193832125,"name":"Tasks","description":"","environment":{"ROUTE":"tasks"},"environmentPropertyOrder":{"&":["ROUTE"]},"metaSortKey":-1710193832125,"_type":"request_group"},{"_id":"wrk_b463e35ab75440e1aa0fdd1f7343d9a5","parentId":null,"modified":1710193816743,"created":1710193816743,"name":"API Tasks","description":"","scope":"collection","_type":"workspace"},{"_id":"req_c24d65d163c547529086d52c444bd9b4","parentId":"fld_b244aecb375e4e348e0f8bf411b5f680","modified":1710206510691,"created":1710194121718,"url":"{{ _.BASE_URL }}/{{ _.ROUTE }}/:id","name":"Update","description":"","method":"PUT","body":{"mimeType":"application/json","text":"{\n\t\"title\": \"Estudar\",\n\t\"description\": \"Estudar para concurso\"\n}"},"parameters":[{"id":"pair_0950e676bbec47ef990278e7adc0d429","name":"","value":"","description":""}],"headers":[{"name":"Content-Type","value":"application/json"},{"name":"User-Agent","value":"insomnia/8.6.1"}],"authentication":{},"metaSortKey":-1710194034358,"isPrivate":false,"pathParameters":[{"name":"id","value":""}],"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_f9cdcd4e11ae4caa9948f8aa9fac800c","parentId":"fld_b244aecb375e4e348e0f8bf411b5f680","modified":1710292843914,"created":1710194034258,"url":"{{ _.BASE_URL }}/{{ _.ROUTE }}","name":"Lists","description":"","method":"GET","body":{},"parameters":[{"id":"pair_643402bec551429b9e38a9095fdd2b75","name":"search","value":"","description":"","disabled":true}],"headers":[{"name":"User-Agent","value":"insomnia/8.6.1"}],"authentication":{},"metaSortKey":-1710194034258,"isPrivate":false,"pathParameters":[],"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_7ccc4d0e5e994d859cd959bc06a28670","parentId":"fld_b244aecb375e4e348e0f8bf411b5f680","modified":1710206307046,"created":1710193839446,"url":"{{ _.BASE_URL }}/{{ _.ROUTE }}","name":"Create","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"title\": \"Estudo\",\n\t\"description\": \"Estudar ingles\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"},{"name":"User-Agent","value":"insomnia/8.6.1"}],"authentication":{},"metaSortKey":-1710193839446,"isPrivate":false,"pathParameters":[],"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"env_10fd51697de04d8d42a2705583009fa2e5b0524b","parentId":"wrk_b463e35ab75440e1aa0fdd1f7343d9a5","modified":1710193889256,"created":1710193816747,"name":"Base Environment","data":{"BASE_URL":"localhost:3333"},"dataPropertyOrder":{"&":["BASE_URL"]},"color":null,"isPrivate":false,"metaSortKey":1710193816747,"_type":"environment"},{"_id":"jar_10fd51697de04d8d42a2705583009fa2e5b0524b","parentId":"wrk_b463e35ab75440e1aa0fdd1f7343d9a5","modified":1710193816749,"created":1710193816749,"name":"Default Jar","cookies":[],"_type":"cookie_jar"}]} -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tasks API 2 | 3 | Criar uma API para realizar o CRUD de suas _tasks_ (tarefas). 4 | 5 | A API deve conter as seguintes funcionalidades: 6 | 7 | - [x] Deve ser possível Criar uma task 8 | - [x] Deve ser possível Listar todas as tasks 9 | - [x] Deve ser possível Atualizar uma task pelo `id` 10 | - [x] Deve ser possível Remover uma task pelo `id` 11 | - [x] Deve ser possível Marcar pelo `id` uma task como completa 12 | - [x] Deve ser possível Importar as tasks em massa por um arquivo CSV 13 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "01-node-introducao", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "01-node-introducao", 9 | "version": "1.0.0", 10 | "license": "ISC", 11 | "dependencies": { 12 | "csv-parse": "^5.5.5" 13 | } 14 | }, 15 | "node_modules/csv-parse": { 16 | "version": "5.5.5", 17 | "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.5.5.tgz", 18 | "integrity": "sha512-erCk7tyU3yLWAhk6wvKxnyPtftuy/6Ak622gOO7BCJ05+TYffnPCJF905wmOQm+BpkX54OdAl8pveJwUdpnCXQ==" 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "01-node-introducao", 3 | "type": "module", 4 | "version": "1.0.0", 5 | "description": "", 6 | "main": "index.js", 7 | "scripts": { 8 | "dev": "node --watch ./src/server.js" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "csv-parse": "^5.5.5" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/database.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises' 2 | 3 | const databasePath = new URL('../db.json', import.meta.url) 4 | 5 | export class Database { 6 | #database = {} 7 | 8 | constructor() { 9 | fs.readFile(databasePath, 'utf8') 10 | .then(data => { 11 | this.#database = JSON.parse(data) 12 | }) 13 | .catch(() => { 14 | this.#persist() 15 | }) 16 | } 17 | 18 | #persist() { 19 | fs.writeFile(databasePath, JSON.stringify(this.#database, null, 2)) 20 | } 21 | 22 | select(table, search) { 23 | let data = this.#database[table] ?? [] 24 | 25 | if (search) { 26 | data = data.filter(row => { 27 | return Object.entries(search).some(([key, value]) => { 28 | if (!value) return true 29 | 30 | return row[key].includes(value) 31 | }) 32 | }) 33 | } 34 | 35 | return data 36 | } 37 | 38 | insert(table, data) { 39 | if (Array.isArray(this.#database[table])) { 40 | this.#database[table].push(data) 41 | } else { 42 | this.#database[table] = [data] 43 | } 44 | 45 | this.#persist() 46 | return data 47 | } 48 | 49 | update(table, id, data) { 50 | const rowIndex = this.#database[table].findIndex(row => row.id === id) 51 | 52 | if (rowIndex > -1) { 53 | const row = this.#database[table][rowIndex] 54 | this.#database[table][rowIndex] = { id, ...row, ...data } 55 | this.#persist() 56 | } 57 | } 58 | 59 | delete(table, id) { 60 | const rowIndex = this.#database[table].findIndex(row => row.id === id) 61 | 62 | if (rowIndex > -1) { 63 | this.#database[table].splice(rowIndex, 1) 64 | this.#persist() 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/middlewares/json.js: -------------------------------------------------------------------------------- 1 | export async function json(req, res) { 2 | const buffers = [] 3 | 4 | for await (const chunk of req) { 5 | buffers.push(chunk) 6 | } 7 | 8 | try { 9 | req.body = JSON.parse(Buffer.concat(buffers).toString()) 10 | } catch { 11 | req.body = null 12 | } 13 | 14 | res.setHeader('Content-type', 'application/json') 15 | } 16 | -------------------------------------------------------------------------------- /src/routes.js: -------------------------------------------------------------------------------- 1 | import { Database } from './database.js' 2 | import { randomUUID } from 'node:crypto' 3 | import { buildRoutePath } from './utils/build-route-path.js' 4 | 5 | const database = new Database() 6 | 7 | export const routes = [ 8 | { 9 | method: 'GET', 10 | path: buildRoutePath('/tasks'), 11 | handler: (req, res) => { 12 | const { search } = req.query 13 | 14 | const tasks = database.select('tasks', { 15 | title: search, 16 | description: search 17 | }) 18 | 19 | return res.end(JSON.stringify(tasks)) 20 | } 21 | }, 22 | { 23 | method: 'POST', 24 | path: buildRoutePath('/tasks'), 25 | handler: (req, res) => { 26 | const { title, description } = req.body 27 | 28 | if (!title) { 29 | return res 30 | .writeHead(400) 31 | .end(JSON.stringify({ message: 'Title is required!' })) 32 | } 33 | 34 | if (!description) { 35 | return res 36 | .writeHead(400) 37 | .end(JSON.stringify({ message: 'Description is required!' })) 38 | } 39 | 40 | const task = { 41 | id: randomUUID(), // UUID => Unique Universal ID 42 | title, 43 | description, 44 | completed_at: null, 45 | created_at: new Date(), 46 | updated_at: new Date() 47 | } 48 | database.insert('tasks', task) 49 | 50 | return res.writeHead(201).end() 51 | } 52 | }, 53 | { 54 | method: 'PUT', 55 | path: buildRoutePath('/tasks/:id'), 56 | handler: (req, res) => { 57 | const { id } = req.params 58 | const { title, description } = req.body 59 | 60 | if (!title && !description) { 61 | return res 62 | .writeHead(400) 63 | .end( 64 | JSON.stringify({ message: 'Title or description are required!' }) 65 | ) 66 | } 67 | 68 | const [tasksData] = database.select('tasks', { 69 | id 70 | }) 71 | 72 | if (!tasksData) { 73 | return res.writeHead(404).end() 74 | } 75 | 76 | // const { created_at, completed_at } = tasksData[0] 77 | 78 | database.update('tasks', id, { 79 | title: title ?? tasksData.title, 80 | description: description ?? tasksData.description, 81 | updated_at: new Date() 82 | }) 83 | 84 | return res.writeHead(204).end() 85 | } 86 | }, 87 | { 88 | method: 'PATCH', 89 | path: buildRoutePath('/tasks/:id/complete'), 90 | handler: (req, res) => { 91 | const { id } = req.params 92 | 93 | const tasksData = database.select('tasks', { id }) 94 | const { title, description, created_at } = tasksData[0] 95 | 96 | database.update('tasks', id, { 97 | title, 98 | description, 99 | completed_at: new Date(), 100 | created_at, 101 | updated_at: new Date() 102 | }) 103 | 104 | return res.writeHead(204).end() 105 | } 106 | }, 107 | { 108 | method: 'DELETE', 109 | path: buildRoutePath('/tasks/:id'), 110 | handler: (req, res) => { 111 | const { id } = req.params 112 | database.delete('tasks', id) 113 | 114 | return res.writeHead(204).end() 115 | } 116 | } 117 | ] 118 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | import http from 'node:http' 2 | import { json } from './middlewares/json.js' 3 | import { routes } from './routes.js' 4 | import { extractQueryParams } from './utils/extract-query-params.js' 5 | 6 | const server = http.createServer(async (req, res) => { 7 | const { method, url } = req 8 | 9 | await json(req, res) 10 | 11 | const route = routes.find(route => { 12 | return route.method === method && route.path.test(url) 13 | }) 14 | 15 | if (route) { 16 | const routeParams = req.url.match(route.path) 17 | const { query, ...params } = routeParams.groups 18 | 19 | req.params = params 20 | req.query = query ? extractQueryParams(query) : {} 21 | 22 | return route.handler(req, res) 23 | } 24 | 25 | return res.writeHead(404).end() 26 | }) 27 | 28 | server.listen(3333) 29 | -------------------------------------------------------------------------------- /src/utils/build-route-path.js: -------------------------------------------------------------------------------- 1 | export function buildRoutePath(path) { 2 | const routeParametersRegex = /:([a-zA-Z]+)/g 3 | const pathWithParams = path.replaceAll(routeParametersRegex, '(?<$1>[a-z0-9\-_]+)') 4 | 5 | const pathRegex = new RegExp(`^${pathWithParams}(?\\?(.*))?$`) 6 | return pathRegex 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/extract-query-params.js: -------------------------------------------------------------------------------- 1 | export function extractQueryParams(query) { 2 | return query 3 | .substr(1) 4 | .split('&') 5 | .reduce((queryParams, param) => { 6 | const [key, value] = param.split('=') 7 | 8 | queryParams[key] = value 9 | 10 | return queryParams 11 | }, {}) 12 | } 13 | -------------------------------------------------------------------------------- /streams/import-csv.js: -------------------------------------------------------------------------------- 1 | import { parse } from 'csv-parse' 2 | import fs from 'node:fs' 3 | 4 | const csvPath = new URL('./table-tasks.csv', import.meta.url) 5 | 6 | const stream = fs.createReadStream(csvPath) 7 | 8 | const csvParse = parse({ 9 | delimiter: ',', 10 | skipEmptyLines: true, 11 | fromLine: 2 // skip the header line 12 | }) 13 | 14 | async function run() { 15 | const linesParse = stream.pipe(csvParse) 16 | let indexTableRow = 0 17 | 18 | for await (const line of linesParse) { 19 | const [title, description] = line 20 | 21 | await fetch('http://localhost:3333/tasks', { 22 | method: 'POST', 23 | headers: { 24 | 'Content-Type': 'application/json' 25 | }, 26 | body: JSON.stringify({ 27 | title, 28 | description 29 | }) 30 | }) 31 | 32 | // Import working in slow motion 33 | console.log(`Sendding CSV: ${++indexTableRow} rows`) 34 | await wait(1000) 35 | } 36 | } 37 | 38 | run() 39 | 40 | function wait(ms) { 41 | return new Promise(resolve => setTimeout(resolve, ms)) 42 | } 43 | -------------------------------------------------------------------------------- /streams/table-tasks.csv: -------------------------------------------------------------------------------- 1 | title,description 2 | Task 01,Descrição da Task 01 3 | Task 02,Descrição da Task 02 4 | Task 03,Descrição da Task 03 5 | Task 04,Descrição da Task 04 6 | Task 05,Descrição da Task 05 --------------------------------------------------------------------------------