Skip to content

Commit

Permalink
feat(context): allow direct injection from [in/out]put
Browse files Browse the repository at this point in the history
  • Loading branch information
lgatellier committed May 8, 2024
1 parent 20e289d commit cb7eff1
Show file tree
Hide file tree
Showing 13 changed files with 462 additions and 248 deletions.
6 changes: 5 additions & 1 deletion hookbridge/api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from datetime import datetime
from dependency_injector.wiring import inject, Provide
from fastapi import Depends, FastAPI, Request
import logging

from hookbridge.configuration import HookBridgeConfig
from hookbridge.request import WebhookRequest
Expand All @@ -9,6 +10,7 @@

api = FastAPI()
start_time = datetime.now()
logger = logging.getLogger(__name__)


@api.post("/route/{route_name}")
Expand All @@ -19,8 +21,10 @@ async def dispatch(
routes: RouteService = Depends(Provide[HookBridgeConfig.routes_service]),
):
wrapper_req = WebhookRequest(req)
await wrapper_req.await_body() # Awaits request body
# Awaits request body and initializes request route context
await wrapper_req.init_context()
call_results = routes.dispatch(route_name, wrapper_req)
logger.debug("Final request route execution context : %s", wrapper_req.context)
return {"route": route_name, "called_rules": [r.__dict__ for r in call_results]}


Expand Down
50 changes: 43 additions & 7 deletions hookbridge/context.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
from jsonpath_ng import parse
import logging
import re
from typing import Any, Optional, Union
from os import environ as env


from hookbridge.exceptions import UnResolvableInjectionException

logger = logging.getLogger(__name__)

INJECTION_PATTERN = re.compile(r"#([a-z]+)\[([a-zA-Z][a-zA-Z0-9_]*)\]")
INJECTION_PATTERN = re.compile(r"#([a-z]+)(\[([a-zA-Z][a-zA-Z0-9_]*)\])?(\..+)?")


class ExecutionContext:
Expand All @@ -14,8 +18,10 @@ class ExecutionContext:
Resolves the #context[var_name] and #env[var_name] tokens.
"""

def __init__(self) -> None:
def __init__(self, input: object = {}) -> None:
self.__variables = {}
self.__input = input
self.__output = {}

def has(self, variable_name) -> bool:
return variable_name in self.__variables
Expand Down Expand Up @@ -43,7 +49,7 @@ def set(self, variable_name, variable_value) -> None:

def apply(self, obj: Union[str, dict]):
"""
Replaces all `#context` and `#env` tokens in given obj.
Replaces all `#context`, #input` and `#env` tokens in given obj.
If obj is a `str`, `apply()` replaces all found tokens with matching value.
If obj is a `dict`, `apply()` replaces all its values with `apply(value)` result.
Expand All @@ -55,19 +61,30 @@ def apply(self, obj: Union[str, dict]):
elif isinstance(obj, dict):
return {k: self.apply(v) for k, v in obj.items()}

def resolve_match(self, match: re.Match) -> str:
def resolve_match(self, match: re.Match) -> str | int | bool:
"""
Resolves the value corresponding to given `Pattern` `Match`.
Input `Match` must look like `#env[var_name]` or `#context[var_name]`.
"""
source, var_name = match.groups()
source, unused_brackets, var_name, jsonpath = match.groups()
logger.debug(f"Resolving variable {var_name} from {source}")

var_value = self.resolve_variable(source, var_name)
if jsonpath:
jsonpath_expr = parse(f"${jsonpath}")

