www-2324

Czasem chcemy stworzyć serwis, który będzie używany w połączeniu z innymi usługami, na przykład będzie wykorzystywany przez jakiś inny serwis, albo będzie odpytywany przez aplikację mobilną, albo przez stronę internetową. W takich przypadkach warto zastanowić się nad stworzeniem API. API to interfejs programistyczny, który pozwala na komunikację między różnymi aplikacjami.

W ramach tego wykładu stworzymy prosty serwis, który będzie udostępniał API napiszemy go w języku Python, a dokładniej w frameworku FastAPI.

Instalacja

Instalacja jest bardzo prosta, wystarczy wykonać poniższą komendę w środowisku wirtualnym:

pip install fastapi[all]
pip install python-multipart

Oprócz FastAPI zainstaluje się także program uvicorn, która pozwoli nam uruchomić nasz serwis. uvicorn to serwer ASGI, który pozwala na uruchomienie aplikacji napisanej w FastAPI. Technicznie rzecz biorąc, uvicorn potrafi obsługiwać dwa protokoły: HTTP/1.1 oraz WebSocket.

Nasza usługa

Napiszemy solver do rozwiązywania równań kwadratowych. Nasz serwis będzie przyjmował trzy parametry: a, b oraz c i zwracał rozwiązania równania kwadratowego w postaci JSON-a.

def solve_quadratic_equation(a, b, c):
    delta = b**2 - 4 * a * c
    if delta < 0:
        return None
    elif delta == 0:
        return -b / (2 * a)
    else:
        x1 = (-b - delta**0.5) / (2 * a)
        x2 = (-b + delta**0.5) / (2 * a)
        return (x1, x2)

FastAPI

Spróbujmy udostępnić naszą funkcję za pomocą FastAPI. FastAPI pozwala na stworzenie API za pomocą dekoratorów.

Plik rozwiazywacz.py:

from typing import Annotated
from pydantic import BaseModel
from fastapi import FastAPI, Form


app = FastAPI()
@app.get("/solve/{a}/{b}/{c}")
async def solve(a: float, b: float, c: float):
    solution = solve_quadratic_equation(a, b, c)
    match solution:
        case None:
            return {"number_of_solutions": 0}
        case (x_1, x_2):
            return {"number_of_solutions": 2, "x_1": x_1, "x_2": x_2}
        case x:
            return {"number_of_solutions": 1, "x": x}

Przyjrzyjmy się powyższemu kodowi. Dekorator @app.get("/solve/{a}/{b}/{c}") oznacza, że nasza funkcja solve będzie dostępna pod adresem /solve/{a}/{b}/{c}. Warto zwrócić uwagę, że w dekoratorze podaliśmy trzy parametry: a, b oraz c. FastAPI potrafi przekonwertować te parametry na odpowiednie typy, w naszym przypadku na float.

Czyli jeśli poprosimy nasz serwis o rozwiązanie równania kwadratowego o współczynnikach a=1, b=2 oraz c=1, to otrzymamy odpowiedź w postaci JSON-a:

{
  "number_of_solutions": 1,
  "x": -1.0
}

Tylko jak to zrobić?

Uruchomienie

Aby uruchomić nasz serwis, wystarczy wykonać poniższą komendę:

uvicorn rozwiazywacz:app --reload

Polecenie uvicorn rozwiazywacz:app --reload uruchamia serwer uvicorn z naszą aplikacją app z pliku rozwiazywacz.py. Opcja --reload powoduje, że serwer będzie automatycznie restartowany po zmianach w kodzie.

U mnie wygląda to tak:

❯ uvicorn rozwiazywacz:app --reload
INFO:     Will watch for changes in these directories: ['/Users/krzysztofciebiera/GitHub/kciebiera/www-2324/docs/wyk7']
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Started reloader process [16283] using StatReload
INFO:     Started server process [16285]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
WARNING:  StatReload detected changes in 'rozwiazywacz.py'. Reloading...
INFO:     Shutting down
INFO:     Waiting for application shutdown.
INFO:     Application shutdown complete.
INFO:     Finished server process [16285]
INFO:     Started server process [16293]
INFO:     Waiting for application startup.
INFO:     Application startup complete.

Komunikat WARNING: StatReload detected changes in 'rozwiazywacz.py'. Reloading... oznacza, że serwer wykrył zmiany w kodzie i zrestartował się.

Wywołanie

W jakimś sensie napisaliśmy backend (czyli mamy coś co czeka na zapytania), teraz czas na frontend (czyli coś co będzie wysyłać zapytania). W tym celu możemy skorzystać z narzędzia curl:

curl -X 'GET'  'http://localhost:8000/solve/1/2/1' -H 'accept: application/json'

Odpowiedź powinna wyglądać tak:

{"number_of_solutions":1,"x":-1.0}

Czyli nasz serwis działa poprawnie. Jak widać potrafimy zrobić rzecz analogiczną do routingu z Django z możliwością przekazania parametrów w URLu.

Możemy sobie wyobrazić co najmniej dwie inne metody wywołania naszego serwisu:

zaimplementujmy je.

POST z formularza

Plik rozwiazywacz.py:

@app.post("/solve_post")
async def solve_post(
    a: Annotated[float, Form()],
    b: Annotated[float, Form()],
    c: Annotated[float, Form()],
):
    solution = solve_quadratic_equation(a, b, c)
    match solution:
        case None:
            return {"number_of_solutions": 0}
        case (x_1, x_2):
            return {"number_of_solutions": 2, "x_1": x_1, "x_2": x_2}
        case x:
            return {"number_of_solutions": 1, "x": x}

W powyższym kodzie zdefiniowaliśmy nową ścieżkę /solve_post, która będzie obsługiwała zapytania POST. Warto zwrócić uwagę, że parametry a, b oraz c są oznaczone dekoratorem Form(). Dzięki temu FastAPI wie, że parametry te powinny być przekazane w formie POST.

Chyba jest dość jasne, że nie jest to najlepszy sposób na przekazywanie parametrów do serwisu, ale czasem takie rozwiązanie może być potrzebne.

Aby przetestować nasz serwis, możemy użyć polecenia curl:

curl -X 'POST' \
  'http://localhost:8000/solve_post' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'a=1&b=2&c=1'

W powyższym curlu przekazujemy parametry a, b oraz c w formie POST. Zwróćmy uwagę, że w nagłówku Content-Type podaliśmy application/x-www-form-urlencoded. Zrobiliśmy to, ponieważ przeglądarki domyślnie wysyłają zapytania POST w formie application/x-www-form-urlencoded.

POST z JSONa

Plik rozwiazywacz.py:

@app.post("/solve_post_json")
async def solve_post_json(equation: Equation):
    a = equation.a
    b = equation.b
    c = equation.c
    solution = solve_quadratic_equation(a, b, c)
    match solution:
        case None:
            return {"number_of_solutions": 0}
        case (x_1, x_2):
            return {"number_of_solutions": 2, "x_1": x_1, "x_2": x_2}
        case x:
            return {"number_of_solutions": 1, "x": x}

Ten kod możemy przetestować za pomocą poniższej komendy:

curl -X 'POST' \
  'http://localhost:8000/solve_post_json' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
  "a": 1,
  "b": 2,
  "c": 1
}'

Ale byłoby to dość dziwne, gdyż musielibyśmy przekazywać JSONa w formie stringa. Możemy zapisać tego jsona w postaci pliku (data.json):

{
  "a": 1,
  "b": 2,
  "c": 1
}

a następnie wywołać nasz serwis w następujący sposób:

curl -X 'POST' \
  'http://localhost:8000/solve_post_json' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
  -d @data.json

wynik powinien być podobny do poprzednich.

Dokumentacja

Dotychczas nasz serwis działał, ale nie mieliśmy żadnej dokumentacji. FastAPI pozwala na automatyczne generowanie dokumentacji naszego serwisu. Wystarczy wejść na adres http://localhost:8000/docs i zobaczymy dokumentację naszego serwisu.

Każda z udostępnianych metod ma taką samą dokumentację. Warto zwrócić uwagę, że FastAPI potrafi wygenerować dokumentację na podstawie typów przekazywanych parametrów. Dzięki temu nie musimy ręcznie tworzyć dokumentacji.

Dokumentacja może mieć dodatkowe pola, typu licencja, autor, opis, itp. Możemy je dodać w następujący sposób:

app = FastAPI(
    title="Quadratic equation solver",
    description="This is a simple service that solves quadratic equations.",
    version="0.1",
    contact={
        "name": "Krzysztof Ciebiera",
        "url": "https://kciebiera.github.io/",
        "email": "no_email",
    },
    license_info={
        "name": "MIT",
        "url": "https://opensource.org/licenses/MIT",
    },
)

Po dodaniu powyższego kodu, dokumentacja autmatycznie się zaktualizuje.

Tagi w dokumentacji

Udostępnione metody możemy podzielić na tagi. W ten sposób możemy grupować metody w dokumentacji.

@app.get("/solve/{a}/{b}/{c}", tags=["solver"])
@app.post("/solve_post", tags=["obsolete"])
@app.post("/solve_post_json", tags=["solver"])

Do tagów można dodawać dodatkowe informacje, takie jak opis:

tags_metadata = [
    {
        "name": "solver",
        "description": "Methods for solving quadratic equations.",
    },
    {
        "name": "obsolete",
        "description": "Methods that are no longer supported.",
    },
]
app = FastAPI(openapi_tags=tags_metadata)

Fast API wyświetla docstringi dla każdej metody, więc warto je dodać:

@app.get("/solve/{a}/{b}/{c}")
async def solve(a: float, b: float, c: float):
    """
    Funkcja rozwiązuje równanie kwadratowe ax^2 + bx + c = 0 i zwraca słownik
    zawierający informacje o rozwiązaniach.

    **Argumenty:**

    * `a` (float): Współczynnik przy x^2.
    * `b` (float): Współczynnik przy x.
    * `c` (float): Wyraz wolny.

    **Wartość zwracana:**

    Słownik zawierający:

    * `number_of_solutions` (int): Liczba rozwiązań równania. Możliwe wartości to:
        * 0: Rówanie nie ma rozwiązań rzeczywistych.
        * 1: Rówanie ma jedno rozwiązanie rzeczywiste.
        * 2: Rówanie ma dwa rozwiązania rzeczywiste.
    * `solutions` (list[float]): Lista rozwiązań równania (jeśli istnieją).

    **Uwaga:**

    Funkcja wykorzystuje algorytm rozwiązywania równań kwadratowych.
    """

Wyświetla się to w dokumentacji: