Skip to content
Tällä sivulla

Tehtävä 1

ToDo API

Python ja FastAPI

Tehtävä tehdään käyttämällä ohjelmointikielenä pythonia ja FastAPI kirjastoa.

GitLab repositorio

Luo projektille repositorio GitLabiin.

Katso ohje tästä

Python versio

Varmista että käyttämäsi pythonin versio on vähintään 3.10 ja oikea python versio toimii myös komentoriviltä

Voit tarkistaa sen suorittamalla komentoriviltä komennon "python --version"

Pythonin virtuaaliympäristö - venv

Käytä aina virtuaaliympäristöä

Varmista että virtuaaliympäristö on aina käytössä, tällä vältytään siltä että pythonin globaalisti asennetut paketit eivät sotkeudu projektissa käytettäviin paketteihin.

Tehtäviin liittyvä huomio!

Luo .gitignore niminen tiedosto projektikansion juureen. Tällä kerrotaan gitille mitä tiedostoja ei haluta lisätä remote repositorioon.

Lisää .gitignore tiedostoon teksti ".venv"

  1. Luo projektille GitLab repositorio. TAI luo uusi kansio ja nimeä se projektin nimen mukaisesti, esim todo-app.
  1. Avaa kansio VS-Codessa
  2. Avaa VS-Coden terminaali (yläpalkki Terminal > New Terminal tai vaihtoehtoisesti ctrl + shift + ö)
  3. Luo projektikansioon pythonin virtuaaliympäristö kirjoittamalla syöttämällä seuraava komento terminaaliin:
python -m venv .venv
  1. Aktivoi virtuaaliympäristö käyttämällä joko VS-Coden ominaisuutta tai vaihtoehtoisesti komentoriviltä:

Huom, Macilla ja Linuxilla komento on "source ./.venv/bin/activate"

.\.venv\Scripts\activate 

"... running scripts is disabled on this system ..."

Windowsin powershell saattaa oletuksena estää powershell scriptien suorittamisen. Voit sallia scriptien suorittamisen ajamalle seuraavan komennon windowsin powershellissä:

ps1
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser

ks. https://sharadchhetri.com/pyhton-virtual-venv/

  1. Kun virtuaaliympäristö on aktivoitu niin terminaalissa tulee polun alkuun (.venv) tägi

venv

Asennettavat paketit

Kun venv on aktivoitu niin voidaan asentaa paketit.

  1. Luo requirements.txt niminen tiedosto kansioon
  2. Lisää tiedostoon fastapi, uvicorn ja sqlalchemy ja tallenna se.

requirements.txt

txt
fastapi
uvicorn
sqlalchemy
  1. Nyt voit asentaa paketit komennolla:
pip install -r requirements.txt

Tarvittavat paketit on nyt asennettu virtuaaliympäristöön ja ovat käytettävissä tässä projektissa.

FastAPI

Aloitetaan Todo-rajapinnan koodaaminen.

  1. Luo kansioon tiedosto nimellä main.py
  2. Lisää koodi tiedostoon ja tallenna:

Decorator?

python
# Tuodaan FastAPI luokka fastapi paketista
from fastapi import FastAPI

# Luodaan uusi fastapi instanssi app nimiseen muuttujaan.
# Muuttujan nimellä on merkitystä sillä uvicorn tarvitsee tämän 
# nimen palvelinta käynnistäessä, tässä tapauksessa se on main:app
app = FastAPI()

# Käytetään fastapi:n @app.get dekoraattoria todos endpointin luomiseen.
# Decorator funktio suoritetaan aina ennen sen alapuolella olevaa funktiota
# Decorator välittää sen alapuolelle määritetylle funktiolle argumentteja. 
# Tässä tapauksessa turvaudutaan FastAPI:n dokumentaatioon jotta tiedetään mitä
# argumentteja dekoraattorin alapuolella oleva 
# funktio ottaa vastaan missäkin tapauksessa.
@app.get('/todos')
def getTodos():
    return "Tässä palautetaan myöhemmin todo-lista"
  1. Käynnistetään FastAPI palvelin hyödyntämällä uvicorn pakettia. Suorita seuraava komento terminaalissa:
uvicorn main:app --reload

Terminaaliin tulostuu uvicorn palvelimen loki.

Väärän python version ongelmat

Tässä vaiheessa ilmenneet ongelmat johtuvat useimmiten väärästä python versiosta. Jos ongelmia ilmeni palvelimen käynnistämisessä, tarkista että pythonin versio on vähintään 3.10 suorittamalla komento:

python --version

Jos versio on vanhempi kuin 3.10 niin joudut päivittämään pythonin ja poistamaan olemassa olevan .venv kansion sekä luomaan virtuaaliympästön uudelleen ja asentamaan paketit uudestaan.

  1. Tarkista terminaalista osoite johon palvelin on käynnistetty. Se voi olla esim. http://127.0.0.1:8000. Jos terminaalin antama osoite tai portti ovat erit, käytä niitä.
  2. Navigoi seuraavaksi endpointtiin http://127.0.0.1:8000/todos

Selaimessa tulisi tällöin tulostua teksti "Tässä palautetaan myöhemmin todo-lista"

  1. FastAPI generoi automaattisesti OpenAPI standardin mukaisen rajapintadokumentaation. Tutustu rajapintadokumentaatioon osoitteessa http://127.0.0.1:8000/docs

Path parametrit

REST API endpoint viittaa aina johonkin resurssiin, esim. todos listaan tai yksittäiseen todo tietueeseen.

Yksittäinen resurssi haetaan tyypillisesti sen id:n perusteella.

  1. Lisää koodiin uusi endpoint "/todos/{id}", jossa id välitetään palvelimelle path parametrina (voidaan kutsua myös route paramiksi)

Path paramin nimeäminen id:ksi on käytäntö, sen voi nimetä halutessaan esim. todo_id:ksi

python
from fastapi import FastAPI

app = FastAPI()

@app.get('/todos')
def get_todos():
    return "Tässä palautetaan myöhemmin todo-lista"

# Lisätään path param endpointtiin mukaan
# Nyt se on mahdollista ottaa vastaan argumenttina getTodoById funktiossa.
# FastAPI käyttää taustalla pydantic kirjastoa mikä mahdollistaa tietotyypin 
# määrittämisen vastaanotettavalle argumentille, tässä tapauksessa vain 
# kokonaisluvut hyväksytään (id:int). Tietotyypin määrittäminen suorittaa tietotyypin 
# validoinnin kyseiselle path paramille. (FastAPI:n ominaisuus) 
@app.get('/todos/{id}')
def get_todo_by_id(id:int):
    return f"Tässä palautetaan myöhemmin yksittäinen todo item id:llä {id}"
  1. Navigoi selaimessa osoitteeseen http://127.0.0.1:8000/todos/1
  2. Kokeile antaa erilaisia argumentteja numeron 1 sijasta ja tutki miten palvelin vastaa jos käytät path paramina muita kuin kokonaislukuja
  3. Katso muutokset rajapintadokumentaatioon osoitteessa http://127.0.0.1:8000/docs

Query parametrit

Query parametrit määritetään URI:n loppuun "?" merkin jälkeen avain-arvo periaatteella. Esim. osoitteessa http://127.0.0.1:8000/todos?done=true query parametriksi on määritetty "done", jolle arvoksi on annettu boolean arvo true.

  1. Tehdään seuraavat muutokset aiempaan koodiin:
python
from fastapi import FastAPI

app = FastAPI()

@app.get('/todos')
def get_todos(done: bool | None = None):
    if done != None:
        return f"Tässä palautetaan myöhemmin todot joiden done status on: {done}"
    return "Tässä palautetaan myöhemmin todo-lista"

@app.get('/todos/{id}')
def get_todo_by_id(id:int):
    return f"Tässä palautetaan myöhemmin yksittäinen todo item id:llä {id}"

done: bool | None = None, tällä kerrotaan FastAPI:lle että done on tyyppiä boolean ja sen antaminen on vapaavalintaista. Oletusarvona done:lle asetetaan None. Jos tyypiksi määrittäisi pelkän done:bool niin FastAPI vaatisi query parametrina aina falsen tai truen.

Query ja Route parametrit FastAPI:ssa

FastAPI tarkistaa ensin että onko funktion argumentti osa Path paramia. Jos näin ei ole niin seuraavaksi se tarkistaa että onko se Query parametri. Esimerkki FastAPI:n dokumentaatiossa jossa molempia käytetään samassa funktiossa: https://fastapi.tiangolo.com/#example

  1. Tarkista todos-endpointin toimivuus selaimessa navigoimalla osoitteisiin
  1. Katso muutokset rajapintadokumentaatiossa

Data, Pydantic ja JSON

FastAPI käyttää pydantic kirjastoa taustalla tietotyyppien validointiin. Pydantic BaseModel objektin avulla voidaan luoda uusi luokka jossa jäsenmuuttujille määritetään pythonin tyyppivihjeillä tietotyypit joiden perusteella FastAPI osaa validoida esim. request bodyssä olevan JSON tietotyypin automaattisesti.

FastAPI osaa myös muuttaa funktiosta palautetun dict tietotyypin tai objektin automaattisesti JSON formaattiin.

  1. Tuo BaseModel pydantic kirjastosta
  2. Tee luokka ToDoItem ja ota BaseModel luokka sen kanssa käyttöön
  3. Palautetaan testimielessä TodoItem objekti get_todo_by_id funktiosta
python
from fastapi import FastAPI
from pydantic import BaseModel

# Pydantic katsoo tietotyypin pythonin tyyppivihjeestä
# FastAPI hyödyntää pydantic kirjastoa datan validoinnissa
class TodoItem(BaseModel):
    id: int       # id on kokonaisluku
    title: str    # title on tekstiä
    done: bool    # done on joko True tai False

app = FastAPI()

@app.get('/todos')
def get_todos(done: bool | None = None):
    if done != None:
        return f"Tässä palautetaan myöhemmin todot joiden done status on: {done}"
    return "Tässä palautetaan myöhemmin todo-lista"

@app.get('/todos/{id}')
def get_todo_by_id(id:int):
    # Palautetaan testimielessä funktiosta uusi TodoItem objekti.
    todo_item = TodoItem(id=id, title="testi", done=False)
    return todo_item

  1. Navigoi selaimessa osoitteeseen http://127.0.0.1:8000/todos/1
  2. Selaimeen tulostuu JSON muodossa oleva TodoItem:
json
{"id":1,"title":"testi","done":false}

Clientin lähettämän datan vastaanottaminen - HTTP POST

Luodaan uusi endpoint joka ottaa vastaan "/todos" routessa HTTP POST kyselyn. POST requestin bodyssä otetaan mukana JSON formaatissa uuden todo-itemin tiedot. FastAPI osaa parsia sekä validoida request bodyssä tulevan JSON muodossa olevan datan TodoItem luokan perusteella.

  1. Tee seuraavat muutokset koodiin:
python
from fastapi import FastAPI
from pydantic import BaseModel

class TodoItem(BaseModel):
    id: int       
    title: str    
    done: bool    

app = FastAPI()

@app.get('/todos')
def get_todos(done: bool | None = None):
    if done != None:
        return f"Tässä palautetaan myöhemmin todot joiden done status on: {done}"
    return "Tässä palautetaan myöhemmin todo-lista"

@app.get('/todos/{id}')
def get_todo_by_id(id:int):
    todo_item = TodoItem(id=id, title="testi", done=False)
    return todo_item

