[
  {
    "path": ".gitignore",
    "content": "# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n\n# C extensions\n*.so\n\n# Distribution / packaging\n.Python\nenv/\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\n*.egg-info/\n.installed.cfg\n*.egg\n\n# PyInstaller\n#  Usually these files are written by a python script from a template\n#  before PyInstaller builds the exe, so as to inject date/other infos into it.\n*.manifest\n*.spec\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.coverage\n.cache\nnosetests.xml\ncoverage.xml\n\n# Translations\n*.mo\n*.pot\n\n# Django stuff:\n*.log\n\n# Sphinx documentation\ndocs/_build/\n\n# PyBuilder\ntarget/\n"
  },
  {
    "path": ".travis.yml",
    "content": "language: python\npython:\n  - \"3.4\"\n  - \"3.3\"\n  - \"2.7\"\n  - \"2.6\"\n  - \"pypy\"\ninstall: pip install -r requirements.txt\nscript:  python manage.py test\n"
  },
  {
    "path": "LICENSE",
    "content": "The MIT License (MIT)\n\nCopyright (c) 2015 Miguel Grinberg\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n"
  },
  {
    "path": "README.md",
    "content": "Is Your REST API RESTful?\n=========================\n\n[![Build Status](https://travis-ci.org/miguelgrinberg/api-pycon2015.png?branch=master)](https://travis-ci.org/miguelgrinberg/api-pycon2015)\n\nThis 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).\n\nThe 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.\n\nRequirements\n------------\n\nTo install and run this application you need:\n\n- Python 3.4 (2.7 works too)\n- Redis (optional, for the rate limiting feature)\n\nInstallation\n------------\n\nThe commands below install the application and its dependencies:\n\n    $ git clone https://github.com/miguelgrinberg/api-pycon2015.git\n    $ cd api-pycon2015\n    $ python3.4 -m venv venv\n    $ source venv/bin/activate\n    (venv) pip install -r requirements.txt\n\nThe 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.\n\nUnit Tests\n----------\n\nTo ensure that your installation was successful you can run the unit tests:\n\n    (venv) $ python manage.py test\n    test_bad_auth (tests.test_api.TestAPI) ... ok\n    test_classes (tests.test_api.TestAPI) ... ok\n    test_etag (tests.test_api.TestAPI) ... ok\n    test_expanded_collections (tests.test_api.TestAPI) ... ok\n    test_filters (tests.test_api.TestAPI) ... ok\n    test_pagination (tests.test_api.TestAPI) ... ok\n    test_password_auth (tests.test_api.TestAPI) ... ok\n    test_rate_limits (tests.test_api.TestAPI) ... ok\n    test_registrations (tests.test_api.TestAPI) ... ok\n    test_sorting (tests.test_api.TestAPI) ... ok\n    test_students (tests.test_api.TestAPI) ... ok\n    test_user_password_not_readable (tests.test_api.TestAPI) ... ok\n\n    Name                   Stmts   Miss Branch BrMiss  Cover   Missing\n    ------------------------------------------------------------------\n    api                        0      0      0      0   100%\n    api.app                   26      2      5      1    90%   34, 38\n    api.auth                  13      0      2      0   100%\n    api.decorators           120     10     61      4    92%   19, 155-163, 167\n    api.errors                41     13      6      3    66%   26, 36-39, 43-46, 50-52, 62-65\n    api.helpers               22      6     11      6    64%   10, 17-19, 26, 33\n    api.models                81      0      0      0   100%\n    api.rate_limit            38      1      6      1    95%   39\n    api.token                 18      6      2      2    60%   13-16, 21, 31\n    api.v1                    20      1      2      0    95%   18\n    api.v1.classes            47      0      0      0   100%\n    api.v1.registrations      25      0      0      0   100%\n    api.v1.students           47      0      0      0   100%\n    ------------------------------------------------------------------\n    TOTAL                    498     39     95     17    91%\n    ----------------------------------------------------------------------\n    Ran 12 tests in 1.583s\n\n    OK\n\nThe 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.\n\nUser Registration\n-----------------\n\nThe API can only be accessed by authenticated users. New users can be registered with the application from the command line:\n\n    (venv) $ python manage.py adduser <username>\n    Password: <password>\n    Confirm: <password>\n    User <username> was registered successfully.\n\nThe 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.\n\nAuthentication\n--------------\n\nThe 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.\n\nTo switch to a simper username and password authentication the configuration stored in `config.py` must be edited as follows:\n\n    USE_TOKEN_AUTH = False \n\nAfter 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.\n\nAPI Documentation\n-----------------\n\nGeneral notes about this API:\n\n- All resource representations are in JSON format.\n\nThe API supported by this application contains three top-level resource collections:\n\n- *students*: The collection of students.\n- *classes*: The collection of classes.\n- *registrations*: The collection of student-to-class registrations.\n\nTo obtain the URLs of these resources clients must send a request to the root URL of the API to obtain the API version catalog:\n\n    {\n        \"versions\": {\n            \"v1\": {\n                \"classes_url\": \"[class-collection-url]\",\n                \"registrations_url\": \"[registration-collection-url]\",\n                \"students_url\": \"[student-collection-url]\"\n            }\n        }\n    }\n\nTo see an example of how a client can use this information to access the API see the unit tests.\n\n### Resource Collections\n\nResource 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.\n\n#### Filtering\n\nResource 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:\n\n    [field_name],[operator],[value]\n\nTo 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:\n\n- Search by exact value: `filter=name,eq,john`\n- Search by range (all names that begin with \"a\"): `filter=name,ge,a;name,lt,b`\n- Search in a set: `filter=name,in,john,susan,mary`\n\nInvalid filters are silently ignored.\n\n#### Sorting\n\nCollections 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:\n\n    [field_name],[asc|desc]\n\nTo specify multiple sort orders concatenate them with a `;` separator. The sort order can be `asc` or `desc`. If not specified `asc` is used. Examples:\n\n- Sort by name: `sort=name`\n- Sort by name in descending order: `sort=name,desc`\n- Sort by name in ascending order and then by id in descending order: `sort=name,asc;id,desc`\n\nInvalid sort specifications are silently ignored.\n\n#### Resource Expansion\n\nBy default, when a collection of resources is returned, only their URLs are returned, as this maximizes caching efficiency. Example:\n\n    {\n        \"students\": [\n            \"[student-resource-url-1]\",\n            \"[student-resource-url-2]\",\n            \"[student-resource-url-3]\"\n        ]\n    }\n\nHowever, 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:\n\n    {\n        \"students\": [\n            {\n                \"name\": \"john\",\n                \"registrations_url\": \"[student-registration-url-1]\",\n                \"self_url\": \"[student-resource-url-1]\"\n            },\n            {\n                \"name\": \"susan\",\n                \"registrations_url\": \"[student-registration-url-2]\",\n                \"self_url\": \"[student-resource-url-2]\"\n            },\n            {\n                \"name\": \"mary\",\n                \"registrations_url\": \"[student-registration-url-3]\",\n                \"self_url\": \"[student-resource-url-3]\"\n            }\n        ]\n    }\n\n#### Pagination\n\nAll 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:\n\n    {\n        \"meta\": {\n            \"first_url\": \"[students-collection-url]?per_page=10&page=1\",\n            \"last_url\": \"[students-collection-url]?per_page=10&page=4\",\n            \"next_url\": \"[students-collection-url]?per_page=10&page=2\",\n            \"page\": 1,\n            \"pages\": 4,\n            \"per_page\": 10,\n            \"prev_url\": null,\n            \"total\": 37\n        },\n        \"students\": [\n            ...\n        ]\n    }\n\nThe `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.\n\nThe `page`, `pages`, `total` and `per_page` provide the current page, total number of pages, total number of items and items per page values respectively.\n\nTo 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.\n\n### Student Resource\n\nA student resource has the following structure:\n\n    {\n        \"name\": [student name],\n        \"registrations_url\": [link to student registrations]\n        \"self_url\": [student URL],\n    }\n\nThe 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.\n\nA `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.\n\n### Class Resource\n\nThe class resource has a similar structure:\n\n    {\n        \"name\": [class name],\n        \"registrations_url\": [link to class registrations]\n        \"self_url\": [class URL],\n    }\n\nThe 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.\n\nA `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.\n\n### Registration Resource\n\nThe registration resource associates a student with a class. Below is the structure of this resource:\n\n    {\n        \"student_url\": [student URL],\n        \"class_url\": [class URL],\n        \"timestamp\": [date of registration]\n        \"self_url\": [registration URL],\n    }\n\nThe registration resource supports `GET`, `POST` and `DELETE` methods, to retrieve, create and delete respectively.\n\nHTTP Caching\n------------\n\nThe 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.\n\nRate Limiting\n-------------\n\nThis 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.\n\nThe 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.\n\nWhen rate limiting is enabled all responses return three additional headers:\n\n    X-RateLimit-Limit: [period in seconds]\n    X-RateLimit-Remaining: [remaining calls in this period]\n    X-RateLimit-Reset: [time when the limits reset, in UTC epoch seconds]\n"
  },
  {
    "path": "api/__init__.py",
    "content": ""
  },
  {
    "path": "api/app.py",
    "content": "import os\nfrom flask import Flask\nfrom .models import db\nfrom .auth import auth\nfrom .decorators import json, etag\nfrom .errors import not_found, not_allowed\n\n\ndef create_app(config_module=None):\n    app = Flask(__name__)\n    app.config.from_object(config_module or\n                           os.environ.get('FLASK_CONFIG') or\n                           'config')\n\n    db.init_app(app)\n\n    from api.v1 import api as api_blueprint\n    app.register_blueprint(api_blueprint, url_prefix='/v1')\n\n    if app.config['USE_TOKEN_AUTH']:\n        from api.token import token as token_blueprint\n        app.register_blueprint(token_blueprint, url_prefix='/auth')\n\n    @app.route('/')\n    @auth.login_required\n    @etag\n    @json\n    def index():\n        from api.v1 import get_catalog as v1_catalog\n        return {'versions': {'v1': v1_catalog()}}\n\n    @app.errorhandler(404)\n    @auth.login_required\n    def not_found_error(e):\n        return not_found('item not found')\n\n    @app.errorhandler(405)\n    def method_not_allowed_error(e):\n        return not_allowed()\n\n    return app\n"
  },
  {
    "path": "api/auth.py",
    "content": "from flask import current_app, g\nfrom flask_httpauth import HTTPBasicAuth\nfrom .models import User\nfrom .errors import unauthorized\n\nauth = HTTPBasicAuth()\n\n\n@auth.verify_password\ndef verify_password(username_or_token, password):\n    if current_app.config['USE_TOKEN_AUTH']:\n        # token authentication\n        g.user = User.verify_auth_token(username_or_token)\n        return g.user is not None\n    else:\n        # username/password authentication\n        g.user = User.query.filter_by(username=username_or_token).first()\n        return g.user is not None and g.user.verify_password(password)\n\n\n@auth.error_handler\ndef unauthorized_error():\n    return unauthorized()\n"
  },
  {
    "path": "api/decorators.py",
    "content": "import functools\nimport hashlib\nfrom flask import jsonify, request, url_for, current_app, make_response, g\nfrom .rate_limit import RateLimit\nfrom .errors import too_many_requests, precondition_failed, not_modified, ValidationError\n\n\ndef json(f):\n    \"\"\"This decorator generates a JSON response from a Python dictionary or\n    a SQLAlchemy model.\"\"\"\n    @functools.wraps(f)\n    def wrapped(*args, **kwargs):\n        rv = f(*args, **kwargs)\n        status_or_headers = None\n        headers = None\n        if isinstance(rv, tuple):\n            rv, status_or_headers, headers = rv + (None,) * (3 - len(rv))\n        if isinstance(status_or_headers, (dict, list)):\n            headers, status_or_headers = status_or_headers, None\n        if not isinstance(rv, dict):\n            # assume it is a model, call its export_data() method\n            rv = rv.export_data()\n\n        rv = jsonify(rv)\n        if status_or_headers is not None:\n            rv.status_code = status_or_headers\n        if headers is not None:\n            rv.headers.extend(headers)\n        return rv\n    return wrapped\n\n\ndef rate_limit(limit, period):\n    \"\"\"This decorator implements rate limiting.\"\"\"\n    def decorator(f):\n        @functools.wraps(f)\n        def wrapped(*args, **kwargs):\n            if current_app.config['USE_RATE_LIMITS']:\n                # generate a unique key to represent the decorated function and\n                # the IP address of the client. Rate limiting counters are\n                # maintained on each unique key.\n                key = '{0}/{1}'.format(f.__name__, str(g.user.id))\n                limiter = RateLimit(key, limit, period)\n\n                # set the rate limit headers in g, so that they are picked up\n                # by the after_request handler and attached to the response\n                g.headers = {\n                    'X-RateLimit-Remaining': str(limiter.remaining\n                        if limiter.remaining >= 0 else 0),\n                    'X-RateLimit-Limit': str(limit),\n                    'X-RateLimit-Reset': str(limiter.reset)\n                }\n\n                # if the client went over the limit respond with a 429 status\n                # code, else invoke the wrapped function\n                if not limiter.allowed:\n                    return too_many_requests()\n\n            # let the request through\n            return f(*args, **kwargs)\n        return wrapped\n    return decorator\n\n\ndef _filter_query(model, query, filter_spec):\n    filters = [f.split(',') for f in filter_spec.split(';')]\n    for f in filters:\n        if len(f) < 3 or (len(f) > 3 and f[1] != 'in'):\n            continue\n        if f[1] == 'in':\n            f = [f[0], f[1], f[2:]]\n        ops = {'eq': '__eq__', 'ne': '__ne__', 'lt': '__lt__', 'le': '__le__',\n               'gt': '__gt__', 'ge': '__ge__', 'in': 'in_', 'like': 'like'}\n        if hasattr(model, f[0]) and f[1] in ops.keys():\n            column = getattr(model, f[0])\n            op = ops[f[1]]\n            query = query.filter(getattr(column, op)(f[2]))\n    return query\n\n\ndef _sort_query(model, query, sort_spec):\n    sort = [s.split(',') for s in sort_spec.split(';')]\n    for s in sort:\n        if hasattr(model, s[0]):\n            column = getattr(model, s[0])\n            if len(s) == 2 and s[1] in ['asc', 'desc']:\n                query = query.order_by(getattr(column, s[1])())\n            else:\n                query = query.order_by(column.asc())\n    return query\n\n\ndef collection(model, name=None, max_per_page=10):\n    \"\"\"This decorator implements pagination, filtering, sorting and expanding\n    for collections. The expected response from the decorated route is a\n    SQLAlchemy query.\"\"\"\n    if name is None:\n        name = model.__tablename__\n\n    def decorator(f):\n        @functools.wraps(f)\n        def wrapped(*args, **kwargs):\n            query = f(*args, **kwargs)\n\n            # filtering and sorting\n            filter = request.args.get('filter')\n            if filter:\n                query = _filter_query(model, query, filter)\n            sort = request.args.get('sort')\n            if sort:\n                query = _sort_query(model, query, sort)\n\n            # pagination\n            page = request.args.get('page', 1, type=int)\n            per_page = min(request.args.get('per_page', max_per_page,\n                                            type=int), max_per_page)\n            expand = request.args.get('expand')\n\n            p = query.paginate(page, per_page)\n            pages = {'page': page, 'per_page': per_page,\n                     'total': p.total, 'pages': p.pages}\n            if p.has_prev:\n                pages['prev_url'] = url_for(request.endpoint, page=p.prev_num,\n                                            per_page=per_page,\n                                            expand=expand, _external=True,\n                                            **kwargs)\n            else:\n                pages['prev_url'] = None\n            if p.has_next:\n                pages['next_url'] = url_for(request.endpoint, filter=filter,\n                                            sort=sort, page=p.next_num,\n                                            per_page=per_page,\n                                            expand=expand, _external=True,\n                                            **kwargs)\n            else:\n                pages['next_url'] = None\n            pages['first_url'] = url_for(request.endpoint, filter=filter,\n                                         sort=sort, page=1, per_page=per_page,\n                                         expand=expand, _external=True,\n                                         **kwargs)\n            pages['last_url'] = url_for(request.endpoint, filter=filter,\n                                        sort=sort, page=p.pages,\n                                        per_page=per_page, expand=expand,\n                                        _external=True, **kwargs)\n            if expand:\n                items = [item.export_data() for item in p.items]\n            else:\n                items = [item.get_url() for item in p.items]\n            return {name: items, 'meta': pages}\n        return wrapped\n    return decorator\n\n\ndef etag(f):\n    \"\"\"This decorator adds an ETag header to the response.\"\"\"\n    @functools.wraps(f)\n    def wrapped(*args, **kwargs):\n        # only for HEAD and GET requests\n        assert request.method in ['HEAD', 'GET'],\\\n            '@etag is only supported for GET requests'\n        rv = f(*args, **kwargs)\n        rv = make_response(rv)\n        etag = '\"' + hashlib.md5(rv.get_data()).hexdigest() + '\"'\n        rv.headers['Cache-Control'] = 'max-age=86400'\n        rv.headers['ETag'] = etag\n        if_match = request.headers.get('If-Match')\n        if_none_match = request.headers.get('If-None-Match')\n        if if_match:\n            etag_list = [tag.strip() for tag in if_match.split(',')]\n            if etag not in etag_list and '*' not in etag_list:\n                rv = precondition_failed()\n        elif if_none_match:\n            etag_list = [tag.strip() for tag in if_none_match.split(',')]\n            if etag in etag_list or '*' in etag_list:\n                rv = not_modified()\n        return rv\n    return wrapped\n"
  },
  {
    "path": "api/errors.py",
    "content": "from flask import jsonify, url_for, current_app\n\n\nclass ValidationError(ValueError):\n    pass\n\n\ndef not_modified():\n    response = jsonify({'status': 304, 'error': 'not modified'})\n    response.status_code = 304\n    return response\n\n\ndef bad_request(message):\n    response = jsonify({'status': 400, 'error': 'bad request',\n                        'message': message})\n    response.status_code = 400\n    return response\n\n\ndef unauthorized(message=None):\n    if message is None:\n        if current_app.config['USE_TOKEN_AUTH']:\n            message = 'Please authenticate with your token.'\n        else:\n            message = 'Please authenticate.'\n    response = jsonify({'status': 401, 'error': 'unauthorized',\n                        'message': message})\n    response.status_code = 401\n    if current_app.config['USE_TOKEN_AUTH']:\n        response.headers['Location'] = url_for('token.request_token')\n    return response\n\n\ndef not_found(message):\n    response = jsonify({'status': 404, 'error': 'not found',\n                        'message': message})\n    response.status_code = 404\n    return response\n\n\ndef not_allowed():\n    response = jsonify({'status': 405, 'error': 'method not allowed'})\n    response.status_code = 405\n    return response\n\n\ndef precondition_failed():\n    response = jsonify({'status': 412, 'error': 'precondition failed'})\n    response.status_code = 412\n    return response\n\n\ndef too_many_requests(message='You have exceeded your request rate'):\n    response = jsonify({'status': 429, 'error': 'too many requests',\n                        'message': message})\n    response.status_code = 429\n    return response\n"
  },
  {
    "path": "api/helpers.py",
    "content": "from flask.globals import _app_ctx_stack, _request_ctx_stack\nfrom werkzeug.urls import url_parse\nfrom werkzeug.exceptions import NotFound\n\n\ndef match_url(url, method=None):\n    appctx = _app_ctx_stack.top\n    reqctx = _request_ctx_stack.top\n    if appctx is None:\n        raise RuntimeError('Attempted to match a URL without the '\n                           'application context being pushed. This has to be '\n                           'executed when application context is available.')\n\n    if reqctx is not None:\n        url_adapter = reqctx.url_adapter\n    else:\n        url_adapter = appctx.url_adapter\n        if url_adapter is None:\n            raise RuntimeError('Application was not able to create a URL '\n                               'adapter for request independent URL matching. '\n                               'You might be able to fix this by setting '\n                               'the SERVER_NAME config variable.')\n    parsed_url = url_parse(url)\n    if parsed_url.netloc is not '' and \\\n                    parsed_url.netloc != url_adapter.server_name:\n        raise NotFound()\n    return url_adapter.match(parsed_url.path, method)\n\n\ndef args_from_url(url, endpoint):\n    r = match_url(url, 'GET')\n    if r[0] != endpoint:\n        raise NotFound()\n    return r[1]\n"
  },
  {
    "path": "api/models.py",
    "content": "from datetime import datetime\nfrom werkzeug.security import generate_password_hash, check_password_hash\nfrom werkzeug.exceptions import NotFound\nfrom itsdangerous import TimedJSONWebSignatureSerializer as Serializer\nfrom flask import url_for, current_app\nfrom flask_sqlalchemy import SQLAlchemy\nfrom .helpers import args_from_url\nfrom .errors import ValidationError\n\ndb = SQLAlchemy()\n\n\nclass Registration(db.Model):\n    __tablename__ = 'registrations'\n    student_id = db.Column('student_id', db.Integer,\n                           db.ForeignKey('students.id'), primary_key=True)\n    class_id = db.Column('class_id', db.Integer,\n                         db.ForeignKey('classes.id'), primary_key=True)\n    timestamp = db.Column(db.DateTime, default=datetime.utcnow)\n\n    def get_url(self):\n        return url_for('api.get_registration', student_id=self.student_id,\n                       class_id=self.class_id, _external=True)\n\n    def export_data(self):\n        return {'self_url': self.get_url(),\n                'student_url': url_for('api.get_student', id=self.student_id,\n                                       _external=True),\n                'class_url': url_for('api.get_class', id=self.class_id,\n                                     _external=True),\n                'timestamp': self.timestamp.isoformat() + 'Z'}\n\n    def import_data(self, data):\n        try:\n            student_id = args_from_url(data['student_url'],\n                                       'api.get_student')['id']\n            self.student = Student.query.get_or_404(student_id)\n        except (KeyError, NotFound):\n            raise ValidationError('Invalid student URL')\n        try:\n            class_id = args_from_url(data['class_url'], 'api.get_class')['id']\n            self.class_ = Class.query.get_or_404(class_id)\n        except (KeyError, NotFound):\n            raise ValidationError('Invalid class URL')\n        return self\n\n\nclass Student(db.Model):\n    __tablename__ = 'students'\n    id = db.Column(db.Integer, primary_key=True)\n    name = db.Column(db.String(64), index=True)\n    registrations = db.relationship(\n        'Registration',\n        backref=db.backref('student', lazy='joined'),\n        lazy='dynamic', cascade='all, delete-orphan')\n\n    def get_url(self):\n        return url_for('api.get_student', id=self.id, _external=True)\n\n    def export_data(self):\n        return {'self_url': self.get_url(),\n                'name': self.name,\n                'registrations_url': url_for('api.get_student_registrations',\n                                             id=self.id, _external=True)}\n\n    def import_data(self, data):\n        try:\n            self.name = data['name']\n        except KeyError as e:\n            raise ValidationError('Invalid student: missing ' + e.args[0])\n        return self\n\n\nclass Class(db.Model):\n    __tablename__ = 'classes'\n    id = db.Column(db.Integer, primary_key=True)\n    name = db.Column(db.String(64), index=True)\n    registrations = db.relationship(\n        'Registration',\n        backref=db.backref('class_', lazy='joined'),\n        lazy='dynamic', cascade='all, delete-orphan')\n\n    def get_url(self):\n        return url_for('api.get_class', id=self.id, _external=True)\n\n    def export_data(self):\n        return {'self_url': self.get_url(),\n                'name': self.name,\n                'registrations_url': url_for('api.get_class_registrations',\n                                             id=self.id, _external=True)}\n\n    def import_data(self, data):\n        try:\n            self.name = data['name']\n        except KeyError as e:\n            raise ValidationError('Invalid class: missing ' + e.args[0])\n        return self\n\n\nclass User(db.Model):\n    __tablename__ = 'users'\n    id = db.Column(db.Integer, primary_key=True)\n    username = db.Column(db.String(64), index=True)\n    password_hash = db.Column(db.String(128))\n\n    @property\n    def password(self):\n        raise AttributeError('password is not a readable attribute')\n\n    @password.setter\n    def password(self, password):\n        self.password_hash = generate_password_hash(password)\n\n    def verify_password(self, password):\n        return check_password_hash(self.password_hash, password)\n\n    def generate_auth_token(self, expires_in=3600):\n        s = Serializer(current_app.config['SECRET_KEY'], expires_in=expires_in)\n        return s.dumps({'id': self.id}).decode('utf-8')\n\n    @staticmethod\n    def verify_auth_token(token):\n        s = Serializer(current_app.config['SECRET_KEY'])\n        try:\n            data = s.loads(token)\n        except:\n            return None\n        return User.query.get(data['id'])\n\n"
  },
  {
    "path": "api/rate_limit.py",
    "content": "import time\nfrom redis import Redis\nfrom flask import current_app\n\nredis = None\n\n\nclass FakeRedis(object):\n    \"\"\"Redis mock used for testing.\"\"\"\n    def __init__(self):\n        self.v = {}\n        self.last_key = None\n\n    def pipeline(self):\n        return self\n\n    def incr(self, key):\n        if self.v.get(key, None) is None:\n            self.v[key] = 0\n        self.v[key] += 1\n        self.last_key = key\n\n    def expireat(self, key, time):\n        pass\n\n    def execute(self):\n        return [self.v[self.last_key]]\n\n\nclass RateLimit(object):\n    expiration_window = 10\n\n    def __init__(self, key_prefix, limit, period):\n        global redis\n        if redis is None and current_app.config['USE_RATE_LIMITS']:\n            if current_app.config['TESTING']:\n                redis = FakeRedis()\n            else:\n                redis = Redis()\n\n        self.reset = (int(time.time()) // period) * period + period\n        self.key = key_prefix + str(self.reset)\n        self.limit = limit\n        self.period = period\n        p = redis.pipeline()\n        p.incr(self.key)\n        p.expireat(self.key, self.reset + self.expiration_window)\n        self.current = p.execute()[0]\n\n    @property\n    def allowed(self):\n        return self.current <= self.limit\n\n    @property\n    def remaining(self):\n        return self.limit - self.current"
  },
  {
    "path": "api/token.py",
    "content": "from flask import Blueprint, jsonify, g\nfrom flask_httpauth import HTTPBasicAuth\nfrom .models import User\nfrom .errors import unauthorized\nfrom .decorators import json\n\ntoken = Blueprint('token', __name__)\ntoken_auth = HTTPBasicAuth()\n\n\n@token_auth.verify_password\ndef verify_password(username, password):\n    g.user = User.query.filter_by(username=username).first()\n    if not g.user:\n        return False\n    return g.user.verify_password(password)\n\n\n@token_auth.error_handler\ndef unauthorized_error():\n    return unauthorized('Please authenticate to get your token.')\n\n\n@token.route('/request-token', methods=['POST'])\n@token_auth.login_required\n@json\ndef request_token():\n    # Note that a colon is appended to the token. When the token is sent in\n    # the Authorization header this will put the token in the username field\n    # and an empty string in the password field.\n    return {'token': g.user.generate_auth_token() + ':'}\n"
  },
  {
    "path": "api/v1/__init__.py",
    "content": "from flask import Blueprint, g, url_for\nfrom ..errors import ValidationError, bad_request, not_found\nfrom ..auth import auth\nfrom ..decorators import json, rate_limit\n\n\napi = Blueprint('api', __name__)\n\n\ndef get_catalog():\n    return {'students_url': url_for('api.get_students', _external=True),\n            'classes_url': url_for('api.get_classes', _external=True),\n            'registrations_url': url_for('api.get_registrations',\n                                         _external=True)}\n\n\n@api.errorhandler(ValidationError)\ndef validation_error(e):\n    return bad_request(str(e))\n\n\n@api.errorhandler(400)\ndef bad_request_error(e):\n    return bad_request('invalid request')\n\n\n@api.before_request\n@auth.login_required\n@rate_limit(limit=5, period=15)\ndef before_request():\n    pass\n\n\n@api.after_request\ndef after_request(response):\n    if hasattr(g, 'headers'):\n        response.headers.extend(g.headers)\n    return response\n\n# do this last to avoid circular dependencies\nfrom . import students, classes, registrations\n"
  },
  {
    "path": "api/v1/classes.py",
    "content": "from flask import request\nfrom ..models import db, Class, Registration\nfrom ..decorators import json, collection, etag\nfrom . import api\n\n\n@api.route('/classes/', methods=['GET'])\n@etag\n@json\n@collection(Class)\ndef get_classes():\n    return Class.query\n\n\n@api.route('/classes/<int:id>', methods=['GET'])\n@etag\n@json\ndef get_class(id):\n    return Class.query.get_or_404(id)\n\n\n@api.route('/classes/<int:id>/registrations/', methods=['GET'])\n@etag\n@json\n@collection(Registration)\ndef get_class_registrations(id):\n    class_ = Class.query.get_or_404(id)\n    return class_.registrations\n\n\n@api.route('/classes/', methods=['POST'])\n@json\ndef new_class():\n    class_ = Class().import_data(request.get_json(force=True))\n    db.session.add(class_)\n    db.session.commit()\n    return {}, 201, {'Location': class_.get_url()}\n\n\n@api.route('/classes/<int:id>/registrations/', methods=['POST'])\n@json\ndef new_class_registration(id):\n    class_ = Class.query.get_or_404(id)\n    data = request.get_json(force=True)\n    data['class_url'] = class_.get_url()\n    reg = Registration().import_data(data)\n    db.session.add(reg)\n    db.session.commit()\n    return {}, 201, {'Location': reg.get_url()}\n\n\n@api.route('/classes/<int:id>', methods=['PUT'])\n@json\ndef edit_class(id):\n    class_ = Class.query.get_or_404(id)\n    class_.import_data(request.get_json(force=True))\n    db.session.add(class_)\n    db.session.commit()\n    return {}\n\n\n@api.route('/classes/<int:id>', methods=['DELETE'])\n@json\ndef delete_class(id):\n    class_ = Class.query.get_or_404(id)\n    db.session.delete(class_)\n    db.session.commit()\n    return {}\n"
  },
  {
    "path": "api/v1/registrations.py",
    "content": "from flask import request\nfrom ..models import db, Registration\nfrom ..decorators import json, collection, etag\nfrom . import api\n\n\n@api.route('/registrations/', methods=['GET'])\n@etag\n@json\n@collection(Registration)\ndef get_registrations():\n    return Registration.query\n\n\n@api.route('/registrations/<int:student_id>/<int:class_id>', methods=['GET'])\n@etag\n@json\ndef get_registration(student_id, class_id):\n    return Registration.query.get_or_404((student_id, class_id))\n\n\n@api.route('/registrations/', methods=['POST'])\n@json\ndef new_registration():\n    reg = Registration().import_data(request.get_json(force=True))\n    db.session.add(reg)\n    db.session.commit()\n    return {}, 201, {'Location': reg.get_url()}\n\n\n@api.route('/registrations/<int:student_id>/<int:class_id>', methods=['DELETE'])\n@json\ndef delete_registration(student_id, class_id):\n    reg = Registration.query.get_or_404((student_id, class_id))\n    db.session.delete(reg)\n    db.session.commit()\n    return {}\n"
  },
  {
    "path": "api/v1/students.py",
    "content": "from flask import request\nfrom ..models import db, Student, Registration\nfrom ..decorators import json, collection, etag\nfrom . import api\n\n\n@api.route('/students/', methods=['GET'])\n@etag\n@json\n@collection(Student)\ndef get_students():\n    return Student.query\n\n\n@api.route('/students/<int:id>', methods=['GET'])\n@etag\n@json\ndef get_student(id):\n    return Student.query.get_or_404(id)\n\n\n@api.route('/students/<int:id>/registrations/', methods=['GET'])\n@etag\n@json\n@collection(Registration)\ndef get_student_registrations(id):\n    student = Student.query.get_or_404(id)\n    return student.registrations\n\n\n@api.route('/students/', methods=['POST'])\n@json\ndef new_student():\n    student = Student().import_data(request.get_json(force=True))\n    db.session.add(student)\n    db.session.commit()\n    return {}, 201, {'Location': student.get_url()}\n\n\n@api.route('/students/<int:id>/registrations/', methods=['POST'])\n@json\ndef new_student_registration(id):\n    student = Student.query.get_or_404(id)\n    data = request.get_json(force=True)\n    data['student_url'] = student.get_url()\n    reg = Registration().import_data(data)\n    db.session.add(reg)\n    db.session.commit()\n    return {}, 201, {'Location': reg.get_url()}\n\n\n@api.route('/students/<int:id>', methods=['PUT'])\n@json\ndef edit_student(id):\n    student = Student.query.get_or_404(id)\n    student.import_data(request.get_json(force=True))\n    db.session.add(student)\n    db.session.commit()\n    return {}\n\n\n@api.route('/students/<int:id>', methods=['DELETE'])\n@json\ndef delete_student(id):\n    student = Student.query.get_or_404(id)\n    db.session.delete(student)\n    db.session.commit()\n    return {}\n"
  },
  {
    "path": "config.py",
    "content": "import os\nimport redis\n\nbasedir = os.path.abspath(os.path.dirname(__file__))\n\nSECRET_KEY = 'secret'\nSQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'api.sqlite')\nUSE_TOKEN_AUTH = True\n\n# enable rate limits only if redis is running\ntry:\n    r = redis.Redis()\n    r.ping()\n    USE_RATE_LIMITS = True\nexcept redis.ConnectionError:\n    USE_RATE_LIMITS = False\n"
  },
  {
    "path": "manage.py",
    "content": "#!/usr/bin/env python\nfrom flask import Flask, g, jsonify\nfrom flask.ext.script import Manager\nfrom api.app import create_app\nfrom api.models import db, User, Class\n\nmanager = Manager(create_app)\n\n\n@manager.command\ndef createdb(testdata=False):\n    app = create_app()\n    with app.app_context():\n        db.drop_all()\n        db.create_all()\n        if testdata:\n            classes = ['Algebra', 'Literature', 'Chemistry', 'Spanish',\n                       'Game Development', 'History', 'Music', 'Psychology',\n                       'Science', 'Photography', 'Drama', 'Business',\n                       'Python Programming']\n            for name in classes:\n                c = Class(name=name)\n                db.session.add(c)\n\n            u = User(username='miguel', password='python')\n            db.session.add(u)\n\n            db.session.commit()\n\n@manager.command\ndef adduser(username):\n    \"\"\"Register a new user.\"\"\"\n    from getpass import getpass\n    password = getpass()\n    password2 = getpass(prompt='Confirm: ')\n    if password != password2:\n        import sys\n        sys.exit('Error: passwords do not match.')\n    db.create_all()\n    user = User(username=username, password=password)\n    db.session.add(user)\n    db.session.commit()\n    print('User {0} was registered successfully.'.format(username))\n\n\n@manager.command\ndef test():\n    from subprocess import call\n    call(['nosetests', '-v',\n          '--with-coverage', '--cover-package=api', '--cover-branches',\n          '--cover-erase', '--cover-html', '--cover-html-dir=cover'])\n\n\nif __name__ == '__main__':\n    manager.run()\n\n"
  },
  {
    "path": "requirements.txt",
    "content": "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.6\nSQLAlchemy==0.9.4\nWerkzeug==0.9.6\ncoverage==3.7.1\nhttpie==0.8.0\nitsdangerous==0.24\nnose==1.3.1\nredis==2.9.1\nrequests==2.2.1\n"
  },
  {
    "path": "test_config.py",
    "content": "TESTING = True\nSECRET_KEY = 'secret'\nSQLALCHEMY_DATABASE_URI = 'sqlite://'\nUSE_TOKEN_AUTH = True\nUSE_RATE_LIMITS = False\n"
  },
  {
    "path": "tests/__init__.py",
    "content": ""
  },
  {
    "path": "tests/test_api.py",
    "content": "import unittest\nfrom werkzeug.exceptions import BadRequest\nfrom .test_client import TestClient\nfrom api.app import create_app\nfrom api.models import db, User\nfrom api.errors import ValidationError\n\n\nclass TestAPI(unittest.TestCase):\n    default_username = 'dave'\n    default_password = 'cat'\n\n    def setUp(self):\n        self.app = create_app('test_config')\n        self.ctx = self.app.app_context()\n        self.ctx.push()\n        db.drop_all()\n        db.create_all()\n        u = User(username=self.default_username,\n                 password=self.default_password)\n        db.session.add(u)\n        db.session.commit()\n        self.client = TestClient(self.app, u.generate_auth_token(), '')\n        self.catalog = self._get_catalog()\n\n    def tearDown(self):\n        db.session.remove()\n        db.drop_all()\n        self.ctx.pop()\n\n    def _get_catalog(self, version='v1'):\n        rv, json = self.client.get('/')\n        return json['versions'][version]\n\n    def test_password_auth(self):\n        self.app.config['USE_TOKEN_AUTH'] = False\n        good_client = TestClient(self.app, self.default_username,\n                                 self.default_password)\n        rv, json = good_client.get(self.catalog['students_url'])\n        self.assertTrue(rv.status_code == 200)\n\n        self.app.config['USE_TOKEN_AUTH'] = True\n        u = User.query.get(1)\n        good_client = TestClient(self.app, u.generate_auth_token(), '')\n        rv, json = good_client.get(self.catalog['students_url'])\n        self.assertTrue(rv.status_code == 200)\n\n    def test_bad_auth(self):\n        self.app.config['USE_TOKEN_AUTH'] = False\n        bad_client = TestClient(self.app, 'abc', 'def')\n        rv, json = bad_client.get(self.catalog['students_url'])\n        self.assertTrue(rv.status_code == 401)\n\n        self.app.config['USE_TOKEN_AUTH'] = True\n        bad_client = TestClient(self.app, 'bad_token', '')\n        rv, json = bad_client.get(self.catalog['students_url'])\n        self.assertTrue(rv.status_code == 401)\n\n    def test_token(self):\n        self.app.config['USE_TOKEN_AUTH'] = True\n        client = TestClient(self.app, self.default_username,\n                            self.default_password)\n        rv, json = client.get(self.catalog['students_url'])\n        self.assertTrue(rv.status_code == 401)\n\n        rv, json = client.post(rv.headers['Location'], data={})\n        self.assertTrue(rv.status_code == 200)\n        token = json['token']\n\n        client = TestClient(self.app, token, '')\n        rv, json = client.get(self.catalog['students_url'])\n        self.assertTrue(rv.status_code == 200)\n\n    def test_user_password_not_readable(self):\n        u = User(username='john', password='cat')\n        self.assertRaises(AttributeError, lambda: u.password)\n\n    def test_http_errors(self):\n        # not found\n        rv, json = self.client.get('/a-bad-url')\n        self.assertTrue(rv.status_code == 404)\n\n        # method not allowed\n        rv, json = self.client.delete(self.catalog['students_url'])\n        self.assertTrue(rv.status_code == 405)\n\n    def test_students(self):\n        # get collection\n        rv, json = self.client.get(self.catalog['students_url'])\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue(json['students'] == [])\n\n        # create new\n        rv, json = self.client.post(self.catalog['students_url'],\n                                    data={'name': 'susan'})\n        self.assertTrue(rv.status_code == 201)\n        susan_url = rv.headers['Location']\n\n        # get\n        rv, json = self.client.get(susan_url)\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue(json['name'] == 'susan')\n        self.assertTrue(json['self_url'] == susan_url)\n\n        # create new\n        rv, json = self.client.post(self.catalog['students_url'],\n                                    data={'name': 'david'})\n        self.assertTrue(rv.status_code == 201)\n        david_url = rv.headers['Location']\n\n        # get\n        rv, json = self.client.get(david_url)\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue(json['name'] == 'david')\n        self.assertTrue(json['self_url'] == david_url)\n\n        # bad request\n        rv,json = self.client.post(self.catalog['students_url'], data=None)\n        self.assertTrue(rv.status_code == 400)\n        rv,json = self.client.post(self.catalog['students_url'], data={})\n        self.assertTrue(rv.status_code == 400)\n        self.assertRaises(ValidationError,\n                          lambda: self.client.post(self.catalog['students_url'],\n                                                   data={'foo': 'david'}))\n\n        # modify\n        rv, json = self.client.put(david_url, data={'name': 'david2'})\n        self.assertTrue(rv.status_code == 200)\n\n        # get\n        rv, json = self.client.get(david_url)\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue(json['name'] == 'david2')\n\n        # get collection\n        rv, json = self.client.get(self.catalog['students_url'])\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue(susan_url in json['students'])\n        self.assertTrue(david_url in json['students'])\n        self.assertTrue(len(json['students']) == 2)\n\n        # delete\n        rv, json = self.client.delete(susan_url)\n        self.assertTrue(rv.status_code == 200)\n\n        # get collection\n        rv, json = self.client.get(self.catalog['students_url'])\n        self.assertTrue(rv.status_code == 200)\n        self.assertFalse(susan_url in json['students'])\n        self.assertTrue(david_url in json['students'])\n        self.assertTrue(len(json['students']) == 1)\n\n    def test_classes(self):\n        # get collection\n        rv, json = self.client.get(self.catalog['classes_url'])\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue(json['classes'] == [])\n\n        # create new\n        rv, json = self.client.post(self.catalog['classes_url'],\n                                    data={'name': 'algebra'})\n        self.assertTrue(rv.status_code == 201)\n        algebra_url = rv.headers['Location']\n\n        # get\n        rv, json = self.client.get(algebra_url)\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue(json['name'] == 'algebra')\n        self.assertTrue(json['self_url'] == algebra_url)\n\n        # create new\n        rv, json = self.client.post(self.catalog['classes_url'],\n                                    data={'name': 'lit'})\n        self.assertTrue(rv.status_code == 201)\n        lit_url = rv.headers['Location']\n\n        # get\n        rv, json = self.client.get(lit_url)\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue(json['name'] == 'lit')\n        self.assertTrue(json['self_url'] == lit_url)\n\n        # bad request\n        rv,json = self.client.post(self.catalog['classes_url'], data=None)\n        self.assertTrue(rv.status_code == 400)\n        rv,json = self.client.post(self.catalog['classes_url'], data={})\n        self.assertTrue(rv.status_code == 400)\n        self.assertRaises(ValidationError,\n                          lambda: self.client.post(self.catalog['classes_url'],\n                                                   data={'foo': 'lit'}))\n\n        # modify\n        rv, json = self.client.put(lit_url, data={'name': 'lit2'})\n        self.assertTrue(rv.status_code == 200)\n\n        # get\n        rv, json = self.client.get(lit_url)\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue(json['name'] == 'lit2')\n\n        # get collection\n        rv, json = self.client.get(self.catalog['classes_url'])\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue(algebra_url in json['classes'])\n        self.assertTrue(lit_url in json['classes'])\n        self.assertTrue(len(json['classes']) == 2)\n\n        # delete\n        rv, json = self.client.delete(lit_url)\n        self.assertTrue(rv.status_code == 200)\n\n        # get collection\n        rv, json = self.client.get(self.catalog['classes_url'])\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue(algebra_url in json['classes'])\n        self.assertFalse(lit_url in json['classes'])\n        self.assertTrue(len(json['classes']) == 1)\n\n    def test_registrations(self):\n        # create new students\n        rv, json = self.client.post(self.catalog['students_url'],\n                                    data={'name': 'susan'})\n        self.assertTrue(rv.status_code == 201)\n        susan_url = rv.headers['Location']\n\n        rv, json = self.client.post(self.catalog['students_url'],\n                                    data={'name': 'david'})\n        self.assertTrue(rv.status_code == 201)\n        david_url = rv.headers['Location']\n\n        # create new classes\n        rv, json = self.client.post(self.catalog['classes_url'],\n                                    data={'name': 'algebra'})\n        self.assertTrue(rv.status_code == 201)\n        algebra_url = rv.headers['Location']\n\n        rv, json = self.client.post(self.catalog['classes_url'],\n                                    data={'name': 'lit'})\n        self.assertTrue(rv.status_code == 201)\n        lit_url = rv.headers['Location']\n\n        # register students to classes\n        rv, json = self.client.post(self.catalog['registrations_url'],\n                                    data={'student_url': susan_url,\n                                          'class_url': algebra_url})\n        self.assertTrue(rv.status_code == 201)\n        susan_in_algebra_url = rv.headers['Location']\n\n        rv, json = self.client.post(self.catalog['registrations_url'],\n                                    data={'student_url': susan_url,\n                                          'class_url': lit_url})\n        self.assertTrue(rv.status_code == 201)\n        susan_in_lit_url = rv.headers['Location']\n\n        rv, json = self.client.post(self.catalog['registrations_url'],\n                                    data={'student_url': david_url,\n                                          'class_url': algebra_url})\n        self.assertTrue(rv.status_code == 201)\n        david_in_algebra_url = rv.headers['Location']\n\n        # get registration\n        rv, json = self.client.get(susan_in_lit_url)\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue(json['student_url'] == susan_url)\n        self.assertTrue(json['class_url'] == lit_url)\n\n        # get collection\n        rv, json = self.client.get(self.catalog['registrations_url'])\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue(susan_in_algebra_url in json['registrations'])\n        self.assertTrue(susan_in_lit_url in json['registrations'])\n        self.assertTrue(david_in_algebra_url in json['registrations'])\n        self.assertTrue(len(json['registrations']) == 3)\n\n        # bad registrations\n        rv,json = self.client.post(self.catalog['registrations_url'],\n                                   data=None)\n        self.assertTrue(rv.status_code == 400)\n        rv,json = self.client.post(self.catalog['registrations_url'], data={})\n        self.assertTrue(rv.status_code == 400)\n\n        # missing class URL\n        self.assertRaises(ValidationError,\n                          lambda: self.client.post(\n                              self.catalog['registrations_url'],\n                              data={'student_url': david_url}))\n\n        # missing student URL\n        self.assertRaises(ValidationError,\n                          lambda: self.client.post(\n                              self.catalog['registrations_url'],\n                              data={'class_url': algebra_url}))\n\n        # class is not a URL\n        self.assertRaises(ValidationError,\n                          lambda: self.client.post(\n                              self.catalog['registrations_url'],\n                              data={'student_url': david_url,\n                                    'class_url': 'foo'}))\n\n        # class is a not found URL\n        self.assertRaises(ValidationError,\n                          lambda: self.client.post(\n                              self.catalog['registrations_url'],\n                              data={'student_url': david_url,\n                                    'class_url': algebra_url + '1'}))\n\n        # class is an invalid URL\n        self.assertRaises(ValidationError,\n                          lambda: self.client.post(\n                              self.catalog['registrations_url'],\n                              data={'student_url': david_url,\n                                    'class_url': david_url}))\n        db.session.remove()\n\n        # get classes from each student\n        rv, json = self.client.get(susan_url)\n        self.assertTrue(rv.status_code == 200)\n        susans_reg_url = json['registrations_url']\n        rv, json = self.client.get(susans_reg_url)\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue(susan_in_algebra_url in json['registrations'])\n        self.assertTrue(susan_in_lit_url in json['registrations'])\n        self.assertTrue(len(json['registrations']) == 2)\n\n        rv, json = self.client.get(david_url)\n        self.assertTrue(rv.status_code == 200)\n        davids_reg_url = json['registrations_url']\n        rv, json = self.client.get(davids_reg_url)\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue(david_in_algebra_url in json['registrations'])\n        self.assertTrue(len(json['registrations']) == 1)\n\n        # get students for each class\n        rv, json = self.client.get(algebra_url)\n        self.assertTrue(rv.status_code == 200)\n        algebras_reg_url = json['registrations_url']\n        rv, json = self.client.get(algebras_reg_url)\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue(susan_in_algebra_url in json['registrations'])\n        self.assertTrue(david_in_algebra_url in json['registrations'])\n        self.assertTrue(len(json['registrations']) == 2)\n\n        rv, json = self.client.get(lit_url)\n        self.assertTrue(rv.status_code == 200)\n        lits_reg_url = json['registrations_url']\n        rv, json = self.client.get(lits_reg_url)\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue(susan_in_lit_url in json['registrations'])\n        self.assertTrue(len(json['registrations']) == 1)\n\n        # unregister students\n        rv, json = self.client.delete(susan_in_algebra_url)\n        self.assertTrue(rv.status_code == 200)\n\n        rv, json = self.client.delete(david_in_algebra_url)\n        self.assertTrue(rv.status_code == 200)\n\n        # get collection\n        rv, json = self.client.get(self.catalog['registrations_url'])\n        self.assertTrue(rv.status_code == 200)\n        self.assertFalse(susan_in_algebra_url in json['registrations'])\n        self.assertTrue(susan_in_lit_url in json['registrations'])\n        self.assertFalse(david_in_algebra_url in json['registrations'])\n        self.assertTrue(len(json['registrations']) == 1)\n\n        # delete student\n        rv, json = self.client.delete(susan_url)\n        self.assertTrue(rv.status_code == 200)\n\n        # get collection\n        rv, json = self.client.get(self.catalog['registrations_url'])\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue(len(json['registrations']) == 0)\n\n        # register through student registrations URL\n        rv, json = self.client.get(david_url)\n        rv, json = self.client.post(json['registrations_url'],\n                                    data={'class_url': lit_url})\n        self.assertTrue(rv.status_code == 201)\n\n        # register through class registrations URL\n        rv, json = self.client.get(algebra_url)\n        rv, json = self.client.post(json['registrations_url'],\n                                    data={'student_url': david_url})\n        self.assertTrue(rv.status_code == 201)\n\n        # get collection\n        rv, json = self.client.get(self.catalog['registrations_url'])\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue(len(json['registrations']) == 2)\n\n    def test_rate_limits(self):\n        self.app.config['USE_RATE_LIMITS'] = True\n\n        rv, json = self.client.get(self.catalog['registrations_url'])\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue('X-RateLimit-Remaining' in rv.headers)\n        self.assertTrue('X-RateLimit-Limit' in rv.headers)\n        self.assertTrue('X-RateLimit-Reset' in rv.headers)\n        self.assertTrue(int(rv.headers['X-RateLimit-Limit']) == \\\n            int(rv.headers['X-RateLimit-Remaining']) + 1)\n        while int(rv.headers['X-RateLimit-Remaining']) > 0:\n            rv, json = self.client.get(self.catalog['registrations_url'])\n        self.assertTrue(rv.status_code == 200)\n        rv, json = self.client.get(self.catalog['registrations_url'])\n        self.assertTrue(rv.status_code == 429)\n\n    def test_expanded_collections(self):\n        # create new students\n        rv, json = self.client.post(self.catalog['students_url'],\n                                    data={'name': 'susan'})\n        self.assertTrue(rv.status_code == 201)\n        susan_url = rv.headers['Location']\n\n        rv, json = self.client.get(self.catalog['students_url'] +\n                                   \"?expand=1\")\n        self.assertTrue(rv.status_code == 200)\n        print(json)\n        self.assertTrue(json['students'][0]['name'] == 'susan')\n        self.assertTrue(json['students'][0]['self_url'] == susan_url)\n\n    def _create_test_students(self):\n        # create several students\n        rv, json = self.client.post(self.catalog['students_url'],\n                                    data={'name': 'one'})\n        self.assertTrue(rv.status_code == 201)\n        one_url = rv.headers['Location']\n        rv, json = self.client.post(self.catalog['students_url'],\n                                    data={'name': 'two'})\n        self.assertTrue(rv.status_code == 201)\n        two_url = rv.headers['Location']\n        rv, json = self.client.post(self.catalog['students_url'],\n                                    data={'name': 'three'})\n        self.assertTrue(rv.status_code == 201)\n        three_url = rv.headers['Location']\n        rv, json = self.client.post(self.catalog['students_url'],\n                                    data={'name': 'four'})\n        self.assertTrue(rv.status_code == 201)\n        four_url = rv.headers['Location']\n        rv, json = self.client.post(self.catalog['students_url'],\n                                    data={'name': 'five'})\n        self.assertTrue(rv.status_code == 201)\n        five_url = rv.headers['Location']\n\n        return [one_url, two_url, three_url, four_url, five_url]\n\n    def test_filters(self):\n        urls = self._create_test_students()\n\n        # test various filter operators\n        rv, json = self.client.get(self.catalog['students_url'] +\n                                   '?filter=name,eq,three')\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue(json['students'] == [urls[2]])\n\n        rv, json = self.client.get(self.catalog['students_url'] +\n                                   '?filter=name,ne,three&sort=id,asc')\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue(json['students'] == [urls[0], urls[1], urls[3],\n                                             urls[4]])\n\n        rv, json = self.client.get(self.catalog['students_url'] +\n                                   '?filter=id,le,2&sort=id,asc')\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue(json['students'] == [urls[0], urls[1]])\n\n        rv, json = self.client.get(self.catalog['students_url'] +\n                                   '?filter=id,ge,2;id,lt,4&sort=id,asc')\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue(json['students'] == [urls[1], urls[2]])\n\n        rv, json = self.client.get(self.catalog['students_url'] +\n                                   '?filter=name,in,three,five&sort=id,asc')\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue(json['students'] == [urls[2], urls[4]])\n\n        # bad operator is ignored\n        rv, json = self.client.get(self.catalog['students_url'] +\n                                   '?filter=name,is,three,five')\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue(len(json['students']) == 5)\n\n        # bad column name is ignored\n        rv, json = self.client.get(self.catalog['students_url'] +\n                                   '?filter=foo,in,three,five')\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue(len(json['students']) == 5)\n\n    def test_sorting(self):\n        urls = self._create_test_students()\n\n        # sort ascending (implicit)\n        rv, json = self.client.get(self.catalog['students_url'] +\n                                   '?sort=name')\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue(json['students'] == [urls[4], urls[3], urls[0],\n                                             urls[2], urls[1]])\n\n        # sort ascending\n        rv, json = self.client.get(self.catalog['students_url'] +\n                                   '?sort=name,asc')\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue(json['students'] == [urls[4], urls[3], urls[0],\n                                             urls[2], urls[1]])\n\n        # sort descending\n        rv, json = self.client.get(self.catalog['students_url'] +\n                                   '?sort=name,desc')\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue(json['students'] == [urls[1], urls[2], urls[0],\n                                             urls[3], urls[4]])\n\n    def test_pagination(self):\n        urls = self._create_test_students()\n\n        # get collection in pages\n        rv, json = self.client.get(self.catalog['students_url'] +\n                                   '?page=1&per_page=2&sort=name,asc')\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue(urls[4] in json['students'])\n        self.assertTrue(urls[3] in json['students'])\n        self.assertTrue(len(json['students']) == 2)\n        self.assertTrue('total' in json['meta'])\n        self.assertTrue(json['meta']['total'] == 5)\n        self.assertTrue('prev_url' in json['meta'])\n        self.assertTrue(json['meta']['prev_url'] is None)\n        first_url = json['meta']['first_url']\n        last_url = json['meta']['last_url']\n        next_url = json['meta']['next_url']\n\n        rv, json = self.client.get(first_url)\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue(urls[4] in json['students'])\n        self.assertTrue(urls[3] in json['students'])\n        self.assertTrue(len(json['students']) == 2)\n\n        rv, json = self.client.get(next_url)\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue(urls[0] in json['students'])\n        self.assertTrue(urls[2] in json['students'])\n        self.assertTrue(len(json['students']) == 2)\n        next_url = json['meta']['next_url']\n\n        rv, json = self.client.get(next_url)\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue(urls[1] in json['students'])\n        self.assertTrue(len(json['students']) == 1)\n\n        rv, json = self.client.get(last_url)\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue(urls[1] in json['students'])\n        self.assertTrue(len(json['students']) == 1)\n\n    def test_etag(self):\n        # create two students\n        rv, json = self.client.post(self.catalog['students_url'],\n                                    data={'name': 'one'})\n        self.assertTrue(rv.status_code == 201)\n        one_url = rv.headers['Location']\n        rv, json = self.client.post(self.catalog['students_url'],\n                                    data={'name': 'two'})\n        self.assertTrue(rv.status_code == 201)\n        two_url = rv.headers['Location']\n\n        # get their etags\n        rv, json = self.client.get(one_url)\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue('Cache-Control' in rv.headers)\n        one_etag = rv.headers['ETag']\n        rv, json = self.client.get(two_url)\n        self.assertTrue(rv.status_code == 200)\n        self.assertTrue('Cache-Control' in rv.headers)\n        two_etag = rv.headers['ETag']\n\n        # send If-None-Match header\n        rv, json = self.client.get(one_url, headers={\n            'If-None-Match': one_etag})\n        self.assertTrue(rv.status_code == 304)\n        rv, json = self.client.get(one_url, headers={\n            'If-None-Match': one_etag + ', ' + two_etag})\n        self.assertTrue(rv.status_code == 304)\n        rv, json = self.client.get(one_url, headers={\n            'If-None-Match': two_etag})\n        self.assertTrue(rv.status_code == 200)\n        rv, json = self.client.get(one_url, headers={\n            'If-None-Match': two_etag + ', *'})\n        self.assertTrue(rv.status_code == 304)\n\n        # send If-Match header\n        rv, json = self.client.get(one_url, headers={\n            'If-Match': one_etag})\n        self.assertTrue(rv.status_code == 200)\n        rv, json = self.client.get(one_url, headers={\n            'If-Match': one_etag + ', ' + two_etag})\n        self.assertTrue(rv.status_code == 200)\n        rv, json = self.client.get(one_url, headers={\n            'If-Match': two_etag})\n        self.assertTrue(rv.status_code == 412)\n        rv, json = self.client.get(one_url, headers={\n            'If-Match': '*'})\n        self.assertTrue(rv.status_code == 200)\n\n        # change a resource\n        rv, json = self.client.put(one_url, data={'name': 'not-one'})\n        self.assertTrue(rv.status_code == 200)\n\n        # use stale etag\n        rv, json = self.client.get(one_url, headers={\n            'If-None-Match': one_etag})\n        self.assertTrue(rv.status_code == 200)\n"
  },
  {
    "path": "tests/test_client.py",
    "content": "from base64 import b64encode\nfrom werkzeug.exceptions import HTTPException\nimport json\n\n\nclass TestClient():\n    def __init__(self, app, username, password):\n        self.app = app\n        self.auth = 'Basic ' + b64encode((username + ':' + password)\n                                         .encode('utf-8')).decode('utf-8')\n\n    def send(self, url, method='GET', data=None, headers={},\n             content_type='application/json'):\n        # Flask's client prefers relative URLs\n        url = url.replace('http://localhost', '')\n\n        # assemble the final header list\n        headers = headers.copy()\n        headers['Authorization'] = self.auth\n        if 'Content-Type' not in headers:\n            headers['Content-Type'] = content_type\n        if 'Accept' not in headers:\n            headers['Accept'] = content_type\n\n        # generate a body if needed\n        if data:\n            data = json.dumps(data)\n\n        # send the request\n        with self.app.test_request_context(url, method=method, data=data,\n                                           headers=headers):\n            try:\n                rv = self.app.preprocess_request()\n                if rv is None:\n                    rv = self.app.dispatch_request()\n                rv = self.app.make_response(rv)\n                rv = self.app.process_response(rv)\n            except HTTPException as e:\n                rv = self.app.handle_user_exception(e)\n\n        return rv, json.loads(rv.data.decode('utf-8'))\n\n    def get(self, url, headers={}):\n        return self.send(url, 'GET', headers=headers)\n\n    def post(self, url, data, headers={}):\n        return self.send(url, 'POST', data, headers=headers)\n\n    def put(self, url, data, headers={}):\n        return self.send(url, 'PUT', data, headers=headers)\n\n    def delete(self, url, headers={}):\n        return self.send(url, 'DELETE', headers=headers)\n"
  }
]