Repository: miguelgrinberg/api-pycon2015 Branch: master Commit: 7c886366b588 Files: 24 Total size: 67.1 KB Directory structure: gitextract_o5qn2nqr/ ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── api/ │ ├── __init__.py │ ├── app.py │ ├── auth.py │ ├── decorators.py │ ├── errors.py │ ├── helpers.py │ ├── models.py │ ├── rate_limit.py │ ├── token.py │ └── v1/ │ ├── __init__.py │ ├── classes.py │ ├── registrations.py │ └── students.py ├── config.py ├── manage.py ├── requirements.txt ├── test_config.py └── tests/ ├── __init__.py ├── test_api.py └── test_client.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .cache nosetests.xml coverage.xml # Translations *.mo *.pot # Django stuff: *.log # Sphinx documentation docs/_build/ # PyBuilder target/ ================================================ FILE: .travis.yml ================================================ language: python python: - "3.4" - "3.3" - "2.7" - "2.6" - "pypy" install: pip install -r requirements.txt script: python manage.py test ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2015 Miguel Grinberg Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ Is Your REST API RESTful? ========================= [![Build Status](https://travis-ci.org/miguelgrinberg/api-pycon2015.png?branch=master)](https://travis-ci.org/miguelgrinberg/api-pycon2015) This repository contains a fully working API project that implements the techniques that I discussed in my [PyCon 2015 talk](https://us.pycon.org/2015/schedule/presentation/355/) on building REST APIs. The slides can be found at [Speaker Deck](https://speakerdeck.com/miguelgrinberg/is-your-rest-api-restful-pycon-2015). The API in this example implements a "students and classes" system and demonstrates RESTful principles, CRUD operations, error handling, user authentication, filtering, sorting and pagination of collections, rate limiting and HTTP caching. Requirements ------------ To install and run this application you need: - Python 3.4 (2.7 works too) - Redis (optional, for the rate limiting feature) Installation ------------ The commands below install the application and its dependencies: $ git clone https://github.com/miguelgrinberg/api-pycon2015.git $ cd api-pycon2015 $ python3.4 -m venv venv $ source venv/bin/activate (venv) pip install -r requirements.txt The core dependencies are Flask, Flask-HTTPAuth, Flask-SQLAlchemy, Flask-Script and redis. For unit tests nose and coverage are used. The httpie command line HTTP client is also installed as a convenience. Unit Tests ---------- To ensure that your installation was successful you can run the unit tests: (venv) $ python manage.py test test_bad_auth (tests.test_api.TestAPI) ... ok test_classes (tests.test_api.TestAPI) ... ok test_etag (tests.test_api.TestAPI) ... ok test_expanded_collections (tests.test_api.TestAPI) ... ok test_filters (tests.test_api.TestAPI) ... ok test_pagination (tests.test_api.TestAPI) ... ok test_password_auth (tests.test_api.TestAPI) ... ok test_rate_limits (tests.test_api.TestAPI) ... ok test_registrations (tests.test_api.TestAPI) ... ok test_sorting (tests.test_api.TestAPI) ... ok test_students (tests.test_api.TestAPI) ... ok test_user_password_not_readable (tests.test_api.TestAPI) ... ok Name Stmts Miss Branch BrMiss Cover Missing ------------------------------------------------------------------ api 0 0 0 0 100% api.app 26 2 5 1 90% 34, 38 api.auth 13 0 2 0 100% api.decorators 120 10 61 4 92% 19, 155-163, 167 api.errors 41 13 6 3 66% 26, 36-39, 43-46, 50-52, 62-65 api.helpers 22 6 11 6 64% 10, 17-19, 26, 33 api.models 81 0 0 0 100% api.rate_limit 38 1 6 1 95% 39 api.token 18 6 2 2 60% 13-16, 21, 31 api.v1 20 1 2 0 95% 18 api.v1.classes 47 0 0 0 100% api.v1.registrations 25 0 0 0 100% api.v1.students 47 0 0 0 100% ------------------------------------------------------------------ TOTAL 498 39 95 17 91% ---------------------------------------------------------------------- Ran 12 tests in 1.583s OK The report printed below the tests is a summary of the test coverage. A more detailed report is written to a `cover` folder. To view it, open `cover/index.html` with your web browser. User Registration ----------------- The API can only be accessed by authenticated users. New users can be registered with the application from the command line: (venv) $ python manage.py adduser Password: Confirm: User was registered successfully. The system supports multiple users, so the above command can be issued as many times as needed with different usernames and passwords. Users are stored in the application's database, which by default uses the SQLite engine. An empty database is created in the current folder if a previous database file is not found. Authentication -------------- The default configuration uses tokens for authentication. When the client sends a request to the API without authenticating, a response with status code 401 is returned. The `Location` header in this response is set to the token request URL. To obtain a token, the client must send a `POST` request to this URL with valid username and password in a HTTP basic authentication header. The response to this request will include a token valid for one hour. After the token expires a new token must be requested. To switch to a simper username and password authentication the configuration stored in `config.py` must be edited as follows: USE_TOKEN_AUTH = False After this change restart the application for the change to take effect. When username and password authentication is used, all request must include the credentials in a HTTP basic authentication header. API Documentation ----------------- General notes about this API: - All resource representations are in JSON format. The API supported by this application contains three top-level resource collections: - *students*: The collection of students. - *classes*: The collection of classes. - *registrations*: The collection of student-to-class registrations. To obtain the URLs of these resources clients must send a request to the root URL of the API to obtain the API version catalog: { "versions": { "v1": { "classes_url": "[class-collection-url]", "registrations_url": "[registration-collection-url]", "students_url": "[student-collection-url]" } } } To see an example of how a client can use this information to access the API see the unit tests. ### Resource Collections Resource collection URLs accept `GET` and `POST` requests. Use a `GET` request to retrieve the collection, and a `POST` request to insert a new item into the collection. #### Filtering Resource collections can be filtered by adding the `filter` argument to the query string of the collection resource URL. The format of a filter is as follows: [field_name],[operator],[value] To build more complex queries multiple filters can be concatenated with a `;` separator. The operators can be `eq`, `ne`, `lt`, `le`, `gt`, `ge`, `like` and `in`. The `in` operator takes a list of values separated by commas, while all other operators take a single value. Examples: - Search by exact value: `filter=name,eq,john` - Search by range (all names that begin with "a"): `filter=name,ge,a;name,lt,b` - Search in a set: `filter=name,in,john,susan,mary` Invalid filters are silently ignored. #### Sorting Collections can be sorted by adding the `sort` argument to the query string of the collection resource URL. The format of this argument is as follows: [field_name],[asc|desc] To specify multiple sort orders concatenate them with a `;` separator. The sort order can be `asc` or `desc`. If not specified `asc` is used. Examples: - Sort by name: `sort=name` - Sort by name in descending order: `sort=name,desc` - Sort by name in ascending order and then by id in descending order: `sort=name,asc;id,desc` Invalid sort specifications are silently ignored. #### Resource Expansion By default, when a collection of resources is returned, only their URLs are returned, as this maximizes caching efficiency. Example: { "students": [ "[student-resource-url-1]", "[student-resource-url-2]", "[student-resource-url-3]" ] } However, in certain occasions it may be more convenient to obtain all the resources expanded. To request the resources in expanded form add `expand=1` to the query string of the collection resource URL. Example: { "students": [ { "name": "john", "registrations_url": "[student-registration-url-1]", "self_url": "[student-resource-url-1]" }, { "name": "susan", "registrations_url": "[student-registration-url-2]", "self_url": "[student-resource-url-2]" }, { "name": "mary", "registrations_url": "[student-registration-url-3]", "self_url": "[student-resource-url-3]" } ] } #### Pagination All requests to resource collection URLs are paginated, regardless of the client requesting so or not. The response from the server includes a `'meta'` key with information that is useful to navigate the pages of resources. Example: { "meta": { "first_url": "[students-collection-url]?per_page=10&page=1", "last_url": "[students-collection-url]?per_page=10&page=4", "next_url": "[students-collection-url]?per_page=10&page=2", "page": 1, "pages": 4, "per_page": 10, "prev_url": null, "total": 37 }, "students": [ ... ] } The `first_url`, `last_url`, `next_url` and `prev_url` fields contain the URLs to request other pages of the collection. When filtering, sorting and embedding options are used, these URLs contain the same options that were given for the current request. The `page`, `pages`, `total` and `per_page` provide the current page, total number of pages, total number of items and items per page values respectively. To request pagination settings that are different than the default, the `per_page` and `page` query string arguments must be added to the collection request URL. The server is not obligated to honor the `per_page` size requested by the client. ### Student Resource A student resource has the following structure: { "name": [student name], "registrations_url": [link to student registrations] "self_url": [student URL], } The student resource supports `GET`, `POST`, `PUT` and `DELETE` methods to retrieve, create, edit and delete respectively. The `POST` and `PUT` requests only require the `name` field in the request body. A `GET` request to the URL given in the `registrations_url` field returns the collection of class registrations for the student. A `POST` request to this URL including `class_url` in the body adds a registration to a class. ### Class Resource The class resource has a similar structure: { "name": [class name], "registrations_url": [link to class registrations] "self_url": [class URL], } The class resource supports `GET`, `POST`, `PUT` and `DELETE` methods to retrieve, create, edit and delete respectively. The `POST` and `PUT` requests only require the `name` field in the request body. A `GET` request to the URL given in the `registrations_url` field returns the collection of registrations for the class. A `POST` request to this URL including `student_url` in the body adds the student to the class. ### Registration Resource The registration resource associates a student with a class. Below is the structure of this resource: { "student_url": [student URL], "class_url": [class URL], "timestamp": [date of registration] "self_url": [registration URL], } The registration resource supports `GET`, `POST` and `DELETE` methods, to retrieve, create and delete respectively. HTTP Caching ------------ The different API endpoints are configured to respond using the appropriate caching directives. All the `GET` requests return an `ETag` header that HTTP caches can use with the `If-Match` and `If-None-Match` headers. Rate Limiting ------------- This API supports rate limiting as an optional feature. To use rate limiting the application must have access to a Redis server running on the same host and listening on the default port. If a redis server isn't available then rate limiting is automatically disabled. The default configuration limits clients to 5 API calls per 15 second interval. When a client goes over the limit a response with the 429 status code is returned immediately, without carrying out the request. The limit resets as soon as the current 15 second period ends. When rate limiting is enabled all responses return three additional headers: X-RateLimit-Limit: [period in seconds] X-RateLimit-Remaining: [remaining calls in this period] X-RateLimit-Reset: [time when the limits reset, in UTC epoch seconds] ================================================ FILE: api/__init__.py ================================================ ================================================ FILE: api/app.py ================================================ import os from flask import Flask from .models import db from .auth import auth from .decorators import json, etag from .errors import not_found, not_allowed def create_app(config_module=None): app = Flask(__name__) app.config.from_object(config_module or os.environ.get('FLASK_CONFIG') or 'config') db.init_app(app) from api.v1 import api as api_blueprint app.register_blueprint(api_blueprint, url_prefix='/v1') if app.config['USE_TOKEN_AUTH']: from api.token import token as token_blueprint app.register_blueprint(token_blueprint, url_prefix='/auth') @app.route('/') @auth.login_required @etag @json def index(): from api.v1 import get_catalog as v1_catalog return {'versions': {'v1': v1_catalog()}} @app.errorhandler(404) @auth.login_required def not_found_error(e): return not_found('item not found') @app.errorhandler(405) def method_not_allowed_error(e): return not_allowed() return app ================================================ FILE: api/auth.py ================================================ from flask import current_app, g from flask_httpauth import HTTPBasicAuth from .models import User from .errors import unauthorized auth = HTTPBasicAuth() @auth.verify_password def verify_password(username_or_token, password): if current_app.config['USE_TOKEN_AUTH']: # token authentication g.user = User.verify_auth_token(username_or_token) return g.user is not None else: # username/password authentication g.user = User.query.filter_by(username=username_or_token).first() return g.user is not None and g.user.verify_password(password) @auth.error_handler def unauthorized_error(): return unauthorized() ================================================ FILE: api/decorators.py ================================================ import functools import hashlib from flask import jsonify, request, url_for, current_app, make_response, g from .rate_limit import RateLimit from .errors import too_many_requests, precondition_failed, not_modified, ValidationError def json(f): """This decorator generates a JSON response from a Python dictionary or a SQLAlchemy model.""" @functools.wraps(f) def wrapped(*args, **kwargs): rv = f(*args, **kwargs) status_or_headers = None headers = None if isinstance(rv, tuple): rv, status_or_headers, headers = rv + (None,) * (3 - len(rv)) if isinstance(status_or_headers, (dict, list)): headers, status_or_headers = status_or_headers, None if not isinstance(rv, dict): # assume it is a model, call its export_data() method rv = rv.export_data() rv = jsonify(rv) if status_or_headers is not None: rv.status_code = status_or_headers if headers is not None: rv.headers.extend(headers) return rv return wrapped def rate_limit(limit, period): """This decorator implements rate limiting.""" def decorator(f): @functools.wraps(f) def wrapped(*args, **kwargs): if current_app.config['USE_RATE_LIMITS']: # generate a unique key to represent the decorated function and # the IP address of the client. Rate limiting counters are # maintained on each unique key. key = '{0}/{1}'.format(f.__name__, str(g.user.id)) limiter = RateLimit(key, limit, period) # set the rate limit headers in g, so that they are picked up # by the after_request handler and attached to the response g.headers = { 'X-RateLimit-Remaining': str(limiter.remaining if limiter.remaining >= 0 else 0), 'X-RateLimit-Limit': str(limit), 'X-RateLimit-Reset': str(limiter.reset) } # if the client went over the limit respond with a 429 status # code, else invoke the wrapped function if not limiter.allowed: return too_many_requests() # let the request through return f(*args, **kwargs) return wrapped return decorator def _filter_query(model, query, filter_spec): filters = [f.split(',') for f in filter_spec.split(';')] for f in filters: if len(f) < 3 or (len(f) > 3 and f[1] != 'in'): continue if f[1] == 'in': f = [f[0], f[1], f[2:]] ops = {'eq': '__eq__', 'ne': '__ne__', 'lt': '__lt__', 'le': '__le__', 'gt': '__gt__', 'ge': '__ge__', 'in': 'in_', 'like': 'like'} if hasattr(model, f[0]) and f[1] in ops.keys(): column = getattr(model, f[0]) op = ops[f[1]] query = query.filter(getattr(column, op)(f[2])) return query def _sort_query(model, query, sort_spec): sort = [s.split(',') for s in sort_spec.split(';')] for s in sort: if hasattr(model, s[0]): column = getattr(model, s[0]) if len(s) == 2 and s[1] in ['asc', 'desc']: query = query.order_by(getattr(column, s[1])()) else: query = query.order_by(column.asc()) return query def collection(model, name=None, max_per_page=10): """This decorator implements pagination, filtering, sorting and expanding for collections. The expected response from the decorated route is a SQLAlchemy query.""" if name is None: name = model.__tablename__ def decorator(f): @functools.wraps(f) def wrapped(*args, **kwargs): query = f(*args, **kwargs) # filtering and sorting filter = request.args.get('filter') if filter: query = _filter_query(model, query, filter) sort = request.args.get('sort') if sort: query = _sort_query(model, query, sort) # pagination page = request.args.get('page', 1, type=int) per_page = min(request.args.get('per_page', max_per_page, type=int), max_per_page) expand = request.args.get('expand') p = query.paginate(page, per_page) pages = {'page': page, 'per_page': per_page, 'total': p.total, 'pages': p.pages} if p.has_prev: pages['prev_url'] = url_for(request.endpoint, page=p.prev_num, per_page=per_page, expand=expand, _external=True, **kwargs) else: pages['prev_url'] = None if p.has_next: pages['next_url'] = url_for(request.endpoint, filter=filter, sort=sort, page=p.next_num, per_page=per_page, expand=expand, _external=True, **kwargs) else: pages['next_url'] = None pages['first_url'] = url_for(request.endpoint, filter=filter, sort=sort, page=1, per_page=per_page, expand=expand, _external=True, **kwargs) pages['last_url'] = url_for(request.endpoint, filter=filter, sort=sort, page=p.pages, per_page=per_page, expand=expand, _external=True, **kwargs) if expand: items = [item.export_data() for item in p.items] else: items = [item.get_url() for item in p.items] return {name: items, 'meta': pages} return wrapped return decorator def etag(f): """This decorator adds an ETag header to the response.""" @functools.wraps(f) def wrapped(*args, **kwargs): # only for HEAD and GET requests assert request.method in ['HEAD', 'GET'],\ '@etag is only supported for GET requests' rv = f(*args, **kwargs) rv = make_response(rv) etag = '"' + hashlib.md5(rv.get_data()).hexdigest() + '"' rv.headers['Cache-Control'] = 'max-age=86400' rv.headers['ETag'] = etag if_match = request.headers.get('If-Match') if_none_match = request.headers.get('If-None-Match') if if_match: etag_list = [tag.strip() for tag in if_match.split(',')] if etag not in etag_list and '*' not in etag_list: rv = precondition_failed() elif if_none_match: etag_list = [tag.strip() for tag in if_none_match.split(',')] if etag in etag_list or '*' in etag_list: rv = not_modified() return rv return wrapped ================================================ FILE: api/errors.py ================================================ from flask import jsonify, url_for, current_app class ValidationError(ValueError): pass def not_modified(): response = jsonify({'status': 304, 'error': 'not modified'}) response.status_code = 304 return response def bad_request(message): response = jsonify({'status': 400, 'error': 'bad request', 'message': message}) response.status_code = 400 return response def unauthorized(message=None): if message is None: if current_app.config['USE_TOKEN_AUTH']: message = 'Please authenticate with your token.' else: message = 'Please authenticate.' response = jsonify({'status': 401, 'error': 'unauthorized', 'message': message}) response.status_code = 401 if current_app.config['USE_TOKEN_AUTH']: response.headers['Location'] = url_for('token.request_token') return response def not_found(message): response = jsonify({'status': 404, 'error': 'not found', 'message': message}) response.status_code = 404 return response def not_allowed(): response = jsonify({'status': 405, 'error': 'method not allowed'}) response.status_code = 405 return response def precondition_failed(): response = jsonify({'status': 412, 'error': 'precondition failed'}) response.status_code = 412 return response def too_many_requests(message='You have exceeded your request rate'): response = jsonify({'status': 429, 'error': 'too many requests', 'message': message}) response.status_code = 429 return response ================================================ FILE: api/helpers.py ================================================ from flask.globals import _app_ctx_stack, _request_ctx_stack from werkzeug.urls import url_parse from werkzeug.exceptions import NotFound def match_url(url, method=None): appctx = _app_ctx_stack.top reqctx = _request_ctx_stack.top if appctx is None: raise RuntimeError('Attempted to match a URL without the ' 'application context being pushed. This has to be ' 'executed when application context is available.') if reqctx is not None: url_adapter = reqctx.url_adapter else: url_adapter = appctx.url_adapter if url_adapter is None: raise RuntimeError('Application was not able to create a URL ' 'adapter for request independent URL matching. ' 'You might be able to fix this by setting ' 'the SERVER_NAME config variable.') parsed_url = url_parse(url) if parsed_url.netloc is not '' and \ parsed_url.netloc != url_adapter.server_name: raise NotFound() return url_adapter.match(parsed_url.path, method) def args_from_url(url, endpoint): r = match_url(url, 'GET') if r[0] != endpoint: raise NotFound() return r[1] ================================================ FILE: api/models.py ================================================ from datetime import datetime from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.exceptions import NotFound from itsdangerous import TimedJSONWebSignatureSerializer as Serializer from flask import url_for, current_app from flask_sqlalchemy import SQLAlchemy from .helpers import args_from_url from .errors import ValidationError db = SQLAlchemy() class Registration(db.Model): __tablename__ = 'registrations' student_id = db.Column('student_id', db.Integer, db.ForeignKey('students.id'), primary_key=True) class_id = db.Column('class_id', db.Integer, db.ForeignKey('classes.id'), primary_key=True) timestamp = db.Column(db.DateTime, default=datetime.utcnow) def get_url(self): return url_for('api.get_registration', student_id=self.student_id, class_id=self.class_id, _external=True) def export_data(self): return {'self_url': self.get_url(), 'student_url': url_for('api.get_student', id=self.student_id, _external=True), 'class_url': url_for('api.get_class', id=self.class_id, _external=True), 'timestamp': self.timestamp.isoformat() + 'Z'} def import_data(self, data): try: student_id = args_from_url(data['student_url'], 'api.get_student')['id'] self.student = Student.query.get_or_404(student_id) except (KeyError, NotFound): raise ValidationError('Invalid student URL') try: class_id = args_from_url(data['class_url'], 'api.get_class')['id'] self.class_ = Class.query.get_or_404(class_id) except (KeyError, NotFound): raise ValidationError('Invalid class URL') return self class Student(db.Model): __tablename__ = 'students' id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(64), index=True) registrations = db.relationship( 'Registration', backref=db.backref('student', lazy='joined'), lazy='dynamic', cascade='all, delete-orphan') def get_url(self): return url_for('api.get_student', id=self.id, _external=True) def export_data(self): return {'self_url': self.get_url(), 'name': self.name, 'registrations_url': url_for('api.get_student_registrations', id=self.id, _external=True)} def import_data(self, data): try: self.name = data['name'] except KeyError as e: raise ValidationError('Invalid student: missing ' + e.args[0]) return self class Class(db.Model): __tablename__ = 'classes' id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(64), index=True) registrations = db.relationship( 'Registration', backref=db.backref('class_', lazy='joined'), lazy='dynamic', cascade='all, delete-orphan') def get_url(self): return url_for('api.get_class', id=self.id, _external=True) def export_data(self): return {'self_url': self.get_url(), 'name': self.name, 'registrations_url': url_for('api.get_class_registrations', id=self.id, _external=True)} def import_data(self, data): try: self.name = data['name'] except KeyError as e: raise ValidationError('Invalid class: missing ' + e.args[0]) return self class User(db.Model): __tablename__ = 'users' id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(64), index=True) password_hash = db.Column(db.String(128)) @property def password(self): raise AttributeError('password is not a readable attribute') @password.setter def password(self, password): self.password_hash = generate_password_hash(password) def verify_password(self, password): return check_password_hash(self.password_hash, password) def generate_auth_token(self, expires_in=3600): s = Serializer(current_app.config['SECRET_KEY'], expires_in=expires_in) return s.dumps({'id': self.id}).decode('utf-8') @staticmethod def verify_auth_token(token): s = Serializer(current_app.config['SECRET_KEY']) try: data = s.loads(token) except: return None return User.query.get(data['id']) ================================================ FILE: api/rate_limit.py ================================================ import time from redis import Redis from flask import current_app redis = None class FakeRedis(object): """Redis mock used for testing.""" def __init__(self): self.v = {} self.last_key = None def pipeline(self): return self def incr(self, key): if self.v.get(key, None) is None: self.v[key] = 0 self.v[key] += 1 self.last_key = key def expireat(self, key, time): pass def execute(self): return [self.v[self.last_key]] class RateLimit(object): expiration_window = 10 def __init__(self, key_prefix, limit, period): global redis if redis is None and current_app.config['USE_RATE_LIMITS']: if current_app.config['TESTING']: redis = FakeRedis() else: redis = Redis() self.reset = (int(time.time()) // period) * period + period self.key = key_prefix + str(self.reset) self.limit = limit self.period = period p = redis.pipeline() p.incr(self.key) p.expireat(self.key, self.reset + self.expiration_window) self.current = p.execute()[0] @property def allowed(self): return self.current <= self.limit @property def remaining(self): return self.limit - self.current ================================================ FILE: api/token.py ================================================ from flask import Blueprint, jsonify, g from flask_httpauth import HTTPBasicAuth from .models import User from .errors import unauthorized from .decorators import json token = Blueprint('token', __name__) token_auth = HTTPBasicAuth() @token_auth.verify_password def verify_password(username, password): g.user = User.query.filter_by(username=username).first() if not g.user: return False return g.user.verify_password(password) @token_auth.error_handler def unauthorized_error(): return unauthorized('Please authenticate to get your token.') @token.route('/request-token', methods=['POST']) @token_auth.login_required @json def request_token(): # Note that a colon is appended to the token. When the token is sent in # the Authorization header this will put the token in the username field # and an empty string in the password field. return {'token': g.user.generate_auth_token() + ':'} ================================================ FILE: api/v1/__init__.py ================================================ from flask import Blueprint, g, url_for from ..errors import ValidationError, bad_request, not_found from ..auth import auth from ..decorators import json, rate_limit api = Blueprint('api', __name__) def get_catalog(): return {'students_url': url_for('api.get_students', _external=True), 'classes_url': url_for('api.get_classes', _external=True), 'registrations_url': url_for('api.get_registrations', _external=True)} @api.errorhandler(ValidationError) def validation_error(e): return bad_request(str(e)) @api.errorhandler(400) def bad_request_error(e): return bad_request('invalid request') @api.before_request @auth.login_required @rate_limit(limit=5, period=15) def before_request(): pass @api.after_request def after_request(response): if hasattr(g, 'headers'): response.headers.extend(g.headers) return response # do this last to avoid circular dependencies from . import students, classes, registrations ================================================ FILE: api/v1/classes.py ================================================ from flask import request from ..models import db, Class, Registration from ..decorators import json, collection, etag from . import api @api.route('/classes/', methods=['GET']) @etag @json @collection(Class) def get_classes(): return Class.query @api.route('/classes/', methods=['GET']) @etag @json def get_class(id): return Class.query.get_or_404(id) @api.route('/classes//registrations/', methods=['GET']) @etag @json @collection(Registration) def get_class_registrations(id): class_ = Class.query.get_or_404(id) return class_.registrations @api.route('/classes/', methods=['POST']) @json def new_class(): class_ = Class().import_data(request.get_json(force=True)) db.session.add(class_) db.session.commit() return {}, 201, {'Location': class_.get_url()} @api.route('/classes//registrations/', methods=['POST']) @json def new_class_registration(id): class_ = Class.query.get_or_404(id) data = request.get_json(force=True) data['class_url'] = class_.get_url() reg = Registration().import_data(data) db.session.add(reg) db.session.commit() return {}, 201, {'Location': reg.get_url()} @api.route('/classes/', methods=['PUT']) @json def edit_class(id): class_ = Class.query.get_or_404(id) class_.import_data(request.get_json(force=True)) db.session.add(class_) db.session.commit() return {} @api.route('/classes/', methods=['DELETE']) @json def delete_class(id): class_ = Class.query.get_or_404(id) db.session.delete(class_) db.session.commit() return {} ================================================ FILE: api/v1/registrations.py ================================================ from flask import request from ..models import db, Registration from ..decorators import json, collection, etag from . import api @api.route('/registrations/', methods=['GET']) @etag @json @collection(Registration) def get_registrations(): return Registration.query @api.route('/registrations//', methods=['GET']) @etag @json def get_registration(student_id, class_id): return Registration.query.get_or_404((student_id, class_id)) @api.route('/registrations/', methods=['POST']) @json def new_registration(): reg = Registration().import_data(request.get_json(force=True)) db.session.add(reg) db.session.commit() return {}, 201, {'Location': reg.get_url()} @api.route('/registrations//', methods=['DELETE']) @json def delete_registration(student_id, class_id): reg = Registration.query.get_or_404((student_id, class_id)) db.session.delete(reg) db.session.commit() return {} ================================================ FILE: api/v1/students.py ================================================ from flask import request from ..models import db, Student, Registration from ..decorators import json, collection, etag from . import api @api.route('/students/', methods=['GET']) @etag @json @collection(Student) def get_students(): return Student.query @api.route('/students/', methods=['GET']) @etag @json def get_student(id): return Student.query.get_or_404(id) @api.route('/students//registrations/', methods=['GET']) @etag @json @collection(Registration) def get_student_registrations(id): student = Student.query.get_or_404(id) return student.registrations @api.route('/students/', methods=['POST']) @json def new_student(): student = Student().import_data(request.get_json(force=True)) db.session.add(student) db.session.commit() return {}, 201, {'Location': student.get_url()} @api.route('/students//registrations/', methods=['POST']) @json def new_student_registration(id): student = Student.query.get_or_404(id) data = request.get_json(force=True) data['student_url'] = student.get_url() reg = Registration().import_data(data) db.session.add(reg) db.session.commit() return {}, 201, {'Location': reg.get_url()} @api.route('/students/', methods=['PUT']) @json def edit_student(id): student = Student.query.get_or_404(id) student.import_data(request.get_json(force=True)) db.session.add(student) db.session.commit() return {} @api.route('/students/', methods=['DELETE']) @json def delete_student(id): student = Student.query.get_or_404(id) db.session.delete(student) db.session.commit() return {} ================================================ FILE: config.py ================================================ import os import redis basedir = os.path.abspath(os.path.dirname(__file__)) SECRET_KEY = 'secret' SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'api.sqlite') USE_TOKEN_AUTH = True # enable rate limits only if redis is running try: r = redis.Redis() r.ping() USE_RATE_LIMITS = True except redis.ConnectionError: USE_RATE_LIMITS = False ================================================ FILE: manage.py ================================================ #!/usr/bin/env python from flask import Flask, g, jsonify from flask.ext.script import Manager from api.app import create_app from api.models import db, User, Class manager = Manager(create_app) @manager.command def createdb(testdata=False): app = create_app() with app.app_context(): db.drop_all() db.create_all() if testdata: classes = ['Algebra', 'Literature', 'Chemistry', 'Spanish', 'Game Development', 'History', 'Music', 'Psychology', 'Science', 'Photography', 'Drama', 'Business', 'Python Programming'] for name in classes: c = Class(name=name) db.session.add(c) u = User(username='miguel', password='python') db.session.add(u) db.session.commit() @manager.command def adduser(username): """Register a new user.""" from getpass import getpass password = getpass() password2 = getpass(prompt='Confirm: ') if password != password2: import sys sys.exit('Error: passwords do not match.') db.create_all() user = User(username=username, password=password) db.session.add(user) db.session.commit() print('User {0} was registered successfully.'.format(username)) @manager.command def test(): from subprocess import call call(['nosetests', '-v', '--with-coverage', '--cover-package=api', '--cover-branches', '--cover-erase', '--cover-html', '--cover-html-dir=cover']) if __name__ == '__main__': manager.run() ================================================ FILE: requirements.txt ================================================ Flask==0.10.1 Flask-HTTPAuth==2.2.1 Flask-SQLAlchemy==1.0 Flask-Script==0.6.7 Jinja2==2.7.2 MarkupSafe==0.19 Pygments==1.6 SQLAlchemy==0.9.4 Werkzeug==0.9.6 coverage==3.7.1 httpie==0.8.0 itsdangerous==0.24 nose==1.3.1 redis==2.9.1 requests==2.2.1 ================================================ FILE: test_config.py ================================================ TESTING = True SECRET_KEY = 'secret' SQLALCHEMY_DATABASE_URI = 'sqlite://' USE_TOKEN_AUTH = True USE_RATE_LIMITS = False ================================================ FILE: tests/__init__.py ================================================ ================================================ FILE: tests/test_api.py ================================================ import unittest from werkzeug.exceptions import BadRequest from .test_client import TestClient from api.app import create_app from api.models import db, User from api.errors import ValidationError class TestAPI(unittest.TestCase): default_username = 'dave' default_password = 'cat' def setUp(self): self.app = create_app('test_config') self.ctx = self.app.app_context() self.ctx.push() db.drop_all() db.create_all() u = User(username=self.default_username, password=self.default_password) db.session.add(u) db.session.commit() self.client = TestClient(self.app, u.generate_auth_token(), '') self.catalog = self._get_catalog() def tearDown(self): db.session.remove() db.drop_all() self.ctx.pop() def _get_catalog(self, version='v1'): rv, json = self.client.get('/') return json['versions'][version] def test_password_auth(self): self.app.config['USE_TOKEN_AUTH'] = False good_client = TestClient(self.app, self.default_username, self.default_password) rv, json = good_client.get(self.catalog['students_url']) self.assertTrue(rv.status_code == 200) self.app.config['USE_TOKEN_AUTH'] = True u = User.query.get(1) good_client = TestClient(self.app, u.generate_auth_token(), '') rv, json = good_client.get(self.catalog['students_url']) self.assertTrue(rv.status_code == 200) def test_bad_auth(self): self.app.config['USE_TOKEN_AUTH'] = False bad_client = TestClient(self.app, 'abc', 'def') rv, json = bad_client.get(self.catalog['students_url']) self.assertTrue(rv.status_code == 401) self.app.config['USE_TOKEN_AUTH'] = True bad_client = TestClient(self.app, 'bad_token', '') rv, json = bad_client.get(self.catalog['students_url']) self.assertTrue(rv.status_code == 401) def test_token(self): self.app.config['USE_TOKEN_AUTH'] = True client = TestClient(self.app, self.default_username, self.default_password) rv, json = client.get(self.catalog['students_url']) self.assertTrue(rv.status_code == 401) rv, json = client.post(rv.headers['Location'], data={}) self.assertTrue(rv.status_code == 200) token = json['token'] client = TestClient(self.app, token, '') rv, json = client.get(self.catalog['students_url']) self.assertTrue(rv.status_code == 200) def test_user_password_not_readable(self): u = User(username='john', password='cat') self.assertRaises(AttributeError, lambda: u.password) def test_http_errors(self): # not found rv, json = self.client.get('/a-bad-url') self.assertTrue(rv.status_code == 404) # method not allowed rv, json = self.client.delete(self.catalog['students_url']) self.assertTrue(rv.status_code == 405) def test_students(self): # get collection rv, json = self.client.get(self.catalog['students_url']) self.assertTrue(rv.status_code == 200) self.assertTrue(json['students'] == []) # create new rv, json = self.client.post(self.catalog['students_url'], data={'name': 'susan'}) self.assertTrue(rv.status_code == 201) susan_url = rv.headers['Location'] # get rv, json = self.client.get(susan_url) self.assertTrue(rv.status_code == 200) self.assertTrue(json['name'] == 'susan') self.assertTrue(json['self_url'] == susan_url) # create new rv, json = self.client.post(self.catalog['students_url'], data={'name': 'david'}) self.assertTrue(rv.status_code == 201) david_url = rv.headers['Location'] # get rv, json = self.client.get(david_url) self.assertTrue(rv.status_code == 200) self.assertTrue(json['name'] == 'david') self.assertTrue(json['self_url'] == david_url) # bad request rv,json = self.client.post(self.catalog['students_url'], data=None) self.assertTrue(rv.status_code == 400) rv,json = self.client.post(self.catalog['students_url'], data={}) self.assertTrue(rv.status_code == 400) self.assertRaises(ValidationError, lambda: self.client.post(self.catalog['students_url'], data={'foo': 'david'})) # modify rv, json = self.client.put(david_url, data={'name': 'david2'}) self.assertTrue(rv.status_code == 200) # get rv, json = self.client.get(david_url) self.assertTrue(rv.status_code == 200) self.assertTrue(json['name'] == 'david2') # get collection rv, json = self.client.get(self.catalog['students_url']) self.assertTrue(rv.status_code == 200) self.assertTrue(susan_url in json['students']) self.assertTrue(david_url in json['students']) self.assertTrue(len(json['students']) == 2) # delete rv, json = self.client.delete(susan_url) self.assertTrue(rv.status_code == 200) # get collection rv, json = self.client.get(self.catalog['students_url']) self.assertTrue(rv.status_code == 200) self.assertFalse(susan_url in json['students']) self.assertTrue(david_url in json['students']) self.assertTrue(len(json['students']) == 1) def test_classes(self): # get collection rv, json = self.client.get(self.catalog['classes_url']) self.assertTrue(rv.status_code == 200) self.assertTrue(json['classes'] == []) # create new rv, json = self.client.post(self.catalog['classes_url'], data={'name': 'algebra'}) self.assertTrue(rv.status_code == 201) algebra_url = rv.headers['Location'] # get rv, json = self.client.get(algebra_url) self.assertTrue(rv.status_code == 200) self.assertTrue(json['name'] == 'algebra') self.assertTrue(json['self_url'] == algebra_url) # create new rv, json = self.client.post(self.catalog['classes_url'], data={'name': 'lit'}) self.assertTrue(rv.status_code == 201) lit_url = rv.headers['Location'] # get rv, json = self.client.get(lit_url) self.assertTrue(rv.status_code == 200) self.assertTrue(json['name'] == 'lit') self.assertTrue(json['self_url'] == lit_url) # bad request rv,json = self.client.post(self.catalog['classes_url'], data=None) self.assertTrue(rv.status_code == 400) rv,json = self.client.post(self.catalog['classes_url'], data={}) self.assertTrue(rv.status_code == 400) self.assertRaises(ValidationError, lambda: self.client.post(self.catalog['classes_url'], data={'foo': 'lit'})) # modify rv, json = self.client.put(lit_url, data={'name': 'lit2'}) self.assertTrue(rv.status_code == 200) # get rv, json = self.client.get(lit_url) self.assertTrue(rv.status_code == 200) self.assertTrue(json['name'] == 'lit2') # get collection rv, json = self.client.get(self.catalog['classes_url']) self.assertTrue(rv.status_code == 200) self.assertTrue(algebra_url in json['classes']) self.assertTrue(lit_url in json['classes']) self.assertTrue(len(json['classes']) == 2) # delete rv, json = self.client.delete(lit_url) self.assertTrue(rv.status_code == 200) # get collection rv, json = self.client.get(self.catalog['classes_url']) self.assertTrue(rv.status_code == 200) self.assertTrue(algebra_url in json['classes']) self.assertFalse(lit_url in json['classes']) self.assertTrue(len(json['classes']) == 1) def test_registrations(self): # create new students rv, json = self.client.post(self.catalog['students_url'], data={'name': 'susan'}) self.assertTrue(rv.status_code == 201) susan_url = rv.headers['Location'] rv, json = self.client.post(self.catalog['students_url'], data={'name': 'david'}) self.assertTrue(rv.status_code == 201) david_url = rv.headers['Location'] # create new classes rv, json = self.client.post(self.catalog['classes_url'], data={'name': 'algebra'}) self.assertTrue(rv.status_code == 201) algebra_url = rv.headers['Location'] rv, json = self.client.post(self.catalog['classes_url'], data={'name': 'lit'}) self.assertTrue(rv.status_code == 201) lit_url = rv.headers['Location'] # register students to classes rv, json = self.client.post(self.catalog['registrations_url'], data={'student_url': susan_url, 'class_url': algebra_url}) self.assertTrue(rv.status_code == 201) susan_in_algebra_url = rv.headers['Location'] rv, json = self.client.post(self.catalog['registrations_url'], data={'student_url': susan_url, 'class_url': lit_url}) self.assertTrue(rv.status_code == 201) susan_in_lit_url = rv.headers['Location'] rv, json = self.client.post(self.catalog['registrations_url'], data={'student_url': david_url, 'class_url': algebra_url}) self.assertTrue(rv.status_code == 201) david_in_algebra_url = rv.headers['Location'] # get registration rv, json = self.client.get(susan_in_lit_url) self.assertTrue(rv.status_code == 200) self.assertTrue(json['student_url'] == susan_url) self.assertTrue(json['class_url'] == lit_url) # get collection rv, json = self.client.get(self.catalog['registrations_url']) self.assertTrue(rv.status_code == 200) self.assertTrue(susan_in_algebra_url in json['registrations']) self.assertTrue(susan_in_lit_url in json['registrations']) self.assertTrue(david_in_algebra_url in json['registrations']) self.assertTrue(len(json['registrations']) == 3) # bad registrations rv,json = self.client.post(self.catalog['registrations_url'], data=None) self.assertTrue(rv.status_code == 400) rv,json = self.client.post(self.catalog['registrations_url'], data={}) self.assertTrue(rv.status_code == 400) # missing class URL self.assertRaises(ValidationError, lambda: self.client.post( self.catalog['registrations_url'], data={'student_url': david_url})) # missing student URL self.assertRaises(ValidationError, lambda: self.client.post( self.catalog['registrations_url'], data={'class_url': algebra_url})) # class is not a URL self.assertRaises(ValidationError, lambda: self.client.post( self.catalog['registrations_url'], data={'student_url': david_url, 'class_url': 'foo'})) # class is a not found URL self.assertRaises(ValidationError, lambda: self.client.post( self.catalog['registrations_url'], data={'student_url': david_url, 'class_url': algebra_url + '1'})) # class is an invalid URL self.assertRaises(ValidationError, lambda: self.client.post( self.catalog['registrations_url'], data={'student_url': david_url, 'class_url': david_url})) db.session.remove() # get classes from each student rv, json = self.client.get(susan_url) self.assertTrue(rv.status_code == 200) susans_reg_url = json['registrations_url'] rv, json = self.client.get(susans_reg_url) self.assertTrue(rv.status_code == 200) self.assertTrue(susan_in_algebra_url in json['registrations']) self.assertTrue(susan_in_lit_url in json['registrations']) self.assertTrue(len(json['registrations']) == 2) rv, json = self.client.get(david_url) self.assertTrue(rv.status_code == 200) davids_reg_url = json['registrations_url'] rv, json = self.client.get(davids_reg_url) self.assertTrue(rv.status_code == 200) self.assertTrue(david_in_algebra_url in json['registrations']) self.assertTrue(len(json['registrations']) == 1) # get students for each class rv, json = self.client.get(algebra_url) self.assertTrue(rv.status_code == 200) algebras_reg_url = json['registrations_url'] rv, json = self.client.get(algebras_reg_url) self.assertTrue(rv.status_code == 200) self.assertTrue(susan_in_algebra_url in json['registrations']) self.assertTrue(david_in_algebra_url in json['registrations']) self.assertTrue(len(json['registrations']) == 2) rv, json = self.client.get(lit_url) self.assertTrue(rv.status_code == 200) lits_reg_url = json['registrations_url'] rv, json = self.client.get(lits_reg_url) self.assertTrue(rv.status_code == 200) self.assertTrue(susan_in_lit_url in json['registrations']) self.assertTrue(len(json['registrations']) == 1) # unregister students rv, json = self.client.delete(susan_in_algebra_url) self.assertTrue(rv.status_code == 200) rv, json = self.client.delete(david_in_algebra_url) self.assertTrue(rv.status_code == 200) # get collection rv, json = self.client.get(self.catalog['registrations_url']) self.assertTrue(rv.status_code == 200) self.assertFalse(susan_in_algebra_url in json['registrations']) self.assertTrue(susan_in_lit_url in json['registrations']) self.assertFalse(david_in_algebra_url in json['registrations']) self.assertTrue(len(json['registrations']) == 1) # delete student rv, json = self.client.delete(susan_url) self.assertTrue(rv.status_code == 200) # get collection rv, json = self.client.get(self.catalog['registrations_url']) self.assertTrue(rv.status_code == 200) self.assertTrue(len(json['registrations']) == 0) # register through student registrations URL rv, json = self.client.get(david_url) rv, json = self.client.post(json['registrations_url'], data={'class_url': lit_url}) self.assertTrue(rv.status_code == 201) # register through class registrations URL rv, json = self.client.get(algebra_url) rv, json = self.client.post(json['registrations_url'], data={'student_url': david_url}) self.assertTrue(rv.status_code == 201) # get collection rv, json = self.client.get(self.catalog['registrations_url']) self.assertTrue(rv.status_code == 200) self.assertTrue(len(json['registrations']) == 2) def test_rate_limits(self): self.app.config['USE_RATE_LIMITS'] = True rv, json = self.client.get(self.catalog['registrations_url']) self.assertTrue(rv.status_code == 200) self.assertTrue('X-RateLimit-Remaining' in rv.headers) self.assertTrue('X-RateLimit-Limit' in rv.headers) self.assertTrue('X-RateLimit-Reset' in rv.headers) self.assertTrue(int(rv.headers['X-RateLimit-Limit']) == \ int(rv.headers['X-RateLimit-Remaining']) + 1) while int(rv.headers['X-RateLimit-Remaining']) > 0: rv, json = self.client.get(self.catalog['registrations_url']) self.assertTrue(rv.status_code == 200) rv, json = self.client.get(self.catalog['registrations_url']) self.assertTrue(rv.status_code == 429) def test_expanded_collections(self): # create new students rv, json = self.client.post(self.catalog['students_url'], data={'name': 'susan'}) self.assertTrue(rv.status_code == 201) susan_url = rv.headers['Location'] rv, json = self.client.get(self.catalog['students_url'] + "?expand=1") self.assertTrue(rv.status_code == 200) print(json) self.assertTrue(json['students'][0]['name'] == 'susan') self.assertTrue(json['students'][0]['self_url'] == susan_url) def _create_test_students(self): # create several students rv, json = self.client.post(self.catalog['students_url'], data={'name': 'one'}) self.assertTrue(rv.status_code == 201) one_url = rv.headers['Location'] rv, json = self.client.post(self.catalog['students_url'], data={'name': 'two'}) self.assertTrue(rv.status_code == 201) two_url = rv.headers['Location'] rv, json = self.client.post(self.catalog['students_url'], data={'name': 'three'}) self.assertTrue(rv.status_code == 201) three_url = rv.headers['Location'] rv, json = self.client.post(self.catalog['students_url'], data={'name': 'four'}) self.assertTrue(rv.status_code == 201) four_url = rv.headers['Location'] rv, json = self.client.post(self.catalog['students_url'], data={'name': 'five'}) self.assertTrue(rv.status_code == 201) five_url = rv.headers['Location'] return [one_url, two_url, three_url, four_url, five_url] def test_filters(self): urls = self._create_test_students() # test various filter operators rv, json = self.client.get(self.catalog['students_url'] + '?filter=name,eq,three') self.assertTrue(rv.status_code == 200) self.assertTrue(json['students'] == [urls[2]]) rv, json = self.client.get(self.catalog['students_url'] + '?filter=name,ne,three&sort=id,asc') self.assertTrue(rv.status_code == 200) self.assertTrue(json['students'] == [urls[0], urls[1], urls[3], urls[4]]) rv, json = self.client.get(self.catalog['students_url'] + '?filter=id,le,2&sort=id,asc') self.assertTrue(rv.status_code == 200) self.assertTrue(json['students'] == [urls[0], urls[1]]) rv, json = self.client.get(self.catalog['students_url'] + '?filter=id,ge,2;id,lt,4&sort=id,asc') self.assertTrue(rv.status_code == 200) self.assertTrue(json['students'] == [urls[1], urls[2]]) rv, json = self.client.get(self.catalog['students_url'] + '?filter=name,in,three,five&sort=id,asc') self.assertTrue(rv.status_code == 200) self.assertTrue(json['students'] == [urls[2], urls[4]]) # bad operator is ignored rv, json = self.client.get(self.catalog['students_url'] + '?filter=name,is,three,five') self.assertTrue(rv.status_code == 200) self.assertTrue(len(json['students']) == 5) # bad column name is ignored rv, json = self.client.get(self.catalog['students_url'] + '?filter=foo,in,three,five') self.assertTrue(rv.status_code == 200) self.assertTrue(len(json['students']) == 5) def test_sorting(self): urls = self._create_test_students() # sort ascending (implicit) rv, json = self.client.get(self.catalog['students_url'] + '?sort=name') self.assertTrue(rv.status_code == 200) self.assertTrue(json['students'] == [urls[4], urls[3], urls[0], urls[2], urls[1]]) # sort ascending rv, json = self.client.get(self.catalog['students_url'] + '?sort=name,asc') self.assertTrue(rv.status_code == 200) self.assertTrue(json['students'] == [urls[4], urls[3], urls[0], urls[2], urls[1]]) # sort descending rv, json = self.client.get(self.catalog['students_url'] + '?sort=name,desc') self.assertTrue(rv.status_code == 200) self.assertTrue(json['students'] == [urls[1], urls[2], urls[0], urls[3], urls[4]]) def test_pagination(self): urls = self._create_test_students() # get collection in pages rv, json = self.client.get(self.catalog['students_url'] + '?page=1&per_page=2&sort=name,asc') self.assertTrue(rv.status_code == 200) self.assertTrue(urls[4] in json['students']) self.assertTrue(urls[3] in json['students']) self.assertTrue(len(json['students']) == 2) self.assertTrue('total' in json['meta']) self.assertTrue(json['meta']['total'] == 5) self.assertTrue('prev_url' in json['meta']) self.assertTrue(json['meta']['prev_url'] is None) first_url = json['meta']['first_url'] last_url = json['meta']['last_url'] next_url = json['meta']['next_url'] rv, json = self.client.get(first_url) self.assertTrue(rv.status_code == 200) self.assertTrue(urls[4] in json['students']) self.assertTrue(urls[3] in json['students']) self.assertTrue(len(json['students']) == 2) rv, json = self.client.get(next_url) self.assertTrue(rv.status_code == 200) self.assertTrue(urls[0] in json['students']) self.assertTrue(urls[2] in json['students']) self.assertTrue(len(json['students']) == 2) next_url = json['meta']['next_url'] rv, json = self.client.get(next_url) self.assertTrue(rv.status_code == 200) self.assertTrue(urls[1] in json['students']) self.assertTrue(len(json['students']) == 1) rv, json = self.client.get(last_url) self.assertTrue(rv.status_code == 200) self.assertTrue(urls[1] in json['students']) self.assertTrue(len(json['students']) == 1) def test_etag(self): # create two students rv, json = self.client.post(self.catalog['students_url'], data={'name': 'one'}) self.assertTrue(rv.status_code == 201) one_url = rv.headers['Location'] rv, json = self.client.post(self.catalog['students_url'], data={'name': 'two'}) self.assertTrue(rv.status_code == 201) two_url = rv.headers['Location'] # get their etags rv, json = self.client.get(one_url) self.assertTrue(rv.status_code == 200) self.assertTrue('Cache-Control' in rv.headers) one_etag = rv.headers['ETag'] rv, json = self.client.get(two_url) self.assertTrue(rv.status_code == 200) self.assertTrue('Cache-Control' in rv.headers) two_etag = rv.headers['ETag'] # send If-None-Match header rv, json = self.client.get(one_url, headers={ 'If-None-Match': one_etag}) self.assertTrue(rv.status_code == 304) rv, json = self.client.get(one_url, headers={ 'If-None-Match': one_etag + ', ' + two_etag}) self.assertTrue(rv.status_code == 304) rv, json = self.client.get(one_url, headers={ 'If-None-Match': two_etag}) self.assertTrue(rv.status_code == 200) rv, json = self.client.get(one_url, headers={ 'If-None-Match': two_etag + ', *'}) self.assertTrue(rv.status_code == 304) # send If-Match header rv, json = self.client.get(one_url, headers={ 'If-Match': one_etag}) self.assertTrue(rv.status_code == 200) rv, json = self.client.get(one_url, headers={ 'If-Match': one_etag + ', ' + two_etag}) self.assertTrue(rv.status_code == 200) rv, json = self.client.get(one_url, headers={ 'If-Match': two_etag}) self.assertTrue(rv.status_code == 412) rv, json = self.client.get(one_url, headers={ 'If-Match': '*'}) self.assertTrue(rv.status_code == 200) # change a resource rv, json = self.client.put(one_url, data={'name': 'not-one'}) self.assertTrue(rv.status_code == 200) # use stale etag rv, json = self.client.get(one_url, headers={ 'If-None-Match': one_etag}) self.assertTrue(rv.status_code == 200) ================================================ FILE: tests/test_client.py ================================================ from base64 import b64encode from werkzeug.exceptions import HTTPException import json class TestClient(): def __init__(self, app, username, password): self.app = app self.auth = 'Basic ' + b64encode((username + ':' + password) .encode('utf-8')).decode('utf-8') def send(self, url, method='GET', data=None, headers={}, content_type='application/json'): # Flask's client prefers relative URLs url = url.replace('http://localhost', '') # assemble the final header list headers = headers.copy() headers['Authorization'] = self.auth if 'Content-Type' not in headers: headers['Content-Type'] = content_type if 'Accept' not in headers: headers['Accept'] = content_type # generate a body if needed if data: data = json.dumps(data) # send the request with self.app.test_request_context(url, method=method, data=data, headers=headers): try: rv = self.app.preprocess_request() if rv is None: rv = self.app.dispatch_request() rv = self.app.make_response(rv) rv = self.app.process_response(rv) except HTTPException as e: rv = self.app.handle_user_exception(e) return rv, json.loads(rv.data.decode('utf-8')) def get(self, url, headers={}): return self.send(url, 'GET', headers=headers) def post(self, url, data, headers={}): return self.send(url, 'POST', data, headers=headers) def put(self, url, data, headers={}): return self.send(url, 'PUT', data, headers=headers) def delete(self, url, headers={}): return self.send(url, 'DELETE', headers=headers)