Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor and improve media type negotiation #14

Merged
merged 7 commits into from
Jul 20, 2016
Merged

Refactor and improve media type negotiation #14

merged 7 commits into from
Jul 20, 2016

Conversation

Changaco
Copy link
Member

The original idea was to move the negotiation to an algorithm function, but in the end I put it in Dynamic.render() instead. Either way moving it out of the simplates module should help us with AspenWeb/salon#13.

This PR adds a few tests and fixes a few issues, it should notably make https://github.com/liberapay/liberapay.com/blob/2c03dbfd75e9a747b309bfad64590a0e2aceca05/liberapay/__init__.py#L67-L74 obsolete.

@Changaco
Copy link
Member Author

Here's the output of git rebase master -x 'rm aspen/**/*.pyc tests/*.pyc; git describe --always; python2 build.py test' so you can see the failing tests (sorry, no color):

Executing: rm aspen/**/*.pyc tests/*.pyc; git describe --always; python2 build.py test
d59c2d9
====================================================================================== test session starts ======================================================================================
platform linux2 -- Python 2.7.12, pytest-2.9.1, py-1.4.31, pluggy-0.3.1
rootdir: /home/changaco/projets/aspen/aspen.py, inifile: 
plugins: cov-2.2.1
collected 709 items 

tests/dispatch_table_test.py .................................................................................................................................................................................................................................................................................................................................................................................................................................................................................
tests/test_configuration.py ......................
tests/test_dispatcher.py ..........................................................................
tests/test_dynamic_resource.py ................................
tests/test_json_resource.py .....................
tests/test_mappings.py ...............
tests/test_renderers.py .
tests/test_request_processor.py ....
tests/test_resources.py ................
tests/test_resources_generics.py .......................
tests/test_simplates.py .........................
tests/test_unicode.py ...
tests/test_utils.py .......
tests/test_weird.py .

================================================================================== 709 passed in 4.17 seconds ===================================================================================
Executing: rm aspen/**/*.pyc tests/*.pyc; git describe --always; python2 build.py test
153742c
====================================================================================== test session starts ======================================================================================
platform linux2 -- Python 2.7.12, pytest-2.9.1, py-1.4.31, pluggy-0.3.1
rootdir: /home/changaco/projets/aspen/aspen.py, inifile: 
plugins: cov-2.2.1
collected 709 items 

tests/dispatch_table_test.py .................................................................................................................................................................................................................................................................................................................................................................................................................................................................................
tests/test_configuration.py ......................
tests/test_dispatcher.py ..........................................................................
tests/test_dynamic_resource.py ................................
tests/test_json_resource.py .....................
tests/test_mappings.py ...............
tests/test_renderers.py .
tests/test_request_processor.py ....
tests/test_resources.py ................
tests/test_resources_generics.py .......................
tests/test_simplates.py .........................
tests/test_unicode.py ...
tests/test_utils.py .......
tests/test_weird.py .

================================================================================== 709 passed in 4.11 seconds ===================================================================================
Executing: rm aspen/**/*.pyc tests/*.pyc; git describe --always; python2 build.py test
3cd0639
====================================================================================== test session starts ======================================================================================
platform linux2 -- Python 2.7.12, pytest-2.9.1, py-1.4.31, pluggy-0.3.1
rootdir: /home/changaco/projets/aspen/aspen.py, inifile: 
plugins: cov-2.2.1
collected 710 items 

tests/dispatch_table_test.py .................................................................................................................................................................................................................................................................................................................................................................................................................................................................................
tests/test_configuration.py ......................
tests/test_dispatcher.py ..........................................................................
tests/test_dynamic_resource.py .......................F.........
tests/test_json_resource.py .....................
tests/test_mappings.py ...............
tests/test_renderers.py .
tests/test_request_processor.py ....
tests/test_resources.py ................
tests/test_resources_generics.py .......................
tests/test_simplates.py .........................
tests/test_unicode.py ...
tests/test_utils.py .......
tests/test_weird.py .

=========================================================================================== FAILURES ============================================================================================
_________________________________________________________________________ test_treat_media_type_variants_as_equivalent __________________________________________________________________________

harness = <aspen.testing.Harness object at 0x7fce79bb8090>

    def test_treat_media_type_variants_as_equivalent(harness):
        _guess_type = mimetypes.guess_type
        mimetypes.guess_type = lambda url, **kw: ('application/x-javascript' if url.endswith('.js') else '', None)
        try:
            output = harness.simple(
                filepath='foobar.spt',
                contents="[---]\n[---] application/javascript\n[---] text/plain\n",
>               uripath='/foobar.js',
            )

tests/test_dynamic_resource.py:166: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
aspen/testing.py:85: in simple
    return self._hit('GET', uripath, querystring, **kw)
aspen/testing.py:94: in _hit
    , return_after=return_after
aspen/request_processor/__init__.py:62: in process
    , **kw
env/lib/python2.7/site-packages/algorithm.py:288: in run
    new_state = function(**deps.as_kwargs)
aspen/request_processor/algorithm.py:74: in render_resource
    return {'output': resource.render(state)}
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = <aspen.http.resource.Dynamic object at 0x7fce79bb8990>
state = {'accept_header': None, 'algorithm': <algorithm.Algorithm object at 0x7fce79bb8390>, 'dispatch_result': DispatchResult..., wild...u'Found.', extra={u'accept': u'application/x-javascript'}, constrain_path=True), 'exception': NotFound(), ...}

    def render(self, state):
        """Render the resource with the given state as context, return Output.

            Before rendering we need to determine what type of content we're going
            to send back, by trying to find a match between the media types the
            client wants and the ones provided by the resource.

            The two sources for what the client wants are the extension in the
            request URL, and the Accept header. If the former fails to match we
            raise NotFound (404), if the latter fails we raise NegotiationFailure
            (406).

            Note that we don't always respect the `Accept` header (the spec says
            we can ignore it: <https://tools.ietf.org/html/rfc7231#section-5.3.2>).
            """
        available = self.available_types
        # When there is an extension in the path, the dispatcher gives us the
        # corresponding media type (or an empty string if unknown)
        accept = dispatch_accept = state['dispatch_result'].extra.get('accept')
        if accept is not None:
            # If the extension is unknown, raise NotFound
            if accept == '':
                raise NotFound()
            # Accept custom JSON media type
            if accept == 'application/json':
                accept += ',' + self.request_processor.media_type_json
        elif len(available) == 1:
            # If there's only one available type and no extension in the path,
            # then we ignore the Accept header
            return super(Dynamic, self).render(available[0], state)
        else:
            accept = state.get('accept_header')
        if accept:
            try:
                best_match = mimeparse.best_match(available, accept)
            except ValueError:
                # Unparseable accept header
                best_match = None
            if best_match:
                return super(Dynamic, self).render(best_match, state)
            elif best_match == '':
                if dispatch_accept is not None:
                    # e.g. client requested `/foo.json` but `/foo.spt` has no JSON page
>                   raise NotFound()
E                   NotFound

aspen/http/resource.py:87: NotFound
============================================================================= 1 failed, 709 passed in 4.12 seconds ==============================================================================
Executing: rm aspen/**/*.pyc tests/*.pyc; git describe --always; python2 build.py test
4f03188
====================================================================================== test session starts ======================================================================================
platform linux2 -- Python 2.7.12, pytest-2.9.1, py-1.4.31, pluggy-0.3.1
rootdir: /home/changaco/projets/aspen/aspen.py, inifile: 
plugins: cov-2.2.1
collected 710 items 

tests/dispatch_table_test.py .................................................................................................................................................................................................................................................................................................................................................................................................................................................................................
tests/test_configuration.py ......................
tests/test_dispatcher.py ..........................................................................
tests/test_dynamic_resource.py .................................
tests/test_json_resource.py .....................
tests/test_mappings.py ...............
tests/test_renderers.py .
tests/test_request_processor.py ....
tests/test_resources.py ................
tests/test_resources_generics.py .......................
tests/test_simplates.py .........................
tests/test_unicode.py ...
tests/test_utils.py .......
tests/test_weird.py .

================================================================================== 710 passed in 3.99 seconds ===================================================================================
Executing: rm aspen/**/*.pyc tests/*.pyc; git describe --always; python2 build.py test
048fce1
====================================================================================== test session starts ======================================================================================
platform linux2 -- Python 2.7.12, pytest-2.9.1, py-1.4.31, pluggy-0.3.1
rootdir: /home/changaco/projets/aspen/aspen.py, inifile: 
plugins: cov-2.2.1
collected 712 items 

tests/dispatch_table_test.py .................................................................................................................................................................................................................................................................................................................................................................................................................................................................................
tests/test_configuration.py ......................
tests/test_dispatcher.py ...........................................................................F
tests/test_dynamic_resource.py .................................
tests/test_json_resource.py .....................
tests/test_mappings.py ...............
tests/test_renderers.py .
tests/test_request_processor.py ....
tests/test_resources.py ................
tests/test_resources_generics.py .......................
tests/test_simplates.py .........................
tests/test_unicode.py ...
tests/test_utils.py .......
tests/test_weird.py .

=========================================================================================== FAILURES ============================================================================================
_____________________________________________________________________ test_extra_accept_header_is_empty_string_when_unknown _____________________________________________________________________

harness = <aspen.testing.Harness object at 0x7f086c558990>

    def test_extra_accept_header_is_empty_string_when_unknown(harness):
        dispatch_result = harness.simple(
            filepath='foo.spt',
            uripath='/foo.unknown-extension',
            return_after='dispatch_path_to_filesystem',
            want='dispatch_result',
        )
>       assert dispatch_result.extra['accept'] == ''
E       assert 'text/plain' == ''
E         - text/plain

tests/test_dispatcher.py:495: AssertionError
============================================================================= 1 failed, 711 passed in 4.11 seconds ==============================================================================
Executing: rm aspen/**/*.pyc tests/*.pyc; git describe --always; python2 build.py test
ea491e6
====================================================================================== test session starts ======================================================================================
platform linux2 -- Python 2.7.12, pytest-2.9.1, py-1.4.31, pluggy-0.3.1
rootdir: /home/changaco/projets/aspen/aspen.py, inifile: 
plugins: cov-2.2.1
collected 712 items 

tests/dispatch_table_test.py .................................................................................................................................................................................................................................................................................................................................................................................................................................................................................
tests/test_configuration.py ......................
tests/test_dispatcher.py ............................................................................
tests/test_dynamic_resource.py .................................
tests/test_json_resource.py .....................
tests/test_mappings.py ...............
tests/test_renderers.py .
tests/test_request_processor.py ....
tests/test_resources.py ................
tests/test_resources_generics.py .......................
tests/test_simplates.py .........................
tests/test_unicode.py ...
tests/test_utils.py .......
tests/test_weird.py .

================================================================================== 712 passed in 4.25 seconds ===================================================================================
Successfully rebased and updated refs/heads/negotiation.

# e.g. client requested `/foo.json` but `/foo.spt` has no JSON page

if dispatch_accept is not None:
# If the extension is unknown, raise NotFound
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was this our previous behavior? I might think that /foo.random-blah should get served by the default type rather than 404ing.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I think the previous behavior was inconsistent, as dispatch_accept would be None, so accept would be set to accept_header, and thus it could: return a 406, or choose a type from the Accept header which that doesn't match the extension in the URL path, or fall back to the first available type.

I think we do want to return a 404. If the developer wants to use a custom extension they can register it in the mimetypes module.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking of the case where there's a dot in the final path part that's not intended to indicate a file extension. @kaguillera is questioning whether "you'd really have that situation." :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess it's possible to hit this corner case, but only if you're parsing request.path.raw yourself instead of using the features that are meant to allow capturing variable data from the path: wildcards and "rfc2396 params".

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm thinking of law browsers such as http://www.ecode360.com/AM2259 or http://www.ecfr.gov/cgi-bin/text-idx?SID=a457db49113d5f8ca45f5252dd7dced0&mc=true&node=se31.3.1010_1100&rgn=div8. You might have on the filesystem:

www/%section.spt

Maybe you are parsing request.path['section'] further, or maybe you're passing it through to a database (SELECT * FROM sections WHERE section=%s).

I agree it's an edge case, and I'm okay with merging this PR as-is and revisiting if/when we hit this edge case in the future.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A wildcard like %section.spt shouldn't be affected, the dispatcher isn't supposed to set extra['accept'] in that case (maybe we need a test for that?).

@Changaco
Copy link
Member Author

I'm not convinced that a single return statement is cleaner/clearer.

@chadwhitacre
Copy link
Contributor

I'm not convinced that a single return statement is cleaner/clearer.

Are you convinced that it's at least as clear? :-)

@chadwhitacre
Copy link
Contributor

I'm ready to merge this if you can live with the single-return version of render.

@Changaco
Copy link
Member Author

Are you convinced that it's at least as clear? :-)

It seems to me that not returning immediately makes it less clear that the negotiation is finished. You have to read the rest of the code to figure out that it's not doing anything else. This is probably more evident for the first return that was removed than for the second.

I can live with the single return, I just don't see how it's better.

@chadwhitacre
Copy link
Contributor

Here's a statement of the conventional wisdom that minimizing returns makes code easier to understand, because it reduces a source of variability (I think I used to own Code Complete, but I can't find it now to see their full argument). I sometimes use short-cut returns at the beginning of a long function, but I tend to think that if a function is large enough to suggest returning in the middle, then it's large enough to be refactored into multiple functions.

How about 5ab64d7?

@chadwhitacre
Copy link
Contributor

(... and d374ce9? :)

@Changaco
Copy link
Member Author

That actually looks worse to me. /o\ I found bugs by putting all the negotiation code together, I'm wary of splitting it back into multiple functions. Also, the negotiate_content_type you created doesn't actually use self, that's a sign that it should be either inlined or decorated with staticmethod or moved somewhere else (again, I'm wary of that).

The SO answer you linked to says that "In certain routines, once you know the answer, you want to return it to the calling routine immediately". I think that applies here, but I'd rather have the single return than the multiple functions.

@chadwhitacre
Copy link
Contributor

Okay, then! :-)

I've pushed the last four commits off the branch. Ready to go?

@Changaco
Copy link
Member Author

Oh, okay.

@Changaco Changaco merged commit b77037f into master Jul 20, 2016
@Changaco Changaco deleted the negotiation branch July 20, 2016 20:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants