Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add fhttp example command #4

Merged
merged 2 commits into from
Sep 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions cmd/fhttp/README.md
Original file line number Diff line number Diff line change
@@ -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.


71 changes: 71 additions & 0 deletions cmd/fhttp/main.py
Original file line number Diff line number Diff line change
@@ -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)
143 changes: 143 additions & 0 deletions http/serve.py
Original file line number Diff line number Diff line change
@@ -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,
})
Loading