Exercise 5 - Q&A API Server (HeapOverrun)

Obiettivo

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.

Procedura/Spiegazione passo-passo

  1. Progettazione delle risorse e degli endpoint

  2. 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"
        }
    ]
    

    Recupero di una singola domanda

    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"
    }
    

    Creazione di una nuova domanda

    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
    }
    

    Elenco delle risposte di una domanda

    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
        }
    ]
    

    Aggiunta di una risposta a una domanda

    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
    }
    

    Modifica di una risposta esistente

    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

    Voto di una risposta

    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.

  3. 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.

  4. 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}`);
    });
    
    
  5. 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.

Codice/Snippet rilevante

Osservazioni critiche