# @app.post('/todos') endpoint ottaa vastaan http POST metodilla tehdyn kyselyn (request).
# todo_item parametri sisältää tässä tapauksessa http bodyn 
# mukana tulevan JSON muotoisen datan. Pydantic kirjasto varmistaa että JSON
# data vastaa määriteltyä TodoItem luokkaa kun todo_item:lle on määritetty
# tyyppivihjeeksi TodoItem (ks. todo_item: TodoItem).
# FastAPI parsii JSON merkkijonon TodoItem objektiksi automaattisesti
# kun data on validoitu.
@app.post('/todos')
def create_todo(todo_item: TodoItem):
    # Tulostetaan komentoriville todo_item parametrista saatu data 
    print(todo_item)
    # Palautetaan testimielessä todo_item myös vastauksena.
    # Myöhemmin tässä funktiossa todo_item lisätään tietokantaan.
    return todo_item
  1. Avaa rajapintadokumentaatio jossa tulisi nyt olla myös HTTP POST metodi dokumentoituna.
  2. Avaa POST metodin dokumentaatio, klikkaa "Try it out"
  3. Huomataan että "Request body" on merkitty "required" tekstillä.
  4. Request body:stä on valmiina esimerkki jossa on TodoItem JSON muotoisena tekstinä.
  5. Kokeile suorittaa testikysely klikkaamalla "Execute".

palvelimen vastauksen näet "Server response" kohdan alapuolla.

Responses kohdassa näet kaikki oletusvastaukset mitkä tästä endpointista voi saada, eli onnistunut kysely tai tapahtunut virhe

  1. Kokeile tehdä muutoksia request bodyn dataan ja suorita kysely uudelleen muutosten jälkeen.

Voit kokeilla laittaa request bodyyn esim. seuraavanlaisia tietoja. Näistä ensimmäisen pitäisi onnistua (status code 200) ja kahden muun antaa virhe.

json
{
  "id": 123,
  "title": "Muista asia X",
  "done": false,
  "tata_ei_ole_olemassa": "toimiiko silti? Tulostaako python tämän konsoliin?"
}
json
{
  "title": "Muista asia X",
  "done": false
}
json
{
  "id": "Tämän pitäisi olla numero",
  "title": "Muista asia X",
  "done": false
}

RESTful Endpointit CRUD operaatioita varten

CRUD (eli Create, Read, Update ja Delete) sisältää tietokantojen perusoperaatiot. Nämä operaatiot toteutuvat tyypillisesti myös REST rajapinnoissa.

Voidaan verrata CRUD ja REST HTTP operaatioita keskenään seuraavasti:

CRUDHTTP
CREATEPOST/PUT
READGET
UPDATEPUT/POST/PATCH
DELETEDELETE

Hieman yksinkertaistettuna RESTful rajapinta mahdollistaa siis käytännössä datan hakemisen, lisäämisen, muokkaamisen ja poistamisen clientilta palvelimen tietokannassa. Palvelimen rooli on validoida data ja varmistaa että clientilla on oikeus suorittaa toimenpiteitä.

  1. Lisätään HTTP metodit put, patch ja delete "/todos" endpointille, huomaa että kaikki ne käsittelevät yhtä TodoItemia id:n perusteella
python
from fastapi import FastAPI
from pydantic import BaseModel

class TodoItem(BaseModel):
    id: int       
    title: str    
    done: bool    

app = FastAPI()

@app.get('/todos')
def get_todos(done: bool | None = None):
    if done != None:
        return f"Tässä palautetaan myöhemmin todot joiden done status on: {done}"
    return "Tässä palautetaan myöhemmin todo-lista"

@app.get('/todos/{id}')
def get_todo_by_id(id:int):
    todo_item = TodoItem(id=id, title="testi", done=False)
    return todo_item

@app.post('/todos')
def create_todo(todo_item: TodoItem):
    return todo_item

@app.put('/todos/{id}')
def update_todo(id: int, todo_item: TodoItem):
    return f"Myöhemmin tässä korvataan tietokannassa olevaa todoitem uudella jonka id on {id}"

@app.patch('/todos/{id}')
def update_todo_status(id: int, todo_item: TodoItem):
    return f"Myöhemmin tässä muokataan tietokannassa olevaa todoitemiä jonka id on {id}"

@app.delete('/todos/{id}')
def delete_todo(id:int):
    return f"Myöhemmin tässä poistetaan tietokannasta todoitem jonka id on {id}"
  1. Tutustu muutoksiin rajapintadokumentaatiossa
  2. Testaa HTTP kyselyiden tekeminen uusiin endpointteihin

Tietokanta - Sqlite

Lisätään tietokanta jotta pysyvän datan säilyttäminen ja käsittely on mahdollista.

Tyypillisesti FastAPI projektit käyttävät jotain ORM (Object Relational Model, esim. sqlalchemy) kirjastoa helpottamaan ja automatisoimaan operaatioita tietokannan kanssa. Tämä mahdollistaa myös taustalla asynkroniset tietokantakyselyt helposti mikä nopeuttaa sovelluksen toimintaa kun järjestelmä ei jää odottelemaan tietokannan vastausta vaan voi suorittaa muuta koodia sillä aikaa.

Esimerkeissä tehdään tietokantaoperaatiot kuitenkin käyttämällä pythonin sisään rakennettua sqlite3 kirjastoa sillä sen käyttäminen on operaatioiden ja kokonaisuuden havainnollistamista varten hyödyllisempi kuin ORM:n käyttö.

Voit halutessasi ottaa ORM:n käyttöön ja käyttää muutakin SQL tietokantaa kuin sqliteä, aiheesta lisää FastAPI:n dokumentaatiossa:

FastAPI:n SQL tietokantoihin liittyvä dokumentaatio

CRUD - CREATE

Aloitetaan tietokannan käyttöönotto ja lisätään aluksi tarvittavat perustoiminnallisuudet kuten tietokannan luominen ja yhteyden muodostaminen.

Tehdään "/todos" POST endpointtiin toiminnallisuus uuden todon lisäämiseksi tietokantaan.

  1. Tutustu alla olevaan koodiin ja tee vastaavat muutokset omaan koodiisi.

main.py

