Prototyping APIs with Flask

Prototyping APIs with Flask

You need to build a new API, but which tools do you use? Flask is a microframework that makes web development a snap, and an ecosystem of extensions and other tools has grown around it to make it perfect for prototyping APIs. In this talk, we'll see how to get started with Flask, and learn the best parts of its ecosystem for API development.

42c77de97965d620ba0b6ae624c3ba7b?s=128

David Baumgold

April 26, 2016
Tweet

Transcript

  1. 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
  2. APIS CONNECT THE WORLD So hot
 right now bit.ly/flask-api-pycon-2016

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

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

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

  6. 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
  7. 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
  8. DAY ONE bit.ly/flask-api-pycon-2016

  9. 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
  10. 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()
  11. None
  12. 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()
  13. 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("/<int:index>") def get_puppy(index): try: puppy = PUPPIES[index] except IndexError: abort(404) return jsonify(puppy) if __name__ == "__main__": app.run()
  14. /0 /1 /2

  15. 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("/<slug>") def get_puppy(slug): try: puppy = PUPPIES[slug] except KeyError: abort(404) return jsonify(puppy) if __name__ == "__main__": app.run()
  16. /rover /spot /lassie

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

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

  19. SEPARATION OF CONCERNS Database contains data View transforms data to

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

  21. 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)
  22. 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
  23. 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
  24. 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
  25. 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
  26. LOTS MORE SQLALCHEMY DOCS docs.sqlalchemy.org bit.ly/flask-api-pycon-2016

  27. 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("/<slug>") 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()
  28. OOPS bit.ly/flask-api-pycon-2016

  29. 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()
  30. OOPS AGAIN: NO DATA bit.ly/flask-api-pycon-2016

  31. 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()
  32. /rover /spot /lassie

  33. 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
  34. 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" }
  35. DYNAMIC DATA! Good news: Bad news: VERBOSE,
 VIEWS REACH INTO

    MODELS
  36. 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("/<slug>") 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
  37. DAY THREE bit.ly/flask-api-pycon-2016

  38. 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
  39. 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
  40. 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("/<slug>") def get_puppy(slug): puppy = Puppy.query.filter(Puppy.slug==slug).first_or_404() return puppy_schema.jsonify(puppy)
  41. 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)
  42. 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
  43. EDIT A PUPPY bit.ly/flask-api-pycon-2016 @app.route("/<slug>", 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
  44. DELETE A PUPPY $ curl -X DELETE localhost:5000/spot { "message":

    "deleted" } $ curl -X DELETE localhost:5000/spot <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN"> <title>404 Not Found</title> <h1>Not Found</h1> bit.ly/flask-api-pycon-2016 @app.route("/<slug>", 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"})
  45. 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
  46. $ 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)
  47. 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 }
  48. 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)
  49. FLEXIBILITY & VALIDATION! Good news: Bad news: BOSS SAYS
 WE

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

  51. 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
  52. 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): [...]
  53. 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()
  54. 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})
  55. LOGIN-PROTECTED VIEWS $ curl localhost:5000/rover <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML

    3.2 Final//EN"> <title>401 Unauthorized</title> <h1>Unauthorized</h1> $ 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("/<slug>") @login_required def get_puppy(slug): puppy = Puppy.query.filter_by(slug=slug).first_or_404() return puppy_schema.jsonify(puppy)
  56. 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
  57. 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
  58. THANKS David Baumgold @singingwolfboy Presenter: Slides: Code: bit.ly/flask-api-pycon-2016 github.com/singingwolfboy/build-a-flask-api