Full Code of miguelgrinberg/api-pycon2015 for AI

master 7c886366b588 cached
24 files
67.1 KB
15.8k tokens
102 symbols
1 requests
Download .txt
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 <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)
Download .txt
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
Download .txt
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[![Build Status](https://travis-ci.org/miguelgrinberg/api-pycon2015"
  },
  {
    "path": "api/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "api/app.py",
    "chars": 1072,
    "preview": "import os\nfrom flask import Flask\nfrom .models import db\nfrom .auth import auth\nfrom .decorators import json, etag\nfrom "
  },
  {
    "path": "api/auth.py",
    "chars": 671,
    "preview": "from flask import current_app, g\nfrom flask_httpauth import HTTPBasicAuth\nfrom .models import User\nfrom .errors import u"
  },
  {
    "path": "api/decorators.py",
    "chars": 7218,
    "preview": "import functools\nimport hashlib\nfrom flask import jsonify, request, url_for, current_app, make_response, g\nfrom .rate_li"
  },
  {
    "path": "api/errors.py",
    "chars": 1636,
    "preview": "from flask import jsonify, url_for, current_app\n\n\nclass ValidationError(ValueError):\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.

Copied to clipboard!