Lab 1: Back end (Python)

I denna laboration kommer vi bygga en enkel webbserver med hjälp av Python och ramverket Flask. Vi kommer även introducera koncepten REST och JSON samt skapa en databas. Med hjälp av detta kommer ni skapa en server innehållande ett API som kommunicerar med JSON över HTTP och följer REST-stilen. Ert API ska i slutändan innehålla följande routes:

  • GET, POST /cars

  • GET, PUT, DELETE /cars/[int:car_id]

  • GET, POST /customers

  • GET, PUT, DELETE /customers/[int:customer_id]

  • GET /customers/[int:customer_id]/cars

Inledningsvis kommer vi använda Postman för att hantera HTTP-kommunikation till och från vår server.

HTTP (Hypertext Transfer Protocol)

Innan vi börjar kommer vi behöva veta mer om kommunikation över internet och specifikt hur detta går till via HTTP. Läs minst igenom följande artikel men vi uppmuntrar till att hitta egna artiklar relaterade till HTTP om artikeln ovan inte skapat grundläggande förståelse. Det är viktigt att förstå:

  • De olika metoderna GET, POST, PUT och DELETE samt hur de används.

  • Headers (Specifikt inklusive “content-type”)

  • Body

  • Statuskoder

Postman

I syfte att förenkla processen med att skicka och ta emot HTTP-meddelanden kommer vi använda ett program som hjälper oss med detta. Ladda ner och installera Postman från denna sida och läs sedan igenom en artikel som går igenom hur Postman kan användas.

REST

Det finns olika sätt att strukturera kommunikation med en webbserver, men den stil vi använder i denna kurs kallas REST och står för Representational State Transfer. REST innebär i korta drag att man definierar resurser (exempelvis en “User”) som kan nås via specifika URIs. En URI i kombination med någon utav HTTP-metoderna (GET, POST, PUT, DELETE) utgör ett kommando. Läs mer om REST och skumma igenom dokumentationen.

JSON

Data kan struktureras på olika sätt, vi använder JSON-formatet eftersom det är enkelt att förstå samt smidigt att hantera med både JavaScript och Python.

Förberedande frågor

Svara på följande frågor och skriv ner era svar innan demonstration:

  1. För vilka typer av operationer ska de olika HTTP-verben anvädas? Ge ett konkret exempel på lämplig funktionalitet för respektive verb.

  2. Ge exempel på minst fyra statuskoder samt när de bör användas.

  3. I vilken del av en HTTP-request kan data i JSON-format hittas?

Del 1: Routing

  1. Börja med att följa Påbörjande av ny labb för att skapa en ny branch för laborationen med namn lab1. Notera att det går att starta labben innan den tidigare är godkänd.

  2. Skapa routen /cars i er main.py som returnerar en lista med bilar i JSON-format. Listan med bilar kan tillsvidare vara hårdkodad enligt följande Python-kod:

    car_list = [
      {"id": 1, "make": "Volvo", "model": "V70"},
      {"id": 2, "make": "SAAB", "model": "95"}
    ]
    

    En route definieras genom att definiera en metod som dekoreras med app.route.

    Ett exempel på en passande deklaration för /cars kan se ut så här
    @app.route('/cars')
    def cars():
      # ...
    

    Tip

    Python-dictionaries och vanliga datatyper kan konverteras automatiskt till JSON-format av Flask, andra datatyper kan konverteras genom att använda jsonify. Notera att listor inte får finnas på topp-nivån i det som returneras, men jsonify kan användas för att returnera listor.

    Tip

    Ni behöver starta om Flask-applikationen när ni har gjort ändringar i koden för att de ska appliceras. Om ni vill att servern automatiskt ska ladda ändringarna när filerna sparas, skicka in debug=True som parameter till app.run().

    När ni anropar servern från Postman ska svaret se ut som följande: lab1-1-13

    Om det inte finns någon bil ska en tom lista presenteras.

  3. Skapa en route /cars med en parameter för att hämta en specifik bil ur listan car_list baserat på dess id. Denna route ska ha följande format: /cars/[int:car_id]

    Tip

    Information om routes och parametrar i Flask.

    Om den efterfrågade bilen hittas ska svaret från servern se ut så här:

    lab1-1-2 Ni ska även inkludera enklare felhantering. I det här fallet innebär det att om en bil inte kan hittas ska HTTP-statuskoden 404 Not Found visas för att indikera att resursen inte kunde hittas. Svaret ska då se hur så här:

    _images/lab1-1-21.png

    Tip

    För att svara med endast en HTTP-statuskod i Flask kan ni använda metoden abort(), se följande artikel för mer information och exempel.

