Skip to content

Commit

Permalink
Merge pull request #351 from zerotired/wip/media-type-validation-docs
Browse files Browse the repository at this point in the history
Improve documentation regarding content type negotiation and media type validation
  • Loading branch information
leplatrem committed Mar 14, 2016
2 parents 157b535 + 5f34382 commit b749473
Show file tree
Hide file tree
Showing 8 changed files with 220 additions and 124 deletions.
1 change: 1 addition & 0 deletions CHANGES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ CHANGELOG

- Nothing changed yet.
- Properly handle content_type callables returning a single internet media type as scalar (#343)
- Improve documentation regarding content type negotiation and media type validation (#91, #343, #350)


1.2.0 (2016-01-18)
Expand Down
83 changes: 43 additions & 40 deletions cornice/tests/test_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@ def test_accept(self):
error_description = response.json['errors'][0]['description']
self.assertEquals('header', error_location)
self.assertEquals('Accept', error_name)
self.assertTrue('application/json' in error_description)
self.assertTrue('text/json' in error_description)
self.assertTrue('text/plain' in error_description)
self.assertIn('application/json', error_description)
self.assertIn('text/json', error_description)
self.assertIn('text/plain', error_description)

# requesting a supported type should give an appropriate response type
response = app.get('/service2', headers={'Accept': 'application/*'})
Expand Down Expand Up @@ -89,13 +89,13 @@ def test_accept(self):
response = app.get('/service3', headers={'Accept': 'audio/*'},
status=406)
error_description = response.json['errors'][0]['description']
self.assertTrue('text/json' in error_description)
self.assertIn('text/json', error_description)

response = app.get('/service3', headers={'Accept': 'text/*'})
self.assertEqual(response.content_type, "text/json")

# test that using a callable to define what's accepted works as well
# now, the callable returns a scalar instead of a list
# Test that using a callable to define what's accepted works as well.
# Now, the callable returns a scalar instead of a list.
response = app.put('/service3', headers={'Accept': 'audio/*'},
status=406)
error_description = response.json['errors'][0]['description']
Expand All @@ -104,11 +104,11 @@ def test_accept(self):
response = app.put('/service3', headers={'Accept': 'text/*'})
self.assertEqual(response.content_type, "text/json")

# if we are not asking for a particular content-type,
# If we are not asking for a particular content-type,
# we should get one of the two types that the service supports.
response = app.get('/service2')
self.assertTrue(response.content_type
in ("application/json", "text/plain"))
self.assertIn(response.content_type,
("application/json", "text/plain"))

def test_accept_issue_113_text_star(self):
app = TestApp(main({}))
Expand All @@ -131,8 +131,8 @@ def test_accept_issue_113_text_application_json(self):
def test_accept_issue_113_text_html_not_acceptable(self):
app = TestApp(main({}))

# requesting an unsupported content type should return a HTTP 406 (Not
# Acceptable)
# Requesting an unsupported content type should
# return HTTP response "406 Not Acceptable".
app.get('/service3', headers={'Accept': 'text/html'}, status=406)

def test_accept_issue_113_audio_or_text(self):
Expand All @@ -143,15 +143,15 @@ def test_accept_issue_113_audio_or_text(self):
})
self.assertEqual(response.content_type, "text/plain")

# if we are not asking for a particular content-type,
# If we are not asking for a particular content-type,
# we should get one of the two types that the service supports.
response = app.get('/service2')
self.assertTrue(response.content_type
in ("application/json", "text/plain"))
self.assertIn(response.content_type,
("application/json", "text/plain"))

def test_override_default_accept_issue_252(self):
# override default acceptable content_types for interoperate with
# legacy applications i.e. ExtJS 3
# Override default acceptable content_types for interoperate with
# legacy applications i.e. ExtJS 3.
from cornice.util import _JsonRenderer
_JsonRenderer.acceptable += ('text/html',)

Expand Down Expand Up @@ -187,84 +187,87 @@ def test_content_type_missing(self):
# test that a Content-Type request headers is present
app = TestApp(main({}))

# requesting without a Content-Type header should return a 415 ...
# Requesting without a Content-Type header should
# return "415 Unsupported Media Type" ...
request = app.RequestClass.blank('/service5', method='POST')
response = app.do_request(request, 415, True)

