├── .gitignore ├── LICENSE ├── README.md ├── camera ├── camera.py ├── fake │ └── README.md ├── pi │ └── README.md ├── pic.jpg └── requirements-pi.txt ├── orders ├── app │ ├── __init__.py │ ├── api_v1 │ │ ├── __init__.py │ │ ├── customers.py │ │ ├── errors.py │ │ ├── items.py │ │ ├── orders.py │ │ └── products.py │ ├── auth.py │ ├── decorators │ │ ├── __init__.py │ │ ├── caching.py │ │ ├── json.py │ │ ├── paginate.py │ │ └── rate_limit.py │ ├── exceptions.py │ ├── models.py │ └── utils.py ├── config │ ├── development.py │ ├── production.py │ └── testing.py ├── run.py ├── test.py └── tests │ ├── __init__.py │ ├── test_client.py │ └── tests.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | __pycache__ 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | 30 | # Translations 31 | *.mo 32 | 33 | # Mr Developer 34 | .mr.developer.cfg 35 | .project 36 | .pydevproject 37 | 38 | # SQLite databases 39 | *.sqlite 40 | 41 | # Virtual environment 42 | venv 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Miguel Grinberg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Building Web APIs with Flask 2 | ============================ 3 | 4 | This repository contains the software that accompanies my O'Reilly training video [Building Web APIs with Flask](https://www.oreilly.com/library/view/building-web-apis/9781491912393/). 5 | 6 | [![Building Web APIs with Flask](http://img.youtube.com/vi/kO8zr-HO8GA/0.jpg)](https://www.oreilly.com/library/view/building-web-apis/9781491912393/) 7 | 8 | The class is structured around two example APIs: 9 | 10 | - **orders**: a service that exposes an orders database to clients. 11 | - **camera**: a service that allows clients to take and view pictures. This project is designed to run on a Raspberry Pi with a camera module, but can also be used on a regular computer with emulated camera hardware. 12 | 13 | Instructions on how to install and use these examples is given in the appropraite segments of the class. Please visit the [product page](https://www.oreilly.com/library/view/building-web-apis/9781491912393/) to see the contents of this video. 14 | -------------------------------------------------------------------------------- /camera/camera.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | import uuid 4 | import functools 5 | from threading import Thread 6 | from glob import glob 7 | from flask import Flask, url_for, jsonify, send_file, make_response, \ 8 | copy_current_request_context, Response, request 9 | 10 | try: 11 | # This will only work on a Raspberry Pi 12 | import picamera 13 | except: 14 | picamera = None 15 | 16 | cameras = {} # available cameras 17 | background_tasks = {} 18 | app = Flask(__name__) 19 | app.config['AUTO_DELETE_BG_TASKS'] = False 20 | 21 | 22 | # custom exceptions 23 | class InvalidCamera(ValueError): 24 | pass 25 | 26 | class InvalidPhoto(ValueError): 27 | pass 28 | 29 | # custom error handlers 30 | @app.errorhandler(InvalidCamera) 31 | def invalid_camera(e): 32 | return jsonify({'error': 'camera not found'}), 404 33 | 34 | @app.errorhandler(InvalidPhoto) 35 | def invalid_photo(e): 36 | return jsonify({'error': 'photo not found'}), 404 37 | 38 | @app.errorhandler(400) 39 | def bad_request(e=None): 40 | return jsonify({'error': 'bad request'}), 400 41 | 42 | @app.errorhandler(404) 43 | def not_found(e=None): 44 | return jsonify({'error': 'resource not found'}), 404 45 | 46 | @app.errorhandler(405) 47 | def method_not_supported(e): 48 | return jsonify({'error': 'method not supported'}), 405 49 | 50 | @app.errorhandler(500) 51 | def internal_server_error(e=None): 52 | return jsonify({'error': 'internal server error'}), 500 53 | 54 | if picamera: 55 | @app.errorhandler(picamera.PiCameraRuntimeError) 56 | def camera_is_in_use(e): 57 | return jsonify({'error': 'service unavailable'}), 503 58 | 59 | 60 | def get_camera_from_id(camid): 61 | """Return the camera object for the given camera ID.""" 62 | camera = cameras.get(camid) 63 | if camera is None: 64 | raise InvalidCamera() 65 | return camera 66 | 67 | 68 | class BaseCamera(object): 69 | """Base camera handler class.""" 70 | def __init__(self): 71 | self.camid = None # to be defined by subclasses 72 | 73 | def get_url(self): 74 | return url_for('get_camera', camid=self.camid, _external=True) 75 | 76 | def export_data(self): 77 | return {'self_url': self.get_url(), 78 | 'photos_url': self.get_photos_url(), 79 | 'timelapses_url': self.get_timelapses_url(), 80 | 'emulated': self.is_emulated()} 81 | 82 | def get_photos_url(self): 83 | return url_for('capture_photo', camid=self.camid, _external=True) 84 | 85 | def get_timelapses_url(self): 86 | return url_for('capture_timelapse', camid=self.camid, _external=True) 87 | 88 | def get_photos(self): 89 | return [os.path.basename(f) for f in glob(self.camid + '/*.jpg')] 90 | 91 | def get_photo_path(self, filename): 92 | path = self.camid + '/' + filename 93 | if not os.path.exists(path): 94 | raise InvalidPhoto() 95 | return path 96 | 97 | def get_new_photo_filename(self, suffix=''): 98 | return uuid.uuid4().hex + suffix + '.jpg' 99 | 100 | 101 | class PiCamera(BaseCamera): 102 | """Raspberry Pi camera module handler class.""" 103 | def __init__(self): 104 | super(PiCamera, self).__init__() 105 | self.camid = 'pi' 106 | 107 | def is_emulated(self): 108 | return False 109 | 110 | def capture(self): 111 | """Capture a picture.""" 112 | filename = self.get_new_photo_filename() 113 | with picamera.PiCamera() as camera: 114 | camera.resolution = (1024, 768) 115 | camera.hflip = True 116 | camera.vflip = True 117 | camera.start_preview() 118 | time.sleep(2) # wait for camera to warm up 119 | camera.capture(self.camid + '/' + filename) 120 | return filename 121 | 122 | def capture_timelapse(self, count, interval): 123 | """Capture a time lapse.""" 124 | filename = self.get_new_photo_filename('_{0:03d}_{1:03d}') 125 | with picamera.PiCamera() as camera: 126 | camera.resolution = (1024, 768) 127 | camera.hflip = True 128 | camera.vflip = True 129 | camera.start_preview() 130 | time.sleep(2) # wait for camera to warm up 131 | for i in range(count): 132 | camera.capture(self.camid + '/' + filename.format(i, count)) 133 | time.sleep(interval) 134 | return filename.format(0, count) 135 | 136 | 137 | class FakeCamera(BaseCamera): 138 | """Emulated camera handler class.""" 139 | def __init__(self): 140 | super(FakeCamera, self).__init__() 141 | self.camid = 'fake' 142 | self.fake_shot = open('pic.jpg', 'rb').read() 143 | 144 | def is_emulated(self): 145 | return True 146 | 147 | def capture(self): 148 | """Capture a (fake) picture. This really copies a stock jpeg.""" 149 | filename = self.get_new_photo_filename() 150 | open(self.camid + '/' + filename, 'wb').write(self.fake_shot) 151 | return filename 152 | 153 | def capture_timelapse(self, count, interval): 154 | """Capture a time lapse.""" 155 | filename = self.get_new_photo_filename('_{0:03d}_{1:03d}') 156 | for i in range(count): 157 | open(self.camid + '/' + filename.format(i, count), 'wb').write( 158 | self.fake_shot) 159 | time.sleep(interval) 160 | return filename.format(0, count) 161 | 162 | 163 | def is_hardware_present(): 164 | """Check if there is a Raspberry Pi camera module available.""" 165 | if picamera is None: 166 | return False 167 | try: 168 | # start the Pi camera and watch for errors 169 | with picamera.PiCamera() as camera: 170 | camera.start_preview() 171 | except: 172 | return False 173 | return True 174 | 175 | # load the cameras global with the list of available cameras 176 | cameras['fake'] = FakeCamera() 177 | if is_hardware_present(): 178 | cameras['pi'] = PiCamera() 179 | 180 | 181 | def background(f): 182 | """Decorator that runs the wrapped function as a background task. It is 183 | assumed that this function creates a new resource, and takes a long time 184 | to do so. The response has status code 202 Accepted and includes a Location 185 | header with the URL of a task resource. Sending a GET request to the task 186 | will continue to return 202 for as long as the task is running. When the task 187 | has finished, a status code 303 See Other will be returned, along with a 188 | Location header that points to the newly created resource. The client then 189 | needs to send a DELETE request to the task resource to remove it from the 190 | system.""" 191 | @functools.wraps(f) 192 | def wrapped(*args, **kwargs): 193 | # The background task needs to be decorated with Flask's 194 | # copy_current_request_context to have access to context globals. 195 | @copy_current_request_context 196 | def task(): 197 | global background_tasks 198 | try: 199 | # invoke the wrapped function and record the returned 200 | # response in the background_tasks dictionary 201 | background_tasks[id] = make_response(f(*args, **kwargs)) 202 | except: 203 | # the wrapped function raised an exception, return a 500 204 | # response 205 | background_tasks[id] = make_response(internal_server_error()) 206 | 207 | # store the background task under a randomly generated identifier 208 | # and start it 209 | global background_tasks 210 | id = uuid.uuid4().hex 211 | background_tasks[id] = Thread(target=task) 212 | background_tasks[id].start() 213 | 214 | # return a 202 Accepted response with the location of the task status 215 | # resource 216 | return jsonify({}), 202, {'Location': url_for('get_task_status', id=id)} 217 | return wrapped 218 | 219 | 220 | @app.route('/cameras/', methods=['GET']) 221 | def get_cameras(): 222 | """Return a list of available cameras.""" 223 | return jsonify({'cameras': [url_for('get_camera', camid=camid, 224 | _external=True) 225 | for camid in cameras.keys()]}) 226 | 227 | @app.route('/cameras/', methods=['GET']) 228 | def get_camera(camid): 229 | """Return information about a camera.""" 230 | camera = get_camera_from_id(camid) 231 | return jsonify(camera.export_data()) 232 | 233 | @app.route('/cameras//photos/', methods=['GET']) 234 | def get_camera_photos(camid): 235 | """Return the collection of photos of a camera.""" 236 | camera = get_camera_from_id(camid) 237 | photos = camera.get_photos() 238 | return jsonify({'photos': [url_for('get_photo', camid=camid, 239 | filename=photo, _external=True) 240 | for photo in photos]}) 241 | 242 | @app.route('/cameras//photos/', methods=['GET']) 243 | def get_photo(camid, filename): 244 | """Return a photo. Photos are in jpeg format, they can be viewed in 245 | a web browser.""" 246 | camera = get_camera_from_id(camid) 247 | path = camera.get_photo_path(filename) 248 | return send_file(path) 249 | 250 | @app.route('/cameras//photos/', methods=['POST']) 251 | def capture_photo(camid): 252 | """Capture a photo.""" 253 | camera = get_camera_from_id(camid) 254 | filename = camera.capture() 255 | return jsonify({}), 201, {'Location': url_for('get_photo', camid=camid, 256 | filename=filename, 257 | _external=True)} 258 | 259 | @app.route('/cameras//photos/', methods=['DELETE']) 260 | def delete_photo(camid, filename): 261 | """Delete a photo.""" 262 | camera = get_camera_from_id(camid) 263 | path = camera.get_photo_path(filename) 264 | os.remove(path) 265 | return jsonify({}) 266 | 267 | def stream_timelapse(path): 268 | """Stream the jpegs in a time lapse as a multipart response.""" 269 | parts = path.split('.')[0].split('_') 270 | count = int(parts[2]) 271 | filename = parts[0] + '_{0:03d}_{1:03d}.jpg' 272 | for i in range(count): 273 | frame = open(filename.format(i, count), 'rb').read() 274 | yield b'--frame\r\nContent-Type: image/jpeg\r\nContent-Length: ' + \ 275 | str(len(frame)).encode() + b'\r\n\r\n' + frame + b'\r\n' 276 | time.sleep(0.5) 277 | 278 | @app.route('/cameras//timelapses/', methods=['GET']) 279 | def get_timelapse(camid, filename): 280 | """Return a time lapse sequence. Time lapses are returned as a streamed 281 | multipart response. Most browsers display the sequence of pictures.""" 282 | camera = get_camera_from_id(camid) 283 | path = camera.get_photo_path(filename) 284 | return Response(stream_timelapse(path), 285 | mimetype='multipart/x-mixed-replace; boundary=frame') 286 | 287 | @app.route('/cameras//timelapses//html', methods=['GET']) 288 | def get_timelapse_html(camid, filename): 289 | """Return an HTML wrapper page for the timelapse stream. This is required 290 | by some browsers (i.e Chrome).""" 291 | return ''.format(url_for('get_timelapse', camid=camid, 292 | filename=filename)) 293 | 294 | @app.route('/cameras//timelapses/', methods=['POST']) 295 | @background 296 | def capture_timelapse(camid): 297 | """Capture a 30 second time lapse sequence, at a rate of a picture per 298 | second. Note this is an asynchronous request.""" 299 | count = request.args.get('count', 30, type=int) 300 | interval = request.args.get('interval', 1, type=float) 301 | camera = get_camera_from_id(camid) 302 | filename = camera.capture_timelapse(count, interval) 303 | return jsonify({}), 201, {'Location': url_for('get_timelapse', 304 | camid=camid, 305 | filename=filename, 306 | _external=True)} 307 | 308 | @app.route('/status/', methods=['GET']) 309 | def get_task_status(id): 310 | """Query the status of an asynchronous task.""" 311 | # obtain the task and validate it 312 | global background_tasks 313 | rv = background_tasks.get(id) 314 | if rv is None: 315 | return not_found(None) 316 | 317 | # if the task object is a Thread object that means that the task is still 318 | # running. In this case return the 202 status message again. 319 | if isinstance(rv, Thread): 320 | return jsonify({}), 202, {'Location': url_for('get_task_status', id=id)} 321 | 322 | # If the task object is not a Thread then it is assumed to be the response 323 | # of the finished task, so that is the response that is returned. 324 | # If the application is configured to auto-delete task status resources once 325 | # the task is done then the deletion happens now, if not the client is 326 | # expected to send a delete request. 327 | if app.config['AUTO_DELETE_BG_TASKS']: 328 | del background_tasks[id] 329 | return rv 330 | 331 | @app.route('/status/', methods=['DELETE']) 332 | def delete_task_status(id): 333 | """Delete an asynchronous task resource.""" 334 | # obtain the task and validate it 335 | global background_tasks 336 | rv = background_tasks.get(id) 337 | if rv is None: 338 | return not_found(None) 339 | 340 | # if the task is still running it cannot be deleted 341 | if isinstance(rv, Thread): 342 | return bad_request() 343 | 344 | del background_tasks[id] 345 | return jsonify({}), 200 346 | 347 | 348 | if __name__ == '__main__': 349 | app.run(host='0.0.0.0', debug=True) 350 | -------------------------------------------------------------------------------- /camera/fake/README.md: -------------------------------------------------------------------------------- 1 | This directory contains pictures captured with the "fake" camera. 2 | -------------------------------------------------------------------------------- /camera/pi/README.md: -------------------------------------------------------------------------------- 1 | This directory contains pictures captured with the "pi" camera. 2 | -------------------------------------------------------------------------------- /camera/pic.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miguelgrinberg/oreilly-flask-apis-video/a0222ecda2ea7fb8db382732b11d3fe1a127b221/camera/pic.jpg -------------------------------------------------------------------------------- /camera/requirements-pi.txt: -------------------------------------------------------------------------------- 1 | Flask==0.10.1 2 | Jinja2==2.7.3 3 | MarkupSafe==0.23 4 | Werkzeug==0.9.6 5 | httpie==0.8.0 6 | itsdangerous==0.24 7 | picamera==1.5 8 | -------------------------------------------------------------------------------- /orders/app/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from flask import Flask, jsonify, g 3 | from flask.ext.sqlalchemy import SQLAlchemy 4 | from .decorators import json, no_cache, rate_limit 5 | 6 | db = SQLAlchemy() 7 | 8 | 9 | def create_app(config_name): 10 | """Create an application instance.""" 11 | app = Flask(__name__) 12 | 13 | # apply configuration 14 | cfg = os.path.join(os.getcwd(), 'config', config_name + '.py') 15 | app.config.from_pyfile(cfg) 16 | 17 | # initialize extensions 18 | db.init_app(app) 19 | 20 | # register blueprints 21 | from .api_v1 import api as api_blueprint 22 | app.register_blueprint(api_blueprint, url_prefix='/api/v1') 23 | 24 | # register an after request handler 25 | @app.after_request 26 | def after_request(rv): 27 | headers = getattr(g, 'headers', {}) 28 | rv.headers.extend(headers) 29 | return rv 30 | 31 | # authentication token route 32 | from .auth import auth 33 | @app.route('/get-auth-token') 34 | @auth.login_required 35 | @rate_limit(1, 600) # one call per 10 minute period 36 | @no_cache 37 | @json 38 | def get_auth_token(): 39 | return {'token': g.user.generate_auth_token()} 40 | 41 | return app 42 | -------------------------------------------------------------------------------- /orders/app/api_v1/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | from ..auth import auth_token 3 | from ..decorators import etag, rate_limit 4 | 5 | api = Blueprint('api', __name__) 6 | 7 | 8 | @api.before_request 9 | @rate_limit(limit=5, period=15) 10 | @auth_token.login_required 11 | def before_request(): 12 | """All routes in this blueprint require authentication.""" 13 | pass 14 | 15 | 16 | @api.after_request 17 | @etag 18 | def after_request(rv): 19 | """Generate an ETag header for all routes in this blueprint.""" 20 | return rv 21 | 22 | 23 | from . import customers, products, orders, items, errors 24 | -------------------------------------------------------------------------------- /orders/app/api_v1/customers.py: -------------------------------------------------------------------------------- 1 | from flask import request 2 | from . import api 3 | from .. import db 4 | from ..models import Customer 5 | from ..decorators import json, paginate 6 | 7 | 8 | @api.route('/customers/', methods=['GET']) 9 | @json 10 | @paginate('customers') 11 | def get_customers(): 12 | return Customer.query 13 | 14 | @api.route('/customers/', methods=['GET']) 15 | @json 16 | def get_customer(id): 17 | return Customer.query.get_or_404(id) 18 | 19 | @api.route('/customers/', methods=['POST']) 20 | @json 21 | def new_customer(): 22 | customer = Customer() 23 | customer.import_data(request.json) 24 | db.session.add(customer) 25 | db.session.commit() 26 | return {}, 201, {'Location': customer.get_url()} 27 | 28 | @api.route('/customers/', methods=['PUT']) 29 | @json 30 | def edit_customer(id): 31 | customer = Customer.query.get_or_404(id) 32 | customer.import_data(request.json) 33 | db.session.add(customer) 34 | db.session.commit() 35 | return {} 36 | -------------------------------------------------------------------------------- /orders/app/api_v1/errors.py: -------------------------------------------------------------------------------- 1 | from flask import jsonify 2 | from ..exceptions import ValidationError 3 | from . import api 4 | 5 | 6 | @api.errorhandler(ValidationError) 7 | def bad_request(e): 8 | response = jsonify({'status': 400, 'error': 'bad request', 9 | 'message': e.args[0]}) 10 | response.status_code = 400 11 | return response 12 | 13 | 14 | @api.app_errorhandler(404) # this has to be an app-wide handler 15 | def not_found(e): 16 | response = jsonify({'status': 404, 'error': 'not found', 17 | 'message': 'invalid resource URI'}) 18 | response.status_code = 404 19 | return response 20 | 21 | 22 | @api.errorhandler(405) 23 | def method_not_supported(e): 24 | response = jsonify({'status': 405, 'error': 'method not supported', 25 | 'message': 'the method is not supported'}) 26 | response.status_code = 405 27 | return response 28 | 29 | 30 | @api.app_errorhandler(500) # this has to be an app-wide handler 31 | def internal_server_error(e): 32 | response = jsonify({'status': 500, 'error': 'internal server error', 33 | 'message': e.args[0]}) 34 | response.status_code = 500 35 | return response 36 | -------------------------------------------------------------------------------- /orders/app/api_v1/items.py: -------------------------------------------------------------------------------- 1 | from flask import request 2 | from . import api 3 | from .. import db 4 | from ..models import Order, Item 5 | from ..decorators import json, paginate 6 | 7 | 8 | @api.route('/orders//items/', methods=['GET']) 9 | @json 10 | @paginate('items') 11 | def get_order_items(id): 12 | order = Order.query.get_or_404(id) 13 | return order.items 14 | 15 | @api.route('/items/', methods=['GET']) 16 | @json 17 | def get_item(id): 18 | return Item.query.get_or_404(id).export_data() 19 | 20 | @api.route('/orders//items/', methods=['POST']) 21 | @json 22 | def new_order_item(id): 23 | order = Order.query.get_or_404(id) 24 | item = Item(order=order) 25 | item.import_data(request.json) 26 | db.session.add(item) 27 | db.session.commit() 28 | return {}, 201, {'Location': item.get_url()} 29 | 30 | @api.route('/items/', methods=['PUT']) 31 | @json 32 | def edit_item(id): 33 | item = Item.query.get_or_404(id) 34 | item.import_data(request.json) 35 | db.session.add(item) 36 | db.session.commit() 37 | return {} 38 | 39 | @api.route('/items/', methods=['DELETE']) 40 | @json 41 | def delete_item(id): 42 | item = Item.query.get_or_404(id) 43 | db.session.delete(item) 44 | db.session.commit() 45 | return {} 46 | -------------------------------------------------------------------------------- /orders/app/api_v1/orders.py: -------------------------------------------------------------------------------- 1 | from flask import request 2 | from . import api 3 | from .. import db 4 | from ..models import Order, Customer 5 | from ..decorators import json, paginate 6 | 7 | 8 | @api.route('/orders/', methods=['GET']) 9 | @json 10 | @paginate('orders') 11 | def get_orders(): 12 | return Order.query 13 | 14 | @api.route('/customers//orders/', methods=['GET']) 15 | @json 16 | @paginate('orders') 17 | def get_customer_orders(id): 18 | customer = Customer.query.get_or_404(id) 19 | return customer.orders 20 | 21 | @api.route('/orders/', methods=['GET']) 22 | @json 23 | def get_order(id): 24 | return Order.query.get_or_404(id) 25 | 26 | @api.route('/customers//orders/', methods=['POST']) 27 | @json 28 | def new_customer_order(id): 29 | customer = Customer.query.get_or_404(id) 30 | order = Order(customer=customer) 31 | order.import_data(request.json) 32 | db.session.add(order) 33 | db.session.commit() 34 | return {}, 201, {'Location': order.get_url()} 35 | 36 | @api.route('/orders/', methods=['PUT']) 37 | @json 38 | def edit_order(id): 39 | order = Order.query.get_or_404(id) 40 | order.import_data(request.json) 41 | db.session.add(order) 42 | db.session.commit() 43 | return {} 44 | 45 | @api.route('/orders/', methods=['DELETE']) 46 | @json 47 | def delete_order(id): 48 | order = Order.query.get_or_404(id) 49 | db.session.delete(order) 50 | db.session.commit() 51 | return {} 52 | -------------------------------------------------------------------------------- /orders/app/api_v1/products.py: -------------------------------------------------------------------------------- 1 | from flask import request 2 | from . import api 3 | from .. import db 4 | from ..models import Product 5 | from ..decorators import json, paginate 6 | 7 | 8 | @api.route('/products/', methods=['GET']) 9 | @json 10 | @paginate('products') 11 | def get_products(): 12 | return Product.query 13 | 14 | @api.route('/products/', methods=['GET']) 15 | @json 16 | def get_product(id): 17 | return Product.query.get_or_404(id) 18 | 19 | @api.route('/products/', methods=['POST']) 20 | @json 21 | def new_product(): 22 | product = Product() 23 | product.import_data(request.json) 24 | db.session.add(product) 25 | db.session.commit() 26 | return {}, 201, {'Location': product.get_url()} 27 | 28 | @api.route('/products/', methods=['PUT']) 29 | @json 30 | def edit_product(id): 31 | product = Product.query.get_or_404(id) 32 | product.import_data(request.json) 33 | db.session.add(product) 34 | db.session.commit() 35 | return {} 36 | -------------------------------------------------------------------------------- /orders/app/auth.py: -------------------------------------------------------------------------------- 1 | from flask import jsonify, g, current_app 2 | from flask.ext.httpauth import HTTPBasicAuth 3 | from .models import User 4 | 5 | auth = HTTPBasicAuth() 6 | auth_token = HTTPBasicAuth() 7 | 8 | 9 | @auth.verify_password 10 | def verify_password(username, password): 11 | g.user = User.query.filter_by(username=username).first() 12 | if g.user is None: 13 | return False 14 | return g.user.verify_password(password) 15 | 16 | @auth.error_handler 17 | def unauthorized(): 18 | response = jsonify({'status': 401, 'error': 'unauthorized', 19 | 'message': 'please authenticate'}) 20 | response.status_code = 401 21 | return response 22 | 23 | @auth_token.verify_password 24 | def verify_auth_token(token, unused): 25 | if current_app.config.get('IGNORE_AUTH') is True: 26 | g.user = User.query.get(1) 27 | else: 28 | g.user = User.verify_auth_token(token) 29 | return g.user is not None 30 | 31 | @auth_token.error_handler 32 | def unauthorized_token(): 33 | response = jsonify({'status': 401, 'error': 'unauthorized', 34 | 'message': 'please send your authentication token'}) 35 | response.status_code = 401 36 | return response 37 | -------------------------------------------------------------------------------- /orders/app/decorators/__init__.py: -------------------------------------------------------------------------------- 1 | from .json import json 2 | from .paginate import paginate 3 | from .caching import cache_control, no_cache, etag 4 | from .rate_limit import rate_limit -------------------------------------------------------------------------------- /orders/app/decorators/caching.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import hashlib 3 | from flask import request, make_response, jsonify 4 | 5 | 6 | def cache_control(*directives): 7 | """Insert a Cache-Control header with the given directives.""" 8 | def decorator(f): 9 | @functools.wraps(f) 10 | def wrapped(*args, **kwargs): 11 | # invoke the wrapped function 12 | rv = f(*args, **kwargs) 13 | 14 | # convert the returned value to a response object 15 | rv = make_response(rv) 16 | 17 | # insert the Cache-Control header and return response 18 | rv.headers['Cache-Control'] = ', '.join(directives) 19 | return rv 20 | return wrapped 21 | return decorator 22 | 23 | 24 | def no_cache(f): 25 | """Insert a no-cache directive in the response. This decorator just 26 | invokes the cache-control decorator with the specific directives.""" 27 | return cache_control('private', 'no-cache', 'no-store', 'max-age=0')(f) 28 | 29 | 30 | def etag(f): 31 | """Add entity tag (etag) handling to the decorated route.""" 32 | @functools.wraps(f) 33 | def wrapped(*args, **kwargs): 34 | # invoke the wrapped function and generate a response object from 35 | # its result 36 | rv = f(*args, **kwargs) 37 | rv = make_response(rv) 38 | 39 | # etags only make sense for request that are cacheable, so only 40 | # GET and HEAD requests are allowed 41 | if request.method not in ['GET', 'HEAD']: 42 | return rv 43 | 44 | # if the response is not a code 200 OK then we let it through 45 | # unchanged 46 | if rv.status_code != 200: 47 | return rv 48 | 49 | # compute the etag for this request as the MD5 hash of the response 50 | # text and set it in the response header 51 | etag = '"' + hashlib.md5(rv.get_data()).hexdigest() + '"' 52 | rv.headers['ETag'] = etag 53 | 54 | # handle If-Match and If-None-Match request headers if present 55 | if_match = request.headers.get('If-Match') 56 | if_none_match = request.headers.get('If-None-Match') 57 | if if_match: 58 | # only return the response if the etag for this request matches 59 | # any of the etags given in the If-Match header. If there is no 60 | # match, then return a 412 Precondition Failed status code 61 | etag_list = [tag.strip() for tag in if_match.split(',')] 62 | if etag not in etag_list and '*' not in etag_list: 63 | response = jsonify({'status': 412, 'error': 'precondition failed', 64 | 'message': 'precondition failed'}) 65 | response.status_code = 412 66 | return response 67 | elif if_none_match: 68 | # only return the response if the etag for this request does not 69 | # match any of the etags given in the If-None-Match header. If 70 | # one matches, then return a 304 Not Modified status code 71 | etag_list = [tag.strip() for tag in if_none_match.split(',')] 72 | if etag in etag_list or '*' in etag_list: 73 | response = jsonify({'status': 304, 'error': 'not modified', 74 | 'message': 'resource not modified'}) 75 | response.status_code = 304 76 | return response 77 | return rv 78 | return wrapped 79 | -------------------------------------------------------------------------------- /orders/app/decorators/json.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from flask import jsonify 3 | 4 | 5 | def json(f): 6 | """Generate a JSON response from a database model or a Python 7 | dictionary.""" 8 | @functools.wraps(f) 9 | def wrapped(*args, **kwargs): 10 | # invoke the wrapped function 11 | rv = f(*args, **kwargs) 12 | 13 | # the wrapped function can return the dictionary alone, 14 | # or can also include a status code and/or headers. 15 | # here we separate all these items 16 | status = None 17 | headers = None 18 | if isinstance(rv, tuple): 19 | rv, status, headers = rv + (None,) * (3 - len(rv)) 20 | if isinstance(status, (dict, list)): 21 | headers, status = status, None 22 | 23 | # if the response was a database model, then convert it to a 24 | # dictionary 25 | if not isinstance(rv, dict): 26 | rv = rv.export_data() 27 | 28 | # generate the JSON response 29 | rv = jsonify(rv) 30 | if status is not None: 31 | rv.status_code = status 32 | if headers is not None: 33 | rv.headers.extend(headers) 34 | return rv 35 | return wrapped 36 | -------------------------------------------------------------------------------- /orders/app/decorators/paginate.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from flask import url_for, request 3 | 4 | 5 | def paginate(collection, max_per_page=25): 6 | """Generate a paginated response for a resource collection. 7 | 8 | Routes that use this decorator must return a SQLAlchemy query as a 9 | response. 10 | 11 | The output of this decorator is a Python dictionary with the paginated 12 | results. The application must ensure that this result is converted to a 13 | response object, either by chaining another decorator or by using a 14 | custom response object that accepts dictionaries.""" 15 | def decorator(f): 16 | @functools.wraps(f) 17 | def wrapped(*args, **kwargs): 18 | # invoke the wrapped function 19 | query = f(*args, **kwargs) 20 | 21 | # obtain pagination arguments from the URL's query string 22 | page = request.args.get('page', 1, type=int) 23 | per_page = min(request.args.get('per_page', max_per_page, 24 | type=int), max_per_page) 25 | expanded = None 26 | if request.args.get('expanded', 0, type=int) != 0: 27 | expanded = 1 28 | 29 | # run the query with Flask-SQLAlchemy's pagination 30 | p = query.paginate(page, per_page) 31 | 32 | # build the pagination metadata to include in the response 33 | pages = {'page': page, 'per_page': per_page, 34 | 'total': p.total, 'pages': p.pages} 35 | if p.has_prev: 36 | pages['prev_url'] = url_for(request.endpoint, page=p.prev_num, 37 | per_page=per_page, 38 | expanded=expanded, _external=True, 39 | **kwargs) 40 | else: 41 | pages['prev_url'] = None 42 | if p.has_next: 43 | pages['next_url'] = url_for(request.endpoint, page=p.next_num, 44 | per_page=per_page, 45 | expanded=expanded, _external=True, 46 | **kwargs) 47 | else: 48 | pages['next_url'] = None 49 | pages['first_url'] = url_for(request.endpoint, page=1, 50 | per_page=per_page, expanded=expanded, 51 | _external=True, **kwargs) 52 | pages['last_url'] = url_for(request.endpoint, page=p.pages, 53 | per_page=per_page, expanded=expanded, 54 | _external=True, **kwargs) 55 | 56 | # generate the paginated collection as a dictionary 57 | if expanded: 58 | results = [item.export_data() for item in p.items] 59 | else: 60 | results = [item.get_url() for item in p.items] 61 | 62 | # return a dictionary as a response 63 | return {collection: results, 'pages': pages} 64 | return wrapped 65 | return decorator 66 | -------------------------------------------------------------------------------- /orders/app/decorators/rate_limit.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from time import time 3 | from flask import current_app, request, g, jsonify 4 | 5 | _limiter = None 6 | 7 | 8 | class MemRateLimit(object): 9 | """Rate limiter that uses a Python dictionary as storage.""" 10 | def __init__(self): 11 | self.counters = {} 12 | 13 | def is_allowed(self, key, limit, period): 14 | """Check if the client's request should be allowed, based on the 15 | hit counter. Returns a 3-element tuple with a True/False result, 16 | the number of remaining hits in the period, and the time the 17 | counter resets for the next period.""" 18 | now = int(time()) 19 | begin_period = now // period * period 20 | end_period = begin_period + period 21 | 22 | self.cleanup(now) 23 | if key in self.counters: 24 | self.counters[key]['hits'] += 1 25 | else: 26 | self.counters[key] = {'hits': 1, 'reset': end_period} 27 | allow = True 28 | remaining = limit - self.counters[key]['hits'] 29 | if remaining < 0: 30 | remaining = 0 31 | allow = False 32 | return allow, remaining, self.counters[key]['reset'] 33 | 34 | def cleanup(self, now): 35 | """Eliminate expired keys.""" 36 | for key, value in list(self.counters.items()): 37 | if value['reset'] < now: 38 | del self.counters[key] 39 | 40 | 41 | def rate_limit(limit, period): 42 | """Limits the rate at which clients can send requests to 'limit' requests 43 | per 'period' seconds. Once a client goes over the limit all requests are 44 | answered with a status code 429 Too Many Requests for the remaining of 45 | that period.""" 46 | def decorator(f): 47 | @functools.wraps(f) 48 | def wrapped(*args, **kwargs): 49 | if current_app.config['TESTING']: 50 | # no rate limiting for debug and testing configurations 51 | return f(*args, **kwargs) 52 | else: 53 | # initialize the rate limiter the first time here 54 | global _limiter 55 | if _limiter is None: 56 | _limiter = MemRateLimit() 57 | 58 | # generate a unique key to represent the decorated function and 59 | # the IP address of the client. Rate limiting counters are 60 | # maintained on each unique key. 61 | key = '{0}/{1}'.format(f.__name__, request.remote_addr) 62 | allowed, remaining, reset = _limiter.is_allowed(key, limit, 63 | period) 64 | 65 | # set the rate limit headers in g, so that they are picked up 66 | # by the after_request handler and attached to the response 67 | g.headers = { 68 | 'X-RateLimit-Remaining': str(remaining), 69 | 'X-RateLimit-Limit': str(limit), 70 | 'X-RateLimit-Reset': str(reset) 71 | } 72 | 73 | # if the client went over the limit respond with a 429 status 74 | # code, else invoke the wrapped function 75 | if not allowed: 76 | response = jsonify( 77 | {'status': 429, 'error': 'too many requests', 78 | 'message': 'You have exceeded your request rate'}) 79 | response.status_code = 429 80 | return response 81 | 82 | # else we let the request through 83 | return f(*args, **kwargs) 84 | return wrapped 85 | return decorator 86 | -------------------------------------------------------------------------------- /orders/app/exceptions.py: -------------------------------------------------------------------------------- 1 | class ValidationError(ValueError): 2 | pass 3 | 4 | 5 | -------------------------------------------------------------------------------- /orders/app/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from dateutil import parser as datetime_parser 3 | from dateutil.tz import tzutc 4 | from werkzeug.security import generate_password_hash, check_password_hash 5 | from itsdangerous import TimedJSONWebSignatureSerializer as Serializer 6 | from flask import url_for, current_app 7 | from . import db 8 | from .exceptions import ValidationError 9 | from .utils import split_url 10 | 11 | 12 | class User(db.Model): 13 | __tablename__ = 'users' 14 | id = db.Column(db.Integer, primary_key=True) 15 | username = db.Column(db.String(64), index=True) 16 | password_hash = db.Column(db.String(128)) 17 | 18 | def set_password(self, password): 19 | self.password_hash = generate_password_hash(password) 20 | 21 | def verify_password(self, password): 22 | return check_password_hash(self.password_hash, password) 23 | 24 | def generate_auth_token(self, expires_in=3600): 25 | s = Serializer(current_app.config['SECRET_KEY'], expires_in=expires_in) 26 | return s.dumps({'id': self.id}).decode('utf-8') 27 | 28 | @staticmethod 29 | def verify_auth_token(token): 30 | s = Serializer(current_app.config['SECRET_KEY']) 31 | try: 32 | data = s.loads(token) 33 | except: 34 | return None 35 | return User.query.get(data['id']) 36 | 37 | 38 | class Customer(db.Model): 39 | __tablename__ = 'customers' 40 | id = db.Column(db.Integer, primary_key=True) 41 | name = db.Column(db.String(64), index=True) 42 | orders = db.relationship('Order', backref='customer', lazy='dynamic') 43 | 44 | def get_url(self): 45 | return url_for('api.get_customer', id=self.id, _external=True) 46 | 47 | def export_data(self): 48 | return { 49 | 'self_url': self.get_url(), 50 | 'name': self.name, 51 | 'orders_url': url_for('api.get_customer_orders', id=self.id, 52 | _external=True) 53 | } 54 | 55 | def import_data(self, data): 56 | try: 57 | self.name = data['name'] 58 | except KeyError as e: 59 | raise ValidationError('Invalid customer: missing ' + e.args[0]) 60 | return self 61 | 62 | 63 | class Product(db.Model): 64 | __tablename__ = 'products' 65 | id = db.Column(db.Integer, primary_key=True) 66 | name = db.Column(db.String(64), index=True) 67 | items = db.relationship('Item', backref='product', lazy='dynamic') 68 | 69 | def get_url(self): 70 | return url_for('api.get_product', id=self.id, _external=True) 71 | 72 | def export_data(self): 73 | return { 74 | 'self_url': self.get_url(), 75 | 'name': self.name 76 | } 77 | 78 | def import_data(self, data): 79 | try: 80 | self.name = data['name'] 81 | except KeyError as e: 82 | raise ValidationError('Invalid product: missing ' + e.args[0]) 83 | return self 84 | 85 | 86 | class Order(db.Model): 87 | __tablename__ = 'orders' 88 | id = db.Column(db.Integer, primary_key=True) 89 | customer_id = db.Column(db.Integer, db.ForeignKey('customers.id'), 90 | index=True) 91 | date = db.Column(db.DateTime, default=datetime.now) 92 | items = db.relationship('Item', backref='order', lazy='dynamic', 93 | cascade='all, delete-orphan') 94 | 95 | def get_url(self): 96 | return url_for('api.get_order', id=self.id, _external=True) 97 | 98 | def export_data(self): 99 | return { 100 | 'self_url': self.get_url(), 101 | 'customer_url': self.customer.get_url(), 102 | 'date': self.date.isoformat() + 'Z', 103 | 'items_url': url_for('api.get_order_items', id=self.id, 104 | _external=True) 105 | } 106 | 107 | def import_data(self, data): 108 | try: 109 | self.date = datetime_parser.parse(data['date']).astimezone( 110 | tzutc()).replace(tzinfo=None) 111 | except KeyError as e: 112 | raise ValidationError('Invalid order: missing ' + e.args[0]) 113 | return self 114 | 115 | 116 | class Item(db.Model): 117 | __tablename__ = 'items' 118 | id = db.Column(db.Integer, primary_key=True) 119 | order_id = db.Column(db.Integer, db.ForeignKey('orders.id'), index=True) 120 | product_id = db.Column(db.Integer, db.ForeignKey('products.id'), 121 | index=True) 122 | quantity = db.Column(db.Integer) 123 | 124 | def get_url(self): 125 | return url_for('api.get_item', id=self.id, _external=True) 126 | 127 | def export_data(self): 128 | return { 129 | 'self_url': self.get_url(), 130 | 'order_url': self.order.get_url(), 131 | 'product_url': self.product.get_url(), 132 | 'quantity': self.quantity 133 | } 134 | 135 | def import_data(self, data): 136 | try: 137 | endpoint, args = split_url(data['product_url']) 138 | self.quantity = int(data['quantity']) 139 | except KeyError as e: 140 | raise ValidationError('Invalid order: missing ' + e.args[0]) 141 | if endpoint != 'api.get_product' or not 'id' in args: 142 | raise ValidationError('Invalid product URL: ' + 143 | data['product_url']) 144 | self.product = Product.query.get(args['id']) 145 | if self.product is None: 146 | raise ValidationError('Invalid product URL: ' + 147 | data['product_url']) 148 | return self 149 | -------------------------------------------------------------------------------- /orders/app/utils.py: -------------------------------------------------------------------------------- 1 | from flask.globals import _app_ctx_stack, _request_ctx_stack 2 | from werkzeug.urls import url_parse 3 | from werkzeug.exceptions import NotFound 4 | from .exceptions import ValidationError 5 | 6 | 7 | def split_url(url, method='GET'): 8 | """Returns the endpoint name and arguments that match a given URL. In 9 | other words, this is the reverse of Flask's url_for().""" 10 | appctx = _app_ctx_stack.top 11 | reqctx = _request_ctx_stack.top 12 | if appctx is None: 13 | raise RuntimeError('Attempted to match a URL without the ' 14 | 'application context being pushed. This has to be ' 15 | 'executed when application context is available.') 16 | 17 | if reqctx is not None: 18 | url_adapter = reqctx.url_adapter 19 | else: 20 | url_adapter = appctx.url_adapter 21 | if url_adapter is None: 22 | raise RuntimeError('Application was not able to create a URL ' 23 | 'adapter for request independent URL matching. ' 24 | 'You might be able to fix this by setting ' 25 | 'the SERVER_NAME config variable.') 26 | parsed_url = url_parse(url) 27 | if parsed_url.netloc is not '' and \ 28 | parsed_url.netloc != url_adapter.server_name: 29 | raise ValidationError('Invalid URL: ' + url) 30 | try: 31 | result = url_adapter.match(parsed_url.path, method) 32 | except NotFound: 33 | raise ValidationError('Invalid URL: ' + url) 34 | return result -------------------------------------------------------------------------------- /orders/config/development.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | basedir = os.path.abspath(os.path.dirname(__file__)) 4 | db_path = os.path.join(basedir, '../data-dev.sqlite') 5 | 6 | DEBUG = True 7 | IGNORE_AUTH = True 8 | SECRET_KEY = 'top-secret!' 9 | SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ 10 | 'sqlite:///' + db_path 11 | -------------------------------------------------------------------------------- /orders/config/production.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | basedir = os.path.abspath(os.path.dirname(__file__)) 4 | db_path = os.path.join(basedir, '../data.sqlite') 5 | 6 | DEBUG = False 7 | SECRET_KEY = 'top-secret!' 8 | SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ 9 | 'sqlite:///' + db_path 10 | -------------------------------------------------------------------------------- /orders/config/testing.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | basedir = os.path.abspath(os.path.dirname(__file__)) 4 | db_path = os.path.join(basedir, '../data-test.sqlite') 5 | 6 | DEBUG = False 7 | TESTING = True 8 | SECRET_KEY = 'top-secret!' 9 | SERVER_NAME = 'example.com' 10 | SQLALCHEMY_DATABASE_URI = 'sqlite:///' + db_path 11 | -------------------------------------------------------------------------------- /orders/run.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | from app import create_app, db 4 | from app.models import User 5 | 6 | if __name__ == '__main__': 7 | app = create_app(os.environ.get('FLASK_CONFIG', 'development')) 8 | with app.app_context(): 9 | db.create_all() 10 | # create a development user 11 | if User.query.get(1) is None: 12 | u = User(username='john') 13 | u.set_password('cat') 14 | db.session.add(u) 15 | db.session.commit() 16 | app.run() 17 | -------------------------------------------------------------------------------- /orders/test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import coverage 3 | COV = coverage.coverage(branch=True, include='app/*') 4 | COV.start() 5 | 6 | import unittest 7 | from tests import suite 8 | unittest.TextTestRunner(verbosity=2).run(suite) 9 | 10 | COV.stop() 11 | COV.report() 12 | -------------------------------------------------------------------------------- /orders/tests/__init__.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from .tests import TestAPI 3 | 4 | suite = unittest.TestLoader().loadTestsFromTestCase(TestAPI) -------------------------------------------------------------------------------- /orders/tests/test_client.py: -------------------------------------------------------------------------------- 1 | from base64 import b64encode 2 | import json 3 | from urllib.parse import urlsplit, urlunsplit 4 | 5 | 6 | class TestClient(): 7 | def __init__(self, app, username, password): 8 | self.app = app 9 | self.auth = 'Basic ' + b64encode((username + ':' + password) 10 | .encode('utf-8')).decode('utf-8') 11 | 12 | def send(self, url, method='GET', data=None, headers={}): 13 | # for testing, URLs just need to have the path and query string 14 | url_parsed = urlsplit(url) 15 | url = urlunsplit(('', '', url_parsed.path, url_parsed.query, 16 | url_parsed.fragment)) 17 | 18 | # append the autnentication headers to all requests 19 | headers = headers.copy() 20 | headers['Authorization'] = self.auth 21 | headers['Content-Type'] = 'application/json' 22 | headers['Accept'] = 'application/json' 23 | 24 | # convert JSON data to a string 25 | if data: 26 | data = json.dumps(data) 27 | 28 | # send request to the test client and return the response 29 | with self.app.test_request_context(url, method=method, data=data, 30 | headers=headers): 31 | rv = self.app.preprocess_request() 32 | if rv is None: 33 | rv = self.app.dispatch_request() 34 | rv = self.app.make_response(rv) 35 | rv = self.app.process_response(rv) 36 | return rv, json.loads(rv.data.decode('utf-8')) 37 | 38 | def get(self, url, headers={}): 39 | return self.send(url, 'GET', headers=headers) 40 | 41 | def post(self, url, data, headers={}): 42 | return self.send(url, 'POST', data, headers=headers) 43 | 44 | def put(self, url, data, headers={}): 45 | return self.send(url, 'PUT', data, headers=headers) 46 | 47 | def delete(self, url, headers={}): 48 | return self.send(url, 'DELETE', headers=headers) 49 | -------------------------------------------------------------------------------- /orders/tests/tests.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from werkzeug.exceptions import NotFound 3 | from app import create_app, db 4 | from app.models import User, Customer 5 | from .test_client import TestClient 6 | 7 | 8 | class TestAPI(unittest.TestCase): 9 | default_username = 'dave' 10 | default_password = 'cat' 11 | 12 | def setUp(self): 13 | self.app = create_app('testing') 14 | self.ctx = self.app.app_context() 15 | self.ctx.push() 16 | db.drop_all() 17 | db.create_all() 18 | u = User(username=self.default_username) 19 | u.set_password(self.default_password) 20 | db.session.add(u) 21 | db.session.commit() 22 | self.client = TestClient(self.app, u.generate_auth_token(), '') 23 | 24 | def tearDown(self): 25 | db.session.remove() 26 | db.drop_all() 27 | self.ctx.pop() 28 | 29 | def test_customers(self): 30 | # get list of customers 31 | rv, json = self.client.get('/api/v1/customers/') 32 | self.assertTrue(rv.status_code == 200) 33 | self.assertTrue(json['customers'] == []) 34 | 35 | # add a customer 36 | rv, json = self.client.post('/api/v1/customers/', 37 | data={'name': 'john'}) 38 | self.assertTrue(rv.status_code == 201) 39 | location = rv.headers['Location'] 40 | rv, json = self.client.get(location) 41 | self.assertTrue(rv.status_code == 200) 42 | self.assertTrue(json['name'] == 'john') 43 | rv, json = self.client.get('/api/v1/customers/') 44 | self.assertTrue(rv.status_code == 200) 45 | self.assertTrue(json['customers'] == [location]) 46 | 47 | # edit the customer 48 | rv, json = self.client.put(location, data={'name': 'John Smith'}) 49 | self.assertTrue(rv.status_code == 200) 50 | rv, json = self.client.get(location) 51 | self.assertTrue(rv.status_code == 200) 52 | self.assertTrue(json['name'] == 'John Smith') 53 | 54 | def test_products(self): 55 | # get list of products 56 | rv, json = self.client.get('/api/v1/products/') 57 | self.assertTrue(rv.status_code == 200) 58 | self.assertTrue(json['products'] == []) 59 | 60 | # add a customer 61 | rv, json = self.client.post('/api/v1/products/', 62 | data={'name': 'prod1'}) 63 | self.assertTrue(rv.status_code == 201) 64 | location = rv.headers['Location'] 65 | rv, json = self.client.get(location) 66 | self.assertTrue(rv.status_code == 200) 67 | self.assertTrue(json['name'] == 'prod1') 68 | rv, json = self.client.get('/api/v1/products/') 69 | self.assertTrue(rv.status_code == 200) 70 | self.assertTrue(json['products'] == [location]) 71 | 72 | # edit the customer 73 | rv, json = self.client.put(location, data={'name': 'product1'}) 74 | self.assertTrue(rv.status_code == 200) 75 | rv, json = self.client.get(location) 76 | self.assertTrue(rv.status_code == 200) 77 | self.assertTrue(json['name'] == 'product1') 78 | 79 | def test_orders_and_items(self): 80 | # define a customer 81 | rv, json = self.client.post('/api/v1/customers/', 82 | data={'name': 'john'}) 83 | self.assertTrue(rv.status_code == 201) 84 | customer = rv.headers['Location'] 85 | rv, json = self.client.get(customer) 86 | orders_url = json['orders_url'] 87 | rv, json = self.client.get(orders_url) 88 | self.assertTrue(rv.status_code == 200) 89 | self.assertTrue(json['orders'] == []) 90 | 91 | # define two products 92 | rv, json = self.client.post('/api/v1/products/', 93 | data={'name': 'prod1'}) 94 | self.assertTrue(rv.status_code == 201) 95 | prod1 = rv.headers['Location'] 96 | rv, json = self.client.post('/api/v1/products/', 97 | data={'name': 'prod2'}) 98 | self.assertTrue(rv.status_code == 201) 99 | prod2 = rv.headers['Location'] 100 | 101 | # create an order 102 | rv, json = self.client.post(orders_url, 103 | data={'date': '2014-01-01T00:00:00Z'}) 104 | self.assertTrue(rv.status_code == 201) 105 | order = rv.headers['Location'] 106 | rv, json = self.client.get(order) 107 | items_url = json['items_url'] 108 | rv, json = self.client.get(items_url) 109 | self.assertTrue(rv.status_code == 200) 110 | self.assertTrue(json['items'] == []) 111 | rv, json = self.client.get('/api/v1/orders/') 112 | self.assertTrue(rv.status_code == 200) 113 | self.assertTrue(len(json['orders']) == 1) 114 | self.assertTrue(order in json['orders']) 115 | 116 | # edit the order 117 | rv, json = self.client.put(order, 118 | data={'date': '2014-02-02T00:00:00Z'}) 119 | self.assertTrue(rv.status_code == 200) 120 | rv, json = self.client.get(order) 121 | self.assertTrue(rv.status_code == 200) 122 | self.assertTrue(json['date'] == '2014-02-02T00:00:00Z') 123 | 124 | # add two items to order 125 | rv, json = self.client.post(items_url, data={'product_url': prod1, 126 | 'quantity': 2}) 127 | self.assertTrue(rv.status_code == 201) 128 | item1 = rv.headers['Location'] 129 | rv, json = self.client.post(items_url, data={'product_url': prod2, 130 | 'quantity': 1}) 131 | self.assertTrue(rv.status_code == 201) 132 | item2 = rv.headers['Location'] 133 | rv, json = self.client.get(items_url) 134 | self.assertTrue(rv.status_code == 200) 135 | self.assertTrue(len(json['items']) == 2) 136 | self.assertTrue(item1 in json['items']) 137 | self.assertTrue(item2 in json['items']) 138 | rv, json = self.client.get(item1) 139 | self.assertTrue(rv.status_code == 200) 140 | self.assertTrue(json['product_url'] == prod1) 141 | self.assertTrue(json['quantity'] == 2) 142 | self.assertTrue(json['order_url'] == order) 143 | rv, json = self.client.get(item2) 144 | self.assertTrue(rv.status_code == 200) 145 | self.assertTrue(json['product_url'] == prod2) 146 | self.assertTrue(json['quantity'] == 1) 147 | self.assertTrue(json['order_url'] == order) 148 | 149 | # edit the second item 150 | rv, json = self.client.put(item2, data={'product_url': prod2, 151 | 'quantity': 3}) 152 | self.assertTrue(rv.status_code == 200) 153 | rv, json = self.client.get(item2) 154 | self.assertTrue(rv.status_code == 200) 155 | self.assertTrue(json['product_url'] == prod2) 156 | self.assertTrue(json['quantity'] == 3) 157 | self.assertTrue(json['order_url'] == order) 158 | 159 | # delete first item 160 | rv, json = self.client.delete(item1) 161 | self.assertTrue(rv.status_code == 200) 162 | rv, json = self.client.get(items_url) 163 | self.assertFalse(item1 in json['items']) 164 | self.assertTrue(item2 in json['items']) 165 | 166 | # delete order 167 | rv, json = self.client.delete(order) 168 | self.assertTrue(rv.status_code == 200) 169 | with self.assertRaises(NotFound): 170 | rv, json = self.client.get(item2) 171 | rv, json = self.client.get('/api/v1/orders/') 172 | self.assertTrue(rv.status_code == 200) 173 | self.assertTrue(len(json['orders']) == 0) 174 | 175 | def test_pagination(self): 176 | # define 55 customers (3 pages at 25 per page) 177 | customers = [] 178 | for i in range(0, 55): 179 | customers.append(Customer(name='customer_{0:02d}'.format(i))) 180 | db.session.add_all(customers) 181 | db.session.commit() 182 | 183 | # get first page of customer list 184 | rv, json = self.client.get('/api/v1/customers/') 185 | self.assertTrue(rv.status_code == 200) 186 | self.assertTrue(len(json['customers']) == 25) 187 | self.assertTrue('pages' in json) 188 | self.assertIsNone(json['pages']['prev_url']) 189 | self.assertTrue(json['customers'][0] == customers[0].get_url()) 190 | self.assertTrue(json['customers'][-1] == customers[24].get_url()) 191 | page1_url = json['pages']['first_url'] 192 | page2_url = json['pages']['next_url'] 193 | 194 | # get second page of customer list 195 | rv, json = self.client.get(page2_url) 196 | self.assertTrue(rv.status_code == 200) 197 | self.assertTrue(len(json['customers']) == 25) 198 | self.assertTrue(json['customers'][0] == customers[25].get_url()) 199 | self.assertTrue(json['customers'][-1] == customers[49].get_url()) 200 | self.assertTrue(page1_url == json['pages']['prev_url']) 201 | page3_url = json['pages']['next_url'] 202 | self.assertTrue(page3_url == json['pages']['last_url']) 203 | 204 | # get third page of customer list 205 | rv, json = self.client.get(page3_url) 206 | self.assertTrue(rv.status_code == 200) 207 | self.assertTrue(len(json['customers']) == 5) 208 | self.assertTrue(json['customers'][0] == customers[50].get_url()) 209 | self.assertTrue(json['customers'][-1] == customers[54].get_url()) 210 | self.assertTrue(json['pages']['prev_url'] == page2_url) 211 | self.assertIsNone(json['pages']['next_url']) 212 | 213 | # get second page, with expanded results 214 | rv, json = self.client.get(page2_url + '&expanded=1') 215 | self.assertTrue(rv.status_code == 200) 216 | self.assertTrue(len(json['customers']) == 25) 217 | self.assertTrue(json['customers'][0]['name'] == customers[25].name) 218 | self.assertTrue(json['customers'][0]['self_url'] == 219 | customers[25].get_url()) 220 | self.assertTrue(json['customers'][-1]['name'] == customers[49].name) 221 | page1_url_expanded = json['pages']['prev_url'] 222 | 223 | # get first page expanded, using previous link from page 2 224 | rv, json = self.client.get(page1_url_expanded) 225 | self.assertTrue(rv.status_code == 200) 226 | self.assertTrue(len(json['customers']) == 25) 227 | self.assertTrue(json['customers'][0]['name'] == customers[0].name) 228 | self.assertTrue(json['customers'][24]['name'] == customers[24].name) 229 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==0.10.1 2 | Flask-HTTPAuth==2.2.1 3 | Flask-SQLAlchemy==1.0 4 | Jinja2==2.7.3 5 | MarkupSafe==0.23 6 | Pygments==1.6 7 | SQLAlchemy==0.9.6 8 | Werkzeug==0.9.6 9 | coverage==3.7.1 10 | httpie==0.8.0 11 | itsdangerous==0.24 12 | python-dateutil==2.2 13 | requests==2.3.0 14 | six==1.7.3 15 | --------------------------------------------------------------------------------