Progettare e realizzare un server HTTP RESTful in Express per l’applicazione di esempio HeapOverrun, definendo le API necessarie alla gestione di domande e risposte, integrandole con il database SQLite costruito nell’esercizio 4.
Progettazione delle risorse e degli endpoint
question (collezione /api/questions)answer (collezione dipendente /api/questions/:qid/answers)Descrizione formale delle API (README.md)
**# `qa-server`
The `qa-server` is the server-side app companion for HeapOverrun. It presents some APIs to perform some CRUD operations on questions and their answers.
## APIs
Hereafter, we report the designed HTTP APIs, also implemented in the project.
### Lista di tutte le domande
URL: `/api/questions`
HTTP Method: GET
Description: Restituisce l'intera collezione di domande in ordine cronologico decrescente.
Request body: None
Response: 200
Response body:
```json
[
{
"id": 1,
"text": "Che cos'è una closure?",
"authorId": 3,
"author": "[email protected]",
"date": "2025-06-28"
},
{
"id": 2,
"text": "Differenza tra let e var?",
"authorId": 5,
"author": "[email protected]",
"date": "2025-06-27"
}
]
URL: /api/questions/:qid
HTTP Method: GET
Description: Restituisce la domanda identificata da :qid.
Request body: None
Response: 200
Response body:
{
"id": 1,
"text": "Che cos'è una closure?",
"authorId": 3,
"author": "[email protected]",
"date": "2025-06-28"
}
URL: /api/questions
HTTP Method: POST
Description: Inserisce una nuova domanda nella piattaforma
Request body:
{
"text": "Differenza tra == e ===?",
"authorId": 1
}
Response: 201
Response body:
{
"id": 42
}
URL: /api/questions/:qid/answers
HTTP Method: GET
Description: Restituisce tutte le risposte associate alla domanda :qid.
Request body: None
Response: 200
Response body:
[
{
"id": 7,
"questionId": 1,
"text": "Una closure è una funzione che...",
"authorId": 4,
"author": "[email protected]",
"date": "2025-06-29",
"score": 5
},
{
"id": 9,
"questionId": 1,
"text": "In pratica una closure consente...",
"authorId": 2,
"author": "[email protected]",
"date": "2025-06-29",
"score": 2
}
]
URL: /api/questions/:qid/answers
HTTP Method: POST
Description: Consente di aggiungere una nuova risposta alla domanda :qid.
Request body:
{
"text": "Una closure è...",
"authorId": 3
}
Response: 201
Response body:
{
"id": 15
}
URL: /api/answers/:aid
HTTP Method: PUT
Description: Aggiorna il contenuto testuale della risposta :aid.
Request body:
{
"text": "Testo aggiornato"
}
Response: 200
Response body: None
URL: /api/answers/:aid/vote
HTTP Method: POST
Description: Incrementa (up) o decrementa (down) il punteggio della risposta :aid.
Request body:
{
"vote": "up"
}
Response: 200
Response body:
{
"score": 6
}
```**
Per ogni voce si specifica lo schema di risposta.
Completamento del Data Access Object (dao.mjs)
/* Data Access Object (DAO) module for accessing Q&A */
/* Initial version taken from exercise 4 (week 03) */
import sqlite from 'sqlite3';
import { Question, Answer } from './QAModels.mjs';
// open the database
const db = new sqlite.Database('questions.sqlite', (err) => {
if (err) throw err;
});
/** QUESTIONS **/
// get all the questions
export const listQuestions = () => new Promise((res, rej) => {
const sql = "SELECT q.*, u.email FROM question q JOIN user u ON q.authorId = u.id ORDER BY q.date DESC";
db.all(sql, [], (err, rows) => {
if (err)
rej(err);
else
res(rows.map(r => new Question(r.id, r.text, r.email, r.authorId, r.date)));
});
});
// get a question given its id
export const getQuestion = (id) => {
return new Promise ((resolve, reject) => {
const sql = 'SELECT question.*, user.email FROM question JOIN user ON question.authorId = user.id WHERE question.id = ?';
db.get(sql, [id], (err, row) => {
if (err) {
reject(err);
} else if (row === undefined) {
resolve('Question not available, check the inserted id.');
} else {
resolve(new Question(row.id, row.text, row.email, row.authorId, row.date));
}
});
});
}
// add a new question
export const addQuestion = (question) => {
return new Promise((resolve, reject) => {
const sql = 'INSERT INTO question(text, authorId, date) VALUES (?,?,?)';
db.run(sql, [question.text, question.userId, question.date], function(err) {
if (err)
reject(err);
else
resolve(this.lastID);
});
});
}
/** ANSWERS **/
// get all the answer of a given question
export const listAnswersOf = (questionId) => {
return new Promise ((resolve, reject) => {
const sql = 'SELECT answer.*, user.email FROM answer JOIN user ON answer.authorId = user.id WHERE answer.questionId = ?';
db.all(sql, [questionId], (err, rows) => {
if (err) {
reject(err);
} else {
const answers = rows.map((ans) => new Answer(ans.id, ans.text, ans.email, ans.authorId, ans.date, ans.score));
resolve(answers);
}
});
});
}
// add a new answer
export const addAnswer = (answer, questionId) => {
return new Promise((resolve, reject) => {
const sql = 'INSERT INTO answer(text, authorId, date, score, questionId) VALUES (?, ?, ?, ?, ?)';
db.run(sql, [answer.text, answer.userId, answer.date, answer.score, questionId], function (err) {
if (err)
reject(err);
else
resolve(this.lastID);
});
});
}
// update an existing answer
export const updateAnswer = (answer) => new Promise((res, rej) => {
const sql = "UPDATE answer SET text = ?, date = ? WHERE id = ?";
db.run(sql, [answer.text, answer.date, answer.id], function(err) {
if (err)
rej(err)
else
res(this.changes);
});
});
// vote for an answer
export const voteAnswer = (answerId, vote) => {
return new Promise((resolve, reject) => {
const sql = 'UPDATE answer SET score = score + ? WHERE id= ?';
const delta = vote === 'up' ? 1 : -1;
db.run(sql, [delta, answerId], function(err) {
if (err)
reject(err);
else
resolve(this.changes);
});
});
}
Query scritte secondo le buone pratiche sui prepared-statement.
Implementazione del server (index.mjs)
import express from 'express';
import morgan from 'morgan';
import dayjs from 'dayjs';
import { body, param, validationResult } from 'express-validator';
import * as dao from './dao.mjs';
/***************************************************
* HeapOverrun - Q&A API Server
* -------------------------------------------------
* Complete implementation of the REST endpoints
* described in README.md
*
* End-points
* GET /api/questions
* GET /api/questions/:qid
* POST /api/questions
* GET /api/questions/:qid/answers
* POST /api/questions/:qid/answers
* PUT /api/answers/:aid
* POST /api/answers/:aid/vote { vote: "up" | "down" }
***************************************************/
const app = express();
const PORT = 3001;
/* -------------------- MIDDLEWARES -------------------- */
app.use(morgan('dev')); // HTTP request logging
app.use(express.json()); // parse application/json bodies
// Utility: validate request and stop on first error
const checkValidation = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty())
return res.status(422).json({ errors: errors.array() });
next();
};
/* --------------------- QUESTIONS --------------------- */
// GET /api/questions - list all questions
app.get('/api/questions', async (req, res, next) => {
try {
const questions = await dao.listQuestions();
res.json(questions);
} catch (err) {
next(err);
}
});
// GET /api/questions/:qid - single question
app.get('/api/questions/:qid',
param('qid').isInt(),
checkValidation,
async (req, res, next) => {
try {
const question = await dao.getQuestion(+req.params.qid);
if (!question)
return res.status(404).json({ message: 'Question not found' });
res.json(question);
} catch (err) {
next(err);
}
});
// POST /api/questions - create a question
app.post('/api/questions',
body('text').isString().isLength({ min: 1 }),
body('authorId').isInt(),
body('date').optional().isISO8601(),
checkValidation,
async (req, res, next) => {
try {
const question = {
text: req.body.text,
userId: req.body.authorId,
date: req.body.date ?? dayjs().format('YYYY-MM-DD')
};
const id = await dao.addQuestion(question);
res.status(201).location(`/api/questions/${id}`).json({ id });
} catch (err) {
next(err);
}
});
/* ---------------------- ANSWERS ---------------------- */
// GET /api/questions/:qid/answers - list answers of a question
app.get('/api/questions/:qid/answers',
param('qid').isInt(),
checkValidation,
async (req, res, next) => {
try {
const answers = await dao.listAnswersOf(+req.params.qid);
res.json(answers);
} catch (err) {
next(err);
}
});
// POST /api/questions/:qid/answers - add answer
app.post('/api/questions/:qid/answers',
param('qid').isInt(),
body('text').isString().isLength({ min: 1 }),
body('authorId').isInt(),
body('date').optional().isISO8601(),
checkValidation,
async (req, res, next) => {
try{
const answer = {
text: req.body.text,
userId: req.body.authorId,
date: req.body.date ?? dayjs().format('YYYY-MM-DD'),
score: 0
};
const id = await dao.addAnswer(answer, +req.params.qid);
res.status(201).location(`/api/answers/${id}`).json({ id });
} catch (err) {
// foreign-key violation - question does not exist
if (err.message?.includes('FOREIGN'))
return res.status(404).json({ message: 'Question not found' });
next(err);
}
});
// PUT /api/answers/:aid - update answer
app.put('/api/answers/:aid',
param('aid').isInt(),
body('text').isString().isLength({ min: 1 }),
checkValidation,
async (req, res, next) => {
try {
const changes = await dao.updateAnswer({
id: +req.params.aid,
text: req.body.text,
date: dayjs().format('YYYY-MM-DD')
});
if (changes === 0)
return res.status(404).json({ message: 'Answer not found' });
res.status(200).end();
} catch (err) {
next(err);
}
});
// POST /api/answers/:aid/vote - up/down vote
app.post('/api/answers/:aid/vote',
param('aid').isInt(),
body('vote').isIn(['up', 'down']),
checkValidation,
async (req, res, next) => {
try {
const changes = await dao.voteAnswer(+req.params.aid, req.body.vote);
if (changes === 0)
return res.status(404).json({ message: 'Answers not found' });
res.json({ vote: req.body.vote });
} catch (err) {
next(err);
}
});
/* ---------------- ERROR HANDLING ---------------- */
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ message: 'Internal Server Error', details: err.message });
});
/* ------------------- START ------------------- */
app.listen(PORT, () => {
console.log(`HeapOverrun QA-server listening on <http://localhost>:${PORT}`);
});
res.json(); stato corretto per creazione (201 + Location).Test degli endpoint
Con l’estensione REST Client si definisce test.http:
@base = <http://localhost:3001>
### Lista di tutte le domande
GET {{base}}/api/questions
### Creazione di una nuova domanda
POST {{base}}/api/questions
Content-Type: application/json
{
"text": "Differenza tra let e var?",
"authorId": 1
}
### Recupero di una singola domanda
GET {{base}}/api/questions/1
### Elenco delle risposte di una domanda
GET {{base}}/api/questions/1/answers
### Aggiunta di una risposta alla domanda 1
POST {{base}}/api/questions/1/answers
Content-Type: application/json
{
"text": "let ha scope di blocco, var no.",
"authorId": 2
}
### Modifica di una risposta
PUT {{base}}/api/answers/1
Content-Type: application/json
{
"text": "let è block-scoped, var è function-scoped."
}
### Up-vote di una risposta
POST {{base}}/api/answers/1/vote
Content-Type: application/json
{
"vote": "up"
}
### Down-vote di una risposta
POST {{base}}/api/answers/1/vote
Content-Type: application/json
{
"vote": "down"
}
L’esecuzione restituisce il corpo/headers e permette debugging rapido.
listQuestions, updateAnswers./questions/:qid/answers) segue la raccomandazione “risorsa-relazione” e favorisce la cache GET.await, evitando il “callback hell”.dao, models, routes) permette test unitari e successiva estensione (es. paginazione, filtri).