Upgrade to Pro — share decks privately, control downloads, hide ads and more …

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.

David Baumgold

April 26, 2016
Tweet

More Decks by David Baumgold

Other Decks in Technology

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

    View Slide

  2. APIS CONNECT THE WORLD
    So hot

    right now
    bit.ly/flask-api-pycon-2016

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  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

    View Slide

  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

    View Slide

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

    View Slide

  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

    View Slide

  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()

    View Slide

  11. View Slide

  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()

    View Slide

  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("/")
    def get_puppy(index):
    try:
    puppy = PUPPIES[index]
    except IndexError:
    abort(404)
    return jsonify(puppy)
    if __name__ == "__main__":
    app.run()

    View Slide

  14. /0
    /1
    /2

    View Slide

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

    View Slide

  16. /rover
    /spot
    /lassie

    View Slide

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

    DATA

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  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)

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

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

    View Slide

  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("/")
    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()

    View Slide

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

    View Slide

  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()

    View Slide

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

    View Slide

  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()

    View Slide

  32. /rover
    /spot
    /lassie

    View Slide

  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

    View Slide

  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"
    }

    View Slide

  35. DYNAMIC
    DATA!
    Good news:
    Bad news:
    VERBOSE,

    VIEWS REACH INTO
    MODELS

    View Slide

  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("/")
    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

    View Slide

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

    View Slide

  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

    View Slide

  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

    View Slide

  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("/")
    def get_puppy(slug):
    puppy = Puppy.query.filter(Puppy.slug==slug).first_or_404()
    return puppy_schema.jsonify(puppy)

    View Slide

  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)

    View Slide

  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

    View Slide

  43. 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

    View Slide

  44. 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"})

    View Slide

  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

    View Slide

  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)

    View Slide

  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
    }

    View Slide

  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)

    View Slide

  49. FLEXIBILITY &
    VALIDATION!
    Good news:
    Bad news:
    BOSS SAYS

    WE NEED USERS

    View Slide

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

    View Slide

  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

    View Slide

  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):
    [...]

    View Slide

  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()

    View Slide

  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})

    View Slide

  55. 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)

    View Slide

  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

    View Slide

  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

    View Slide

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

    View Slide