# ... with an appropriate json error structure
# ... with an appropriate json error structure.
error_location = response.json['errors'][0]['location']
error_name = response.json['errors'][0]['name']
error_description = response.json['errors'][0]['description']
self.assertEqual('header', error_location)
self.assertEqual('Content-Type', error_name)
self.assertTrue('application/json' in error_description)
self.assertIn('application/json', error_description)

def test_content_type_wrong_single(self):
# tests that the Content-Type request header satisfies the requirement
# Tests that the Content-Type request header satisfies the requirement.
app = TestApp(main({}))

# requesting the wrong Content-Type header should return a 415 ...
# Requesting the wrong Content-Type header should
# return "415 Unsupported Media Type" ...
response = app.post('/service5',
headers={'Content-Type': 'text/plain'},
status=415)

# ... with an appropriate json error structure
# ... with an appropriate json error structure.
error_description = response.json['errors'][0]['description']
self.assertTrue('application/json' in error_description)
self.assertIn('application/json', error_description)

def test_content_type_wrong_multiple(self):
# tests that the Content-Type request header satisfies the requirement
# Tests that the Content-Type request header satisfies the requirement.
app = TestApp(main({}))

# requesting the wrong Content-Type header should return a 415 ...
# Requesting without a Content-Type header should
# return "415 Unsupported Media Type" ...
response = app.put('/service5',
headers={'Content-Type': 'text/xml'},
status=415)

# ... with an appropriate json error structure
# ... with an appropriate json error structure.
error_description = response.json['errors'][0]['description']
self.assertTrue('text/plain' in error_description)
self.assertTrue('application/json' in error_description)
self.assertIn('text/plain', error_description)
self.assertIn('application/json', error_description)

def test_content_type_correct(self):
# tests that the Content-Type request header satisfies the requirement
# Tests that the Content-Type request header satisfies the requirement.
app = TestApp(main({}))

# requesting with one of the allowed Content-Type headers should work,
# even when having a charset parameter as suffix
# Requesting with one of the allowed Content-Type headers should work,
# even when having a charset parameter as suffix.
response = app.put('/service5', headers={
'Content-Type': 'text/plain; charset=utf-8'
})
self.assertEqual(response.json, "some response")

def test_content_type_on_get(self):
# test that a Content-Type request header is not
# checked on GET requests, they don't usually have a body
# Test that a Content-Type request header is not
# checked on GET requests, they don't usually have a body.
app = TestApp(main({}))
response = app.get('/service5')
self.assertEqual(response.json, "some response")

def test_content_type_with_callable(self):
# test that using a callable for content_type works as well
# Test that using a callable for content_type works as well.
app = TestApp(main({}))
response = app.post('/service6', headers={'Content-Type': 'audio/*'},
status=415)
error_description = response.json['errors'][0]['description']
self.assertTrue('text/xml' in error_description)
self.assertTrue('application/json' in error_description)
self.assertIn('text/xml', error_description)
self.assertIn('application/json', error_description)

def test_content_type_with_callable_returning_scalar(self):
# test that using a callable for content_type works as well
# now, the callable returns a scalar instead of a list
# Test that using a callable for content_type works as well.
# Now, the callable returns a scalar instead of a list.
app = TestApp(main({}))
response = app.put('/service6', headers={'Content-Type': 'audio/*'},
status=415)
error_description = response.json['errors'][0]['description']
self.assertIn('text/xml', error_description)

def test_accept_and_content_type(self):
# tests that giving both Accept and Content-Type
# request headers satisfy the requirement
# Tests that using both the "Accept" and "Content-Type"
# request headers satisfy the requirement.
app = TestApp(main({}))

# POST endpoint just has one accept and content_type definition
Expand Down
11 changes: 8 additions & 3 deletions cornice/tests/validationapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import json


# Service for testing callback-based validators.
service = Service(name="service", path="/service")


Expand Down Expand Up @@ -50,6 +51,7 @@ def post1(request):
return {"body": request.body}


# Service for testing the "accept" parameter (scalar and list).
service2 = Service(name="service2", path="/service2")


Expand All @@ -59,6 +61,7 @@ def get2(request):
return {"body": "yay!"}


# Service for testing the "accept" parameter (callable).
service3 = Service(name="service3", path="/service3")


Expand Down Expand Up @@ -104,7 +107,8 @@ def post4(request):
def get4(request):
return "unfiltered" # should be overwritten on GET

# test the "content_type" parameter (scalar)

# Service for testing the "content_type" parameter (scalar and list).
service5 = Service(name="service5", path="/service5")


Expand All @@ -115,7 +119,7 @@ def post5(request):
return "some response"


# service for testing the "content_type" parameter (callable)
# Service for testing the "content_type" parameter (callable).
service6 = Service(name="service6", path="/service6")


Expand All @@ -124,7 +128,8 @@ def post5(request):
def post6(request):
return {"body": "yay!"}

# test a mix of "accept" and "content_type" parameters

# Service for testing a mix of "accept" and "content_type" parameters.
service7 = Service(name="service7", path="/service7")


Expand Down
32 changes: 26 additions & 6 deletions cornice/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,21 +104,41 @@ def json_error(errors):

def match_accept_header(func, context, request):
"""
Return True if the request matches the values returned by the given :param:
func callable.
Return True if the request ``Accept`` header match
the list returned by the callable specified in :param:func.
Also attach the total list of possible accepted
egress media types to the request.
Utility function for performing content negotiation.
:param func:
The callable returning the list of acceptable content-types,
given a request. It should accept a "request" argument.
The callable returning the list of acceptable
internet media types for content negotiation.
It obtains the request object as single argument.
"""
# attach the accepted egress content types to the request
acceptable = to_list(func(request))
request.info['acceptable'] = acceptable
return request.accept.best_match(acceptable) is not None


def match_content_type_header(func, context, request):
# attach the accepted ingress content types to the request
"""
Return True if the request ``Content-Type`` header match
the list returned by the callable specified in :param:func.
Also attach the total list of possible accepted
ingress media types to the request.
Utility function for performing request body
media type checks.
:param func:
The callable returning the list of acceptable
internet media types for request body
media type checks.
It obtains the request object as single argument.
"""
supported_contenttypes = to_list(func(request))
request.info['supported_contenttypes'] = supported_contenttypes
return content_type_matches(request, supported_contenttypes)
Expand Down
28 changes: 16 additions & 12 deletions docs/source/exhaustive_list.rst
Original file line number Diff line number Diff line change
Expand Up @@ -26,24 +26,28 @@ Authorization can be done using the `acl` parameter. If the authentication or
the authorization fails at this stage, a 401 or 403 error is returned,
depending on the cases.

Content Types (ingress)
=======================
Content negotiation
===================

Each method can specify a list of content types it can receive. Per default,
any content type is allowed. In the case the client sends a request with an
invalid `Content-Type` header, cornice will return a
`415 Unsupported Media Type` with an error message containing the list of
valid request content types for the particular URI and method.
This relates to **response body** internet media types aka. egress content types.

Content Types (egress)
======================

Each method can specify a list of content types it can respond with.
Each method can specify a list of internet media types it can **respond** with.
Per default, `text/html` is assumed. In the case the client requests an
invalid content type via `Accept` header, cornice will return a
invalid media type via `Accept` header, cornice will return a
`406 Not Acceptable` with an error message containing the list of available
response content types for the particular URI and method.

Request media type
==================

This relates to **request body** internet media types aka. ingress content types.

Each method can specify a list of internet media types it accepts as **request**
body format. Per default, any media type is allowed. In the case the client
sends a request with an invalid `Content-Type` header, cornice will return a
`415 Unsupported Media Type` with an error message containing the list of available
request content types for the particular URI and method.

Warning when returning JSON lists
=================================

Expand Down
5 changes: 2 additions & 3 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,7 @@ What Cornice will do for you here is:
to it, resulting in a *406 Not Acceptable* with the list of acceptable ones
if it can't answer.

You can also have a complete overview of the builtin validations provided by
cornice in :doc:`builtin-features`
Please follow up with :doc:`exhaustive_list` to get the picture.


Documentation content
Expand All @@ -93,7 +92,7 @@ Documentation content
config
resources
validation
builtin_validation
builtin-validation
sphinx
testing
exhaustive_list
Expand Down
19 changes: 19 additions & 0 deletions docs/source/testing.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,25 @@
Testing
=======

Running tests
-------------
To run all tests in all Python environments configured in ``tox.ini``,
just setup ``tox`` and run it inside the toplevel project directory::

tox

To run a single test inside a specific Python environment, do e.g.::

tox -e py27 cornice/tests/test_validation.py:TestServiceDefinition.test_content_type_missing

or::

tox -e py27 cornice.tests.test_validation:TestServiceDefinition.test_content_type_missing


Testing cornice services
------------------------

Testing is nice and useful. Some folks even said it helped saving kittens. And
childs. Here is how you can test your Cornice's applications.

Expand Down

0 comments on commit b749473

Please sign in to comment.