Szybkie i stabilne API w Pythonie

Standardem staje się że witam się z Wami rozpoczynając zdanie od „dawno tu nie pisałem” – dziś, niestety nie będzie inaczej – więc witam Was, dawno tu nic nie pisałem. Ale co nieco nadrobię. A mianowicie pokaże jak szybko i łatwo można zrobić stabilne API w Pythonie.

Używać będziemy swaggera, flaska i connexion’a – trzy biblioteki, pierwsza pozwala stworzyć API, tj jego strukturę, przyjmowane parametry itp, druga to mini serwer HTTP który będzie obsługiwał żądania a trzecia pozwoli połączyć nam definicję naszego API z właściwymi metodami które będą wykonywane w momencie nadejścia żądania. A i jeszcze SqlAlchemy – dość duży ORM, ale zdecydowanie ułatwia manipulacje na bazie danych.

No więc zacznijmy od.. Początku. Stwórzmy plik main.py:

1
2
3
4
5
import connexion

app = connexion.App(__name__, specification_dir='specs/')
app.add_api('api10.yaml')
app.run(port=8080)

importujemy w nim connexion’a który opakowuje flaska pozwalając uruchomić serwer HTTP na porcie 8080. W przedostatniej linii wskazujemy plik z naszą specyfikacją. Plik będzie szukany w folderze zdefiniowanym argumentem specification_dir w linijce wyżej.

Podstawy specyfikacji swaggera – czyli pliku yaml (inny format danych, coś jak JSON) który przechowuje definicję naszego API. Minimalna wersja która nic nie robi wygląda tak:

1
2
3
4
5
6
7
8
swagger: '2.0'
info
:
title
: CD's shelf'
version
: "1.0"
consumes
:
 - application/json
produces
:
 - application/json

W sumie to tylko definiujemy jaki format danych otrzymujemy (Content-Type) i jaki deklarujemy się zwracać. Rozszerzmy to o pierwszą akcje:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
swagger: '2.0'
info
:
title
: CD's shelf'
version
: "1.0"
consumes
:
 - application/json
produces
:
 - application/json

basePath
: /1.0
paths
:
/cd
:
  get
:
    operationId
: app.cd.get_cds
    summary
: Get all cds
    responses
:
      200
:
        description
: Return cds
        schema
:
          $ref
: '#/definitions/CDExt'

dodaliśmy definicję akcji która pod adresem „/cd” i metodą GET zwróci nam wszystkie płyty CD z naszej półki. Zauważ że w sekcji responses zdefiniowaliśmy jaki kod HTTP co oznacza oraz schemat odpowiedzi. Zazwyczaj pobierając dane chcemy pobierać je wraz z IDkiem elementu tak żeby ewentualnie móc go później edytować – dlatego korzystamy tutaj z schematu odpowiedzi „CDExt”.
Definicja schematu odpowiedzi wygląda następująco:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
definitions:
CD
:
  type
: object
  required
:
     - artist
    - title
  properties
:
    artist
:
      type
: string
      description
: CD artists name
      example
: "VNM"
      minLength
: 1
      maxLength
: 100
    title
:
      type
: string
      description
: Title of CD
      example
: "EDKT"
      minLength
: 1

CDExt
:
  type
: object
  allOf
:
    - $ref
: '#/definitions/CD'
    - properties
:
        cd_id
:
          type
: integer

Określa ona dwa schematy, jeden to CD i posiada pola takie jak artist i title. Schemat CDExt rozszerza schemat CD o pole „cd_id”. Skoro mamy już definicję bazową (CD) to skorzystajmy z niej i napiszmy definicję dla metody POST:

1
2
3
4
5
6
7
8
9
10
11
12
13
   post:
    operationId
: app.cd.post
    summary
: Create a CD
    parameters
:
      - name
: cd
        in
: body
        schema
:
          $ref
: '#/definitions/CD'
    responses
:
      200
:
        description
: CD created
        schema
:
          $ref
: '#/definitions/CDExt'

Definiujemy teraz że w ciele zapytania metodą POST ma się znajdować JSON o definicji zgodnej z schematem CD. Pole operationId które pominąłem opowiadając o metodzie GET wskazuje ścieżkę do metody która ma się uruchomić w momencie przyjścia żądania. I w taki sposób mamy zdefiniowane metody do pobierania i tworzenia encji płyty CD. Dodajmy metodę która zwróci konkretny, wskazany w parametrze obiekt:

1
2
3
4
5
6
7
8
9
10
11
12
13
 /cd/{cd_id}:
  get
:
    operationId
: app.cd.get_cd
    summary
: Get a cd
    parameters
:
      - $ref
: '#/parameters/cd_id'
    responses
:
      200
:
        description
: Return cd
        schema
:
          $ref
: '#/definitions/CD'
      404
:
        description
: CD does not exist

Tutaj jedyna różnica to użycie parametru, który możemy zdefiniować tak:

1
2
3
4
5
6
7
parameters:
cd_id
:
  name
: cd_id
  description
: CD ID
  in
: path
  type
: integer
  required
: true

Taki zapis powoduje że będziemy oczekiwać żądania na adres w stylu cd/{cd_id}.

Podobnie zachowamy się dla PUT’a i DELETE’a:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
   delete:
    operationId
: app.cd.delete_cd
    summary
: Remove cd
    parameters
:
      - $ref
: '#/parameters/cd_id'
    responses
:
      204
:
        description
: CD was deleted
      404
:
        description
: CD does not exist
  put
:
    operationId
: app.cd.put
    summary
: Update a CD
    parameters
:
      - $ref
: '#/parameters/cd_id'
      - name
: cd
        in
: body
        schema
:
          $ref
: '#/definitions/CD'
    responses
:
      200
:
        description
: CD updated
        schema
:
          $ref
: '#/definitions/CDExt'
      404
:
        description
: CD not found!

No i fajnie – mamy zdefiniowane całe nasze API umożliwiające modyfikacje płyt CD na naszej „półce”.

Zgodnie z polem „operationId” stworzymy metody odpowiadające naszej definicji – tworzymy folder o nazwie „app” a w nim plik __init__.py i obok niego plik cd.py:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def get_cds():
  pass

def get_cd(cd_id):
  pass

def put(cd_id, cd=None):
  pass

def delete_cd(cd_id):
  pass

def post(cd=None):
  pass

Już teraz uruchomienie naszej aplikacji poprzez

1
2
mmazurek@mmazurek ~/wpisconnectionx> python main.py
* Running on http://0.0.0.0:8080/ (Press CTRL+C to quit)

i otworzenie w przeglądarce

1
http://localhost:8080/1.0/ui/

pokaże nam:

A jak klikniemy na „List Operations” to mamy listę naszych endpointów:

Natomiast „Expand operations” pokaże nam wygenerowaną dokumentację do naszego API wraz z przykładami i możliwością testowania:

jest to ogromna wartość dodana, ponieważ modyfikując definicję automatycznie tworzymy dokumentację. Ogólnie pobaw się – fajnie.

No ale żeby to mogło faktycznie coś robić – powinno modyfikować bazę danych. Skorzystajmy z wcześniej wspomnianego ORMa i na wysokości pliku main.py stwórzmy folder „db” a w nim pliku __init__.py z zawartością:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from sqlalchemy import *
from sqlalchemy.engine.url import URL
from sqlalchemy.orm import sessionmaker, scoped_session
from sqlalchemy.ext.declarative import declarative_base

import settings

def db_connect():
  """
  Performs database connection using database settings from settings.py.
  Returns sqlalchemy engine instance
  """

  return create_engine(URL(**settings.DATABASE))

def row2dict(row):
  d = {}
  for column in row.__table__.columns:
      d[column.name] = str(getattr(row, column.name))

  return d

session = scoped_session(sessionmaker(bind=db_connect()))
Base = declarative_base()

i obok niego plik settings.py:

1
2
3
4
5
6
7
8
DATABASE = {
  'drivername': 'postgres',
  'host': 'localhost',
  'port': '5432',
  'username': 'test',
  'password': 'test',
  'database': 'wpis2'
}

Oczywiście należy mieć postgresa – ja użyłem do tego dockera – https://hub.docker.com/_/postgres/ więc u mnie wystaczy docker start postgres – u Ciebie nie wiele więcej ;)

