Slide 1

Slide 1 text

PROTOTYPING NEW APIS
 WITH FLASK DAVID BAUMGOLD @SINGINGWOLFBOY TUESDAY MAY 31 PYCON 2016 IN PORTLAND slides: bit.ly/flask-api-pycon-2016
 code: github.com/singingwolfboy/build-a-flask-api

Slide 2

Slide 2 text

APIS CONNECT THE WORLD So hot
 right now bit.ly/flask-api-pycon-2016

Slide 3

Slide 3 text

LET’S BUILD ONE! bit.ly/flask-api-pycon-2016

Slide 4

Slide 4 text

YOU’VE ONLY GOT ONE WEEK bit.ly/flask-api-pycon-2016

Slide 5

Slide 5 text

LET’S BUILD ONE! FAST! bit.ly/flask-api-pycon-2016

Slide 6

Slide 6 text

WHAT DO WE WANT? • JSON data format • CRUD operations • REST semantics • Flexible code (create, read, update, delete) (it’s a prototype!) bit.ly/flask-api-pycon-2016

Slide 7

Slide 7 text

WHAT DO WE NOT CARE ABOUT? • Stability & testing • Long-term maintainability • Edge cases • Operations & deployment IT’S ONLY A PROTOTYPE! bit.ly/flask-api-pycon-2016

Slide 8

Slide 8 text

DAY ONE bit.ly/flask-api-pycon-2016

Slide 9

Slide 9 text

HELLO WORLD from flask import Flask app = Flask(__name__) @app.route("/") def hello(): return "Hello World!" if __name__ == "__main__": app.run() bit.ly/flask-api-pycon-2016

Slide 10

Slide 10 text

HELLO JSON bit.ly/flask-api-pycon-2016 from flask import Flask, jsonify app = Flask(__name__) @app.route("/") def hello(): return jsonify({"message": "Hello, World!"}) if __name__ == "__main__": app.run()

Slide 11

Slide 11 text

No content

Slide 12

Slide 12 text

HELLO ROVER bit.ly/flask-api-pycon-2016 from flask import Flask, jsonify app = Flask(__name__) @app.route("/") def get_puppy(): puppy = { "name": "Rover", "image_url": "http://example.com/rover.jpg", } return jsonify(puppy) if __name__ == "__main__": app.run()

Slide 13

Slide 13 text

from flask import Flask, jsonify, abort app = Flask(__name__) PUPPIES = [ { "name": "Rover", "image_url": "http://example.com/rover.jpg", }, { "name": "Spot", "image_url": "http://example.com/spot.jpg", }, ] @app.route("/") def get_puppy(index): try: puppy = PUPPIES[index] except IndexError: abort(404) return jsonify(puppy) if __name__ == "__main__": app.run()

Slide 14

Slide 14 text

/0 /1 /2

Slide 15

Slide 15 text

from flask import Flask, jsonify, abort app = Flask(__name__) PUPPIES = { "rover": { "name": "Rover", "image_url": "http://example.com/rover.jpg", }, "spot": { "name": "Spot", "image_url": "http://example.com/spot.jpg", }, } @app.route("/") def get_puppy(slug): try: puppy = PUPPIES[slug] except KeyError: abort(404) return jsonify(puppy) if __name__ == "__main__": app.run()

Slide 16

Slide 16 text

/rover /spot /lassie

Slide 17

Slide 17 text

IT WORKS! Good news: Bad news: ONLY STATIC
 DATA

Slide 18

Slide 18 text

DAY TWO bit.ly/flask-api-pycon-2016

Slide 19

Slide 19 text

SEPARATION OF CONCERNS Database contains data View transforms data to JSON bit.ly/flask-api-pycon-2016

Slide 20

Slide 20 text

Object Relational Mapper (ORM) bit.ly/flask-api-pycon-2016

Slide 21

Slide 21 text

SQLALCHEMY MODEL models.py bit.ly/flask-api-pycon-2016 fast lookup by slug required values from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() class Puppy(db.Model): id = db.Column(db.Integer, primary_key=True) slug = db.Column(db.String(64), index=True) name = db.Column(db.String(64), nullable=False) image_url = db.Column(db.String(128), nullable=False)

Slide 22

Slide 22 text

INSERT INTO DATABASE # create a Puppy object, and add it to the session p1 = Puppy(slug="rover", name="Rover", image_url="http://example.com/rover.jpg") db.session.add(p1) # create and add a second Puppy p2 = Puppy(slug="spot", name="Spot", image_url="http://example.com/spot.jpg") db.session.add(p2) # changes won't be saved until committed! db.session.commit() bit.ly/flask-api-pycon-2016

Slide 23

Slide 23 text

QUERY DATABASE # .all() returns a list all_puppies = Puppy.query.all() len(all_puppies) == 2 # .first() returns the first item that matches spot = Puppy.query.filter(Puppy.slug=="spot").first() spot.name == "Spot" spot.image_url == "http://example.com/spot.jpg" # .filter_by() is a shortcut rover = Puppy.query.filter_by(slug="rover").first() rover.name == "Rover" rover.image_url == "http://example.com/rover.jpg" # if no matches, .first() returns None lassie = Puppy.query.filter_by(slug="lassie").first() lassie == None bit.ly/flask-api-pycon-2016

Slide 24

Slide 24 text

UPDATE DATABASE # query to get a Puppy object spot = Puppy.query.filter(Puppy.slug=="spot").first() spot.image_url == "http://example.com/spot.jpg" # update image_url spot.image_url = "http://example.com/adorable.jpg" # updates won't be saved until committed db.session.add(spot) db.session.commit() # query again, and it's updated! p2 = Puppy.query.filter(Puppy.slug=="spot").first() p2.image_url == "http://example.com/adorable.jpg" bit.ly/flask-api-pycon-2016

Slide 25

Slide 25 text

DELETE FROM DATABASE # Query to get a Puppy object rover = Puppy.query.filter_by(slug="rover").first() rover.name == "Rover" # We can delete Rover. Sorry, buddy! db.session.delete(rover) db.session.commit() # Rover is gone! p2 = Puppy.query.filter_by(slug="rover").first() p2 == None # But the local variable still has the old information rover.name == "Rover" # However, all local variables vanish at the end of # the HTTP request bit.ly/flask-api-pycon-2016

Slide 26

Slide 26 text

LOTS MORE SQLALCHEMY DOCS docs.sqlalchemy.org bit.ly/flask-api-pycon-2016

Slide 27

Slide 27 text

INTEGRATE FLASK-SQLALCHEMY bit.ly/flask-api-pycon-2016 from flask import Flask, jsonify from models import db, Puppy app = Flask(__name__) app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///puppy.db" db.init_app(app) @app.route("/") def get_puppy(slug): puppy = Puppy.query.filter(Puppy.slug==slug).first_or_404() output = { "name": puppy.name, "image_url": puppy.image_url, } return jsonify(output) if __name__ == "__main__": app.run()

Slide 28

Slide 28 text

OOPS bit.ly/flask-api-pycon-2016

Slide 29

Slide 29 text

NEED TO CREATE DATABASE TABLES $ python puppy.py createdb Database created! bit.ly/flask-api-pycon-2016 import sys [...] if __name__ == "__main__": if "createdb" in sys.argv: with app.app_context(): db.create_all() print("Database created!") else: app.run()

Slide 30

Slide 30 text

OOPS AGAIN: NO DATA bit.ly/flask-api-pycon-2016

Slide 31

Slide 31 text

SEED DATA INTO DATABASE $ python puppy.py seeddb Database seeded! if __name__ == "__main__": if "createdb" in sys.argv: with app.app_context(): db.create_all() print("Database created!") elif "seeddb" in sys.argv: with app.app_context(): p1 = Puppy(slug="rover", name="Rover", image_url="http://example.com/rover.jpg") db.session.add(p1) p2 = Puppy(slug="spot", name="Spot", image_url="http://example.com/spot.jpg") db.session.add(p2) db.session.commit() print("Database seeded!") else: app.run()

Slide 32

Slide 32 text

/rover /spot /lassie

Slide 33

Slide 33 text

pip install slugify from slugify import slugify from flask import request from flask import url_for @app.route("/", methods=["POST"]) def create_puppy(): # validate attributes name = request.form.get("name") if not name: return "name required", 400 image_url = request.form.get("image_url") if not image_url: return "image_url required", 400 slug = slugify(name) # create in database puppy = Puppy(slug=slug, name=name, image_url=image_url) db.session.add(puppy) db.session.commit() # return HTTP response resp = jsonify({"message": "created"}) resp.status_code = 201 location = url_for("get_puppy", slug=slug) resp.headers["Location"] = location return resp

Slide 34

Slide 34 text

