diff --git a/cmd/fhttp/README.md b/cmd/fhttp/README.md new file mode 100644 index 00000000..7bf929d1 --- /dev/null +++ b/cmd/fhttp/README.md @@ -0,0 +1,6 @@ +# Function HTTP Test Command + +fhttp is a command which illustrates how the func-python library +wraps a function and exposes it as a service. Useful for development. + + diff --git a/cmd/fhttp/main.py b/cmd/fhttp/main.py new file mode 100644 index 00000000..65b4230f --- /dev/null +++ b/cmd/fhttp/main.py @@ -0,0 +1,71 @@ +import argparse +import logging +import func_python.http + +logging.basicConfig(level=logging.INFO) + +parser = argparse.ArgumentParser(description='Serve a Test Function') +parser.add_argument('--static', action='store_true', + help='Serve the example static handler (default is to ' + 'instantiate and serve the example class)') +args = parser.parse_args() + + +async def handle(scope, receive, send): + """ handle is an example of a static handler which can be sent to the + middleware as a funciton. It will be wrapped in a default Funciton + instance before being served as an ASGI application. + """ + logging.info("OK: static") + + await send({ + 'type': 'http.response.start', + 'status': 200, + 'headers': [[b'content-type', b'text/plain']], + }) + await send({ + 'type': 'http.response.body', + 'body': 'OK: static'.encode(), + }) + + +class Function: + """ Function is an example of a functioon instance. The structure + implements the function which will be deployed as a network service. + The class name can be changed. The only required method is "handle". + """ + + async def handle(self, scope, receive, send): + """ handle is the only method which must be implemented by the + function instance for it to be served as an ASGI handler. + """ + logging.info("OK: instanced") + + await send({ + 'type': 'http.response.start', + 'status': 200, + 'headers': [[b'content-type', b'text/plain']], + }) + await send({ + 'type': 'http.response.body', + 'body': 'OK: instanced'.encode(), + }) + + +def new(): + """ new is the factory function (or constructor) which will create + a new function instance when invoked. This must be named "new", and the + structure returned must include a method named "handle" which implements + the ASGI specification's method signature. The name of the class itself + can be changed. + """ + return Function() + + +if __name__ == "__main__": + if args.static: + logging.info("Starting static handler") + func_python.http.serve(handle) + else: + logging.info("Starting new instance") + func_python.http.serve(new) diff --git a/http/serve.py b/http/serve.py new file mode 100644 index 00000000..f79898ee --- /dev/null +++ b/http/serve.py @@ -0,0 +1,143 @@ +# ASGI main +import asyncio +import logging +import os +import hypercorn.config +import hypercorn.asyncio + +DEFAULT_LOG_LEVEL = logging.INFO +DEFAULT_LISTEN_ADDRESS = "127.0.0.1:8080" + +logging.basicConfig(level=DEFAULT_LOG_LEVEL) + + +def serve(f): + """serve a function f by wrapping it in an ASGI web application + and starting. The function can be either a constructor for a functon + instance (named "new") or a simple ASGI handler function (named "handle"). + """ + logging.debug("func runtime creating function instance") + + if f.__name__ == 'new': + return ASGIApplication(f()).serve() + elif f.__name__ == 'handle': + return ASGIApplication(DefaultFunction(f)).serve() + else: + raise ValueError("function must be either be a constructor 'new' or a " + "handler 'handle'.") + + +class DefaultFunction: + """DefaultFunction is used when the provided functon is not a constructor + for a funciton instance, but rather a simple handler function""" + + def __init__(self, handler): + self.handle = handler + + +class ASGIApplication(): + def __init__(self, f): + self.f = f + # Inform the user via logs that defaults will be used for health + # endpoints if no matchin methods were provided. + if hasattr(self.f, "alive") is not True: + logging.info( + "function does not implement 'alive'. Using default " + "implementation for liveness checks." + ) + + if hasattr(self.f, "ready") is not True: + logging.info( + "function does not implement 'ready'. Using default " + "implementation for readiness checks." + ) + + def serve(self): + """serve serving this ASGIhandler, delegating implementation of + methods as necessary to the wrapped Function instance""" + cfg = hypercorn.config.Config() + cfg.bind = [os.getenv('LISTEN_ADDRESS', DEFAULT_LISTEN_ADDRESS)] + + logging.debug(f"function starting on {cfg.bind}") + return asyncio.run(hypercorn.asyncio.serve(self, cfg)) + + async def on_start(self): + """on_start handles the ASGI server start event, delegating control + to the internal Function instance if it has a "start" method.""" + if hasattr(self.f, "start"): + self.f.start(os.environ.copy()) + else: + logging.info("function does not implement 'start'. Skipping.") + + async def on_stop(self): + if hasattr(self.f, "stop"): + self.f.stop() + else: + logging.info("function does not implement 'stop'. Skipping.") + + # Register ASGIFunctoin as a callable ASGI Function + async def __call__(self, scope, receive, send): + if scope['type'] == 'lifespan': + while True: + message = await receive() + if message['type'] == 'lifespan.startup': + await self.on_start() + await send({'type': 'lifespan.startup.complete'}) + elif message['type'] == 'lifespan.shutdown': + await self.on_stop() + await send({'type': 'lifespan.shutdown.complete'}) + return + else: + break + + # Assert request is HTTP + if scope["type"] != "http": + await send_exception(send, 400, + "Functions currenly only support ASGI/HTTP " + f"connections. Got {scope['type']}" + ) + return + + # Route request + try: + if scope['path'] == '/health/liveness': + await self.handle_liveness(scope, receive, send) + elif scope['path'] == '/health/readiness': + await self.handle_readiness(scope, receive, send) + else: + if hasattr(self.f, "handle"): + await self.f.handle(scope, receive, send) + else: + raise Exception("function does not implement handle") + except Exception as e: + await send_exception(send, 500, f"Error: {e}") + + async def handle_liveness(self, scope, receive, send): + if hasattr(self.f, "alive"): + self.f.alive() + else: + await send({'type': 'http.response.start', 'status': 200, + 'headers': [[b'content-type', b'text/plain']]}) + await send({'type': 'http.response.body', + 'body': b'OK', + }) + + async def handle_readiness(self, scope, receive, send): + if hasattr(self.f, "ready"): + self.f.ready() + else: + await send({'type': 'http.response.start', 'status': 200, + 'headers': [[b'content-type', b'text/plain']]}) + await send({'type': 'http.response.body', + 'body': b'OK', + }) + + +async def send_exception(send, code, message): + await send({ + 'type': 'http.response.start', 'status': code, + 'headers': [[b'content-type', b'text/plain']], + }) + await send({ + 'type': 'http.response.body', 'body': message, + })