Del 2: Databas

I de flesta webbapplikationer behövs något sätt att spara data över tid. Oftast hanteras detta genom att använda en databas. I denna laboration ska vi använda databasen SQLite. SQLite är en databas som är väldigt enkel att komma igång med, men bör ej användas på detta sätt i en produktionsmiljö. För att enklare kunna hantera databasen ska vi också använda SQLAlchemy. SQLAlchemy innehåller en så kallad ORM (Object Relational Mapper) som hjälper oss att mappa våra framtida Python-klasser mot databastabeller. Mer djupgående dokumentation om SQLAlchemy finns här.

Vi ska nu ordna så att bilar sparas i en databas istället för att bestå av en hårdkodad Python-lista.

  1. Installera Flask-SQLAlchemy (SQLAlchemy + integration mot Flask) genom att köra följande kommando (i er virtuella Python-miljö): (venv)$ pip install flask_sqlalchemy

    Tip

    För att enklare hantera paket i framtiden kan ni skapa en fil, server/requirements.txt, med följande innehåll:

    Flask==2.0.2
    Flask-SQLAlchemy==2.5.1
    SQLAlchemy==1.4.31
    

    Sedan är det bara att köra följande för att installera alla paket på en gång:

    (venv)$ pip install -r requirements.txt
    

    Det går även att använda pip för att automatiskt skapa denna fil. Detta görs med kommandot:

    (venv)$ pip freeze >> requirements.txt
    
  2. Lägg till följande konfiguration under tilldelningen av variabeln app i main.py:

    app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///database.db'
    app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
    db = SQLAlchemy(app)
    

    Ni behöver även importera SQLAlchemy:

    from flask_sqlalchemy import SQLAlchemy
    

    Databasen kommer sedan bestå av filen database.db vilken vi inte vill versionshantera. Lägg därför till i er .gitignore:

    # Database
    server/database.db
    
  3. Det är nu dags att skapa vår första modell, “Car”. En modell skapas genom att skapa en klass som ärver av db.Model enligt följande:

    class Car(db.Model):
      id = db.Column(db.Integer, primary_key=True)
      make = db.Column(db.String, nullable=False)
      model = db.Column(db.String, nullable=False)
    
      def __repr__(self):
        return '<Car {}: {} {}'.format(self.id, self.make, self.model)
    

    Som visas i exemplet kan en modell, precis som vanligt, innehålla godtyckliga metoder och attribut precis som en vanlig klass i Python. Tack vare att den ärver av SQLAlchemys db.Model finns dessutom många metoder tillgängliga automatiskt i klassen, exempelvis Car.query.all() som returnerar alla bil-instanser i databasen. db.Column indikerar att attributet ska mappas mot en kolumn i databasen.

    Modellen ovan kommer att representeras av en tabell i databasen med fälten id, make samt model, som visas i följande bild.

    (venv) ~/tddd83-labs/server$ sqlite3 database.db -header -column
    SQLite version 3.22.0 2018-01-22 18:45:57
    Enter ".help" for usage hints.
    sqlite> SELECT * FROM car;
    id       make      model
    -------  --------  ----------
    1        Volvo     V70
    2        SAAB      95
    
  4. Ta nu bort era hårdkodade bilar, d.v.s. cars_list, helt och hållet.

  5. Det är nu dags att testa att manipulera databasen. Vi börjar med att göra det genom Python-interpretatorn:

    Starta python, med er virtuella miljö aktiverad:

    (venv) ~/tddd83-labs/server$ python
    Python 3.8.10 (default, Nov 26 2021, 20:14:08)
    [GCC 9.3.0] on linux
    Type "help", "copyright", "credits" or "license" for more information.
    >>>
    

    Importera nu db och er nya modell, Car, från main.py genom att skriva:

    >>> from main import db, Car
    

    Låt SQLAlchemy skapa tabeller i databasen genom att köra följande:

    >>> db.create_all()
    

    För att spara en ny bil i databasen skapar ni helt enkelt en instans av Car och sparar den i databasen så här:

    >>> volvo = Car(make="Volvo", model="V70")
    >>> db.session.add(volvo)
    >>> db.session.commit()
    

    Fältet id sköts automatiskt eftersom det är definierat som en primary key.

    För att sedan hämta alla bilar i databasen använder ni exempelvis:

    >>> Car.query.all()
    [<Car 1: Volvo V70, <Car 2: SAAB 95]
    

    Mer information om hur ni hämtar, lägger till samt tar bort data hittar ni i dokumentationen för SQLAlchemy.

  6. Nu ska ni äntligen få använda databasen när ert API svarar på HTTP-anrop! Skriv om era metoder för /cars och /cars/[int:car_id] så att de fungerar precis som tidigare, men hämtar all data från databasen.

    Tip

    Tänk på att jsonify inte kan hantera godtyckliga datatyper och att ni på något sätt behöver konvertera en Car-instans till en Python-dictionary för att den sedan ska kunna konverteras till JSON. En bra början kan vara att lägga till en metod i Car-klassen så att ni får något i stil med detta:

    class Car(db.Model):
      id = db.Column(db.Integer, primary_key=True)
      make = db.Column(db.String, nullable=False)
      model = db.Column(db.String, nullable=False)
    
      def __repr__(self):
        return '<Car {}: {} {}'.format(self.id, self.make, self.model)
    
      def seralize(self):
        return dict(id=self.id, make=self.make, model=self.model)
    