python
# Tuodaan datetime kirjasto
from datetime import datetime
# Tuodaan Response luokka fastapi kirjastosta
from fastapi import FastAPI, Response
from pydantic import BaseModel
# Tuodaan pythonin mukana tuleva sqlite kirjasto projektiin
import sqlite3

# Avataan sqlite yhteys ja asetetaan se muuttujaan con
con = sqlite3.connect("todos.sqlite", check_same_thread=False)

# Sql kysely jolla luodaan todo tietokanta jos sellaista ei vielä ole (... IF NOT EXISTS ...)
# Luodaan seuraavat kolumnit kantaan: id, title, description, done, created_at
sql_create_todo_table = "CREATE TABLE IF NOT EXISTS todo(id INTEGER PRIMARY KEY, title VARCHAR, description VARCHAR, done INTEGER, created_at INTEGER)"

# Suoritetaan sql kysely tietokannan luomiseksi
with con:
    con.execute(sql_create_todo_table)

class TodoItem(BaseModel):
    id: int       
    title: str    
    done: bool
    # Lisätään TodoItem luokkaan description joka on str tyyppinen sekä created_at,
    # joka tulee olemaan epoch aikaleima sekunteina. 
    description: str 
    created_at: int 

# Tehdään NewTodoItem luokka jota käytetään uuden todon tekemisessä.
# Tämä tehdään siksi että id, created_at ja done asetetaan palvelimen toimesta 
# ja niitä on näin ollen turhaa pyytää clientilta
class NewTodoItem(BaseModel):
    title: str
    description: str

app = FastAPI()

# Suljetaan tietokantatyhteys kun fastapi palvelin sammutetaan
@app.on_event("shutdown")
def database_disconnect():
    con.close()

@app.get('/todos')
def get_todos(done: bool | None = None):
    if done != None:
        return f"Tässä palautetaan myöhemmin todot joiden done status on: {done}"
    return "Tässä palautetaan myöhemmin todo-lista"

@app.get('/todos/{id}')
def get_todo_by_id(id:int):
    todo_item = TodoItem(id=id, title="testi", done=False)
    return todo_item

@app.post('/todos')
# Vaihdetaan TodoItem -> NewTodoItem
# Lisätään uusi parametri response jonka tietotyypiksi asetetaan Response luokka.
# response parametrin tietoja muokkaamalla voidaan lisätä responseen esimerkiksi räätälöityjä
# HTTP- headereita tai muuttaa responsen statuskoodia
def create_todo(todo_item: NewTodoItem, response: Response):

    # Tietokantakysely voi epäonnistua joten mahdollisen virheen tapahtuessa try-except
    # ottaa virheestä "kopin" jonka jälkeen voidaan palauttaa sopiva virheviesti 
    # clientille.
    try:
        # Otetaan tietokantayhteys käyttöön
        with con:
            # Luodaan aikaleima (Aikaleima on tässä kuluneet sekunnit vuodesta 1970 https://en.wikipedia.org/wiki/Unix_time).
            dt = datetime.now()
            ts = int(datetime.timestamp(dt))

            # Suoritetaan parametrisoitu tietokantakysely jolla luodaan uusi rivi todo kantaan.
            # Koska (todo_item.title, todo_item.description, int(False), ts,) on tyyppiä Tuple niin 
            # viimeinen pilkku ts:n jälkeen on tarpeellinen!
            # HUOM! Kun teet omia ratkaisuja koodiin niin katso että sql kyselyn 
            # parametrit menevät oikeille paikoille tuplessa!
            cur = con.execute("INSERT INTO todo(title, description, done, created_at) VALUES(?, ?, ?, ?)", (todo_item.title, todo_item.description, int(False), ts,))
            
            # Asetetaan responsen statuskoodiksi 201 eli created
            response.status_code = 201
        
            # Jotta ylimääräiseltä tietokantakyselyltä vältytytään niin voidaan palauttaa uusi TodoItem
            # tiedossa olevilla arvoilla jotka tiedetään nyt olevan samat myös tietokannassa.
            # cur.lastrowid sisältää luodun todo:n id:n tietokannasta.
            return TodoItem(id=cur.lastrowid, title=todo_item.title, done=False, description=todo_item.description, created_at=ts)
            
    except Exception as e:

        # Jos tietokantakysely epäonnistuu kerrotaan siitä tässä clientille asettamalla responselle 
        # sopiva statuskoodi, esim. 500 
        response.status_code = 500
        # Palautetaan virhe clientille
        return {"err": str(e)}
    
@app.put('/todos/{id}')
def update_todo(id: int, todo_item: TodoItem):
    return f"Myöhemmin tässä korvataan tietokannassa olevaa todoitem uudella jonka id on {id}"

@app.patch('/todos/{id}')
def update_todo_status(id: int, todo_item: TodoItem):
    return f"Myöhemmin tässä muokataan tietokannassa olevaa todoitemiä jonka id on {id}"

@app.delete('/todos/{id}')
def delete_todo(id:int):
    return f"Myöhemmin tässä poistetaan tietokannasta todoitem jonka id on {id}"
  1. Palvelimen käynnistäminen luo todos.sqlite tiedoston projektikansion juureen. Lisää "todos.sqlite" .gitignore tiedostoon. Tietokanta ei kuulu versionhallintaan.

sqlite tietokanta on kokonaisuudessaan tässä yhdessä tiedostossa, sen voi siis poistaa ja koodi luo automaattisesti uuden tyhjän sqlite kannan kun palvelimen käynnistää uudelleen.

.gitignore

