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?
=========================
[](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 <username>
Password: <password>
Confirm: <password>
User <username> 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/<int:id>', methods=['GET'])
@etag
@json
def get_class(id):
return Class.query.get_or_404(id)
@api.route('/classes/<int:id>/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/<int:id>/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/<int:id>', 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/<int:id>', 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/<int:student_id>/<int:class_id>', 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/<int:student_id>/<int:class_id>', 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/<int:id>', methods=['GET'])
@etag
@json
def get_student(id):
return Student.query.get_or_404(id)
@api.route('/students/<int:id>/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/<int:id>/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/<int:id>', 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/<int:id>', 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)
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
SYMBOL INDEX (102 symbols across 15 files)
FILE: api/app.py
function create_app (line 9) | def create_app(config_module=None):
FILE: api/auth.py
function verify_password (line 10) | def verify_password(username_or_token, password):
function unauthorized_error (line 22) | def unauthorized_error():
FILE: api/decorators.py
function json (line 8) | def json(f):
function rate_limit (line 33) | def rate_limit(limit, period):
function _filter_query (line 65) | def _filter_query(model, query, filter_spec):
function _sort_query (line 81) | def _sort_query(model, query, sort_spec):
function collection (line 93) | def collection(model, name=None, max_per_page=10):
function etag (line 154) | def etag(f):
FILE: api/errors.py
class ValidationError (line 4) | class ValidationError(ValueError):
function not_modified (line 8) | def not_modified():
function bad_request (line 14) | def bad_request(message):
function unauthorized (line 21) | def unauthorized(message=None):
function not_found (line 35) | def not_found(message):
function not_allowed (line 42) | def not_allowed():
function precondition_failed (line 48) | def precondition_failed():
function too_many_requests (line 54) | def too_many_requests(message='You have exceeded your request rate'):
FILE: api/helpers.py
function match_url (line 6) | def match_url(url, method=None):
function args_from_url (line 30) | def args_from_url(url, endpoint):
FILE: api/models.py
class Registration (line 13) | class Registration(db.Model):
method get_url (line 21) | def get_url(self):
method export_data (line 25) | def export_data(self):
method import_data (line 33) | def import_data(self, data):
class Student (line 48) | class Student(db.Model):
method get_url (line 57) | def get_url(self):
method export_data (line 60) | def export_data(self):
method import_data (line 66) | def import_data(self, data):
class Class (line 74) | class Class(db.Model):
method get_url (line 83) | def get_url(self):
method export_data (line 86) | def export_data(self):
method import_data (line 92) | def import_data(self, data):
class User (line 100) | class User(db.Model):
method password (line 107) | def password(self):
method password (line 111) | def password(self, password):
method verify_password (line 114) | def verify_password(self, password):
method generate_auth_token (line 117) | def generate_auth_token(self, expires_in=3600):
method verify_auth_token (line 122) | def verify_auth_token(token):
FILE: api/rate_limit.py
class FakeRedis (line 8) | class FakeRedis(object):
method __init__ (line 10) | def __init__(self):
method pipeline (line 14) | def pipeline(self):
method incr (line 17) | def incr(self, key):
method expireat (line 23) | def expireat(self, key, time):
method execute (line 26) | def execute(self):
class RateLimit (line 30) | class RateLimit(object):
method __init__ (line 33) | def __init__(self, key_prefix, limit, period):
method allowed (line 51) | def allowed(self):
method remaining (line 55) | def remaining(self):
FILE: api/token.py
function verify_password (line 12) | def verify_password(username, password):
function unauthorized_error (line 20) | def unauthorized_error():
function request_token (line 27) | def request_token():
FILE: api/v1/__init__.py
function get_catalog (line 10) | def get_catalog():
function validation_error (line 18) | def validation_error(e):
function bad_request_error (line 23) | def bad_request_error(e):
function before_request (line 30) | def before_request():
function after_request (line 35) | def after_request(response):
FILE: api/v1/classes.py
function get_classes (line 11) | def get_classes():
function get_class (line 18) | def get_class(id):
function get_class_registrations (line 26) | def get_class_registrations(id):
function new_class (line 33) | def new_class():
function new_class_registration (line 42) | def new_class_registration(id):
function edit_class (line 54) | def edit_class(id):
function delete_class (line 64) | def delete_class(id):
FILE: api/v1/registrations.py
function get_registrations (line 11) | def get_registrations():
function get_registration (line 18) | def get_registration(student_id, class_id):
function new_registration (line 24) | def new_registration():
function delete_registration (line 33) | def delete_registration(student_id, class_id):
FILE: api/v1/students.py
function get_students (line 11) | def get_students():
function get_student (line 18) | def get_student(id):
function get_student_registrations (line 26) | def get_student_registrations(id):
function new_student (line 33) | def new_student():
function new_student_registration (line 42) | def new_student_registration(id):
function edit_student (line 54) | def edit_student(id):
function delete_student (line 64) | def delete_student(id):
FILE: manage.py
function createdb (line 11) | def createdb(testdata=False):
function adduser (line 31) | def adduser(username):
function test (line 47) | def test():
FILE: tests/test_api.py
class TestAPI (line 9) | class TestAPI(unittest.TestCase):
method setUp (line 13) | def setUp(self):
method tearDown (line 26) | def tearDown(self):
method _get_catalog (line 31) | def _get_catalog(self, version='v1'):
method test_password_auth (line 35) | def test_password_auth(self):
method test_bad_auth (line 48) | def test_bad_auth(self):
method test_token (line 59) | def test_token(self):
method test_user_password_not_readable (line 74) | def test_user_password_not_readable(self):
method test_http_errors (line 78) | def test_http_errors(self):
method test_students (line 87) | def test_students(self):
method test_classes (line 153) | def test_classes(self):
method test_registrations (line 219) | def test_registrations(self):
method test_rate_limits (line 393) | def test_rate_limits(self):
method test_expanded_collections (line 409) | def test_expanded_collections(self):
method _create_test_students (line 423) | def _create_test_students(self):
method test_filters (line 448) | def test_filters(self):
method test_sorting (line 490) | def test_sorting(self):
method test_pagination (line 514) | def test_pagination(self):
method test_etag (line 555) | def test_etag(self):
FILE: tests/test_client.py
class TestClient (line 6) | class TestClient():
method __init__ (line 7) | def __init__(self, app, username, password):
method send (line 12) | def send(self, url, method='GET', data=None, headers={},
method get (line 43) | def get(self, url, headers={}):
method post (line 46) | def post(self, url, data, headers={}):
method put (line 49) | def put(self, url, data, headers={}):
method delete (line 52) | def delete(self, url, headers={}):
Condensed preview — 24 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (72K chars).
[
{
"path": ".gitignore",
"chars": 675,
"preview": "# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n\n# C extensions\n*.so\n\n# Distribution / packaging\n.Python\n"
},
{
"path": ".travis.yml",
"chars": 148,
"preview": "language: python\npython:\n - \"3.4\"\n - \"3.3\"\n - \"2.7\"\n - \"2.6\"\n - \"pypy\"\ninstall: pip install -r requirements.txt\nscr"
},
{
"path": "LICENSE",
"chars": 1083,
"preview": "The MIT License (MIT)\n\nCopyright (c) 2015 Miguel Grinberg\n\nPermission is hereby granted, free of charge, to any person o"
},
{
"path": "README.md",
"chars": 12746,
"preview": "Is Your REST API RESTful?\n=========================\n\n[:\n pass\n\n\ndef not_modified():\n "
},
{
"path": "api/helpers.py",
"chars": 1287,
"preview": "from flask.globals import _app_ctx_stack, _request_ctx_stack\nfrom werkzeug.urls import url_parse\nfrom werkzeug.exception"
},
{
"path": "api/models.py",
"chars": 4638,
"preview": "from datetime import datetime\nfrom werkzeug.security import generate_password_hash, check_password_hash\nfrom werkzeug.ex"
},
{
"path": "api/rate_limit.py",
"chars": 1343,
"preview": "import time\nfrom redis import Redis\nfrom flask import current_app\n\nredis = None\n\n\nclass FakeRedis(object):\n \"\"\"Redis "
},
{
"path": "api/token.py",
"chars": 935,
"preview": "from flask import Blueprint, jsonify, g\nfrom flask_httpauth import HTTPBasicAuth\nfrom .models import User\nfrom .errors i"
},
{
"path": "api/v1/__init__.py",
"chars": 1020,
"preview": "from flask import Blueprint, g, url_for\nfrom ..errors import ValidationError, bad_request, not_found\nfrom ..auth import "
},
{
"path": "api/v1/classes.py",
"chars": 1604,
"preview": "from flask import request\nfrom ..models import db, Class, Registration\nfrom ..decorators import json, collection, etag\nf"
},
{
"path": "api/v1/registrations.py",
"chars": 981,
"preview": "from flask import request\nfrom ..models import db, Registration\nfrom ..decorators import json, collection, etag\nfrom . i"
},
{
"path": "api/v1/students.py",
"chars": 1656,
"preview": "from flask import request\nfrom ..models import db, Student, Registration\nfrom ..decorators import json, collection, etag"
},
{
"path": "config.py",
"chars": 371,
"preview": "import os\nimport redis\n\nbasedir = os.path.abspath(os.path.dirname(__file__))\n\nSECRET_KEY = 'secret'\nSQLALCHEMY_DATABASE_"
},
{
"path": "manage.py",
"chars": 1600,
"preview": "#!/usr/bin/env python\nfrom flask import Flask, g, jsonify\nfrom flask.ext.script import Manager\nfrom api.app import creat"
},
{
"path": "requirements.txt",
"chars": 247,
"preview": "Flask==0.10.1\nFlask-HTTPAuth==2.2.1\nFlask-SQLAlchemy==1.0\nFlask-Script==0.6.7\nJinja2==2.7.2\nMarkupSafe==0.19\nPygments==1"
},
{
"path": "test_config.py",
"chars": 121,
"preview": "TESTING = True\nSECRET_KEY = 'secret'\nSQLALCHEMY_DATABASE_URI = 'sqlite://'\nUSE_TOKEN_AUTH = True\nUSE_RATE_LIMITS = False"
},
{
"path": "tests/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "tests/test_api.py",
"chars": 25782,
"preview": "import unittest\nfrom werkzeug.exceptions import BadRequest\nfrom .test_client import TestClient\nfrom api.app import creat"
},
{
"path": "tests/test_client.py",
"chars": 1875,
"preview": "from base64 import b64encode\nfrom werkzeug.exceptions import HTTPException\nimport json\n\n\nclass TestClient():\n def __i"
}
]
About this extraction
This page contains the full source code of the miguelgrinberg/api-pycon2015 GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 24 files (67.1 KB), approximately 15.8k tokens, and a symbol index with 102 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.