LET’S CREATE A PUPPY bit.ly/flask-api-pycon-2016 $ curl -X POST localhost:5000 -d name=Lassie \ -d url=http://example.com/lassie.jpg -i HTTP/1.0 201 CREATED Content-Type: application/json Content-Length: 26 Location: http://localhost:5000/lassie Server: Werkzeug/0.10.4 Python/2.7.11 Date: Mon, 25 Apr 2016 22:49:08 GMT { "message": "created" }

Slide 35

Slide 35 text

DYNAMIC DATA! Good news: Bad news: VERBOSE,
 VIEWS REACH INTO MODELS

Slide 36

Slide 36 text

from flask import Flask, jsonify from models import db, Puppy app = Flask(__name__) app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///puppy.db" db.init_app(app) @app.route("/") def get_puppy(slug): puppy = Puppy.query.filter(Puppy.slug==slug).first_or_404() output = { "name": puppy.name, "image_url": puppy.image_url, } return jsonify(output) if __name__ == "__main__": app.run() EXAMPLE bit.ly/flask-api-pycon-2016

Slide 37

Slide 37 text

DAY THREE bit.ly/flask-api-pycon-2016

Slide 38

Slide 38 text

SEPARATION OF CONCERNS Database holds all internal data View communicates with external clients Transformer
 converts between
 internal DB resources and external API resources bit.ly/flask-api-pycon-2016

Slide 39

Slide 39 text

MARSHMALLOW SCHEMA from flask_marshmallow import Marshmallow from models import Puppy ma = Marshmallow() class PuppySchema(ma.ModelSchema): class Meta: model = Puppy puppy_schema = PuppySchema() puppies_schema = PuppySchema(many=True) schemas.py bit.ly/flask-api-pycon-2016

Slide 40

Slide 40 text

INTEGRATE FLASK-MARSHMALLOW from flask import Flask, jsonify from models import db, Puppy from schemas import ma, puppy_schema app = Flask(__name__) app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///puppy.db" db.init_app(app) ma.init_app(app) @app.route("/") def get_puppy(slug): puppy = Puppy.query.filter(Puppy.slug==slug).first_or_404() return puppy_schema.jsonify(puppy)

Slide 41

Slide 41 text

DEFINE OUTPUT FIELDS schemas.py from flask_marshmallow import Marshmallow from models import Puppy ma = Marshmallow() class PuppySchema(ma.ModelSchema): class Meta: model = Puppy exclude = ["id", "slug"] puppy_schema = PuppySchema() puppies_schema = PuppySchema(many=True)

Slide 42

Slide 42 text

CREATE A PUPPY bit.ly/flask-api-pycon-2016 @app.route("/", methods=["POST"]) def create_puppy(): puppy, errors = puppy_schema.load(request.form) if errors: resp = jsonify(errors) resp.status_code = 400 return resp puppy.slug = slugify(puppy.name) db.session.add(puppy) db.session.commit() resp = jsonify({"message": "created"}) resp.status_code = 201 location = url_for("get_puppy", slug=puppy.slug) resp.headers["Location"] = location return resp

Slide 43

Slide 43 text

EDIT A PUPPY bit.ly/flask-api-pycon-2016 @app.route("/", methods=["POST"]) def edit_puppy(slug): puppy = Puppy.query.filter(Puppy.slug==slug).first_or_404() puppy, errors = puppy_schema.load(request.form, instance=puppy) if errors: resp = jsonify(errors) resp.status_code = 400 return resp puppy.slug = slugify(puppy.name) db.session.add(puppy) db.session.commit() resp = jsonify({"message": "updated"}) location = url_for("get_puppy", slug=puppy.slug) resp.headers["Location"] = location return resp

Slide 44

Slide 44 text

DELETE A PUPPY $ curl -X DELETE localhost:5000/spot { "message": "deleted" } $ curl -X DELETE localhost:5000/spot 404 Not Found

Not Found

bit.ly/flask-api-pycon-2016 @app.route("/", methods=["DELETE"]) def delete_puppy(slug): puppy = Puppy.query.filter(Puppy.slug==slug).first_or_404() db.session.delete(puppy) db.session.commit() return jsonify({"message": "deleted"})

Slide 45

Slide 45 text

LET’S JSONIFY THAT ERROR $ curl -X DELETE localhost:5000/spot { "error": "not found" } bit.ly/flask-api-pycon-2016 @app.errorhandler(404) def page_not_found(error): resp = jsonify({"error": "not found"}) resp.status_code = 404 return resp

Slide 46

Slide 46 text