PgAdminem logujemy się do postgresa i w schemacie public tworzymy tabelkę:

1
2
3
4
5
6
7
8
9
10
11
12
CREATE TABLE cds
(
id serial NOT NULL,
artist CHARACTER VARYING(100),
title CHARACTER VARYING(100),
CONSTRAINT pk PRIMARY KEY (id)
)
WITH (
OIDS=FALSE
);
ALTER TABLE cds
OWNER TO postgres;

I wracając do kodu – w folderze db stwórzmy folder „models” i w nim plik cd.py z zawartością:

1
2
3
4
5
6
7
8
9
10
11
12
13
from db import Base
from sqlalchemy import Column, Integer, String


class CD(Base):
  __tablename__ = 'cds'

  id = Column(Integer, primary_key=True)
  artist = Column(String)
  title = Column(String)

  def __repr__(self):
      return '<id {}>'.format(self.id)

czyli po prostu mapujemy naszą tabelkę na obiekt. Teraz możemy już zaimplementować nasz plik cd.py z katalogu app:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
from db import session, row2dict
from db.models.cd import CD

def get_cds(collected=None):
  result = session.query(CD).all()
  rows = []
  for row in result:
      rows.append(row2dict(row))

  return rows, 200

def get_cd(cd_id):
  result = session.query(CD).get(cd_id)
  if not result:
      return None, 404
  else:
      return row2dict(result), 200

def put(cd_id, cd=None):
  cd_obj = session.query(CD).get(cd_id)
  if not cd_obj:
      return None, 404
  for key, value in cd.iteritems():
      setattr(cd_obj, key, value)
  session.merge(cd_obj)
  session.commit()
  session.flush()
  return row2dict(cd_obj), 200

def delete_cd(cd_id):
  cd_obj = session.query(CD).get(cd_id)
  if not cd_obj:
      return None, 404
  session.delete(cd_obj)
  session.commit()
  session.flush()
  return None, 204

def post(cd=None):
  cd = CD(**cd)
  session.add(cd)
  session.commit()
  session.flush()

i cieszyć się stabilnym i spójnym API. Od teraz wszystkie akcje na tej stronie swaggera, będą faktycznie modyfikować bazę danych. Warto dodać, bo pominąłem to, że swagger w oparciu o definicje przeprowadza również walidacje przesyłanych danych a samo definiowanie schematów – pozwala uniknąć sytuacji gdzie raz jakaś dana jest wysyłana jako int a raz jako string.

Bierzcie i radujcie się z tego wszyscy :)

PS. Projekt udostępniam na githubie – github

Dzięki za wizytę,
Mateusz Mazurek
Mateusz M.

Ostatnie wpisy

Podsumowanie: luty i marzec 2024

Ostatnio tygodnie były tak bardzo wypełnione, że nie udało mi się napisać nawet krótkiego podsumowanie. Więc dziś zbiorczo podsumuję luty… Read More

1 tydzień ago

Podsumowanie: styczeń 2024

Zapraszam na krótkie podsumowanie miesiąca. Książki W styczniu przeczytałem "Homo Deus: Historia jutra". Książka łudząco podoba do wcześniejszej książki tego… Read More

2 miesiące ago

Podsumowanie roku 2023

Cześć! Zapraszam na podsumowanie roku 2023. Książki Zacznijmy od książek. W tym roku cel 35 książek nie został osiągnięty. Niemniej… Read More

3 miesiące ago

Podsumowanie: grudzień 2023

Zapraszam na krótkie podsumowanie miesiąca. Książki W grudniu skończyłem czytać Mein Kampf. Nudna książka. Ciekawsze fragmenty można by było streścić… Read More

4 miesiące ago

Praca zdalna – co z nią dalej?

Cześć, ostatnio w Internecie pojawiło się dużo artykułów, które nie były przychylne pracy zdalnej. Z drugiej strony większość komentarzy pod… Read More

4 miesiące ago

Podsumowanie: listopad 2023

Zapraszam na krótkie podsumowanie miesiąca. Książki W listopadzie dokończyłem cykl "Z mgły zrodzony" Sandersona. Tylko "Stop prawa" mi nie do… Read More

5 miesięcy ago