Skip to content

Commit

Permalink
Adding function mappings (#7)
Browse files Browse the repository at this point in the history
* Adding function mappings

- as it says on the tin, we can now add functions as route 'condition -> response' shortcuts
- I learned that "routing function" are actually called views, so I did some renaming
- fixed some documentation examples that were malformed
- nailing sphinx to 3.0.1 because of sphinx-doc/sphinx#7516
  • Loading branch information
a-recknagel committed Apr 22, 2020
1 parent 9bab010 commit 7a8a2a1
Show file tree
Hide file tree
Showing 5 changed files with 147 additions and 57 deletions.
90 changes: 66 additions & 24 deletions README.rst
Expand Up @@ -5,6 +5,7 @@

.. header-end
Project Description
-------------------

Expand All @@ -20,11 +21,38 @@ microservice is implemented, which bugs and minor changes it experiences over
time, testing basic API compatibility gets a lot more manageable.


What is a Shortcut?
-------------------

In the context of this package, a shortcut is a pair of condition -> response.
The response is `anything that an view function can return`_, and the
condition depends on one of the three possible mapping contexts.

In the first context, only the response is passed as the shortcut, and the
condition is assumed to always be true, effectively replacing the route to
always just return the given response. Showcased with the view ``foo``
in the usage section.

In the second context, a dictionary that maps strings to responses is passed
as the shortcut. The strings need to be deserializeable as json, and the
first one that can be fully matched as a substructure into the request body
will see its condition as fulfilled, returning its associated response.
If none of them are valid sub-matches, the original view function will run.
Showcased with the view ``bar`` in the usage section.

In the third context, either a single function or a list of functions is
passed as the shortcut. The functions can run any code whatsoever and will
be executed one after the other as long as they return ``None``, which means
that their condition is not fulfilled. As soon as one of them returns
something different, it is passed on as the response. If all of them return
``None``, the original view function is executed. Showcased with the view
``baz`` in the usage section.


Usage
-----

You can add shortcuts to your route functions either individually with
You can add shortcuts to your view functions either individually with
decorators, or in a single swoop once all routes have been defined. Both ways
are functionally equivalent.

Expand All @@ -41,19 +69,25 @@ Applying Shortcuts
app = Flask(__name__)
short = Shortcut(app)
app.route('/foo', methods=['GET'])
short.cut(('short_foo', 200))
@app.route('/foo', methods=['GET'])
@short.cut(('short_foo', 200))
def foo():
return 'foo'
app.route('/bar', methods=['POST'])
short.cut({
'{"name": "TestUser"}': ('short_bar', 200)},
@app.route('/bar', methods=['POST'])
@short.cut({
'{"name": "TestUser"}': ('short_bar', 200),
'{"name": "UserTest"}': ('longer_bar', 200),
)
})
def bar():
return 'bar'
@app.route('/baz', methods=['POST'])
@short.cut(lambda: ("json_baz", 200) if "json" in request.mimetype else None)
def baz():
return 'baz'
**With a wire call**

.. code-block:: python
Expand All @@ -63,21 +97,26 @@ Applying Shortcuts
app = Flask(__name__)
app.route('/foo', methods=['GET'])
@app.route('/foo', methods=['GET'])
def foo():
return 'foo'
app.route('/bar', methods=['POST'])
@app.route('/bar', methods=['POST'])
def bar():
return 'bar'
@app.route('/baz', methods=['POST'])
def baz():
return 'baz'
Shortcut(app).wire(
{
'/foo': ('short_foo', 200),
'/bar': {
'{"name": "TestUser"}': ('short_bar', 200),
'{"name": "UserTest"}': ('longer_bar', 200),
}
'/baz': lambda: ("json_baz", 200) if "json" in request.mimetype else None
}
)
Expand All @@ -100,28 +139,31 @@ if it were run with ``FLASK_ENV=test flask run``:
'short_bar' # shortcut match
>>> post('http://127.0.0.1:5000/bar', json={"name": "UserTest", "job": None}).text
'longer_bar' # shortcut only needs to be contained for a match
>>> post('http://127.0.0.1:5000/baz').text
'baz' # no shortcut match -> the function returned None
>>> post('http://127.0.0.1:5000/baz', json={"name": "me"}).text
'json_baz' # shortcut matched -> the function returned a valid response
One focus of this package was, that a production deployment would remain
One focus of this package is that a production deployment would remain
as ignorant as possible about the existence of shortcuts. While the
shortcut object is still created, it only delegates the route functions
and no shortcut code has any chance of being run.
shortcut object is still created, it only delegates the view functions
and no shortcut code has any chance of being run or showing up in .


Configuration
-------------

By default, shortcuts will only be applied when ``FLASK_ENV`` is set to
something different than the default setting ``production``. You can
extend that list through the ``SHORTCUT_EXCLUSIONS`` config setting,
either by adding it to your app's config before creating any Shortcut
objects, or preferably by setting up the whole config `through a file`_.

Possible values for it are all environments other than ``production`` that
you want to block separated by commas, for example ``staging,master``.
Shortcuts will only be applied when ``FLASK_ENV`` is set to something
different from its default setting, ``production``. You can extend that list
through the ``SHORTCUT_EXCLUSIONS`` config setting, either by adding it to
your app's config before creating any Shortcut objects, or preferably by
setting up the whole config `with a file`_.

----
Possible values for it are all environments that you want to block other
than ``production`` separated by commas. For example ``staging,master`` will
block the envs ``production``, ``staging``, and ``master`` from receiving
shortcuts.

Project home is `on github`_.

.. |Logo| image:: https://user-images.githubusercontent.com/2063412/79631833-c1b39400-815b-11ea-90da-d9264420ef68.png
:alt: Logo
Expand Down Expand Up @@ -152,6 +194,6 @@ Project home is `on github`_.
:alt: Any color you want
:target: https://black.readthedocs.io/en/stable/

.. _on github: https://github.com/a-recknagel/Flask-Shortcut
.. _with a file: https://flask.palletsprojects.com/en/1.1.x/config/#configuring-from-files

.. _through a file: https://flask.palletsprojects.com/en/1.1.x/config/#configuring-from-files
.. _anything that an view function can return: https://flask.palletsprojects.com/en/1.1.x/quickstart/#about-responses
8 changes: 8 additions & 0 deletions docs/index.rst
Expand Up @@ -11,3 +11,11 @@ Flask-Shortcut documentation

.. include:: ../README.rst
:start-after: header-end


----

Project home is `on github`_.


.. _on github: https://github.com/a-recknagel/Flask-Shortcut
85 changes: 64 additions & 21 deletions flask_shortcut/shortcut.py
@@ -1,11 +1,12 @@
from logging import getLogger
from typing import Union, Tuple, Dict, Any
from types import MethodType
from typing import Union, Tuple, Dict, Any, Callable, Optional
from types import MethodType, FunctionType
from functools import wraps
import inspect
import json

from click import secho
from flask import request, Flask
from flask import Flask

from flask_shortcut.util import diff, get_request_data

Expand All @@ -18,22 +19,30 @@
class Shortcut:
"""Object that handles the shortcut rerouting.
Calling an instance of this class on a function gives that function the
Calling an instance of this class on a view function gives the view an
option to behave differently in non-production environments. The way in
which the behavior may differ is constrained in two possible ways.
which the behavior may differ is constrained in three possible ways.
In the first one, only the arguments for a response are passed to the
shortcut definition, meaning that the original function is effectively
shortcut definition, meaning that the original view is effectively
disabled and the route will instead just return the shortcut's arguments
as its only response.
In the second one, any number of shortcut response arguments are mapped
to condition-keys. The condition is a string that is used to assert a
substructure in requests that reach that route, and will only apply its
respective shortcut-response iff that substructure can be matched.
to condition-keys. The condition is a json-like string that is used to
assert a substructure in request bodies that reach that route, and will
only apply its respective shortcut-response iff that substructure can
be matched.
In the third one, a function represents the shortcut and can run
arbitrary code to ensure whatever the user deems necessary on the
request body, header, etc. Such a shortcut function may not accept
arguments and needs to either return None, to signal that the shortcut
condition failed and the original logic should be run, or valid
response arguments in the form of a tuple.
If none of the condition can be satisfied, the route will run its
original logic.
original view.
There are two different ways to register shortcuts, one using decorators
on the target functions before the are decorated as routes, and the
Expand Down Expand Up @@ -74,8 +83,8 @@ def __init__(self, app: Flask):
# make .cut(...) return a wrapper that does nothing
self.cut = MethodType(lambda _self, mapping: lambda f: f, self) # type: ignore

def cut(self, mapping: Union[RESPONSE_ARGS, Dict[str, RESPONSE_ARGS]]):
"""Returns route wrappers.
def cut(self, mapping: Union[RESPONSE_ARGS, Dict[str, RESPONSE_ARGS], Callable[[], Optional[RESPONSE_ARGS]]]):
"""Returns view function wrappers.
Depending on the input argument, a different wrapper will be returned.
This function can only run in applications that are not listed in the
Expand All @@ -89,10 +98,10 @@ def cut(self, mapping: Union[RESPONSE_ARGS, Dict[str, RESPONSE_ARGS]]):
def simple_map(f):
f_name = f"{f.__module__}.{f.__name__}"
logger.info(f"Adding simple_map shortcut for routing function '{f_name}'.")
assert isinstance(mapping, tuple), "Messed up shortcut wiring, abort." # nosec

@wraps(f)
def decorated(*_, **__):
assert isinstance(mapping, tuple), "Messed up shortcut wiring, abort." # nosec
logger.debug(f"Running shortcut for '{f_name}'.")

response, status = mapping
Expand All @@ -104,19 +113,24 @@ def decorated(*_, **__):
def dict_map(f):
f_name = f"{f.__module__}.{f.__name__}"
logger.info(f"Adding dict_map shortcut for routing function '{f_name}'.")
assert isinstance(mapping, dict), "Messed up shortcut wiring, abort." # nosec
for s in mapping:
try:
json.loads(s)
except Exception as e:
raise TypeError(f"'{s}' can't be deserialized into valid json, raises '{str(e)}'.")

@wraps(f)
def decorated(*args, **kwargs):
assert isinstance(mapping, dict), "Messed up shortcut wiring, abort." # nosec
logger.debug(f"Running shortcut for '{f_name}'.")

for condition, (response, status) in mapping.items():
request_data = get_request_data(request)
request_data = get_request_data()
try:
sub_resolves = diff(request_data, json.loads(condition))
except TypeError as e:
except TypeError as e_:
logger.debug(
f"Couldn't walk '{condition}' in the target request, got error message '{str(e)}'. "
f"Couldn't walk '{condition}' in the target request, got error message '{str(e_)}'. "
f"This could mean that the shortcut for this function is not well-defined."
)
continue
Expand All @@ -125,7 +139,31 @@ def decorated(*args, **kwargs):
return self.app.make_response(response), status
else:
logger.debug(f"Shortcut conditions couldn't be satisfied, defaulting to actual implementation.")
return f(*args, **kwargs)
return f(*args, **kwargs)

return decorated

# wrapper for function mappings
def func_map(f):
f_name = f"{f.__module__}.{f.__name__}"
logger.info(f"Adding func_map shortcut for routing function '{f_name}'.")
assert isinstance(mapping, (FunctionType, list)), "Messed up shortcut wiring, abort." # nosec
func_list = mapping if isinstance(mapping, list) else [mapping]
for func in func_list:
assert isinstance(func, FunctionType), "All mappings in a shortcut lists need to be functions." # nosec
assert not inspect.signature(func).parameters, "Mapping functions can't take arguments." # nosec

@wraps(f)
def decorated(*args, **kwargs):
logger.debug(f"Running shortcut for '{f_name}'.")

for function in func_list:
response = function()
if response is not None:
return response
else:
logger.debug(f"Shortcut conditions couldn't be satisfied, defaulting to actual implementation.")
return f(*args, **kwargs)

return decorated

Expand All @@ -134,17 +172,22 @@ def decorated(*args, **kwargs):
return simple_map
elif isinstance(mapping, dict):
return dict_map
elif isinstance(mapping, (FunctionType, list)):
return func_map
else:
raise TypeError(f"'{type(mapping)}' is not a supported mapping type for shortcuts yet.")

def wire(self, shortcuts=Dict[str, Union[RESPONSE_ARGS, Dict[str, RESPONSE_ARGS]]]):
def wire(
self,
shortcuts: Dict[str, Union[RESPONSE_ARGS, Dict[str, RESPONSE_ARGS], Callable[[], Optional[RESPONSE_ARGS]]]],
):
"""Manual wiring function.
If you don't want to have the shortcut definitions in your routing
file for some reason (e.g. there are lots of shortcuts and it would
make the whole thing hard to read), you can use this function at
some point after all routes were registered, and before the server
is started.
some point after all view functions were registered and before the
server is started.
Args:
shortcuts: A dictionary that maps routes to the mappings that
Expand Down
17 changes: 7 additions & 10 deletions flask_shortcut/util.py
@@ -1,18 +1,15 @@
from typing import Any

from flask import Request
from flask import request
import xmltodict


def get_request_data(r: Request) -> Any:
def get_request_data() -> Any:
"""Request data fetcher.
This function inspects the mimetype in order to figure out how the data
can be represented in a standard python-dictionary form.
Args:
r: The request whose body is going to be parsed.
Returns:
The data in a form that sticks as close a parsed json object as possible, since
that is the format in which the mappings expect to be passed by.
Expand All @@ -22,11 +19,11 @@ def get_request_data(r: Request) -> Any:
Exception: In case of junk data, hard to list what each parser
decides is best to raise.
"""
if "json" in r.mimetype:
return r.json
if "xml" in r.mimetype:
return xmltodict.parse(r.data, dict_constructor=dict)
raise ValueError(f"Mimetype '{r.mimetype}' not parsable.")
if "json" in request.mimetype:
return request.json
if "xml" in request.mimetype:
return xmltodict.parse(request.data, dict_constructor=dict)
raise ValueError(f"Mimetype '{request.mimetype}' not parsable.")


def diff(target, sub, path_=None) -> bool:
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "flask_shortcut"
version = "0.3.2"
version = "0.4.0"
description = "Extension that provides an easy way to add dev-only shortcuts to your routes."
license = "MIT"
authors = ["Arne <arecknag@gmail.com>"]
Expand Down Expand Up @@ -28,7 +28,7 @@ xmltodict = "^0.12.0"
pytest = "^5.2"
black = "^19.10b0"
requests = "^2.23.0"
sphinx = "^3.0.1"
sphinx = "<3.0.2"
Pallets-Sphinx-Themes = "^1.2.3"
mypy = "^0.770"
pytest-coverage = "^0.0"
Expand Down

0 comments on commit 7a8a2a1

Please sign in to comment.