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
[email protected] ~/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
Podziel się na:
    Facebook email PDF Wykop Twitter

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *

This site uses Akismet to reduce spam. Learn how your comment data is processed.