Repository: dreynolds/local-lambda

Issue:
Run each request in a process so we can inject in environment variables
I'm open to other ways of doing this too.

Discussion:

Patch:
diff --git a/setup.py b/setup.py
index af9a656..9595884 100644
--- a/setup.py
+++ b/setup.py
@@ -3,23 +3,26 @@ import pathlib
 from setuptools import find_packages, setup
 
 here = pathlib.Path(__file__).parent.resolve()
-long_description = (here / 'README.md').read_text(encoding='utf-8')
+long_description = (here / "README.md").read_text(encoding="utf-8")
 
 
 setup(
-    name='local_lambda',
-    description='Simple mechanism for running lambdas locally for testing',
+    name="local_lambda",
+    description="Simple mechanism for running lambdas locally for testing",
     long_description=long_description,
-    long_description_content_type='text/markdown',
-    package_dir={'': 'src'},  # Optional
-    packages=find_packages(where='src'),
-    python_requires='>=3.6, <4',
+    long_description_content_type="text/markdown",
+    package_dir={"": "src"},  # Optional
+    packages=find_packages(where="src"),
+    python_requires=">=3.6, <4",
     install_requires=[
-        'jsonschema==3.2.0',
+        "jsonschema==3.2.0",
     ],
     use_scm_version=True,
     setup_requires=['setuptools_scm'],
-    scripts=['src/scripts/local_lambda'],
+    scripts=[
+        "src/scripts/local_lambda",
+        "src/scripts/call_command.py",
+    ],
     url="https://github.com/dreynolds/local-lambda",
     author="David Reynolds",
     author_email="david@reynoldsfamily.org.uk",
diff --git a/src/config.py b/src/config.py
index 2fcfe2b..b0944f9 100644
--- a/src/config.py
+++ b/src/config.py
@@ -36,6 +36,7 @@ class UrlConfigFile:
         config_file = self.load_file(self.file_name)
         try:
             config_data = json.load(config_file.open())
+            LOG.debug(config_data)
         except json.decoder.JSONDecodeError:
             LOG.debug(f'"{self.file_name}" is not readable JSON')
             return None
diff --git a/src/config_schema.py b/src/config_schema.py
index bab5cef..eebbdb9 100644
--- a/src/config_schema.py
+++ b/src/config_schema.py
@@ -11,7 +11,10 @@ CONFIG_SCHEMA = {
                     "patternProperties": {
                         ".*": {
                             "type": "object",
-                            "properties": {"function": {"type": "string"}},
+                            "properties": {
+                                "function": {"type": "string"},
+                                "env": {"type": "object"},
+                            },
                             "required": ["function"],
                         }
                     },
diff --git a/src/lambda_server.py b/src/lambda_server.py
index ac3414e..5f2cd91 100644
--- a/src/lambda_server.py
+++ b/src/lambda_server.py
@@ -4,7 +4,7 @@ import os
 import sys
 
 from config import UrlConfigFile
-from server import run, server_methods, AlreadyRegistered
+from server import AlreadyRegistered, run, server_methods
 
 LOG = logging.getLogger(__name__)
 DEFAULT_PORT = 5000
@@ -40,6 +40,7 @@ def main() -> None:
         action="store",
         help="The port to run the API on",
         default=os.environ.get("PORT", DEFAULT_PORT),
+        type=int,
     )
     parser.add_argument(
         "-c",
diff --git a/src/scripts/call_command.py b/src/scripts/call_command.py
new file mode 100644
index 0000000..1bbfc39
--- /dev/null
+++ b/src/scripts/call_command.py
@@ -0,0 +1,40 @@
+import argparse
+import sys
+import json
+from pathlib import Path
+
+from utils import get_function_from_string
+
+sys.path.insert(0, Path.cwd().as_posix())
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser()
+    parser.add_argument("command", type=str, nargs=1, help="The command to call")
+    parser.add_argument(
+        "--event",
+        type=str,
+        action="store",
+        help="The event to pass to the command",
+        default="{}",
+    )
+    parser.add_argument(
+        "--context",
+        type=str,
+        action="store",
+        help="The event to pass to the command",
+        default="{}",
+    )
+    args = parser.parse_args()
+
+    func = get_function_from_string(args.command[0])
+    if func:
+        try:
+            event = json.loads(args.event)
+        except json.JSONDecodeError:
+            event = {}
+        try:
+            context = json.loads(args.context)
+        except json.JSONDecodeError:
+            context = {}
+        output = func(event, context)
+        print(json.dumps(output))
diff --git a/src/server.py b/src/server.py
index 41ba4e3..f19c8a4 100644
--- a/src/server.py
+++ b/src/server.py
@@ -1,36 +1,61 @@
-from collections import OrderedDict
-from http.server import HTTPServer, BaseHTTPRequestHandler
+import json
 import logging
-from urllib.parse import urlparse, parse_qs
+import os
+import subprocess
+from collections import OrderedDict
+from http import HTTPStatus
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from urllib.parse import parse_qs, urlparse
 
-from utils import get_function_from_string, request_to_event
+from utils import request_to_event
 
 LOG = logging.getLogger(__name__)
 
 
 class LambdaHandler(BaseHTTPRequestHandler):
-    def _call_method(self, path, method, qs, body, headers):
-        function_path = (
-            server_methods.get(path, {}).get(method, {}).get("function", None)
-        )
-        if function_path is not None:
-            func = get_function_from_string(function_path)
-            if func is not None:
-                event = request_to_event(path, method, qs, body, headers)
-                return func(event, {})
+
+    def _bad_method_response(self):
         return {
             "body": "Bad method",
-            "statusCode": 405,
+            "statusCode": HTTPStatus.METHOD_NOT_ALLOWED.value,
         }
 
-    def _process(self, method):
+    def _call_method(self, path, method, qs, body, headers):
+        function_details = server_methods.get(path, {}).get(method, {})
+        function_path = function_details.get("function", None)
+        function_env = function_details.get("env", {})
+        current_env = os.environ.copy()
+        current_env.update(function_env)
+        LOG.debug("Generated ENV: %s", current_env)
+
+        if function_path is not None:
+            event = request_to_event(path, method, qs, body, headers)
+            command = ["call_command.py", function_path, "--event", json.dumps(event)]
+            LOG.debug("Command %s", ' '.join(command))
+            output = subprocess.check_output(
+                command,
+                env=current_env,
+            )
+            try:
+                output = json.loads(output)
+            except json.JSONDecodeError:
+                LOG.exception("Error decoding method output: %s", output)
+                output = self._bad_method_response()
+            else:
+                LOG.debug("Command output: %s", output)
+            return output
+        return self._bad_method_response()
+
+    def _process(self, method, body=None):
+        if body is None:
+            body = ""
         url = urlparse(self.path)
         qs = parse_qs(url.query)
         response = self._call_method(
             url.path,
             method,
             qs,
-            "",
+            body,
             self.headers.__dict__,
         )
         self.send_response(response["statusCode"])
@@ -43,7 +68,9 @@ class LambdaHandler(BaseHTTPRequestHandler):
         self._process("GET")
 
     def do_POST(self):
-        self._process("POST")
+        content_length = int(self.headers.get('Content-Length', 0))
+        post_data = self.rfile.read(content_length)
+        self._process("POST", post_data.decode('utf8'))
 
     def do_HEAD(self):
         self._process("HEAD")
diff --git a/src/utils.py b/src/utils.py
index a118004..be9e18b 100644
--- a/src/utils.py
+++ b/src/utils.py
@@ -31,8 +31,10 @@ def request_to_event(
         "path": path,
         "httpMethod": method,
         "isBase64Encoded": False,
-        "headers": headers,
+        "headers": {},
         "queryStringParameters": qs,
     }
+    for k, v in headers.get("_headers", []):
+        event["headers"][k] = v
     LOG.debug(event)
     return event


Test patch:
diff --git a/tests/functions/main.py b/tests/functions/main.py
index eec8935..7cb2f7a 100644
--- a/tests/functions/main.py
+++ b/tests/functions/main.py
@@ -1,6 +1,14 @@
+import os
+import json
+
+
 def debug_me(event, context):
+    body = {
+        "AWS_DEFAULT_REGION": os.environ.get("AWS_DEFAULT_REGION", ""),
+        "AWS_ACCOUNT": os.environ.get("AWS_ACCOUNT", ""),
+    }
     return {
         "headers": {},
         "statusCode": 200,
-        "body": "TEST_CODE",
+        "body": json.dumps(body),
     }
diff --git a/tests/test_utils.py b/tests/test_utils.py
index c0ea535..768997f 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -9,7 +9,7 @@ def test_request_to_event():
     method = "POST"
     qs = {}
     body = "BODY"
-    headers = {"Host": "example.org", "Connection": "keep-alive"}
+    headers = {"_headers": [("Host", "example.org"), ("Connection", "keep-alive")]}
     event = request_to_event(path, method, qs, body, headers)
     assert event == {
         "body": body,
@@ -17,7 +17,10 @@ def test_request_to_event():
         "path": path,
         "httpMethod": method,
         "isBase64Encoded": False,
-        "headers": headers,
+        "headers": {
+            "Host": "example.org",
+            "Connection": "keep-alive",
+        },
         "queryStringParameters": qs,
     }