var_value = None
if source in ["context", "env"]:
var_value = self.resolve_variable(source, var_name)
elif source == "input":
var_value = self.resolve_input(jsonpath_expr)
elif source == "output":
var_value = self.resolve_output(
rule_name=var_name, jsonpath_expr=jsonpath_expr
)
if var_value:
logger.debug(f"Resolved to {var_value}")
return var_value
return str(var_value)
else:
logger.warning(
f"Unable to resolve {var_name} from {source}. Possible misconfiguration"
Expand All @@ -80,3 +97,22 @@ def resolve_variable(self, source: str, var_name: str) -> Optional[str]:
elif source == "env" and var_name in env:
return env[var_name]
return None

def add_output(self, rule_name: str, response_body: object = {}):
logger.debug("Adding rule %s output to context : %s", rule_name, response_body)
self.__output[rule_name] = response_body

def __str__(self) -> str:
return f"ExecutionContext[input: {self.__input}, output: {self.__output}]"

def resolve_input(self, json_path_expr: any) -> any:
return self.__resolve_jsonpath(json_path_expr, self.__input)

def resolve_output(self, rule_name: str, jsonpath_expr: any) -> any:
return self.__resolve_jsonpath(jsonpath_expr, self.__output[rule_name])

def __resolve_jsonpath(self, jsonpath_expr, source) -> any:
results = jsonpath_expr.find(source)
if len(results) == 0:
raise UnResolvableInjectionException(jsonpath_expr)
return results[0].value
7 changes: 7 additions & 0 deletions hookbridge/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,10 @@ def __init__(self, message, *parameters) -> None:

class ConfigurationException(Exception):
pass


class UnResolvableInjectionException(HTTPExceptionWithParameters):
def __init__(self, injectpath, *parameters) -> None:
super().__init__(
f"Could not resolve injection {injectpath}", *parameters, http_status=500
)
11 changes: 3 additions & 8 deletions hookbridge/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@
class WebhookRequest(Request):
def __init__(self, req: Request) -> None:
self.__req = req
self.__body = None
self.__context = ExecutionContext()
self.__context = None

@property
def headers(self) -> Headers:
Expand All @@ -23,13 +22,9 @@ def headers(self) -> Headers:
def cookies(self) -> Dict[str, str]:
return self.__req.cookies

@property
def body(self) -> bytes:
return self.__body

@property
def context(self) -> ExecutionContext:
return self.__context

async def await_body(self):
self.__body = json.loads(await self.__req.body())
async def init_context(self):
self.__context = ExecutionContext(input=json.loads(await self.__req.body()))
39 changes: 20 additions & 19 deletions hookbridge/routes/rules/input.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@
from jsonpath_ng import parse
from typing import Optional, final

from jsonpath_ng.jsonpath import DatumInContext

from ...request import WebhookRequest
from ..exceptions import RequestDoNotMatchRouteException
from hookbridge.request import WebhookRequest
from hookbridge.routes.exceptions import RequestDoNotMatchRouteException
from hookbridge.exceptions import UnResolvableInjectionException

logger = logging.getLogger(__name__)

Expand All @@ -24,6 +23,10 @@ def __init__(self, target: str, config: dict) -> None:
self.__variable_name = None
logger.debug(f"Loading rule {self.__name} with target {target}")

@property
def name(self):
return self.__name

@property
def target(self) -> Optional[str]:
return self.__target
Expand All @@ -39,7 +42,7 @@ def variable_name(self) -> Optional[str]:
@final
def apply(self, req: WebhookRequest) -> None:
if not self._do_apply(req):
raise RequestDoNotMatchRouteException(self.__name, self.__target)
raise RequestDoNotMatchRouteException(self.name, self.target)

@abc.abstractmethod
def _do_apply(self, req) -> bool:
Expand All @@ -55,34 +58,32 @@ def __init__(self, property_json_path: str, config: dict) -> None:

@final
def _do_apply(self, req: WebhookRequest) -> bool:
matches: list[DatumInContext] = self.__json_path_expr.find(req.body)
try:
value = req.context.resolve_input(self.__json_path_expr)
except UnResolvableInjectionException:
raise RequestDoNotMatchRouteException(self.name, self.target)

if self.variable_name:
var_value = matches[0].value if len(matches) > 0 else None
req.context.set(self.variable_name, var_value)
req.context.set(self.variable_name, value)

return self._matches_property(matches)
return self._matches_property(value)

@abc.abstractmethod
def _matches_property(self, matches: list) -> bool:
def _matches_property(self, value: any) -> bool:
return False


class BodyPropertyPresentInputRule(RouteBodyInputRule):
def _matches_property(self, matches: list) -> bool:
return len(matches) > 0
def _matches_property(self, value: any) -> bool:
return bool(value)


class BodyPropertyEqualsToInputRule(RouteBodyInputRule):
def _matches_property(self, matches: list) -> bool:
if len(matches) == 0:
def _matches_property(self, value: any) -> bool:
if not value:
return False

for m in matches:
if m.value != self.config["equalsTo"]:
return False

return True
return value == self.config["equalsTo"]


input_rules = {
Expand Down
1 change: 1 addition & 0 deletions hookbridge/routes/rules/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def apply(self, req: WebhookRequest):
logger.debug(f"apply: {self.name} got HTTP status {response.status_code}")
try:
json_object = json.loads(response.content)
req.context.add_output(self.name, json_object)
for var_name, json_path_expr in self.__variables.items():
matches = json_path_expr.find(json_object)
var_value = matches[0].value if len(matches) > 0 else None
Expand Down
6 changes: 4 additions & 2 deletions hookbridge/routes/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ def validate_config(
elif not isinstance(config, str):
raise Exception(f"Invalid type {type(config)} for config object")

for source, var_name in INJECTION_PATTERN.findall(config):
for source, unused_brackets, var_name, jsonpath in INJECTION_PATTERN.findall(
config
):
if source == "context" and var_name not in var_names:
raise ConfigurationException(
f"Route {route_name} : variable {var_name} is used but never declared"
Expand All @@ -51,7 +53,7 @@ def validate_config(
raise ConfigurationException(
f"Route {route_name} : environment variable {var_name} does not exist"
)
elif source not in ["context", "env"]:
elif source not in ["context", "env", "input", "output"]:
raise ConfigurationException(
f"Route {route_name} : variable source {source} does not exist"
)
Loading

0 comments on commit cb7eff1

Please sign in to comment.