.venv
todos.sqlite
  1. Sqlite tietokannan visualisointia varten voit asentaa esim. VS Code lisäosan SQLite Viewer. Tämän asentamalla voit klikata todos.sqlite tiedostoa ja nähdä kannan sekä sen sisältämän datan.

  2. Testaa uuden todo-tehtävän luominen tekemällä POST requesteja endpointtiin "/todos", huomaa että enää et tarvitse muuta kuin titlen ja descriptionin request bodyssä. (ks. NewTodoItem luokka). Voit tehdä requestit haluamallasi tavalla, esim. rajapintadokumentaatiosta http://127.0.0.1:8000/docs tai Insomnialla.

  3. Katso muutokset tietokannassa (ks. kohta 3.)

  4. Nähdään että sqlite tietokanta lisää uudelle todo-itemille ID:n automaattisesti, tätä ominaisuutta tullaan hyödyntämään seuraavassa vaiheessa.

Esimerkki JSON muotoinen vastaus palvelimelta kun neljäs todo on lisätty kantaan

json
{
  "id": 4,
  "title": "Testi",
  "done": false,
  "description": "Tee testi",
  "created_at": 1676015239
}

CRUD - READ

Toteutetaan ToDo-rajapintaan toiminnallisuus kaikkien todo objektien hakemiseksi tietokannasta ja niiden välittämämiseksi clientille.

  1. Tee vastaavat muutokset koodiin @app.get('/todos') endpointille:
python
@app.get('/todos')
def get_todos(response: Response, done: bool | None = None):
    try:
        with con:
            if done != None:
                # Jos query parametri done on asetettu, haetaan tietokannasta kaikki todot joilla done on False tai True.
                # SQL tietokannassa ei ole erikseen boolean arvoa vaan arvo on joko 0 tai 1, tätä varten done muutetaan kokonaisluvuksi (int(done),).
                # (int(done),) sulut ja pilkku muuttujan lopussa tarkoittaa että kyseessä on tuple jossa on yksi arvo.
                cur = con.execute("SELECT id, title, description, done, created_at FROM todo WHERE done = ?", (int(done),))
            else:
                # Jos query parametri done ei ole asetettu, haetaan kaikki todot kannasta.
                cur = con.execute("SELECT id, title, description, done, created_at FROM todo")

            # Alustetaan lista joka voi sisältää TodoItem tyyppisiä objekteja
            values: list[TodoItem] = []

            # Haetaan tietokannan kursorilla suoritetun kyselyn tulokset ja lisätään ne "values" listaan
            for item in cur.fetchall():
                # item on tässä tuple joka sisältää yhden rivin datan, puretaan se omiin muuttujiinsa.
                # Järjestys on sama mikä sql kyselyssä on määritetty
                id, title, description, done, created_at = item
                # Tehdään tietokannasta haetun datan pohjalta uusi TodoItem joka lisätään listaan "values"
                todo = TodoItem(id=id, title=title, description=description, done=done != 0, created_at=created_at)
                values.append(todo)
            
            # Palautetaan clientille todo lista. FastAPI muuttaa sen JSON formaattiin automaattisesti.
            return values
    except Exception as e:
        response.status_code = 500
        return {"err": str(e)}
  1. Testaa toimivuus tekemällä GET request "/todos" endpointtiin. Voit tehdä sen joko rajapintadokumentaation kautta tai insomnialla. Myös verkkoselain tekee oletuksena GET requestin kun navigoit uudelle sivulle joten voit testata tämän esim. avaamalla osoitteen http://127.0.0.1:8000/todos selaimessa.

  2. Testaa myös mitä tapahtuu kun asetat query paramin http://127.0.0.1:8000/todos?done=false tai http://127.0.0.1:8000/todos?done=true

  3. Tutustu alla olevaan koodiin ja tee vastaavat muutokset omaan koodiin @app.get('/todos/{id}') endpointille:

Tässä haetaan tietokannasta yksittäisen todo tehtävän tiedot id:n perusteella.

python
@app.get('/todos/{id}')
def get_todo_by_id(id: int, response: Response):
    try:
        with con:
            # Sql kysely yksittäisen todon hakemiseksi id:n perusteella
            cur = con.execute(
                "SELECT id, title, description, done, created_at FROM todo WHERE id = ?", (id,))

            result = cur.fetchone()

            # Jos result on tyhjä, se tarkoittaa ettei tietokannasta
            # löytynyt path parametrina saadun id:n perusteella todo itemiä.
            # Palautetaan clientille tällöin virhe viesti ja asetetaan statuskoodiksi 404
            if result == None:
                response.status_code = 404
                return {"err": f"Todo item with id {id} does not exist."}

            # Puretaan result tuplen sisältö omiin muuttujiinsa, järjestys on sama kuin
            # sql kyselyssä.
            id, title, description, done, created_at = result

            # Tehdään uusi TodoItem objekti tietokannasta saadun datan perusteella
            # Muutetaan done muuttujan arvo boolean arvoksi.
            return TodoItem(
                id=id,
                title=title,
                description=description,
                done=bool(done),
                created_at=created_at
            )
    except Exception as e:
        response.status_code = 500
        return {"err": str(e)}
  1. Muutosten jälkeen tee GET request endpointtiin "/todos/{id}" käyttämällä rajapintadokumentaatiota tai Insomniaa. Kokeile tehdä kysely eri id vaihtoehdoilla, sellaisen id:n perusteella jonka tiedät olevan tietokannassa sekä sellaisen joka ei siellä ole. esim. http://127.0.0.1:8000/todos/1 tai http://127.0.0.1:8000/todos/123

CRUD - DELETE

Toteutetaan todo-itemin poistaminen tietokannasta id:n perusteella. Id saadaan path parametrina.

  1. Tee vastaavat muutokset omaan koodiisi @app.delete('/todos/{id}') funktiolle:
python
@app.delete('/todos/{id}')
def delete_todo(id:int, response: Response):
    try:
        with con:
            cur = con.execute("DELETE FROM todo WHERE id = ?", (id,))
            
            # Jos muuttuneiden rivien määrä on pienempi kuin yksi, 
            # tiedetään että path paramina saadulla id:llä ei ole löytynyt 
            # todo itemiä tietokannasta. 
            if cur.rowcount < 1:
                response.status_code = 404
                return {"err": f"Can't delete todo item, id {id} does not exist."}

            # Voidaan palauttaa clientille esim. teksti "ok", tämän voi tosin jättää myös tyhjäksi sillä
            # HTTP statuskoodin 200 perusteella tiedetään että poisto on onnistunut. 
            return "ok"

    except Exception as e:
        response.status_code = 500
        return {"err": str(e)}

  1. Katso että tietokannassa on todo tehtäviä esim. tekemällä GET request endpointtiin "/todos". Jos todo-itemeita ei ole niin ks. CRUD - CREATE niiden lisäämiseksi.
  2. Tee HTTP request metodilla DELETE endpointtiin "/todos/{id}", korvaa {id} tietokannassa olevalla todo-itemin id:llä.
  3. Tarkista että poisto onnistui.

CRUD - UPDATE

Tehdään toiminnalisuudet tietokannassa olevien todo itemien muokkaamiseen. Tehdään ensin toiminnallisuus todon sisällön muokkaamiseen (title, description ja done) tekemällä muutokset PUT metodille. Tämän jälkeen tehdään todon statuksen kevyttä vaihtamista varten PATCH funktiolle muutokset.

PUT ja PATCH metodeja voidaan käyttää kumpiakin tietojen muokkaamiseen. PUT metodia käytetään yleensä kun korvataan olemassa oleva data uudella vastaavalla datalla ja PATCH metodia käytetään kun muokataan vain osaa datasta.

  1. Tee vastaavat muutokset omaan koodiisi:
python
@app.put('/todos/{id}')
def update_todo(id: int, todo_item: TodoItem, response: Response):
    try:
        with con:
            # Vaikka todo_item sisältää myös id:n ja created_at aikaleiman, niitä ei kuitenkaan haluta clientin voivan muuttaa.
            # Ne voidaan kuitenkin pyytää clientilta requestissa ja tämä onkin suositeltavaa sillä 
            # toiminnallisuuksien toteuttaminen clientin päässä on tällöin myös helpompaa kun data säilyy käsiteltäessä yhdenmukaisena.
            # RETURNING * palauttaa päivitetyn todon tiedot sql kyselyssä.
            cur = con.execute(
                "UPDATE todo SET title = ?, description = ?, done = ? WHERE id = ? RETURNING *", (todo_item.title, todo_item.description, todo_item.done, id,))
            
            result = cur.fetchone()

            if result == None:
                response.status_code = 404
                return {"err": f"Todo item with id {id} does not exist."}

            id, title, description, done, created_at = result

            # Palautetaan päivitetty todo clientille
            return TodoItem(
                id=id,
                title=title,
                description=description,
                done=bool(done),
                created_at=created_at
            )

    except Exception as e:
        response.status_code = 500
        return {"err": str(e)}
  1. Testaa muutosten toimivuus.
  2. Tee myös seuraavat muutokset koodiin:
python
# Otetaan uusi done status path parametrina
@app.patch('/todos/{id}/{done}')
# Path parametrit otetaan argumenttina vastaan funktiossa jossa niiden tietotyyppi määritetään
def update_todo_status(id: int, done: bool, response: Response):
    try:
        with con:
            # Päivitetään todon done status path parametrista saadun done arvon perusteella, joka voi olla joko True tai False.
            cur = con.execute("UPDATE todo SET done = ? WHERE id = ? RETURNING done", (int(done), id,))
            result = cur.fetchone()

            if result == None:
                response.status_code = 404
                return {"err": f"Todo item with id {id} does not exist."}
            
            # Palautetaan todon done status clientille esimerkiksi seuraavasti:
            return {"done": bool(result[0])}

    except Exception as e:
        response.status_code = 500
        return {"err": str(e)}
  1. Testaa olemassa olevan todon done statuksen muuttaminen tekemällä PATCH request endpointtiin "/todos/{id}/false" tai "/todos/{id}/true"
  2. Testaa muuttaa eri todo itemien done statusta ja kokeile tehdä välissä GET request "/todos" endpointtiin käyttämällä query parametreja "/todos?done=false" ja "/todos?done=true"

CORS - Cross-Origin Resource Sharing

Tässä vaiheessa olemme rakentaneet toimivan RESTful rajapinnan. Jos rajapintaa halutaan käyttää selainpohjaiselta clientilta (esim. React web-sovellus), jonka origin on eri kuin API:lla, tulee palvelimen lisätä response headeriin seuraava tieto: Access-Control-Allow-Origin: {tähän lista origineista}.

Origin on käytännössä clientin palvelimen osoite, se saattaa olla esim. devausvaiheessa http://127.0.0.1:5000. Origin kertoo palvelimelle siis että mikä client on tehnyt requestin.

Devausvaiheessa voidaan asettaa CORS headeriin ns. wildcard joka sallii yhteydet miltä tahansa clientilta Access-Control-Allow-Origin: *

CORS on nimenomaan selaimien tietoturvaominaisuus, CORS headerin perusteella verkkoselain tekee päätökset että voiko kyseinen nettisivu (client) tehdä requestin määritettyyn rajapintaan.

Lisää CORS:sta mozillan dokumentaatiossa

Esimerkki kuinka CORS virhe näkyy selaimen konsolissa kun clientin koodista tehdään request rajapintaan jossa ei ole CORS headereita määritetty:

cors1

Lisätään CORS headerit wildcardilla (*) ToDo API:lle.

  1. Lisää seuraavat koodit:
python
from fastapi.middleware.cors import CORSMiddleware
python
# Lisätään CORS middleware FastAPI instanssille. 
# Asetetaan kehitysvaiheessa kaikki originit wildcardiksi *:llä
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)
  1. Varmista että rajapinta toimii edelleen oikein
  2. Tee verkkoselainclientilta request esim. "/todos" endpointtiin. Voit käyttää esimerkiksi seuraavaa javaScript playgroundia https://www.sololearn.com/compiler-playground/javascript requestin tekemiseen.

