Skip to content

Commit

Permalink
Version 1.0.0 release
Browse files Browse the repository at this point in the history
  • Loading branch information
johns31459 committed Jan 2, 2025
1 parent a8c2524 commit f0a6233
Show file tree
Hide file tree
Showing 10 changed files with 167 additions and 132 deletions.
50 changes: 36 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Add the SDK to your project by following the [installation](#installation) instr
then create your `handler.py`:

```python
import logging
from crowdstrike.foundry.function import (
APIError,
Request,
Expand All @@ -35,7 +36,8 @@ func = Function.instance() # *** (1) ***


@func.handler(method='POST', path='/create') # *** (2) ***
def on_create(request: Request, config: [dict[str, any], None]) -> Response: # *** (3), (4) ***
def on_create(request: Request, config: [dict[str, any], None],
logger: logging.Logger) -> Response: # *** (3), (4), (5) ***
if len(request.body) == 0:
return Response(
code=400,
Expand All @@ -46,14 +48,30 @@ def on_create(request: Request, config: [dict[str, any], None]) -> Response: #
# do something useful
#####

return Response( # *** (5) ***
return Response( # *** (6) ***
body={'hello': 'world'},
code=200,
)


@func.handler(method='PUT', path='/update')
def on_update(request: Request) -> Response: # *** (7) ***
# do stuff
return Response(
# ...snip...
)


@func.handler(method='DELETE', path='/foo')
def on_delete(request: Request, config: [dict[str, any], None]) -> Response: # *** (8) ***
# do stuff
return Response(
# ...snip...
)


if __name__ == '__main__':
func.run() # *** (6) ***
func.run() # *** (9) ***
```

1. `Function`: The `Function` class wraps the Foundry Function implementation.
Expand All @@ -75,14 +93,22 @@ if __name__ == '__main__':
3. `url`: The request path relative to the function as a string.
4. `method`: The request HTTP method or verb.
5. `access_token`: Caller-supplied access token.
5. Return from a `@handler` function: Returns a `Response` object.
5. `logger`: Unless there is specific reason not to, the function author should use the `Logger` provided to the
function.
When deployed, the supplied `Logger` will be formatted in a custom manner and will have fields injected to assist
with working against our internal logging infrastructure.
Failure to use the provided `Logger` can thus make triage more difficult.
6. Return from a `@handler` function: Returns a `Response` object.
The `Response` object contains fields `body` (payload of the response as a `dict`),
`code` (an `int` representing an HTTP status code),
`errors` (a list of any `APIError`s), and `header` (a `dict[str, list[str]]` of any special HTTP headers which
should be present on the response).
If no `code` is provided but a list of `errors` is, the `code` will be derived from the greatest positive valid HTTP
code present on the given `APIError`s.
6. `func.run()`: Runner method and general starting point of execution.
7. `on_update(request: Request)`: If only one argument is provided, only a `Request` will be provided.
8. `on_delete(request: Request, config: [dict[str, any], None])`: If two arguments are provided, a `Request` and config
will be provided.
9. `func.run()`: Runner method and general starting point of execution.
Calling `run()` causes the `Function` to finish initializing and start executing.
Any code declared following this method may not necessarily be executed.
As such, it is recommended to place this as the last line of your script.
Expand Down Expand Up @@ -110,21 +136,18 @@ curl -X POST 'http://localhost:8081' \
}'
```

## Convenience Functionality 🧰
## Working with `falconpy`

### `falconpy`
Function authors should import `falconpy` explicitly as a requirement in their project when needed.

Foundry Function Python ships with [falconpy](https://github.com/CrowdStrike/falconpy) pre-integrated and a convenience
constructor.
While it is not strictly necessary to use the convenience function, it is recommended.
### General usage

**Important:** Create a new instance of each `falconpy` client you want on each request.

```python
# omitting other imports
from falconpy.alerts import Alerts
from falconpy.event_streams import EventStreams
from crowdstrike.foundry.function import falcon_client, Function
from crowdstrike.foundry.function import cloud, Function

func = Function.instance()

Expand All @@ -136,8 +159,7 @@ def endpoint(request, config):
# !!! create a new client instance on each request !!!
# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

alerts_client = falcon_client(Alerts)
event_streams_client = falcon_client(EventStreams)
falconpy_alerts = Alerts(access_token=request.access_token, base_url=cloud())

# ... omitting other code ...
```
Expand Down
1 change: 0 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
build>=1.0.3
crowdstrike-falconpy>=1.3.2
pytest>=7.4.2
urllib3>=1.26.16,<2.0
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
SETUP_REQUIRES = [
'setuptools',
]
VERSION = '0.6.0'
VERSION = '1.0.0'


def main():
Expand Down
17 changes: 16 additions & 1 deletion src/crowdstrike/foundry/function/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
from crowdstrike.foundry.function.falconpy import falcon_client
from crowdstrike.foundry.function.model import *


Expand Down Expand Up @@ -106,3 +105,19 @@ def call(func):
))

return call


def cloud() -> str:
"""
Retrieves a FalconPy-compatible identifier which identifies the cloud in which this function is running.
:return: Cloud in which this function is executing.
"""
import os

_default = 'auto'
c = os.environ.get('CS_CLOUD', _default)
c = c.lower().replace('-', '').strip()
if c == '':
c = _default

return c
37 changes: 0 additions & 37 deletions src/crowdstrike/foundry/function/falconpy.py

This file was deleted.

4 changes: 3 additions & 1 deletion src/crowdstrike/foundry/function/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ class Request:
access_token: str = field(default='')
body: Dict[str, any] = field(default_factory=lambda: {})
context: Dict[str, any] = field(default_factory=lambda: {})
fn_id: str = field(default='')
fn_version: int = field(default=0)
method: str = field(default='')
params: RequestParams = field(default_factory=lambda: RequestParams())
trace_id: str = field(default='')
url: str = field(default='')
cloud: str = field(default='')


@dataclass
Expand Down
18 changes: 16 additions & 2 deletions src/crowdstrike/foundry/function/router.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from crowdstrike.foundry.function.model import FDKException, Request, Response
from dataclasses import dataclass
from http.client import BAD_REQUEST, METHOD_NOT_ALLOWED, NOT_FOUND, SERVICE_UNAVAILABLE
from inspect import signature
from logging import Logger
from typing import Callable


Expand All @@ -16,8 +18,9 @@ class Router:
Serves to route function requests to the appropriate handler functions.
"""

def __init__(self, config):
def __init__(self, config, logger: [Logger, None] = None):
self._config = config
self._logger = logger
self._routes = {}

def route(self, req: Request) -> Response:
Expand All @@ -43,7 +46,18 @@ def route(self, req: Request) -> Response:
if r is None:
raise FDKException(code=METHOD_NOT_ALLOWED, message="Method Not Allowed: {}".format(req_method))

return r.func(req, self._config)
return self._call_route(r, req)

def _call_route(self, route: Route, req: Request):
f = route.func
len_params = len(signature(f).parameters)

# We'll make this more flexible in the future if needed.
if len_params == 3:
return f(req, self._config, self._logger)
if len_params == 2:
return f(req, self._config)
return f(req)

def register(self, r: Route):
"""
Expand Down
99 changes: 91 additions & 8 deletions tests/crowdstrike/foundry/function/test__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
from crowdstrike.foundry.function import Function, Request, Response, FDKException
import os
from crowdstrike.foundry.function import Function, Request, Response, FDKException, cloud
from crowdstrike.foundry.function.router import Route, Router
from logging import Logger
from tests.crowdstrike.foundry.function.utils import CapturingRunner, StaticConfigLoader
from unittest import main, TestCase
from unittest.mock import patch

if __name__ == '__main__':
main()


def do_request(req, config):
def do_request1(req):
return Response(
body={
'req': req.body,
},
code=200,
)


def do_request2(req, config):
return Response(
body={
'config': config,
Expand All @@ -17,15 +29,37 @@ def do_request(req, config):
)


def do_request3(req, config, logger):
return Response(
body={
'config': config,
'logger': logger,
'req': req.body,
},
code=200,
)


class TestRequestLifecycle(TestCase):
logger = Logger(__name__)

def setUp(self):
config = {'a': 'b'}
router = Router(config)
router = Router(config, self.logger)
router.register(Route(
method='POST',
path='/request',
func=do_request,
path='/request1',
func=do_request1,
))
router.register(Route(
method='POST',
path='/request2',
func=do_request2,
))
router.register(Route(
method='POST',
path='/request3',
func=do_request3,
))
self.runner = CapturingRunner()
self.runner.bind_router(router)
Expand All @@ -35,11 +69,27 @@ def setUp(self):
runner=self.runner,
)

def test_request(self):
def test_request1(self):
req = Request(
body={'hello': 'world'},
method='POST',
url='/request',
url='/request1',
)
self.function.run(req)
resp = self.runner.response
self.assertIsNotNone(resp, 'response is none')
self.assertEqual(200, resp.code, f'expected response of 200 but got {resp.code}')
self.assertDictEqual(
{'req': {'hello': 'world'}},
resp.body,
'actual body differs from expected body'
)

def test_request2(self):
req = Request(
body={'hello': 'world'},
method='POST',
url='/request2',
)
self.function.run(req)
resp = self.runner.response
Expand All @@ -51,6 +101,26 @@ def test_request(self):
'actual body differs from expected body'
)

def test_request3(self):
req = Request(
body={'hello': 'world'},
method='POST',
url='/request3',
)
self.function.run(req)
resp = self.runner.response
self.assertIsNotNone(resp, 'response is none')
self.assertEqual(200, resp.code, f'expected response of 200 but got {resp.code}')
self.assertDictEqual(
{
'config': {'a': 'b'},
'logger': self.logger,
'req': {'hello': 'world'},
},
resp.body,
'actual body differs from expected body'
)

def test_unknown_endpoint(self):
with self.assertRaisesRegex(FDKException, "Not Found: /xyz"):
req = Request(
Expand All @@ -65,6 +135,19 @@ def test_unknown_method(self):
req = Request(
body={'hello': 'world'},
method='GET',
url='/request',
url='/request1',
)
self.function.run(req)


class TestCloud(TestCase):

def test_cloud_returns_default_if_none_specified(self):
with patch.dict(os.environ, {}, clear=True):
c = cloud()
self.assertEqual("auto", c)

def test_cloud_returns_cloud_in_env(self):
with patch.dict(os.environ, {'CS_CLOUD': 'us-gov-1'}, clear=True):
c = cloud()
self.assertEqual("usgov1", c)
Loading

0 comments on commit f0a6233

Please sign in to comment.