Monday, June 16, 2025

Local AWS API Gateway development with Python: The initial FastAPI implementation

Based on some LinkedIn conversations prompted by my post there about the previous post on this topic, I feel like I should clarify what I'm trying to accomplish, and what I'm not trying to accomplish with this package before I go too much further.

The scenario I had in mind centers around someone who is developing an application or API that will eventually be hosted through an AWS API Gateway resource, and whose backing compute logic is provided, in the main, by Lambda Functions. That much I think was clear. Where I apparently was not clear enough was in some additional wrinkles: I'm thinking of cases where the developer of the application cannot usefully deploy an in-flight instance of their efforts as they are working on it, leaving them with no way to actually make HTTP requests to an instance of the application to test and debug as they are working on it. There could be any number of reasons why that's not possible. The ones I can think of, or that I've encountered include:

  • The process involved for deploying an in-flight instance involves something that they do not have access to — maybe they aren't actually allowed to create the AWS resources for security reasons, or the deployment process isn't geared towards allowing developer-owned copies of the application.
  • They may not have sufficient computer power/resources to run local options like AWS' sam local or LocalStack, both of which require Docker.
    (Alternately, maybe they do have the basic computing power, but those are not responsive enough to make the experience even remotely painless)
  • They may need to be able to develop and test while disconnected from the Internet.
  • Other options that allow local execution of in-progress work might add unwanted dependencies and code that shouldn't be deployed to any real environment. The best example I know of for this is the chalice package, which behaves much like FastAPI or Flask, but also implies that chalice will be used to deploy the code.

A common key point here is that deployment of the in-flight work is not practical, or even not possible. In those cases, the idea of being able to run a local application/API process while working on the code is still very desirable. That is what I'm trying to accomplish.

So, with that out of the way, and with a recent review of Python's decorators and how they actually work still fresh in my mind after the previous post, here's where I picked it back up again. First, I came to the conclusion that in order to really establish that I was doing what I wanted to, I needed a bit more variety in the application/API model that I was going to work with. I landed on having three basic object-types represented, person, place and thing, and was mainly focused on the decorators that support CRUD operations for an API. From an application-centric perspective, generating and sending web pages as responses, the Create and Read would be the only operations that would be needed. Putting all o that together, the endpoints, their operations, the relevant HTTP methods, and the

Endpoints and their Lambda Code
HTTP Method CRUD operation Endpoint Path Lambda Code Path
(examples/src/…)
POST create /person people/crud_operations.py
::create_person
/place places/crud_operations.py
::create_place
/thing things/crud_operations.py
::create_thing

GET read /person/{oid} people/crud_operations.py
::read_person
/people people/crud_operations.py
::read_people
/places/{oid} places/crud_operations.py
::read_place
/place places/crud_operations.py
::read_places
/things/{oid} things/crud_operations.py
::read_thing
/thing things/crud_operations.py
::read_things

PATCH
or
PUT
update /person/{oid} people/crud_operations.py
::update_person
/place/{oid} places/crud_operations.py
::update_place
/thing/{oid} things/crud_operations.py
::update_thing

DELETE delete /person/{oid} people/crud_operations.py
::delete_person
/place/{oid} places/crud_operations.py
::delete_place
/thing/{oid} things/crud_operations.py
::delete_thing
Lambda Function modules are in the examples/src directory of the project's repository

The individual target Lambda Function handlers in the example I used are very simple: All that I expected I needed to do, for now at least, was to be able to observe that the appropriate target function was being called when the local applicatoin endpoint was called. With that in mind, all that I really needed to do was set things up to follow my standard logging pattern, described in earlier posts here. An example of one of those functions, the read_person handler that would map locally to /v1/person/{oid}/ if the local process was successful is:

def read_person(event, context):
    # HTTP GET handler (for single person items)
    logger.info('Calling read_person')
    try:
        logger.debug(f'event ..... {event}')
        _body = event.get('body')
        body = json.loads(_body) if _body else None
        logger.debug(f'body ...... {body}')
        logger.debug(f'context ... {context}')
        result = {
            'statusCode': 200,
            'body': 'read_person completed successfully'
        }
        logger.debug(f'result .... {result}')
    except Exception as error:
        result = {
            'statusCode': 500,
            'body': f'read_person raised '
            f'{error.__class__.__name__}'
        }
        logger.exception(f'{error.__class__.__name__}: {error}')
    logger.info('read_person completed')
    logger.debug(f'<= {json.dumps(event)}')
    logger.debug(f'=> {json.dumps(result)}')
    return result

In this example, since all it's returning is a string, all I would expect back from a browser request to that endpoint on the local application would be.

read_person completed successfully

To get to that point, there are a few steps involved. In a pure FastAPI implementation, any incoming request is picked up by FastAPI's standard request-detection processes, and a Request object is created and available to read request data from, provided that it is imported. The function that actually handles a request is mapped with a decorator that is provided by a FastAPI application object. An example of a typical handler for the same /v1/person/{oid}/ endpoint might look something like this:

from fastapi import FastAPI, Request

# ...

app = FastAPI()

# ...

@app.get('/v1/person/{oid}/')
def get_person(request: Request) -> dict:
    """GET handler for person requests"""
    logger.debug(f'Calling {__name__}.get_person:')
    logger.debug(f'variables: {pformat(vars())}')
    try:
        result = {
            'statusCode': 200,
            'body': 'get_person completed successfully'
        }
    except Exception as error:
        msg = (
            f'get_person raised an exception: '
            f'{error.__class__.__name__}'
        )
        logger.exception(msg)
        result = {
            'statusCode': 200,
            'body': msg
        }
    finally:
        return result

I started my implementation using FastAPI for one very simple reason: Its decorator structure more closely mirrors the sort of resource definitions for Lambda Functions that are called by an API Gateway instance as defined in a SAM template structure. An (incomplete) example of that sort of templated function declaration might look something like this for the same /v1/person/{oid}/ that would call the read_person function shown above:

Type: AWS::Serverless::Function
Properties:

  # The (relative) path from the template to the
  # directory where the function's module can be found
  CodeUri: ../src/people

  # The namespace of the function, in the form
  # module_name.function_name
  Handler: crud_operations.read_person

  Events:
    ApiEvent:
      Type: Api
      Properties:
        Method: get
        Path: /v1/person/{oid}/
        RestApiId:
          Ref: SomeApiIdentifier

  # Other properties that might be of use later, but not today
  # Description: String
  # Environment: Environment
  # FunctionName: String
  # MemorySize: Integer
  # Timeout: Integer

This template structure provides all of the information that would be needed to set up the local route: The Handler is just an import-capable path, and the Method and Path under Events.ApiEvent.Properties indicate the HTTP method and the API path that would be used in a FastAPI decorator to define the function to be called when that method/endpoint combination receives a request.

Goal
Right now, the entire mapping process is manual, but knowing that a SAM template provides those values is a solid step towards eventually automating the process. Somewhere down the line, I plan to write a command-line tool that will read a SAM template (and maybe later a CloudFormation template), and automatically generate the relevant local API mappings.

Back to the process flow! My immediate goal, then, was to provide an override for the various FastAPI HTTP-method decorators (for example, its get decorator) that would accept a handler-function or a namespace representation of one in addition to its existing path specification, and route requests to that path to the specified handler-function. Along the way, it would need to conver the FastAPI Request object's data into a Lambda Proxy Input event and a LambdaContext object that could be passed to the target handler-function. A high-level outline of the code structure, using the get decorator again, shows the basic processes involved, and the parameters used:

def get(
        self,
    path: str,
    external_function: LambdaSpec | str,
    *args,
    **kwargs
) -> Callable:
    """
    Overrides the parent (FastAPI) decorator of the same name, to
    allow the specification of an external function that will be
    used to handle requests.

    Parameters:
    -----------
    path : str
        The path argument to be used in calling the parent class'
        method that this method overrides.
    external_function : LambdaSpec | str
        The "external" function to be wrapped and returned by the
        method
    *args : Any
        Any additional positional or listed arguments to be used
        in calling the parent class' method that this method
        overrides.
    **kwargs : Any
        Any keyword or keyword-only arguments to be used in
        calling the parent class' method that this method
        overrides.
    """
    # At this layer, we're just getting the arguments passed.
    # Resolve the external function
    if isinstance(external_function, str):
        target_function = get_external_function(
            external_function
        )
    elif callable(external_function):
        target_function = external_function
    else:
        raise TypeError()

    def _wrapper(target: Callable | None = None):
        """
        The initial function returned by the decoration process,
        which will be called by the Python runtime with the
        target function it is decorating, if used as a decorator.
        """
        # At this level, we're retrieving the target function
        # that is being decorated, if one was provided.

        # Handle async vs. sync functions based on FastAPI's
        # apparent preferences and the target function, if one
        # has been provided
        if iscoroutinefunction(target):
            async def _replacer(request: Request):
                """
                An async version of the function that will be
                returned, replacing a decorator target where
                applicable.
                """
        else:
            def _replacer(request: Request):
                """
                A sync version of the function that will be
                returned, replacing a decorator target where
                applicable.
                """

        # Call the original decorator to keep all the things
        # it does
        new_function = _FastAPI.get(
            self, path, *args, **kwargs
        )(_replacer)
        return new_function

    return _wrapper

At the outermost layer of the decorator structure, the external_function that is passed in the decorator arguments can either be a function imported earlier, or the namespace of an importable function — the same sort of string-value noted in the SAM template example shown above. The next layer in, _wrapper, is reponsible for retrieving the target function that the decorator is decorating. It also accepts a None value, for reasons that I'll dig into in more detail later. The _wrapper is responsible for defining the function that will be returned to replace the decoration target function. That function, _replacer, is created on the fly inside the decorator's closure, and will return either an async version of the function, or a normal synchronous function, depending on whether the decorator target is async or not.

Note
FastAPI looks like it prefers async functions, though it supports both sync and async. Trying to account for all the potential crossovers between sync and async functions doesn't feel necessary, since the purpose of this package is to provide a developer convenience tool that allows them to work on local Lambda Functions and execute them with local HTTP requests.

Once everything is figured out, the _replacer function is passed to the original, standard FastAPI decorator, and the response from that, new_function is returned. The complete code is far too long to reproduce here — I don't want to overload the reader with a wall of code — but any who are curious can look at it in detail in the project repository.

This post has already gotten much longer than I'd anticipated, and I have other things that I want to show, so rather than dive into the various helper functions that are called in the get decorator above, I'll refer the reader to their entries in the project's repository as well:

I also created a fairly robust, if very simple, test-harness at local/test-harness.py that exercises all of the initially-suported HTTP methods across all of the resource-types. I'll end this post with a couple of log-dumps from the current version (v.0.0.3, after making a couple of minor corrections to a few items). First, the successful routing set-up for the decorator-based FastAPI mapping example:

pipenv run uvicorn --port 5000 app:app
[INFO]  Using sync replacer for delete_person.
[INFO]  Returning _replacer at 0x106c88680 to decorate delete_person.
[INFO]  Using sync replacer for get_person.
[INFO]  Returning _replacer at 0x106c88720 to decorate get_person.
[INFO]  Using sync replacer for get_people.
[INFO]  Returning _replacer at 0x106c88a40 to decorate get_people.
[INFO]  Using sync replacer for patch_person.
[INFO]  Returning _replacer at 0x106c88d60 to decorate patch_person.
[INFO]  Using sync replacer for post_person.
[INFO]  Returning _replacer at 0x106c89080 to decorate post_person.
[INFO]  Using sync replacer for put_person.
[INFO]  Returning _replacer at 0x106c893a0 to decorate put_person.
[INFO]  Using sync replacer for delete_place.
[INFO]  Returning _replacer at 0x106c89d00 to decorate delete_place.
[INFO]  Using sync replacer for get_place.
[INFO]  Returning _replacer at 0x106c89da0 to decorate get_place.
[INFO]  Using sync replacer for get_places.
[INFO]  Returning _replacer at 0x106c8a0c0 to decorate get_places.
[INFO]  Using sync replacer for patch_place.
[INFO]  Returning _replacer at 0x106c8a3e0 to decorate patch_place.
[INFO]  Using sync replacer for post_place.
[INFO]  Returning _replacer at 0x106c8a700 to decorate post_place.
[INFO]  Using sync replacer for put_place.
[INFO]  Returning _replacer at 0x106c8aa20 to decorate put_place.
[INFO]  Using sync replacer for delete_thing.
[INFO]  Returning _replacer at 0x106c8b420 to decorate delete_thing.
[INFO]  Using sync replacer for get_thing.
[INFO]  Returning _replacer at 0x106c8b4c0 to decorate get_thing.
[INFO]  Using sync replacer for get_things.
[INFO]  Returning _replacer at 0x106c8b7e0 to decorate get_things.
[INFO]  Using sync replacer for patch_thing.
[INFO]  Returning _replacer at 0x106c8bb00 to decorate patch_thing.
[INFO]  Using sync replacer for post_thing.
[INFO]  Returning _replacer at 0x106c8be20 to decorate post_thing.
[INFO]  Using sync replacer for put_thing.
[INFO]  Returning _replacer at 0x106cb0180 to decorate put_thing.
INFO:     Started server process [5626]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:5000 (Press CTRL+C to quit)

...and the results of a request made to the /v1/people/ endpoint:

[INFO]  Created LambdaContext(
    [
        aws_request_id=99abaa85-ace8-4890-9753-8113be1c1e5a,
        log_group_name=None,
        log_stream_name=None,
        function_name=None,
        memory_limit_in_mb=None,
        function_version=None,
        invoked_function_arn=None,
        client_context=ClientContext(
            [custom=None,env=None,client=None]
        ),
        identity=CognitoIdentity(
            [
                cognito_identity_id=None
                cognito_identity_pool_id=None
            ]
        )
    ]
) with 900000 ms remaining before timeout.
[app] [INFO]  Created LambdaContext(...).
[INFO]  _request_to_lambda_signature completed
[INFO]  Calling read_people
[INFO]  read_people completed
[INFO]  Calling _request_to_lambda_signature:
[INFO]  Returning <starlette.responses.Response
           object at 0x106ce3790>
INFO:     127.0.0.1:54456 - "GET /v1/people/ HTTP/1.1" 200 OK

As a final consideration, I'd also point out that the examples/local-fastapi/app-funcs-only.py module, which defines the endpoint mappings like this, with no functions being decorated, also works:

# Endpoint-Handler Functions
app.delete(
    '/v1/person/{oid}/', 'people.crud_operations.delete_person'
)()

app.get(
    '/v1/person/{oid}/', 'people.crud_operations.read_person'
)()

app.get(
    '/v1/people/', 'people.crud_operations.read_people'
)()

app.patch(
    '/v1/person/{oid}/', 'people.crud_operations.update_person'
)()

app.post(
    '/v1/person/', 'people.crud_operations.create_person'
)()

app.put(
    '/v1/person/{oid}/', 'people.crud_operations.update_person'
)()

app.delete(
    '/v1/place/{oid}/', 'places.crud_operations.delete_place'
)()

app.get(
    '/v1/place/{oid}/', 'places.crud_operations.read_place'
)()

app.get(
    '/v1/places/', 'places.crud_operations.read_places
')()

app.patch(
    '/v1/place/{oid}/', 'places.crud_operations.update_place'
)()

app.post(
    '/v1/place/', 'places.crud_operations.create_place'
)()

app.put(
    '/v1/place/{oid}/', 'places.crud_operations.update_place'
)()

app.delete(
    '/v1/thing/{oid}/', 'things.crud_operations.delete_thing'
)()

app.get(
    '/v1/thing/{oid}/', 'things.crud_operations.read_thing'
)()

app.get(
    '/v1/things/', 'things.crud_operations.read_things'
)()

app.patch(
    '/v1/thing/{oid}/', 'things.crud_operations.update_thing'
)()

app.post(
    '/v1/thing/', 'places.crud_operations.create_place'
)()

app.put(
    '/v1/thing/{oid}/', 'things.crud_operations.update_thing'
)()

This package isn't complete by any stretch of the imagination, but it is already covering the majority of what I had in mind.

Thursday, June 12, 2025

Change to publishing schedule

I started this blog up, as I have started others before, while looking for a job, as much as anythng else to have somewhere that I could point recruiters and prospective employers at examples of how I write Python code. The fact that the projects I'm writing about are installable copies of tools and processes that I've found useful in previous jobs is a bonus. At this point, though, I'm working again, and between that and my already-existant obligations to continue with the writing on the second edition of my book, my time is going to be signficantly constrained: I simply wont have the time every day to maintain the pace of development and writing that I had when I started this blog.

I'm planning to keep this going, though: I've found that I really like having these tools that I'm packaging up available for projects, whether they are work-related or personal efforts. From this point on, I'll rite as I have time, and publish what I've written (when it's ready) on the following Monday or Thursday, and announce new posts in my usual places:

Thursday, June 5, 2025

Local AWS API Gateway development with Python: Dissecting decorators

Yes, I'm still poking around at the goblinfish.testing.pact package in the background. It's going slowly, since I've been dealing with job-hunting stuff, prepping for a contract job, and still working on the second edition of my first book. It was in the context of that last item that inspiration struck on this package project. As I'm writing this, I had just gotten to the point of starting to write about serverless application development with the services that are officially part of the collection of resources that are, presumably official AWS Serverless Application Model (SAM) services (this is based on their inclusion in the AWS SAM resources and properties documentation), and finished the content about local and server- or container-resident applications. I had focused on Flask and FastAPI in that content because those were, as noted in my previous post, the most popular options (that I saw in the wild, at least).

The inspiration that struck was that it should be possible to extend the various decorators that both of those frameworks use in order to wrap a Lambda Function's entry-point handler-function, translating the native ASGI or WSGI request-input into a Lambda Proxy Integration input, call the wrapped/decorated Lambda function code, and translate its return-value back to an ASGI- or WSGI-compatible response (assuming that the Lambda returns a Lambda Proxy Integration output).

To understand how this will work, it will probably be useful to take a look at what decorators actually do. Here's an example of a simple decorator, applied to a simple function, and called, with print and pprint calls sprinkled through the code at key points to whow what's happening:

from pprint import pprint

head_len = 56

print('-- Defining simple_decorator '.ljust(head_len, '-'))

def simple_decorator(target):
    print(f'simple_decorator({target.__name__}) called.')
    def _inner(*args, **kwargs):
        print('_inner(...) called:')
        pprint(vars())
        print(f'Calling {target.__name__} from _inner')

        # This is where the original function is called
        result = target(*args, **kwargs)

        print(f'Returning results from call to\\\n  {target}')
        return result
    print(f'Returning {_inner.__name__}.')

    # This is where the replacement function comes from
    return _inner

print(
    '-- Applying simple_decorator to simple_function'
    .ljust(head_len, '-')
)

@simple_decorator
def simple_function(arg, *args, kwdonlyarg, **kwargs):
    print('simple_function(...) called:')
    pprint(vars())
    return vars()

if __name__ == '__main__':
    print('-- Calling simple_function '.ljust(head_len, '-'))
    call_result = simple_function(
        'arg', 2, 3, 4, kwdonlyarg=True, kwarg1=object()
    )
    pprint(call_result)

When this code is executed, the output it returns is:

-- Defining simple_decorator ---------------------------
-- Applying simple_decorator to simple_function---------
simple_decorator(simple_function) called.
Returning _inner.
-- Calling simple_function -----------------------------
_inner(...) called:
{'args': ('arg', 2, 3, 4),
 'kwargs': {
     'kwarg1': <object object at 0x1022b85a0>,
     'kwdonlyarg': True
 },
 'target': <function simple_function at 0x102352480>}
Calling simple_function from _inner
simple_function(...) called:
{'arg': 'arg',
 'args': (2, 3, 4),
 'kwargs': {'kwarg1': <object object at 0x1022b85a0>},
 'kwdonlyarg': True}
Returning results from call to\
  <function simple_function at 0x102352480>
{'arg': 'arg',
 'args': (2, 3, 4),
 'kwargs': {'kwarg1': <object object at 0x1022b85a0>},
 'kwdonlyarg': True}

Step by step, this unfolds as:

  • The simple_decorator is defined. It doesn't actually get called just yet, so none of the print calls generate any output.
  • The simple_decorator decorator is applied to the simple_function function as part of the definition of that function.
    • The Python runtime passes the simple_function as an argument — This is an important item to keep in mind, since the target name association with simple_function remains available in the entire scope of the simple_decorator call, even inside the _inner function.
    • A new instance of the _inner function is created.
    • When that function is called, it calls the original target function.
    • The new _inner function instance is returned.
    • The Python runtime replaces the original simple_function with the _inner function returned
  • From that point on, any calls made to the original simple_function name actually call the _inner function instance that was returned during the decoration process. Whether the original simple_function actually gets called as part of the decorated-function process is determined by whether it is called in the _inner function.

This nesting of functions, including the nested call to the original target function, and the persistance of those nested functions outside the scope where they were defined is an example of a closure.

FastAPI- and Flask-application endpoint decorators also accept arguments, though. The quickstart examples for each show that for the FastAPI @app.get() and Flask @app.route() decorations:

# FastAPI bare-bones/quick-start code
from fastapi import FastAPI

app = FastAPI()

@app.get("/")
async def root():
    return {"message": "Hello World"}
# Flask bare-bones/quick-start code
from flask import Flask

app = Flask(__name__)

@app.route("/")
def hello_world():
    return "

Hello, World!

"

Decorators that accept arguments are a bit more complicated: they have three layers of functions instead of two. The outermost layer is where the arguments for the decoration are retrieved — the "/" in the @app.get("/") and @app.route("/") decorations in the quick-start examples above would be captured at this level, and available to the nested functions within it, for example. The next layer in behaves just like the outermost layer of a simple decorator, accepting the target of the decoration. The innermost layer also behaves like the innermost layer of a simple decorator, actually calling the original target function, with whatever addition al logic is needed before and after that call happens. An example decorator that accepts one argument (decorated), and printing noteworthy occurrences in the process similar to the simple example above is:

from pprint import pprint

head_len = 56

print('-- Defining outer '.ljust(head_len, '-'))

def outer(decorated):
    print(f'outer(...) called:')
    pprint(vars())
    def _middle(target):
        print(f'_middle(...) called:')
        pprint(vars())

        # The "decorated" argument passed above is still
        # available, but only appears in vars() if it is used
        print(f'decorated (from outer): {decorated}')

        def _inner(*args, **kwargs):
            print('_inner(...) called:')
            pprint(vars())

            # The "decorated" argument passed above is still
            # available but only appears in vars() if it is used
            print(f'decorated (from outer): {decorated}')

            print(f'Calling {target.__name__} from _inner')
            result = target(*args, **kwargs)
            print(
                f'Returning results from call to\\\n  {target}'
            )
            return result
        print(f'Returning {_inner.__name__}.')
        return _inner
    return _middle

print(
    '-- Applying outer to target_function'
    .ljust(head_len, '-')
)

@outer(True)
def target_function(arg, *args, kwdonlyarg, **kwargs):
    print('target_function(...) called:')
    pprint(vars())
    return vars()

if __name__ == '__main__':
    print('-- Calling target_function '.ljust(head_len, '-'))
    call_result = target_function(
        'arg', 2, 3, 4, kwdonlyarg=True, kwarg1=object()
    )
    pprint(call_result)

…and running this code results in this output:

-- Defining outer ------------------------
-- Applying outer to target_function------
outer(...) called:
{'decorated': True}
_middle(...) called:
{
    'decorated': True,
    'target': <function target_function at 0x104d6d120>
}
decorated (from outer): True
Returning _inner.
-- Calling target_function -----------------------------
_inner(...) called:
{'args': ('arg', 2, 3, 4),
 'decorated': True,
 'kwargs': {
     'kwarg1': <object object at 0x104a785a0>,
     'kwdonlyarg': True
 },
 'target': <function target_function at 0x104d6d120>}
decorated (from outer): True
Calling target_function from _inner
target_function(...) called:
{'arg': 'arg',
 'args': (2, 3, 4),
 'kwargs': {'kwarg1': <object object at 0x104a785a0>},
 'kwdonlyarg': True}
Returning results from call to\
  <function target_function at 0x104d6d120>
{'arg': 'arg',
 'args': (2, 3, 4),
 'kwargs': {'kwarg1': <object object at 0x104a785a0>},
 'kwdonlyarg': True}
Important
It may seem odd, but any arguments provided to a decorator will not be available within the function being decorated, even though they are demonstrably available to the inner function that is calling the target function. The target function, though it is referenced inside the scope of the decorator, is not actually in that scope, so the preservation of those names and values provided by the closure does not carry through to the target function.

It is also worth noting that because of the way decorators work, and how they are defined, it's possible to call a decorator as a function, and pass the target function as an argument to the result of the decorator function call. That is, running this code after the outer decorator is defined...

def external_function(*args, **kwargs):
    print('external_function(...) called:')
    pprint(vars())
    return vars()

local_function = outer('no-decorator')(external_function)
print('-- Calling local_function '.ljust(head_len, '-'))
call_result = local_function(
    'arg', 2, 3, 4, kwdonlyarg=True, kwarg1=object()
)
pprint(call_result)

... will yield this output (truncated to just the new items):

-- Calling local_function ------------------------------
_inner(...) called:
{'args': ('arg', 2, 3, 4),
 'decorated': 'no-decorator',
 'kwargs': {
     'kwarg1': <object object at 0x1044d85a0>,
     'kwdonlyarg': True
 },
 'target': <function external_function at 0x104572340>
}
decorated (from outer): no-decorator
Calling external_function from _inner
external_function(...) called:
{'args': ('arg', 2, 3, 4),
 'kwargs': {
     'kwarg1': <object object at 0x1044d85a0>,
     'kwdonlyarg': True
 }
}
Returning results from call to\
  <function external_function at 0x104572340>
{'args': ('arg', 2, 3, 4),
 'kwargs': {
     'kwarg1': <object object at 0x1044d85a0>,
     'kwdonlyarg': True
 }
}

Another key factor to keep in mind when working with decorators is that all they really do, when it comes right down to it, is replace the target being decorated with the callable returned by the decorator. That, in turn, means that a decorator function can, in fact, return a completely different function. That is probably not a common use case, but it is a legitimate one, and that capability opens the door for the process that I have in mind. At a high level, after doing some work with throwaway code for this project, what I see as the most likely path forward looks something like this:

  • Defining a class deriving from the FastAPI and Flask classes provided by those packages;
  • Overriding the relevant decorators (FastAPI.get and the other HTTP-verb decorator-methods FastAPI provides, and the Flask.route method), such that:
    • The override accepts a target function that can be pointed at a Lambda Handler elsewhere in a project, either by importing it and passing it, or by specifying its namespace and letting the decorator perform the import.
    • The override would create a new wrapper function.
    • The wrapper function handles converting the FastAPI or Flask request into a Lambda Handler event and context.
    • The wrapper function calls the Lambda Handler with those items.
    • The wrapper function handles converting the response from the Lambda Handler back into whatever response format is expected by FastAPI or Flask before returning it.
  • As part of that decoration process, the override would also be responsible for calling the original decorator — the function that it overrides — with all of the normal arguments that decorator expects or accepts, but pointing it at the wrapper function. This should insure that the normal behaviour expected of the FastAPI or Flask decorators is preserved with respect to registering and configuring endpoint handler functions.

With all this done, I would expect that manually generating an endpoint-to-Lambda-Function mapping would look something like this:

from goblinfish.aws.local.fastapi_apigw import FastAPI
from my_lambda_functions import root_get

app = FastAPI()

@app.get(root_get, '/')
async def root():
    # No code is actually needed here, since this would be
    # *completely replaced* by the decorator.
    ...

Ideally, I would also like to be able to support the function-call-only structure shown above, allowing the same definition above to be executed something like this:

from goblinfish.aws.local.fastapi_apigw import FastAPI
from my_lambda_functions import root_get

app = FastAPI()

def mapping_failed(*args, **kwargs):
    # This function should ALWAYS be replaced by an application
    # endpoint mapping, but in case it isn't it will raise an
    # error.
    raise Exception('Mapping failed; check your code!')

root = app.get()

@app.get(root_get, '/')(mapping_failed)

This post has gotten far longer than I had originally intended — decorators are a complex subject, and I could probably be fairly accused of rambling on about them. With all of the exploration into them that has happened here, though, I feel comfortable moving forward with a first attempt my goal. That is where I will pick up in my next post about this package.

Local AWS API Gateway development with Python: The initial FastAPI implementation

Based on some LinkedIn conversations prompted by my post there about the previous post on this topic , I feel like I shoul...