Esimerkkikoodi javaScriptilla requestin tekemiseen verkkoselaimelta käyttämällä fetchiä

js
fetch("http://127.0.0.1:8000/todos").then(response => {
    return response.json()
}).then(data => {
    console.log(data)
}).catch(error => {
    console.log(error)
})
  1. Avaa selaimen kehittäjän työkalut, varmista network välilehdeltä että requestit todos endpointtiin onnistuvat kun suoritat javaScript koodin.
  2. Avaa network välilehdellä yksittäisen todos endpointtiin tehdyn kyselyn tiedot ja katso että "access-control-allow-origin: *" löytyy response headerista.

Ympäristömuuttujat

Tätä ei tarvitse tehdä omaan koodiin ellet käytä API avaimia tai salaisuuksia tehtävässä

Ympäristömuuttujat ovat tietoja jotka vaihtuvat projektin suoritusympäristön mukaan. Ne EIVÄT kuulu versionhallintaan. Esimerkkinä kehitysympäristössä käytetään eri salausavaimia kuin tuotantoympäristössä jotta tietovuodon riski voidaan minimoida salausavaimen katoamisen osalta.

Myös API avaimet kuuluvat ympäristömuuttujiin.

Jos käytät API avaimia projektissa, ota käyttöön FastAPI:n ympäristömuuttujat ominaisuus: https://fastapi.tiangolo.com/advanced/settings/#environment-variables TAI katso esimerkki alempaa.

Paljon käytetty tapa on luoda .env tiedosto salaisuuksia varten: https://fastapi.tiangolo.com/advanced/settings/#reading-a-env-file

Salaisuudet, esim API avaimet.

.env on tarkoitettu ympäristömuuttujille ja salaisille tiedoille, kuten salausavaimille ja muille tiedoille, jotka vaihtuvat esim. projektia siirrettäessä kehitysympäristöstä tuotantoon.

Älä koskaan laita .env tiedostoa gittiin tai muuhun versionhallintaan, tämä voi johtaa vakaviin tietovuotoihin jos esimerkiksi lisäät githubiin vahingossa salausavaimet ja tietokannan salasanat yms. Jos lisäät projektin myöhemmin julkiseksi niin tällöin kuka tahansa saattaa löytää projektin historiasta vahingossa commitoidut salaisuudet.

.gitignore tiedostoa käyttämällä voidaan varmistaa ettei .env tiedosto mene versionhallintaan vaikka .env tiedosto on paikallisesti samassa kansiossa muiden tiedostojen kanssa.

Opintojakson aikana on suositeltavaa ottaa omat salaisuudet .env tiedostosta talteen vaikkapa omalle verkkolevylle, sähköpostiin tai pilvipalveluun.

Esimerkki ympäristömuuttujien lataamiseksi .env tiedostosta pythonilla:

  1. Lisää python-dotenv kirjasto requirements.txt tiedostoon ja asenna paketit.
  2. Tee .env niminen tiedosto projektikansion juureen. Esimerkissä .env tiedosto sisältää API_KEY nimisen avaimen jolla on itse API avain asetettu arvoksi.

.env

API_KEY=tähän-tulee-api-avain
  1. Lisää ".env" .gitignore tiedostoon niin ettet vahingossa siirrä sitä versionhallintaan.
  2. Lisää seuraava koodi main.py tiedostoon heti importtien jälkeen:
python
from dotenv import load_dotenv

load_dotenv(dotenv_path=".env")
  1. Lataa ympäristömuuttujan arvo muuttujaan koodissa:
python
import os

api_key = os.environ.get("API_KEY")

# Authorization header vain esimerkkinä, 
# API avain voi rajapinnasta riippuen olla myös query paramina, 
# custom headerina tai vaikkapa request bodyssä.
headers = {
    "Authorization": f"Bearer {api_key}"
}

Soveltavat tehtävät

Tähän saakka tehdyt asiat kattavat 20 pistettä projektista.

Soveltavat tehtävät ovat yhteensä 10 pistettä.

Suunnittele ja toteuta rajapintaan lisäominaisuus/lisäominaisuudet

Lisäominaisuuden tulee hyödyntää joko valitsemaasi 3. osapuolen API:a ainakin yhdessä funktiossa TAI valitsemaasi koodikirjastoa/ohjelmaa. Tehtävässä tulee myös toteuttaa tarvittavat tietorakenteet ja funktiot HTTP-metodeille, niin että ratkaisusi toimii RESTful-periaatteiden mukaisesti. Mikäli joudut poikkeamaan RESTful-periaatteista, perustele ratkaisusi projektin README.md-dokumentissa.

Toteuta tarvittavat CRUD-operaatiot ja mahdollista pysyvän tiedon tallentaminen tietokantaan, tiedostojärjestelmään tai pilvipalveluun. Tämän lisäksi varmista, että toteuttamasi lisäominaisuudet rajapintaan ovat dokumentoitu swagger/openapi-muodossa.

Tehtävän suorittamisessa voit käyttää apuna netistä löytyviä ajantasaisia materiaaleja. Tehtävä on jaettu eri osioihin, joista jokainen osio on pisteytetty erikseen. Osioittain saatavilla olevat pisteet ovat seuraavat:

  1. Lisäominaisuus hyödyntää 3. osapuolen API:a tai koodikirjastoa/ohjelmaa: 2p
  2. Tarvittavien tietorakenteiden toteutus (ks. class TodoItem ja NewTodoItem): 2p
  3. Tarvittavien funktioiden toteutus HTTP-metodeille RESTful-periaatteiden mukaisesti: 2p
  4. CRUD-operaatioiden toteutus ja pysyvän tiedon tallentaminen: 2p
  5. Lisäominaisuuksien dokumentointi swagger/openapi-muodossa. Tallenna valmiin projektin openapi.json dokumentti projektikansion juureen. 1p
  6. Refaktoroi projekti jakamalla koodi tiedostoihin, omiina selkeisiin kokonaisuuksiinsa. Ota käyttöön FastAPI router. Perehdy API Routeriin FastAPI:n dokumentaatiossa 1p

