.. _lab1: 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 :ref:`start_new_lab` 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: .. code:: python 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``. .. code-block :: python :caption: 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: .. figure:: images/lab1/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: .. code :: bash (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: .. code :: (venv)$ pip freeze >> requirements.txt 2. Lägg till följande konfiguration under tilldelningen av variabeln app i main.py: .. code :: python app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///database.db' app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False db = SQLAlchemy(app) Ni behöver även importera SQLAlchemy: .. code :: python 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``: .. code :: bash # 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: .. code :: python 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 ' 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: .. code :: bash (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: .. code :: python >>> from main import db, Car Låt SQLAlchemy skapa tabeller i databasen genom att köra följande: .. code :: python >>> 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: .. code :: python >>> 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: .. code :: python >>> Car.query.all() [`__ 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: .. code :: python 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 '`__. .. tip :: Att skilja på HTTP-metoderna kan göras enligt .. code :: python @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: .. code :: text REQUEST: PUT /cars/1 { "make": "BMW" } RESPONSE: 200 OK { "id": 1, "make": "BMW", "model": "A6" } och: .. code :: text 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: .. code :: text (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. >>> .. code :: python >>> 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: .. figure:: images/lab1/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 :ref:`redovisning`. .. |lab1-1-12| image:: images/lab1/lab1-1-12.png .. |lab1-1-13| image:: images/lab1/lab1-1-13.png .. |lab1-1-2| image:: images/lab1/lab1-1-2.png .. |lab1-2-21| image:: images/lab1/lab1-2-21.png .. |lab1-2-22| image:: images/lab1/lab1-2-22.png