Commits

Cmed Technology committed 60f6084 Draft

reorganised package structure

Comments (0)

Files changed (46)

 cover\/.*
 .coverage
 .noseids
-^static\/apidocs\/.*
+/static\/apidocs\/.*
+recursive-include restapiblueprint *
 Status
 ------
 
-It is early days. So far I am reasonably happy with the [automatic testing of the
-API using BDD/Gherkin](https://bitbucket.org/tcorbettclark/rest-api-blueprint/wiki/AutomaticTesting) (an approach which works against any REST API).
+It is early days. So far I am reasonably happy with the [automatic testing of
+the API using BDD/Gherkin](https://bitbucket.org/tcorbettclark/rest-api-
+blueprint/wiki/AutomaticTesting) (an approach which works against any REST API).
 
 See the [open issues](https://bitbucket.org/tcorbettclark/rest-api-blueprint/issues?status=new&status=open).
 
 Example usage
 -------------
 
-(although this is not a library to be run so much as an approach to be read and copied).
+(although this is not a library to be run so much as an approach to be read and
+(copied).
 
 ### Start the example app server
 

blueprints/__init__.py

Empty file removed.

blueprints/counter.py

-from flask import Blueprint, jsonify
-from lib import make_ok
-
-
-blueprint = Blueprint(__name__, __name__)
-
-
-class Counter:
-
-    value = 0
-
-
-@blueprint.route('', methods=['GET'])
-def counter():
-    Counter.value += 1
-    return jsonify(value=Counter.value)
-
-
-@blueprint.route('', methods=['DELETE'])
-def reset_counter():
-    Counter.value = 0
-    return make_ok()

blueprints/people.py

-from flask import Blueprint, request
-from schema import (Schema, Optional)
-from validate_email import validate_email
-
-from database import people_database
-from lib import (
-    http_method_dispatcher, make_ok, make_error, check,
-    document_using, validate_json, if_content_exists_then_is_json)
-
-
-blueprint = Blueprint(__name__, __name__)
-
-
-# Define input schemas.
-# See https://github.com/halst/schema
-person_full = Schema(
-    {
-        'email': validate_email,
-        'comment': basestring
-    }, error='Invalid specification for a person'
-)
-
-
-person_partial = Schema(
-    {
-        Optional('email'): validate_email,
-        Optional('comment'): basestring
-    }, error='Invalid partial specification for a person'
-)
-
-
-@blueprint.route('', methods=['GET', 'DELETE'])
-@document_using('static/apidocs/people.html')
-@check(if_content_exists_then_is_json)
-@http_method_dispatcher
-class People(object):
-
-    def delete(self):
-        people_database.reset()
-        return make_ok()
-
-
-@blueprint.route('/<name>', methods=['GET', 'PATCH', 'POST', 'PUT', 'DELETE'])
-@document_using('static/apidocs/people.html')
-@check(if_content_exists_then_is_json)
-@http_method_dispatcher
-class PeopleWithName(object):
-
-    def person_exists(self, name):
-        if not people_database.has_person(name):
-            return make_error('Person does not exist', 404)
-
-    @check(person_exists)
-    def get(self, name):
-        email = people_database.get_email_address(name)
-        comment = people_database.get_comment(name)
-        return make_ok(name=name, email=email, comment=comment)
-
-    @check(person_exists)
-    @validate_json(person_full.validate)
-    def post(self, name):
-        json = request.json
-        people_database.set_email_address(name, json['email'])
-        people_database.set_comment(name, json['comment'])
-        return make_ok()
-
-    @validate_json(person_partial.validate, default=dict)
-    def put(self, name):
-        json = request.json
-        people_database.add_person(name, json.get('email'), json.get('comment'))
-        return make_ok()
-
-    @check(person_exists)
-    @validate_json(person_partial.validate, default=dict)
-    def patch(self, name):
-        json = request.json
-        if 'email' in json:
-            people_database.set_email_address(name, json['email'])
-        if 'comment' in json:
-            people_database.set_comment(name, json['comment'])
-        return make_ok()
-
-    @check(person_exists)
-    def delete(self, name):
-        people_database.delete_person(name)
-        return make_ok()

blueprints/private.py

-from flask import Blueprint, request
-from lib import http_method_dispatcher, make_ok, make_error, check, oauth
-
-
-blueprint = Blueprint(__name__, __name__)
-
-
-@blueprint.route('', methods=['GET'])
-@http_method_dispatcher
-class Private(object):
-
-    def dumb_get_consumer_secret(self, key):
-        if key == 'akey':
-            return 'asecret'
-        raise KeyError
-
-    def access(self):
-        if not oauth.verify(
-                self.dumb_get_consumer_secret,
-                request.url, request.method, request.headers):
-            return make_error(
-                'Unauthorized', 401,
-                additional_headers={'WWW-Authenticate': 'OAuth'})
-
-    @check(access)
-    def get(self):
-        return make_ok()

database.py

-from validate_email import validate_email
-
-
-class PeopleDatabase(object):
-
-    """Simple database of people (names) to email addresses."""
-
-    # Internal keys
-    EMAIL = 'email'
-    COMMENT = 'comment'
-
-    def __init__(self):
-        self.people = {}  # {name: {email: ..., comment: ...}}
-
-    def add_person(self, person, email_address=None, comment=None):
-        if email_address is not None and not validate_email(email_address):
-            raise ValueError('Invalid email address')
-        self.people[person] = {self.EMAIL: email_address, self.COMMENT: comment}
-
-    def has_person(self, name):
-        return name in self.people
-
-    def get_email_address(self, person):
-        return self.people[person][self.EMAIL]
-
-    def get_comment(self, person):
-        return self.people[person][self.COMMENT]
-
-    def set_email_address(self, person, email_address):
-        if person not in self.people:
-            raise KeyError('Person does not exist')
-        if email_address is not None and not validate_email(email_address):
-            raise ValueError('Invalid email address')
-        self.people[person][self.EMAIL] = email_address
-
-    def set_comment(self, person, comment):
-        if person not in self.people:
-            raise KeyError('Person does not exist')
-        self.people[person][self.COMMENT] = comment
-
-    def delete_person(self, person):
-        if person not in self.people:
-            raise KeyError('Person does not exist')
-        del self.people[person]
-
-    def reset(self):
-        self.people.clear()
-
-
-# Database instance.
-people_database = PeopleDatabase()

features/add_person.feature

-Feature: Add a person
-  As an API client
-  I want to be able to create a new person
-
-  Background: Set server URL and reset database
-    Given I am using server "http://localhost:5000/v1"
-    And I set Accept header to "application/json"
-    When I send a DELETE request to "people"
-    Then the response status should be "200"
-
-  Scenario: Add a new person, declare json, but don't send anything
-    Given I set Content-Type header to "application/json"
-    When I send a PUT request to "people/fred"
-    Then the response status should be "400"
-
-  Scenario: Add a new person, declare json, but send invalid json
-    Given I set Content-Type header to "application/json"
-    When I send a PUT request to "people/fred"
-      """
-      {"email": "forgot the end quote}
-      """
-    Then the response status should be "400"
-
-  Scenario: Add a person but with non-JSON Content-Type
-    Given I set Content-Type header to "application/xml"
-    When I send a PUT request to "people/tim"
-      """
-      <something></something>
-      """
-    Then the response status should be "406"
-
-  Scenario: Add a person with data of unspecified Content-Type
-    When I send a PUT request to "people/tim"
-      """
-      some unspecified format (no Content-Type set)
-      """
-    Then the response status should be "406"
-
-  Scenario: Add a new person
-    When I send a PUT request to "people/fred"
-    Then the response status should be "200"
-    And the JSON should be:
-      """
-      {"status": "ok"}
-      """
-    When I send a GET request to "people/fred"
-    Then the response status should be "200"
-
-  Scenario: Add new person with no email address
-    When I send a PUT request to "people/fred"
-    Then the response status should be "200"
-    When I send a GET request to "people/fred"
-    Then the response status should be "200"
-    And the JSON at path "result.email" should be null
-
-  Scenario: Add new person with invalid email address
-    Given I set Content-Type header to "application/json"
-    When I send a PUT request to "people/fred":
-      """
-      {"email": "not-a-valid-email-address"}
-      """
-    Then the response status should be "400"
-    And the JSON at path "message" should be "Invalid partial specification for a person"
-
-  Scenario: Add new person with valid email address
-    Given I set Content-Type header to "application/json"
-    When I send a PUT request to "people/fred":
-      """
-      {"email": "a@b.c"}
-      """
-    Then the response status should be "200"
-    When I send a GET request to "people/fred"
-    Then the response status should be "200"
-    And the JSON at path "result.email" should be "a@b.c"
-
-  Scenario: Add the same person twice
-    When I send a PUT request to "people/fred"
-    And I send a PUT request to "people/fred"
-    Then the response status should be "200"
-    And the JSON at path "status" should be "ok"
-
-  Scenario: Add a new person but with a list not a dict
-    Given I set Content-Type header to "application/json"
-    When I send a PUT request to "people/fred"
-      """
-      []
-      """
-    Then the JSON at path "message" should be "Invalid partial specification for a person"
-    And the response status should be "400"

features/delete_person.feature

-Feature: Delete a person
-  As an API client
-  I want to be able to remove a person
-
-  Background: Reset and have a valid user
-    Given I am using server "http://localhost:5000/v1"
-    And I set Accept header to "application/json"
-    When I send a DELETE request to "people"
-    Then the response status should be "200"
-
-  Scenario: Cannot delete a person before they exist
-    When I send a DELETE request to "people/fred"
-    Then the response status should be "404"
-    And the JSON at path "status" should be "error"
-    And the JSON at path "message" should be "Person does not exist"
-
-  Scenario: Delete a person
-    When I send a PUT request to "people/fred"
-    And I send a DELETE request to "people/fred"
-    And I send a GET request to "people/fred"
-    Then the response status should be "404"
-    And the JSON at path "status" should be "error"
-    And the JSON at path "message" should be "Person does not exist"

features/environment.py

-def before_scenario(context, scenario):
-    # Seed empty HTTP headers so steps do not need to check and create.
-    context.headers = {}
-
-    # Seed empty Jinja2 template data so steps do not need to check and create.
-    context.template_data = {}
-
-    # Default repeat attempt counts and delay for polling GET.
-    context.n_attempts = 20
-    context.pause_between_attempts = 0.1
-
-    # No authentication by default.
-    context.auth = None

features/errors.feature

-Feature: Correct behaviour on incorrect usage
-  As an API client
-  I expect sane behaviour when I don't use the API correctly
-
-  Background: Reset and have a valid user
-    Given I am using server "http://localhost:5000/v1"
-    When I send a DELETE request to "people"
-    Then the response status should be "200"
-
-  Scenario: Attempt to use non existent resource
-    When I send a GET request to "no-such-url"
-    Then the response status should be "404"

features/misc.feature

-Feature: Miscellaneous
-  As an API client
-  I expect the usual miscellaneous features of an API
-
-  Background: Set server name and reset database
-    Given I am using server "http://localhost:5000/v1"
-    And I set Accept header to "application/json"
-    When I send a DELETE request to "people"
-    Then the response status should be "200"
-
-  Scenario: Ask for HTTP methods on a valid people URL
-    When I send an OPTIONS request to "people/tim"
-    Then the response status should be "200"
-    And the Allow header should be:
-      """
-      HEAD, GET, PUT, POST, DELETE, OPTIONS, PATCH
-      """
-
-  Scenario: Ask for HTTP methods on an invalid URL
-    When I send an OPTIONS request to "foobar"
-    Then the response status should be "404"
-
-  Scenario: Can store non-ASCII data on comments
-    Given I set Content-Type header to "application/json"
-    When I send a PUT request to "people/tim"
-      """
-      {"comment": "a@思é.c"}
-      """
-    Then the response status should be "200"
-    When I send a GET request to "people/tim"
-    Then the JSON at path "result.comment" should be "a@\u601d\u00e9.c"
-    And the JSON at path "result.comment" should be "a@思é.c"
-
-  Scenario: Polling for a state change
-    When I send a DELETE request to "counter"
-    Then the response status should be "200"
-    When I poll GET "counter" until JSON at path "value" is
-      """
-      5
-      """
-
-  Scenario: GET invalid URL
-    Given I set Content-Type header to "application/json"
-    When I send a GET request to "does-not-exist"
-    Then the response status should be "404"
-
-  Scenario: GET and remember value for future GET
-    Given I set Content-Type header to "application/json"
-    When I send a PUT request to "people/fred":
-      """
-      {"email": "a@b.c"}
-      """
-    Then the response status should be "200"
-    When I send a GET request to "people/fred"
-    And I store the JSON at path "result.email" in "freds_email"
-    And I send a GET request to "people/fred"
-    Then the JSON should be:
-      """
-      {
-        "status": "ok",
-        "result": {
-            "name": "fred",
-            "email": "{{ freds_email }}",
-            "comment": null
-          }
-      }
-      """

features/oauth.feature

-Feature: Two-legged OAuth
-  As an API client
-  I expect to be able to authentication using two-legged oauth
-
-  Background: Set server name and reset database
-    Given I am using server "http://localhost:5000/v1"
-    And I set Accept header to "application/json"
-    When I send a DELETE request to "people"
-    Then the response status should be "200"
-
-  Scenario: Cannot access a restricted URL without any authentication
-    When I send a GET request to "private"
-    Then the response status should be "401"
-    And the WWW-Authenticate header should be "OAuth"
-
-  Scenario: Can access a restricted URL with query OAuth
-    Given I use query OAuth with key="akey" and secret="asecret"
-    When I send a GET request to "private"
-    Then the response status should be "200"
-
-  Scenario: Cannot access a restricted URL with query OAuth and wrong secret
-    Given I use query OAuth with key="akey" and secret="wrong_asecret"
-    When I send a GET request to "private"
-    Then the response status should be "401"
-
-  Scenario: Cannot access a restricted URL with query OAuth and wrong key
-    Given I use query OAuth with key="wrong_akey" and secret="asecret"
-    When I send a GET request to "private"
-    Then the response status should be "401"
-
-  Scenario: Can access a restricted URL with header OAuth
-    Given I use header OAuth with key="akey" and secret="asecret"
-    When I send a GET request to "private"
-    Then the response status should be "200"
-
-  Scenario: Can access a restricted URL with header OAuth and wrong secret
-    Given I use header OAuth with key="akey" and secret="wrong_asecret"
-    When I send a GET request to "private"
-    Then the response status should be "401"
-
-  Scenario: Can access a restricted URL with header OAuth and wrong key
-    Given I use header OAuth with key="wrong_akey" and secret="asecret"
-    When I send a GET request to "private"
-    Then the response status should be "401"

features/steps/rest.py

-from nose.tools import assert_equal
-import behave
-import jinja2
-import jpath
-import json
-import purl
-import requests
-import time
-
-
-def get_data_from_context(context):
-    """Use context.text as a template and render against any stored state."""
-    try:
-        data = context.text
-    except AttributeError:
-        data = ''
-    # Always clear the text to avoid accidental re-use.
-    context.text = u''
-    # NB rendering the template always returns unicode.
-    result = jinja2.Template(data).render(context.template_data)
-    return result.encode('utf8')
-
-
-@behave.given('I am using server "{server}"')
-def using_server(context, server):
-    context.server = purl.URL(server)
-
-
-@behave.given('I set {var} header to "{value}"')
-def set_header(context, var, value):
-    # We must keep the headers as implicit ascii to avoid encoding failure when
-    # the entire HTTP body is constructed by concatenating strings.
-    context.headers[var.encode('ascii')] = value.encode('ascii')
-
-
-@behave.given('I use query OAuth with key="{key}" and secret="{secret}"')
-def query_oauth(context, key, secret):
-    context.auth = requests.auth.OAuth1(
-        key, secret, signature_type='query')
-
-
-@behave.given('I use header OAuth with key="{key}" and secret="{secret}"')
-def header_oauth(context, key, secret):
-    context.auth = requests.auth.OAuth1(
-        key, secret, signature_type='auth_header')
-
-
-@behave.given('I set context "{variable}" to {value}')
-def set_config(context, variable, value):
-    setattr(context, variable, json.loads(value))
-
-
-@behave.when(
-    'I poll GET "{url_path_segment}" until JSON at path "{jsonpath}" is')
-def poll_GET(context, url_path_segment, jsonpath):
-    json_value = json.loads(get_data_from_context(context))
-    url = context.server.add_path_segment(url_path_segment)
-    for i in range(context.n_attempts):
-        response = requests.get(url, headers=context.headers, auth=context.auth)
-        if jpath.get(jsonpath, response.json) == json_value:
-            return
-        time.sleep(context.pause_between_attempts)
-    raise AssertionError(
-        'Condition not met after %d attempts' % context.n_attempts)
-
-
-@behave.when('I send an OPTIONS request to "{url_path_segment}"')
-def options_request(context, url_path_segment):
-    context.response = requests.options(
-        context.server.add_path_segment(url_path_segment))
-
-
-@behave.when('I send a PATCH request to "{url_path_segment}"')
-def patch_request(context, url_path_segment):
-    data = get_data_from_context(context)
-    url = context.server.add_path_segment(url_path_segment)
-    context.response = requests.patch(
-        url, data=data, headers=context.headers, auth=context.auth)
-
-
-@behave.when('I send a PUT request to "{url_path_segment}"')
-def put_request(context, url_path_segment):
-    data = get_data_from_context(context)
-    url = context.server.add_path_segment(url_path_segment)
-    context.response = requests.put(
-        url, data=data, headers=context.headers, auth=context.auth)
-
-
-@behave.when('I send a POST request to "{url_path_segment}"')
-def post_request(context, url_path_segment):
-    data = get_data_from_context(context)
-    url = context.server.add_path_segment(url_path_segment)
-    context.response = requests.post(
-        url, data=data, headers=context.headers, auth=context.auth)
-
-
-@behave.when('I send a GET request to "{url_path_segment}"')
-def get_request(context, url_path_segment):
-    headers = context.headers.copy()
-    data = get_data_from_context(context)
-    if not data:
-        # Don't set the Content-Type if we have no data because no data is not
-        # valid JSON.
-        if 'Content-Type' in headers:
-            del headers['Content-Type']
-    url = context.server.add_path_segment(url_path_segment)
-    context.response = requests.get(
-        url, data=data, headers=headers, auth=context.auth)
-
-
-@behave.when('I send a DELETE request to "{url_path_segment}"')
-def delete_request(context, url_path_segment):
-    url = context.server.add_path_segment(url_path_segment)
-    context.response = requests.delete(
-        url, headers=context.headers, auth=context.auth)
-
-
-@behave.when('I store the JSON at path "{jsonpath}" in "{variable}"')
-def store_for_template(context, jsonpath, variable):
-    context.template_data[variable] = jpath.get(
-        jsonpath, context.response.json)
-
-
-@behave.then('the response status should be "{status}"')
-def response_status(context, status):
-    assert_equal(context.response.status_code, int(status))
-
-
-@behave.then('the {var} header should be')
-def check_header(context, var):
-    data = get_data_from_context(context)
-    assert_equal(context.response.headers[var], data.encode('ascii'))
-
-
-@behave.then('the {var} header should be "{value}"')
-def check_header_inline(context, var, value):
-    assert_equal(context.response.headers[var], value.encode('ascii'))
-
-
-@behave.then('the JSON should be')
-def json_should_be(context):
-    json_value = json.loads(get_data_from_context(context))
-    assert_equal(context.response.json, json_value)
-
-
-@behave.then('the JSON at path "{jsonpath}" should be {value}')
-def json_at_path_inline(context, jsonpath, value):
-    json_value = json.loads(value)
-    assert_equal(jpath.get(jsonpath, context.response.json), json_value)
-
-
-@behave.then('the JSON at path "{jsonpath}" should be')
-def json_at_path(context, jsonpath):
-    json_value = json.loads(get_data_from_context(context))
-    assert_equal(jpath.get(jsonpath, context.response.json), json_value)
-

features/update_person.feature

-Feature: Update a person
-  As an API client
-  I want to be able to update the email address for a person
-
-  Background: Set server name, reset, and add a person
-    Given I am using server "http://localhost:5000/v1"
-    And I set Accept header to "application/json"
-    When I send a DELETE request to "people"
-    Then the response status should be "200"
-    When I send a PUT request to "people/fred"
-    Then the response status should be "200"
-
-  Scenario: Update person with comment
-    Given I set Content-Type header to "application/json"
-    When I send a PATCH request to "people/fred":
-      """
-      {"comment": "this person is very special to me"}
-      """
-    Then the response status should be "200"
-    When I send a GET request to "people/fred"
-    Then the response status should be "200"
-    And the JSON at path "result.comment" should be "this person is very special to me"
-
-  Scenario: Update person with valid email address
-    Given I set Content-Type header to "application/json"
-    When I send a PATCH request to "people/fred":
-      """
-      {"email": "b@c.d"}
-      """
-    Then the response status should be "200"
-    When I send a GET request to "people/fred"
-    Then the response status should be "200"
-    And the JSON at path "result.email" should be "b@c.d"
-
-  Scenario: Update person with invalid email address
-    Given I set Content-Type header to "application/json"
-    When I send a PATCH request to "people/fred":
-      """
-      {"email": "invalid"}
-      """
-    Then the response status should be "400"
-    And the JSON at path "message" should be "Invalid partial specification for a person"
-
-  Scenario: Update person with an additional invalid field
-    Given I set Content-Type header to "application/json"
-    When I send a PATCH request to "people/fred":
-      """
-      {"blah": "blah"}
-      """
-    Then the response status should be "400"
-    And the JSON at path "message" should be "Invalid partial specification for a person"
-
-Scenario: Update person with zero fields
-    Given I set Content-Type header to "application/json"
-    When I send a PATCH request to "people/fred":
-      """
-      {}
-      """
-    Then the response status should be "200"
-
-  Scenario: Update full person with only a comment
-    Given I set Content-Type header to "application/json"
-    When I send a POST request to "people/fred":
-      """
-      {"comment": "this person is very special to me"}
-      """
-    Then the response status should be "400"
-    And the JSON at path "message" should be "Invalid specification for a person"
-
-  Scenario: Update full person with only a valid email
-    Given I set Content-Type header to "application/json"
-    When I send a POST request to "people/fred":
-      """
-      {"email": "a@b.c"}
-      """
-    Then the response status should be "400"
-    And the JSON at path "message" should be "Invalid specification for a person"
-
-  Scenario: Update full person with comment and invalid email
-    Given I set Content-Type header to "application/json"
-    When I send a POST request to "people/fred":
-      """
-      {
-        "email": "abc",
-        "comment": "blah"
-      }
-      """
-    Then the response status should be "400"
-    And the JSON at path "message" should be "Invalid specification for a person"
-
-  Scenario: Update full person with comment and valid email
-    Given I set Content-Type header to "application/json"
-    When I send a POST request to "people/fred":
-      """
-      {
-        "email": "a@x.y",
-        "comment": "foobar"
-      }
-      """
-    Then the response status should be "200"
-    When I send a GET request to "people/fred"
-    Then the response status should be "200"
-    And the JSON at path "result.email" should be "a@x.y"
-    And the JSON at path "result.comment" should be "foobar"

lib/__init__.py

-import BaseHTTPServer  # For HTTP codes.
-from flask import (
-    jsonify, request, render_template, redirect, abort, make_response)
-import mimeparse
-from werkzeug.exceptions import default_exceptions, HTTPException
-
-from decorator import decorator
-
-# Table mapping response codes to messages; entries have the
-# form {code: (shortmessage, longmessage)}.
-# See RFC 2616.
-HTTP_CODES = BaseHTTPServer.BaseHTTPRequestHandler.responses
-
-
-def request_prefers_json_over_html():
-    """True if request Accept header indicates preference for json over html."""
-    try:
-        best_mimetype = mimeparse.best_match(
-            ['application/json', 'text/html'], request.headers['Accept'])
-        return best_mimetype == 'application/json'
-    except:
-        # E.g. best_matches raises if mimetype does not even contain a '/'.
-        return False
-
-
-def use_pretty_default_error_handlers(app):
-    """Set default error handlers to use lib.make_error."""
-    def make_json_error(ex):
-        status_code = ex.code if isinstance(ex, HTTPException) else 500
-        return make_error('', status_code)
-
-    for code in default_exceptions.iterkeys():
-        app.error_handler_spec[None][code] = make_json_error
-
-
-def make_ok(**kwargs):
-    """Make JSON OK message response."""
-    if kwargs:
-        return jsonify(status='ok', result=kwargs)
-    else:
-        return jsonify(status='ok')
-
-
-def make_error(message, status_code, additional_headers=None):
-    """Return a suitable HTML or JSON error message response."""
-    short_message, long_message = HTTP_CODES.get(status_code, ('', ''))
-    result = dict(
-        status='error',
-        message=message,
-        status_code=status_code,
-        status_short_message=short_message,
-        status_long_message=long_message)
-    if request_prefers_json_over_html():
-        response = jsonify(result)
-        response.status_code = status_code
-    else:
-        response = make_response(
-            render_template('error.html', **result), status_code)
-    if additional_headers:
-        response.headers.extend(additional_headers)
-    return response
-
-
-def http_method_dispatcher(cls):
-    """Decorate a class so as to dispatch by HTTP method.
-
-    Converts the class into a function containing a single instance of the class
-    and the necessary switching code to call the class method corresponding to
-    the HTTP method. There is nothing special about the class so long as it
-    defines the necessary methods (lower case versions of the HTTP methods e.g.
-    "get" and "put") with the arguments as per the routed URL.
-
-    There are two notable differences between this approach and the method views
-    described in:
-
-        http://flask.pocoo.org/docs/views/#method-views-for-apis
-
-    First, normal Flask decorators may be used to decorate the result of this
-    decorator. Further, normal Flask decorators may also be used on the
-    individual class methods.  In other words, we retain the clean declarative
-    Flask routing pattern using decorators.
-
-    Second, this class decorator returns a function holding a closure containing
-    an instantiation of the class. The class is instantiated once at the time of
-    "application setup state". See http://flask.pocoo.org/docs/appcontext/.
-
-    """
-    instance = cls()
-
-    def method_dispatcher(*args, **kwargs):
-        method_name = request.method.lower()
-        try:
-            f = getattr(instance, method_name)
-        except AttributeError:
-            abort(405)
-        return f(*args, **kwargs)
-
-    # Name the method_dispatcher function after the class so that it is unique.
-    # Otherwise the flask routing tries to attach every route to the same
-    # function (Gotcha!).
-    method_dispatcher.__name__ = cls.__name__
-    return method_dispatcher
-
-
-def document_using(doc_url):
-    """Decorator to redirect to documentation at given url.
-
-    This is only done for http GET's which express a preference for HTML over
-    JSON in the Accept header.
-
-    """
-    @decorator
-    def intercept_GET(f, *args, **kwargs):
-        if request.method == 'GET' and not request_prefers_json_over_html():
-            return redirect(doc_url)
-        return f(*args, **kwargs)
-    return intercept_GET
-
-
-def check(checker_function):
-    """Decorator to abort a call through to a view if given check fails.
-
-    The given checker_function is executed. If the result is non-None then the
-    call stack is terminated early with that non-None value. Otherwise the call
-    stack continues.
-
-    """
-    @decorator
-    def check_with_checker_function(f, *args, **kwargs):
-        result = checker_function(*args, **kwargs)
-        if result is not None:
-            return result
-        return f(*args, **kwargs)
-    return check_with_checker_function
-
-
-def validate_json(validate_function, default=None):
-    """Decorator to validate and marshal the incoming JSON.
-
-    Set request.json to the value returned from calling validate_function on
-    request.json (or default() if request.json is None). If this raises
-    an exception then the call stack is terminated early with a
-    :func:`make_error` from the contents of the exception stack trace and a 400.
-
-    If request.json is None then default() is used. Note that
-      1) it is a callable which must create the default value
-         (to avoid accidental re-use of mutables)
-      2) the result is still passed through the validate_function
-         (to ensure the invariant holds)
-
-    """
-    @decorator
-    def validate_with_validate_function(f, *args, **kwargs):
-        input_json = request.json
-        if input_json is None and callable(default):
-            input_json = default()
-        try:
-            request.json = validate_function(input_json)
-        except Exception, e:
-            return make_error(str(e), 400)
-        return f(*args, **kwargs)
-    return validate_with_validate_function
-
-
-def if_content_exists_then_is_json(*args, **kwargs):
-    """Return error response if content exists and is declared as not JSON.
-
-    Otherwise return None.
-
-    Intended to be used with :func:`lib.check`.
-
-    """
-    if len(request.data.strip()) > 0:
-        if 'application/json' not in request.headers['Content-Type']:
-            return make_error(
-                'API only accepts Content-Type: application/json', 406)

lib/oauth.py

-"""2-legged oauth 1.0 utilities.
-
-Simplify usage down to essentially two functions:
-
-    1) sign
-    2) verify
-
-We do NOT include the body in the signature or create a body hash. Therefore
-there is no oauth_body_hash parameter. This is because it is not part of the
-specification and because it has a performance impact with large messages. We
-trick the oauth2 library into not hashing the body by setting
-is_form_encoded=True. See
-
-    http://oauth.googlecode.com/svn/spec/ext/body_hash/1.0/oauth-bodyhash.html
-
-Note that we also assume that the body does NOT contain any of the oauth
-parameters. They must be in the Authorization HTTP header or in the URL.
-
-See
-    http://tools.ietf.org/html/rfc5849
-    http://oauth.net
-
-"""
-import oauth2
-import time
-import urlparse
-
-
-# Declared oauth2 Signature method (standard HMAC_SHA1).
-SIGNATURE_METHOD = oauth2.SignatureMethod_HMAC_SHA1()
-
-
-# A "server" to sign requests.
-oauth_server = oauth2.Server()
-oauth_server.add_signature_method(SIGNATURE_METHOD)
-
-
-def verify(get_secret, url, method, headers):
-    """Verify (2-legged) oauth request.
-
-    The get_secret function should take a consumer key and return the consumer
-    secret or raise a KeyError.
-
-    The oauth parameters can be in the "Authorization" header (found in headers)
-    or in the URL parameters.
-
-    """
-    # We will build a set of all parameters...
-    parameters = {}
-
-    # Merge in parameters from the header, if defined.
-    if 'Authorization' in headers:
-        auth_header = headers['Authorization']
-        if auth_header[:6] == 'OAuth ':
-            auth_header = auth_header[6:]
-            try:
-                # Get the parameters from the header.
-                header_params = oauth2.Request._split_header(auth_header)
-                parameters.update(header_params)
-            except:
-                raise ValueError(
-                    'Unable to parse OAuth parameters from header.')
-
-    # Merge in parameters from the URL.
-    param_str = urlparse.urlparse(url)[4]
-    url_params = oauth2.Request._split_url_string(param_str)
-    parameters.update(url_params)
-
-    # Make the oauth2.Consumer object (key and secret).
-    try:
-        key = parameters['oauth_consumer_key']
-        secret = get_secret(key)
-        consumer = oauth2.Consumer(key, secret)
-    except KeyError:
-        return False  # Missing consumer key or secret
-
-    # Create the oauth Request.
-    (scheme, netloc, path, params, query, fragment) = urlparse.urlparse(url)
-    base_url = urlparse.urlunparse((scheme, netloc, path, None, None, None))
-    req = oauth2.Request(method, base_url, parameters, is_form_encoded=True)
-
-    # Verify signature.
-    try:
-        oauth_server.verify_request(req, consumer, None)
-        return True
-    except oauth2.Error:
-        return False
-
-
-def get_HMAC_SHA1_signature_as_url(key, secret, url, http_method):
-    """Create a two legged oauth HMAC_SHA1 signature.
-
-    The key and secret are oauth consumer pairs.
-
-    The created oauth signature parameters are included in the returned URL.
-
-    """
-    return _sign(SIGNATURE_METHOD, key, secret, url, http_method).to_url()
-
-
-def get_HMAC_SHA1_signature_as_header(key, secret, url, http_method):
-    """Create a two legged oauth HMAC_SHA1 signature.
-
-    The key and secret are oauth consumer pairs.
-
-    The created oauth signature parameters are included in the returned headers
-    object.
-
-    """
-    return _sign(SIGNATURE_METHOD, key, secret, url, http_method).to_header()
-
-
-def _sign(signature_method, key, secret, url, http_method):
-    """Sign and return details in an oauth2.Request object."""
-    parameters = {}
-    parameters.update({
-        'oauth_version': "1.0",
-        'oauth_nonce': oauth2.generate_nonce(),
-        'oauth_timestamp': int(time.time()),
-        'oauth_consumer_key': key,
-        'oauth_token': ''
-    })
-    consumer = oauth2.Consumer(key, secret)
-    req = oauth2.Request(
-        url=url, method=http_method, parameters=parameters,
-        is_form_encoded=True)
-    req.sign_request(signature_method, consumer, None)
-    return req

lib/oauth_test.py

-import mock
-import unittest
-import urlparse
-
-from lib import oauth
-
-# Test consumer key and secret.
-KEY = 'akey'
-SECRET = 'asecret'
-
-
-def _test_get_secret(key):
-    if key == KEY:
-        return SECRET
-    raise KeyError(key)
-
-
-class TestOauthUtils(unittest.TestCase):
-
-    def test_get_HMAC_SHA1_signature_as_url(self):
-        url = oauth.get_HMAC_SHA1_signature_as_url(
-            KEY, SECRET, 'http://a.b.c', 'GET')
-        self.assertTrue(oauth.verify(_test_get_secret, url, 'GET', {}))
-
-    def test_get_HMAC_SHA1_signature_as_url_with_query(self):
-        url = oauth.get_HMAC_SHA1_signature_as_url(
-            KEY, SECRET, 'http://a.b.c?name=tim', 'GET')
-        self.assertTrue(oauth.verify(_test_get_secret, url, 'GET', {}))
-
-    def test_get_HMAC_SHA1_signature_as_header(self):
-        url = 'http://a.b.c'
-        header = oauth.get_HMAC_SHA1_signature_as_header(
-            KEY, SECRET, url, 'GET')
-        self.assertTrue(oauth.verify(_test_get_secret, url, 'GET', header))
-
-    def test_get_HMAC_SHA1_signature_as_header_with_query(self):
-        url = 'http://a.b.c?name=tim'
-        header = oauth.get_HMAC_SHA1_signature_as_header(
-            KEY, SECRET, url, 'GET')
-        self.assertTrue(oauth.verify(_test_get_secret, url, 'GET', header))
-
-    def test_against_google_example(self):
-        # Confirm that we create the same signature as provided in Appendix A of
-        # http://oauth.googlecode.com/svn/spec/ext/consumer_request/
-        #     1.0/drafts/2/spec.html
-
-        def my_time():
-            return 1191242096
-
-        def my_nonce():
-            return 'kllo9940pd9333jh'
-
-        key = 'dpf43f3p2l4k3l03'
-        secret = 'kd94hf93k423kf44'
-
-        with mock.patch('time.time', my_time):
-            with mock.patch('oauth2.generate_nonce', my_nonce):
-                url = oauth.get_HMAC_SHA1_signature_as_url(
-                    key, secret, 'http://provider.example.net/profile', 'GET')
-
-        query_string = urlparse.urlparse(url).query
-        self.assertEqual(
-            urlparse.parse_qs(query_string)['oauth_signature'][0],
-            u'IxyYZfG2BaKh8JyEGuHCOin/4bA=')
-
-
 #!/usr/bin/env bash
 
+# Start a server if not already running
+n_running_servers=$(pgrep -fc 'python runserver.py')
+if [ "$n_running_servers" == 0 ]; then {
+    python runserver.py &
+    server_ppid=$!
+}; fi
+
+pushd restapiblueprint
+
 # Cleanup and prepare directories.
 rm -rf apidocs/build
 rm -rf static/apidocs
 mkdir -p static
 
-# Start a server if not already running
-n_running_servers=$(pgrep -fc 'python server.py')
-if [ "$n_running_servers" == 0 ]; then {
-    python server.py &
-    server_ppid=$!
-}; fi
-
 # Build docs
 sphinx-build -b html -d apidocs/build/doctrees apidocs static/apidocs
 
 # Kill server if we started it.
 if [ "$n_running_servers" == 0 ]; then pkill -P $server_ppid; fi
+
+popd

restapiblueprint/__init__.py

+from flask import Flask
+from restapiblueprint.lib import use_pretty_default_error_handlers
+
+app = Flask(__name__)
+
+
+# Use nice error handlers for all common HTTP codes.
+use_pretty_default_error_handlers(app)
+
+
+# This is the recommended way of packaging a Flask app.
+# See http://flask.pocoo.org/docs/patterns/packages/
+import restapiblueprint.views

restapiblueprint/apidocs/conf.py

+# Activate desired extensions.
+extensions = [
+    'sphinxcontrib.programoutput',
+    'sphinxcontrib.ansi']
+
+# The suffix of source filenames.
+source_suffix = '.rst'
+
+# The master toctree document.
+master_doc = 'index'
+
+# General information about the project.
+project = 'People REST API'
+copyright = '2012, Timothy Corbett-Clark'
+version = '0.1'
+release = '0.1.1'
+html_title = '%s (%s)' % (project, release)
+html_short_title = 'People'
+
+# Choose a slightly better theme than default.
+html_theme = 'sphinxdoc'
+
+# Don't show links to the reST source.
+html_show_sourcelink = False
+
+# Disable index on the html page.
+html_use_index = False
+
+# Capture ansi colour in programoutput.
+# See http://packages.python.org/sphinxcontrib-programoutput
+programoutput_use_ansi = True
+
+# Enable ansi plugin.
+# See http://packages.python.org/sphinxcontrib-ansi
+html_ansi_stylesheet = 'black-on-white.css'

restapiblueprint/apidocs/index.rst

+Resources
+=========
+
+.. toctree::
+   :maxdepth: 1
+
+   people
+
+General notes
+=============
+
+This is the REST API for managing and retrieving email addresses for people.
+
+URL template
+------------
+
+The URLs all follow pattern::
+
+    /<version>/<resource>/<optional resource_id>?<optional_args>
+
+Authentication
+--------------
+
+TODO: explain once authentication is implemented.
+
+Data formatting
+---------------
+
+Only `JSON`_ is supported. The is reflected by an HTTP header of ``Content-Type:
+application/json``. As per `rfc4627`_, JSON is always encoded Unicode with a
+default encoding of UTF-8. So it is fine to include non-ASCII in the messages.
+
+For maximum compatibility, normalize to http://unicode.org/reports/tr15 (Unicode
+Normalization Form C) (NFC) before UTF-8 encoding.
+
+Date format
+-----------
+
+All dates passed to and from the API are strings in the following format::
+
+    2012-02-09 15:06 +00:00
+    2012-02-09 15:06:31 +01:00
+    2012-02-09 15:06:31.428 -03:00
+
+Error handling
+--------------
+
+Errors are indicated using standard `HTTP error codes`_. Additional information
+is usually included in the returned JSON. Specific meanings for the error codes
+are given below.
+
+TODO: list them
+
+.. _HTTP error codes: http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
+.. _JSON: http://json.org
+.. _rfc4627: http://www.ietf.org/rfc/rfc4627.txt

restapiblueprint/apidocs/people.rst

+======
+People
+======
+
+Add, remove, or update the entry for a given person.
+
+GET
+===
+
+DESCRIPTION
+    Retrieve information on a given person.
+
+URL STRUCTURE
+    .. code-block:: guess
+
+        https://www.example.com/<version>/people/<name>
+
+    :version: API version.
+    :name: Name of the person
+
+VERSIONS
+    v1
+
+ERRORS
+    :404: No user of that name
+
+EXAMPLES
+    * `GET unknown person`_
+    * `PUT then GET`_
+    * `Redirect to documentation`_
+
+PUT
+===
+
+DESCRIPTION
+    Create a new person. Also overwrites an existing person of that name.
+
+URL STRUCTURE
+
+    .. code-block:: guess
+
+        https://www.example.com/<version>/people/<name>
+
+    :version: API version.
+    :name: Name of the person
+
+VERSIONS
+    v1
+
+ERRORS
+    | 200 - Ok
+    | 404 - No user of that name
+
+EXAMPLE
+    * `PUT then GET`_
+
+
+PATCH
+=====
+
+DESCRIPTION
+    Update a new person.
+
+URL STRUCTURE
+
+    .. code-block:: guess
+
+        https://www.example.com/<version>/people/<name>
+
+    :version: API version.
+    :name: Name of the person
+
+VERSIONS
+    v1
+
+ERRORS
+    | 200 - Ok
+    | 404 - No user of that name
+
+EXAMPLE
+    * `PATCH then GET`_
+
+
+DELETE
+======
+
+DESCRIPTION
+    Delete the entry for a specific person.
+
+URL STRUCTURE
+    .. code-block:: guess
+
+        https://www.example.com/<version>/people/<name>
+
+    :version: API version.
+    :name: Name of the person
+
+VERSIONS
+    v1
+
+ERRORS
+    :404: No user of that name
+
+EXAMPLE
+    * `DELETE person`_
+
+DELETE (all people)
+===================
+
+DESCRIPTION
+    Delete entries for everyone.
+
+URL STRUCTURE
+    .. code-block:: guess
+
+        https://www.example.com/<version>/people
+
+    :version: API version.
+
+VERSIONS
+    v1
+
+ERRORS
+    (none)
+
+EXAMPLE
+    * `DELETE all`_
+
+
+Examples
+========
+
+GET unknown person
+~~~~~~~~~~~~~~~~~~
+
+    .. command-output:: http --pretty all -jv GET localhost:5000/v1/people/nobody
+
+PUT then GET
+~~~~~~~~~~~~
+
+    .. command-output:: http --pretty all -jv PUT localhost:5000/v1/people/tim email="a@b.c"; echo "\n"; http --pretty all -jv GET localhost:5000/v1/people/tim
+        :shell:
+
+PATCH then GET
+~~~~~~~~~~~~~~
+
+    .. command-output:: http --pretty all -jv PATCH localhost:5000/v1/people/tim comment="hello world"; echo "\n"; http --pretty all -jv GET localhost:5000/v1/people/tim
+        :shell:
+
+DELETE person
+~~~~~~~~~~~~~
+
+Note that the second DELETE fails.
+
+    .. command-output:: http --pretty all -jv DELETE localhost:5000/v1/people/tim; echo "\n"; http --pretty all -jv DELETE localhost:5000/v1/people/tim
+        :shell:
+
+DELETE all
+~~~~~~~~~~
+
+    .. command-output:: http --pretty all -jv DELETE localhost:5000/v1/people
+
+Redirect to documentation
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+A GET with an HTTP Accept Header preferring HTML over JSON will redirect to the
+on-line documentation.
+
+    .. command-output:: http --pretty all -v GET localhost:5000/v1/people

restapiblueprint/blueprints/__init__.py

Empty file added.

restapiblueprint/blueprints/counter.py

+from flask import Blueprint, jsonify
+from restapiblueprint.lib import make_ok
+
+
+blueprint = Blueprint(__name__, __name__)
+
+
+class Counter:
+
+    value = 0
+
+
+@blueprint.route('', methods=['GET'])
+def counter():
+    Counter.value += 1
+    return jsonify(value=Counter.value)
+
+
+@blueprint.route('', methods=['DELETE'])
+def reset_counter():
+    Counter.value = 0
+    return make_ok()

restapiblueprint/blueprints/people.py

+from flask import Blueprint, request
+from schema import (Schema, Optional)
+from validate_email import validate_email
+
+from restapiblueprint.database import people_database
+from restapiblueprint.lib import (
+    http_method_dispatcher, make_ok, make_error, check,
+    document_using, validate_json, if_content_exists_then_is_json)
+
+
+blueprint = Blueprint(__name__, __name__)
+
+
+# Define input schemas.
+# See https://github.com/halst/schema
+person_full = Schema(
+    {
+        'email': validate_email,
+        'comment': basestring
+    }, error='Invalid specification for a person'
+)
+
+
+person_partial = Schema(
+    {
+        Optional('email'): validate_email,
+        Optional('comment'): basestring
+    }, error='Invalid partial specification for a person'
+)
+
+
+@blueprint.route('', methods=['GET', 'DELETE'])
+@document_using('static/apidocs/people.html')
+@check(if_content_exists_then_is_json)
+@http_method_dispatcher
+class People(object):
+
+    def delete(self):
+        people_database.reset()
+        return make_ok()
+
+
+@blueprint.route('/<name>', methods=['GET', 'PATCH', 'POST', 'PUT', 'DELETE'])
+@document_using('static/apidocs/people.html')
+@check(if_content_exists_then_is_json)
+@http_method_dispatcher
+class PeopleWithName(object):
+
+    def person_exists(self, name):
+        if not people_database.has_person(name):
+            return make_error('Person does not exist', 404)
+
+    @check(person_exists)
+    def get(self, name):
+        email = people_database.get_email_address(name)
+        comment = people_database.get_comment(name)
+        return make_ok(name=name, email=email, comment=comment)
+
+    @check(person_exists)
+    @validate_json(person_full.validate)
+    def post(self, name):
+        json = request.json
+        people_database.set_email_address(name, json['email'])
+        people_database.set_comment(name, json['comment'])
+        return make_ok()
+
+    @validate_json(person_partial.validate, default=dict)
+    def put(self, name):
+        json = request.json
+        people_database.add_person(name, json.get('email'), json.get('comment'))
+        return make_ok()
+
+    @check(person_exists)
+    @validate_json(person_partial.validate, default=dict)
+    def patch(self, name):
+        json = request.json
+        if 'email' in json:
+            people_database.set_email_address(name, json['email'])
+        if 'comment' in json:
+            people_database.set_comment(name, json['comment'])
+        return make_ok()
+
+    @check(person_exists)
+    def delete(self, name):
+        people_database.delete_person(name)
+        return make_ok()

restapiblueprint/blueprints/private.py

+from flask import Blueprint, request
+from restapiblueprint.lib import (
+    http_method_dispatcher, make_ok, make_error, check, oauth)
+
+
+blueprint = Blueprint(__name__, __name__)
+
+
+@blueprint.route('', methods=['GET'])
+@http_method_dispatcher
+class Private(object):
+
+    def dumb_get_consumer_secret(self, key):
+        if key == 'akey':
+            return 'asecret'
+        raise KeyError
+
+    def access(self):
+        if not oauth.verify(
+                self.dumb_get_consumer_secret,
+                request.url, request.method, request.headers):
+            return make_error(
+                'Unauthorized', 401,
+                additional_headers={'WWW-Authenticate': 'OAuth'})
+
+    @check(access)
+    def get(self):
+        return make_ok()

restapiblueprint/database.py

+from validate_email import validate_email
+
+
+class PeopleDatabase(object):
+
+    """Simple database of people (names) to email addresses."""
+
+    # Internal keys
+    EMAIL = 'email'
+    COMMENT = 'comment'
+
+    def __init__(self):
+        self.people = {}  # {name: {email: ..., comment: ...}}
+
+    def add_person(self, person, email_address=None, comment=None):
+        if email_address is not None and not validate_email(email_address):
+            raise ValueError('Invalid email address')
+        self.people[person] = {self.EMAIL: email_address, self.COMMENT: comment}
+
+    def has_person(self, name):
+        return name in self.people
+
+    def get_email_address(self, person):
+        return self.people[person][self.EMAIL]
+
+    def get_comment(self, person):
+        return self.people[person][self.COMMENT]
+
+    def set_email_address(self, person, email_address):
+        if person not in self.people:
+            raise KeyError('Person does not exist')
+        if email_address is not None and not validate_email(email_address):
+            raise ValueError('Invalid email address')
+        self.people[person][self.EMAIL] = email_address
+
+    def set_comment(self, person, comment):
+        if person not in self.people:
+            raise KeyError('Person does not exist')
+        self.people[person][self.COMMENT] = comment
+
+    def delete_person(self, person):
+        if person not in self.people:
+            raise KeyError('Person does not exist')
+        del self.people[person]
+
+    def reset(self):
+        self.people.clear()
+
+
+# Database instance.
+people_database = PeopleDatabase()

restapiblueprint/features/add_person.feature

+Feature: Add a person
+  As an API client
+  I want to be able to create a new person
+
+  Background: Set server URL and reset database
+    Given I am using server "http://localhost:5000/v1"
+    And I set Accept header to "application/json"
+    When I send a DELETE request to "people"
+    Then the response status should be "200"
+
+  Scenario: Add a new person, declare json, but don't send anything
+    Given I set Content-Type header to "application/json"
+    When I send a PUT request to "people/fred"
+    Then the response status should be "400"
+
+  Scenario: Add a new person, declare json, but send invalid json
+    Given I set Content-Type header to "application/json"
+    When I send a PUT request to "people/fred"
+      """
+      {"email": "forgot the end quote}
+      """
+    Then the response status should be "400"
+
+  Scenario: Add a person but with non-JSON Content-Type
+    Given I set Content-Type header to "application/xml"
+    When I send a PUT request to "people/tim"
+      """
+      <something></something>
+      """
+    Then the response status should be "406"
+
+  Scenario: Add a person with data of unspecified Content-Type
+    When I send a PUT request to "people/tim"
+      """
+      some unspecified format (no Content-Type set)
+      """
+    Then the response status should be "406"
+
+  Scenario: Add a new person
+    When I send a PUT request to "people/fred"
+    Then the response status should be "200"
+    And the JSON should be:
+      """
+      {"status": "ok"}
+      """
+    When I send a GET request to "people/fred"
+    Then the response status should be "200"
+
+  Scenario: Add new person with no email address
+    When I send a PUT request to "people/fred"
+    Then the response status should be "200"
+    When I send a GET request to "people/fred"
+    Then the response status should be "200"
+    And the JSON at path "result.email" should be null
+
+  Scenario: Add new person with invalid email address
+    Given I set Content-Type header to "application/json"
+    When I send a PUT request to "people/fred":
+      """
+      {"email": "not-a-valid-email-address"}
+      """
+    Then the response status should be "400"
+    And the JSON at path "message" should be "Invalid partial specification for a person"
+
+  Scenario: Add new person with valid email address
+    Given I set Content-Type header to "application/json"
+    When I send a PUT request to "people/fred":
+      """
+      {"email": "a@b.c"}
+      """
+    Then the response status should be "200"
+    When I send a GET request to "people/fred"
+    Then the response status should be "200"
+    And the JSON at path "result.email" should be "a@b.c"
+
+  Scenario: Add the same person twice
+    When I send a PUT request to "people/fred"
+    And I send a PUT request to "people/fred"
+    Then the response status should be "200"
+    And the JSON at path "status" should be "ok"
+
+  Scenario: Add a new person but with a list not a dict
+    Given I set Content-Type header to "application/json"
+    When I send a PUT request to "people/fred"
+      """
+      []
+      """
+    Then the JSON at path "message" should be "Invalid partial specification for a person"
+    And the response status should be "400"

restapiblueprint/features/delete_person.feature

+Feature: Delete a person
+  As an API client
+  I want to be able to remove a person
+
+  Background: Reset and have a valid user
+    Given I am using server "http://localhost:5000/v1"
+    And I set Accept header to "application/json"
+    When I send a DELETE request to "people"
+    Then the response status should be "200"
+
+  Scenario: Cannot delete a person before they exist
+    When I send a DELETE request to "people/fred"
+    Then the response status should be "404"
+    And the JSON at path "status" should be "error"
+    And the JSON at path "message" should be "Person does not exist"
+
+  Scenario: Delete a person
+    When I send a PUT request to "people/fred"
+    And I send a DELETE request to "people/fred"
+    And I send a GET request to "people/fred"
+    Then the response status should be "404"
+    And the JSON at path "status" should be "error"
+    And the JSON at path "message" should be "Person does not exist"

restapiblueprint/features/environment.py

+def before_scenario(context, scenario):
+    # Seed empty HTTP headers so steps do not need to check and create.
+    context.headers = {}
+
+    # Seed empty Jinja2 template data so steps do not need to check and create.
+    context.template_data = {}
+
+    # Default repeat attempt counts and delay for polling GET.
+    context.n_attempts = 20
+    context.pause_between_attempts = 0.1
+
+    # No authentication by default.
+    context.auth = None

restapiblueprint/features/errors.feature

+Feature: Correct behaviour on incorrect usage
+  As an API client
+  I expect sane behaviour when I don't use the API correctly
+
+  Background: Reset and have a valid user
+    Given I am using server "http://localhost:5000/v1"
+    When I send a DELETE request to "people"
+    Then the response status should be "200"
+
+  Scenario: Attempt to use non existent resource
+    When I send a GET request to "no-such-url"
+    Then the response status should be "404"

restapiblueprint/features/misc.feature

+Feature: Miscellaneous
+  As an API client
+  I expect the usual miscellaneous features of an API
+
+  Background: Set server name and reset database
+    Given I am using server "http://localhost:5000/v1"
+    And I set Accept header to "application/json"
+    When I send a DELETE request to "people"
+    Then the response status should be "200"
+
+  Scenario: Ask for HTTP methods on a valid people URL
+    When I send an OPTIONS request to "people/tim"
+    Then the response status should be "200"
+    And the Allow header should be:
+      """
+      HEAD, GET, PUT, POST, DELETE, OPTIONS, PATCH
+      """
+
+  Scenario: Ask for HTTP methods on an invalid URL
+    When I send an OPTIONS request to "foobar"
+    Then the response status should be "404"
+
+  Scenario: Can store non-ASCII data on comments
+    Given I set Content-Type header to "application/json"
+    When I send a PUT request to "people/tim"
+      """
+      {"comment": "a@思é.c"}
+      """
+    Then the response status should be "200"
+    When I send a GET request to "people/tim"
+    Then the JSON at path "result.comment" should be "a@\u601d\u00e9.c"
+    And the JSON at path "result.comment" should be "a@思é.c"
+
+  Scenario: Polling for a state change
+    When I send a DELETE request to "counter"
+    Then the response status should be "200"
+    When I poll GET "counter" until JSON at path "value" is
+      """
+      5
+      """
+
+  Scenario: GET invalid URL
+    Given I set Content-Type header to "application/json"
+    When I send a GET request to "does-not-exist"
+    Then the response status should be "404"
+
+  Scenario: GET and remember value for future GET
+    Given I set Content-Type header to "application/json"
+    When I send a PUT request to "people/fred":
+      """
+      {"email": "a@b.c"}
+      """
+    Then the response status should be "200"
+    When I send a GET request to "people/fred"
+    And I store the JSON at path "result.email" in "freds_email"
+    And I send a GET request to "people/fred"
+    Then the JSON should be:
+      """
+      {
+        "status": "ok",
+        "result": {
+            "name": "fred",
+            "email": "{{ freds_email }}",
+            "comment": null
+          }
+      }
+      """

restapiblueprint/features/oauth.feature

+Feature: Two-legged OAuth
+  As an API client
+  I expect to be able to authentication using two-legged oauth
+
+  Background: Set server name and reset database
+    Given I am using server "http://localhost:5000/v1"
+    And I set Accept header to "application/json"
+    When I send a DELETE request to "people"
+    Then the response status should be "200"
+
+  Scenario: Cannot access a restricted URL without any authentication
+    When I send a GET request to "private"
+    Then the response status should be "401"
+    And the WWW-Authenticate header should be "OAuth"
+
+  Scenario: Can access a restricted URL with query OAuth
+    Given I use query OAuth with key="akey" and secret="asecret"
+    When I send a GET request to "private"
+    Then the response status should be "200"
+
+  Scenario: Cannot access a restricted URL with query OAuth and wrong secret
+    Given I use query OAuth with key="akey" and secret="wrong_asecret"
+    When I send a GET request to "private"
+    Then the response status should be "401"
+
+  Scenario: Cannot access a restricted URL with query OAuth and wrong key
+    Given I use query OAuth with key="wrong_akey" and secret="asecret"
+    When I send a GET request to "private"
+    Then the response status should be "401"
+
+  Scenario: Can access a restricted URL with header OAuth
+    Given I use header OAuth with key="akey" and secret="asecret"
+    When I send a GET request to "private"
+    Then the response status should be "200"
+
+  Scenario: Can access a restricted URL with header OAuth and wrong secret
+    Given I use header OAuth with key="akey" and secret="wrong_asecret"
+    When I send a GET request to "private"
+    Then the response status should be "401"
+
+  Scenario: Can access a restricted URL with header OAuth and wrong key
+    Given I use header OAuth with key="wrong_akey" and secret="asecret"
+    When I send a GET request to "private"
+    Then the response status should be "401"

restapiblueprint/features/steps/rest.py

+from nose.tools import assert_equal
+import behave
+import jinja2
+import jpath
+import json
+import purl
+import requests
+import time
+
+
+def get_data_from_context(context):
+    """Use context.text as a template and render against any stored state."""
+    try:
+        data = context.text
+    except AttributeError:
+        data = ''
+    # Always clear the text to avoid accidental re-use.
+    context.text = u''
+    # NB rendering the template always returns unicode.
+    result = jinja2.Template(data).render(context.template_data)
+    return result.encode('utf8')
+
+
+@behave.given('I am using server "{server}"')
+def using_server(context, server):
+    context.server = purl.URL(server)
+
+
+@behave.given('I set {var} header to "{value}"')
+def set_header(context, var, value):
+    # We must keep the headers as implicit ascii to avoid encoding failure when
+    # the entire HTTP body is constructed by concatenating strings.
+    context.headers[var.encode('ascii')] = value.encode('ascii')
+
+
+@behave.given('I use query OAuth with key="{key}" and secret="{secret}"')
+def query_oauth(context, key, secret):
+    context.auth = requests.auth.OAuth1(
+        key, secret, signature_type='query')
+
+
+@behave.given('I use header OAuth with key="{key}" and secret="{secret}"')
+def header_oauth(context, key, secret):
+    context.auth = requests.auth.OAuth1(
+        key, secret, signature_type='auth_header')
+
+
+@behave.given('I set context "{variable}" to {value}')
+def set_config(context, variable, value):
+    setattr(context, variable, json.loads(value))
+
+
+@behave.when(
+    'I poll GET "{url_path_segment}" until JSON at path "{jsonpath}" is')
+def poll_GET(context, url_path_segment, jsonpath):
+    json_value = json.loads(get_data_from_context(context))
+    url = context.server.add_path_segment(url_path_segment)
+    for i in range(context.n_attempts):
+        response = requests.get(url, headers=context.headers, auth=context.auth)
+        if jpath.get(jsonpath, response.json) == json_value:
+            return
+        time.sleep(context.pause_between_attempts)
+    raise AssertionError(
+        'Condition not met after %d attempts' % context.n_attempts)
+
+
+@behave.when('I send an OPTIONS request to "{url_path_segment}"')
+def options_request(context, url_path_segment):
+    context.response = requests.options(
+        context.server.add_path_segment(url_path_segment))
+
+
+@behave.when('I send a PATCH request to "{url_path_segment}"')
+def patch_request(context, url_path_segment):
+    data = get_data_from_context(context)
+    url = context.server.add_path_segment(url_path_segment)
+    context.response = requests.patch(
+        url, data=data, headers=context.headers, auth=context.auth)
+
+
+@behave.when('I send a PUT request to "{url_path_segment}"')
+def put_request(context, url_path_segment):
+    data = get_data_from_context(context)
+    url = context.server.add_path_segment(url_path_segment)
+    context.response = requests.put(
+        url, data=data, headers=context.headers, auth=context.auth)
+
+
+@behave.when('I send a POST request to "{url_path_segment}"')
+def post_request(context, url_path_segment):
+    data = get_data_from_context(context)
+    url = context.server.add_path_segment(url_path_segment)
+    context.response = requests.post(
+        url, data=data, headers=context.headers, auth=context.auth)
+
+
+@behave.when('I send a GET request to "{url_path_segment}"')
+def get_request(context, url_path_segment):
+    headers = context.headers.copy()
+    data = get_data_from_context(context)
+    if not data:
+        # Don't set the Content-Type if we have no data because no data is not
+        # valid JSON.