Käytä apuna netistä löytyviä ajantasaisia materiaaleja. Katso myös esimerkkiprojekti openai API:n hyödyntämisestä

Jos tehtävän suorittamisessa/aloittamisessa on ylitsepääsemättömiä haasteita niin ota reilusti yhteyttä opintojakson ohjaajaan, esim. Teamsissä niin katsotaan homma alkuun!

OpenAI API esimerkki

Esimerkki luennolla 2 käydyn openai:n rajapinnan hyödyntämisestä FastAPI:n kanssa.

Esimerkissä on käytetty aiofiles ja httpx kirjastoja mitkä mahdollistavat asynkroniset eli ei-tosiaikaiset operaatiot FastAPI:n kanssa niin ettei koodin suorittaminen keskeydy (non-blocking) kun FastAPI odottaa vastausta tiedostojärjestelmästä tai toisesta rajapinnasta.

ks. https://www.twilio.com/blog/working-with-files-asynchronously-in-python-using-aiofiles-and-asyncio

  • Esimerkissä on FastAPI:n APIRouter käytössä.
  • Luokat joissa datarakenne on määritelty ovat siirrettynä models.py tiedostoon, eli TodoItem ja NewTodoItem
python
import base64
import os
import aiofiles
import httpx
from fastapi import APIRouter, Response
from models import TodoItem

ai_image_router = APIRouter(tags=['AI-Images'])

@ai_image_router.post('/ai-image', status_code=201)
async def create_ai_image_with_id(response: Response, todo_item: TodoItem):
    try:
        # Haetaan API avain .env tiedostosta. ks. Ympäristömuuttujat kohta.
        api_key = os.environ.get("API_KEY")

        # Request headerit
        headers: httpx.Headers = {
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
        }

        # Request body
        payload = {
            "prompt": todo_item.description,
            "n": 1,
            "size": "256x256"
        }

        # Avataan asynkroninen httpx client, tämä mahdollistaa sen että ToDo API
        # voi suorittaa muuta koodia tai muita kyselyitä sillä välin kun vastausta odotellaan toisesta rajapinnasta.
        async with httpx.AsyncClient() as client:
            # POST Requestin tekeminen openai:n rajapintaan
            res = await client.post("https://api.openai.com/v1/images/generations", headers=headers, json=payload)

            if res.status_code != 200:
                response.status_code = res.status_code
                return {"err": "Error fetching image urls from openai API"}

            # JSON muotoisen datan parsiminen
            parsed_response = res.json()

            # Haetaan vastauksena saadun kuvan osoitteen perusteella itse kuva
            image_response = await client.get(parsed_response['data'][0]['url'])

            # Tallennetaan kuva tiedostojärjestelmään myös asynkronisesti käyttämällä id:tä osana kuvan nimeä
            async with aiofiles.open(f"image_{todo_item.id}.png", "wb") as f:
                await f.write(image_response.content)

            # Muutetaan kuva BASE64 koodatuksi merkkijonoksi
            image_data_base64 = base64.b64encode(image_response.content).decode("utf-8")

            # Luodaan data_url jossa base64 merkkijonolle asetetaan sen MIME tyyppi.
            # Tämä mahdollistaa sen että data_url voidaan clientilla laittaa suoraan kuvana
            # esim. html img tägiin src:ksi
            image_url = f"data:image/png;base64,{image_data_base64}"

            # Palautetaan clientille JSON muotoinen vastaus joka sisältää BASE64 muotoisen data_url:n
            return {"data_url": image_url}
    except Exception as e:
        response.status_code = 500
        return {"err": str(e)}


@ai_image_router.get("/ai-image/{id}")
async def get_ai_image_by_id(response: Response, id: int):
    try:
        # Luetaan kuva tiedostojärjestelmästä id:n perusteella asynkronisesti
        # hyödyntämällä aiofiles kirjastoa.
        async with aiofiles.open(f"image_{id}.png", "rb") as f:
            # Kuva luetaan binääridatana muuttujaan image_data
            image_data = await f.read()
            # Muutetaan binääridata BASE64 koodattuun muotoon
            image_data_base64 = base64.b64encode(image_data).decode("utf-8")

            # Luodaan data_url jossa base64 merkkijonolle asetetaan sen MIME tyyppi.
            # Tämä mahdollistaa sen että data_url voidaan clientilla laittaa suoraan kuvana
            # esim. html img tägiin src:ksi
            image_url = f"data:image/png;base64,{image_data_base64}"

            # Palautetaan clientille JSON muotoinen vastaus
            return {"data_url": image_url}

    except Exception as e:
        # Jos tiedostoa ei löydy annetulla id:llä, palautetaan statuskoodi 404
        response.status_code = 404
        return {"err": str(e)}
    
@ai_image_router.delete("/ai-image/{id}")
def remove_ai_image_by_id(response: Response,id: int):
    try:
        # Käytetään os kirjaston remove metodia tiedoston poistamisessa 
        os.remove(f"image_{id}.png")

        # Voidaan palauttaa esim. seuraavanlainen viesti kun poisto onnistuu
        return {"msg": "AI generated image deleted successfully"}
    except Exception as e:
        response.status_code = 404
        return {"err": str(e)}

Tehtävän palautus

Varmista seuraavat asiat:

  1. Projektin (ToDo-API) Gitlab-repositorio on ajantasalla.
  2. Olet lisännyt ohjaajalle pääsyn repositorioosi.
  3. Olet tehnyt openapi.json tiedoston projektikansion juureen ja siellä on ajantasainen rajapintadokumentaatio.

Kun olet valmis, palauta moodleen linkki repositorioosi deadlineen mennessä.

APIa tullaan hyödyntämään myös loppukurssilla kun sille rakennetaan client.

Lapin AMK:n Web-ohjelmointirajapinnat opintojakson nettisivu.