$ curl localhost:5000 [ { "image_url": "http://example.com/rover.jpg", "name": "Rover" }, { "image_url": "http://example.com/spot.jpg", "name": "Spot" } ] LIST PUPPIES bit.ly/flask-api-pycon-2016 @app.route("/", methods=["GET"]) def list_puppies(): all_puppies = Puppy.query.all() data, errors = puppies_schema.dump(all_puppies) return jsonify(data)

Slide 47

Slide 47 text

class Puppy(db.Model): id = db.Column(db.Integer, primary_key=True) slug = db.Column(db.String(64), index=True) name = db.Column(db.String(64), nullable=False) image_url = db.Column(db.String(128), nullable=False) age = db.Column(db.Integer, default=1) BENEFIT: DATA FLEXIBILITY bit.ly/flask-api-pycon-2016 Modifying the data models just works! models.py $ curl localhost:5000/spot { "image_url": "http://example.com/rover.jpg", "name": "Rover", "age": 1 }

Slide 48

Slide 48 text

BENEFIT: DATA VALIDATION bit.ly/flask-api-pycon-2016 Automatic, complete error messages! $ curl -X POST localhost:5000/ { "image_url": [ "Missing data for required field." ], "name": [ "Missing data for required field." ] } (“age” is optional, so leaving it off is not an error)

Slide 49

Slide 49 text

FLEXIBILITY & VALIDATION! Good news: Bad news: BOSS SAYS
 WE NEED USERS

Slide 50

Slide 50 text

DAY FOUR Flask-Login bit.ly/flask-api-pycon-2016

Slide 51

Slide 51 text

GET /rover HTTP/1.1 Host: localhost:5000 User-Agent: curl/7.43.0 Accept: */* HTTP Request Who is the user?
 No idea. Authorized HTTP Request Look up the API key
 to find the user! HOW DO I LOGIN? bit.ly/flask-api-pycon-2016 GET /rover HTTP/1.1 Host: localhost:5000 User-Agent: curl/7.43.0 Accept: */* Authorized: my-api-key

Slide 52

Slide 52 text

USER MODEL models.py bit.ly/flask-api-pycon-2016 from flask_sqlalchemy import SQLAlchemy from flask_login import UserMixin db = SQLAlchemy() class User(db.Model, UserMixin): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(64), nullable=False) api_key = db.Column(db.String(64), unique=True, index=True) class Puppy(db.Model): [...]

Slide 53

Slide 53 text

INTEGRATE FLASK-LOGIN bit.ly/flask-api-pycon-2016 from flask import Flask from flask_login import LoginManager from models import db, User from schemas import ma app = Flask(__name__) app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///puppy.db" db.init_app(app) ma.init_app(app) login_manager = LoginManager() login_manager.init_app(app) @login_manager.request_loader def load_user_from_request(request): api_key = request.headers.get('Authorization') if not api_key: return None return User.query.filter_by(api_key=api_key).first()

Slide 54

Slide 54 text

TEST IT OUT $ curl localhost:5000/whoami { "name": "anonymous" } $ curl localhost:5000/whoami \ -H "Authorization: abc123" { "name": "Foo Bar" } (after creating the User object in the database) bit.ly/flask-api-pycon-2016 from flask_login import current_user @app.route("/whoami") def who_am_i(): if current_user.is_authenticated: name = current_user.name else: name = "anonymous" return jsonify({"name": name})

Slide 55

Slide 55 text

LOGIN-PROTECTED VIEWS $ curl localhost:5000/rover 401 Unauthorized

Unauthorized

$ curl localhost:5000/rover -H "Authorization: abc123" { "image_url": "http://example.com/rover.jpg", "name": "Rover", "age": 1 } from flask_login import login_required @app.route("/") @login_required def get_puppy(slug): puppy = Puppy.query.filter_by(slug=slug).first_or_404() return puppy_schema.jsonify(puppy)

Slide 56

Slide 56 text

LET’S JSONIFY THAT ERROR $ curl localhost:5000/rover { "error": "unauthorized" } bit.ly/flask-api-pycon-2016 @app.errorhandler(401) def unauthorized(error): resp = jsonify({"error": "unauthorized"}) resp.status_code = 401 return resp

Slide 57

Slide 57 text

DAY FIVE Pagination with Flask-SQLAlchemy Rate Limiting with Flask-Limiter Swagger documentation with Flask-APISpec …but I’m out of time! bit.ly/flask-api-pycon-2016

Slide 58

Slide 58 text

THANKS David Baumgold @singingwolfboy Presenter: Slides: Code: bit.ly/flask-api-pycon-2016 github.com/singingwolfboy/build-a-flask-api