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/ |
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
Mateusz Mazurek