Del 3: Stöd för fler HTTP-metoder

Att visa data på förfrågning är visserligen väldigt bra, men det är ju också väldigt praktiskt om användarna kan lägga till, uppdatera och ta bort data själva via ert API!

För att möjliggöra detta ska ni nu få implementera funktionalitet för att även stödja HTTP-metoderna POST, PUT och DELETE.

  1. Utöka metoden för routen /cars så att den även accepterar POST-anrop. Vid GET ska metoden fungera precis som tidigare.
    Vid POST ska en ny bil läggas till i databasen med det märke och den modell som angetts i anropet. Anropet ska, i Postman, se ut såhär:
    _images/lab1-3-1.png

    Svaret till POST-anropet ska bestå av det nya bil-objektet (på samma sätt som /cars/[int:car_id]), d.v.s:

    {
        "id": 1,
        "make": "Audi",
        "model": "A6"
    }
    

    Läs om hur ni hanterar olika HTTP-metoder och indata.

    Tip

    Att skilja på HTTP-metoderna kan göras enligt

    @app.route('/cars', methods=['GET', 'POST'])
    def cars():
      if request.method == 'GET':
        # Handle GET request
      elif request.method == 'POST':
        # Handle POST request
    

    (request importeras från flask) eller genom att skapa en separat metod som endast hanterar exempelvis POST.

    Tip

    För att få ut JSON-datan från förfrågningen kan ni exempelvis använda request.get_json(). Headern Content-Type måste vara satt till application/json. Här kan ni läsa mer om detta.

  2. Uppdatera metoden för /cars/[int:car_id] så att den även stödjer uppdatering via PUT. Om en bil med efterfrågat id inte hittas ska ni svara med statuskoden 404 (Not Found).

    Vid PUT ska ni ta in ett JSON-objekt som representerar en bil eller delar av en bil och uppdatera de fält i databasen som finns angivna i det inkommande JSON-objektet. Svaret ska bestå av en uppdaterad bil.

    Exempelvis:

    REQUEST:
    PUT /cars/1
    
    {
        "make": "BMW"
    }
    
    RESPONSE:
    200 OK
    
    {
        "id": 1,
        "make": "BMW",
        "model": "A6"
    }
    

    och:

    REQUEST: PUT /cars/1
    
    { “id”: 1234, “make”: “Volvo”, “model”: “V70” }
    
    RESPONSE: 200 OK
    
    { "id”: 1, “make”: “Volvo”, “model”: “V70” }
    

    Tänk på att användaren inte ska kunna modifiera attributet id.

  3. Implementera funktionalitet för DELETE /cars/[int:car_id] för borttagning av bilar. Om en bil med efterfrågat id inte hittas ska ni svara med statuskoden 404 (Not Found).

    Om bilen hittas ska den tas bort ur databasen och svaret ska endast innehålla statuskoden 200 (Success), ingen ytterligare data behövs.

Del 4: Kund-modell och relationer

Med hjälp av databasen och modeller kan relationer mellan olika typer av objekt beskrivas. Ni ska nu utöka systemet med en kund-modell som har en relation till bil-modellen. En bil ska kunna ha 0 eller 1 en kund åt gången, men en kund ska kunna ha 0 eller fler bilar (One-to-Many-relation, 1->N).

  1. Skapa en modell Customer som innehåller minst datan id, name och email på samma sätt som bil-modellen.

  2. Modifiera bil-modellen samt kund-modellen så att de har en sådan relation som beskrivs ovan (1->N). Tänk på att en kund inte nödvändigtvis alltid måste ha en relation till en bil.

    Tip

    Läs om relationer i databaser.

  3. För att era ändringar ska återspeglas i databasen behöver ni berätta för SQLAlchemy att utföra ändringarna. Det enklaste sättet är helt enkelt att tömma databasen och skapa den på nytt. öppna Python-interpretatorn och utför följande:

    (venv) ~/tddd83-labs/server$ python
    Python 3.8.10 (default, Nov 26 2021, 20:14:08)
    [GCC 9.3.0] on linux
    Type "help", "copyright", "credits" or "license" for more information.
    >>>
    
    >>> from main import db
    >>> db.drop_all()
    >>> db.create_all()
    

    Observera att all data kommer att tas bort! Detta tillvägagångssätt är ej rekommenderat till annat än enkelt testning/utveckling. För att utföra ändringar i databaser där datan ej kan förloras kan förändringar exempelvis beskrivas i form av migrations. Se exempel 1 eller exempel 2 om ni vill hitta ett passande verktyg för detta ändamål.

  4. Skapa nu, på motsvarande sätt som tidigare, funktionalitet för

    • GET, POST /customers

    • GET, PUT, DELETE /customers/[int:customer_id]

  5. Uppdatera funktionaliteten för GET /cars så att en eventuella kunder också visas i svaret, d.v.s:

    REQUEST:
    GET /cars
    
    RESPONSE:
    200 OK
    
    [
         {
             "id": 1,
             "make": "Volvo",
             "model": "V70",
             "customer": {
                 "id": 1,
                 "name": "Test Person",
                 "email": "test@test.t"
             }
         },
         {
             "id": 2,
             "make": "Audi",
             "model": "A8",
             "customer": null
         }
         ...
    ]
    

    Testa detta genom att skapa en kund och sedan sätta en relation till en bil med hjälp av Python-interpretatorn (se del 2 punkt 5 ovan) och låta en bil vara utan kund.

  6. Uppdatera funktionaliteten för GET /cars/[int:car_id] så att en eventuell kund också visas i svaret. Svaret från ert API ska alltså se ut så här ifall en bil har en relation till en kund:

    _images/lab1-4-2.png
  7. Uppdatera funktionaliteten för PUT /cars/[int:car_id] så att även attributet för kund-relationen kan uppdateras, d.v.s:

    REQUEST:
    PUT /cars/2
    
    {
        "customer_id": 1
    }
    

    Svaret från servern ska se ut som för GET /cars/[int:car_id] ovan, med den nya kunden. Ifall det inte finns en kund med det id som angivits går det bra att lämna relationen orörd och svara med statuskoden 404.

  8. Lägg till funktionalitet för GET /customer/[int:customer_id]/cars. Er server ska svara med en lista över bilar som har en relation till kunden i fråga. d.v.s:

    [
        {
            "id": 1,
            "make": "Volvo",
            "model": "V70",
        },
        {
            "id": 2,
            "make": "Audi",
            "model": "A8",
        }
    ]
    

    Om kunden inte har någon relation till någon bil ska en tom lista presenteras.

  9. Sist men inte minst, uppdatera funktionaliteten för borttagning av kunder så att relationerna hanteras. Om en kund tas bort ska eventuella befintliga relationer också nollställas.

Redovisning

Se Redovisning.