Skip to content

Commit d8a36cb

Browse files
authored
Applications powered by Fn (#5)
* FDK: Applications powered by Fn * Fixing PEP8 * Improving README
1 parent de70102 commit d8a36cb

12 files changed

Lines changed: 399 additions & 6 deletions

File tree

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@ RUN mkdir /code
55
ADD . /code/
66
RUN pip install -e /code/
77

8-
WORKDIR /code/samples/hot/json/echo
8+
WORKDIR /code/fdk/tests/fn/traceback
99
ENTRYPOINT ["python3", "func.py"]

README.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,93 @@ if __name__ == "__main__":
123123

124124
```
125125

126+
Applications powered by Fn: Concept
127+
-----------------------------------
128+
129+
FDK is not only about developing functions, but providing necessary API to build serverless applications
130+
that look like nothing but classes with methods powered by Fn.
131+
132+
```python
133+
from fdk.application import decorators
134+
135+
136+
@decorators.fn_app
137+
class Application(object):
138+
139+
def __init__(self, *args, **kwargs):
140+
pass
141+
142+
@decorators.fn_route(fn_image="denismakogon/os.environ:latest")
143+
def env(self, fn_data=None):
144+
return fn_data
145+
146+
@decorators.fn_route(fn_image="denismakogon/py-traceback-test:0.0.1",
147+
fn_format="http")
148+
def traceback(self, fn_data=None):
149+
return fn_data
150+
151+
if __name__ == "__main__":
152+
app = Application(config={})
153+
154+
res, err = app.env()
155+
if err:
156+
raise err
157+
print(res)
158+
159+
res, err = app.traceback()
160+
if err:
161+
raise err
162+
print(res)
163+
164+
```
165+
In order to identify to which Fn instance code needs to talk set following env var:
166+
167+
```bash
168+
export API_URL=http://localhost:8080
169+
```
170+
with respect to IP address or domain name where Fn lives.
171+
172+
173+
Applications powered by Fn: supply data to a function
174+
-----------------------------------------------------
175+
176+
At this moment those helper-decorators let developers interact with Fn-powered functions as with regular class methods.
177+
In order to pass necessary data into a function developer just needs to do following
178+
```python
179+
180+
if __name__ == "__main__":
181+
app = Application(config={})
182+
183+
app.env(keyone="blah", keytwo="blah", somethingelse=3)
184+
185+
```
186+
Key-value args will be turned into JSON instance and will be sent to a function as payload body.
187+
188+
189+
Applications powered by Fn: working with function's result
190+
----------------------------------------------------------
191+
192+
In order to work with result from function you just need to read key-value argument `fn_data`:
193+
```python
194+
@decorators.fn_route(fn_image="denismakogon/py-traceback-test:0.0.1",
195+
fn_format="http")
196+
def traceback(self, fn_data=None):
197+
return fn_data
198+
```
199+
200+
Applications powered by Fn: exceptions
201+
--------------------------------------
202+
203+
Applications powered by Fn are following Go-like errors concept. It gives you full control on errors whether raise them or not.
204+
```python
205+
res, err = app.env()
206+
if err:
207+
raise err
208+
print(res)
209+
210+
```
211+
Each error is an instance fn `FnError` that encapsulates certain logic that makes hides HTTP errors and turns them into regular Python-like exceptions.
212+
126213
TODOs
127214
-----
128215

fdk/application/__init__.py

Whitespace-only changes.

fdk/application/decorators.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
# All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
4+
# not use this file except in compliance with the License. You may obtain
5+
# a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12+
# License for the specific language governing permissions and limitations
13+
# under the License.
14+
15+
import functools
16+
import os
17+
import requests
18+
19+
20+
from fdk.application import errors
21+
22+
23+
def fn_app(fn_app_class):
24+
"""
25+
Sets up Fn app with config
26+
27+
@fn_app
28+
class MyApp(object):
29+
30+
def __init__(self, *args, **kwargs):
31+
pass
32+
33+
@fn_route(
34+
fn_image="denismakogon/hot-json-python:0.0.1",
35+
fn_type="sync",
36+
fn_memory=256,
37+
fn_format="json",
38+
fn_timeout=60,
39+
fn_idle_timeout=200,
40+
)
41+
def do_stuff(self, *args, fn_data=None, **kwargs):
42+
pass
43+
44+
:param fn_app_class: any class
45+
:return: class decorator
46+
"""
47+
@functools.wraps(fn_app_class)
48+
def wrapper(*args, **kwargs):
49+
app_name = fn_app_class.__name__
50+
fn_api_url = os.environ.get("API_URL")
51+
requests.get(fn_api_url).raise_for_status()
52+
fn_app_url = "{}/v1/apps/{}".format(
53+
fn_api_url, app_name.lower())
54+
del_resp = requests.delete(fn_app_url)
55+
if del_resp.status_code != 404:
56+
del_resp.close()
57+
del_resp.raise_for_status()
58+
requests.post(
59+
"{}/v1/apps".format(fn_api_url),
60+
json={
61+
"app": {
62+
"name": app_name.lower(),
63+
"config": kwargs.get("config"),
64+
},
65+
}
66+
).raise_for_status()
67+
return fn_app_class(*args)
68+
69+
return wrapper
70+
71+
72+
def fn_route(fn_image=None, fn_type=None,
73+
fn_memory=256, fn_format=None,
74+
fn_timeout=60, fn_idle_timeout=200,
75+
fn_method="GET"):
76+
"""
77+
Sets up Fn app route based on parameters given above
78+
:param fn_image: Docker image
79+
:type fn_image: str
80+
:param fn_type: Fn route type (async/sync)
81+
:type fn_type: str
82+
:param fn_memory: Fn RAM to allocate
83+
:type fn_memory: int
84+
:param fn_format: Fn route format to accept
85+
:type fn_format: str
86+
:param fn_timeout: Fn route call timeout
87+
:type fn_timeout: int
88+
:param fn_idle_timeout: Fn route idle timeout (timeout between calls)
89+
:type fn_idle_timeout: int
90+
:param fn_method: HTTP method to use when calling Fn function
91+
:type fn_method: str
92+
:return: monkey-patched action (almost the same as decorated)
93+
"""
94+
95+
def ext_wrapper(action):
96+
@functools.wraps(action)
97+
def inner_wrapper(*f_args, **f_kwargs):
98+
fn_api_url = os.environ.get("API_URL")
99+
requests.get(fn_api_url).raise_for_status()
100+
self = f_args[0]
101+
fn_path = action.__name__.lower()
102+
if not hasattr(action, "__path_created"):
103+
fn_routes_url = "{}/v1/apps/{}/routes".format(
104+
fn_api_url, self.__class__.__name__.lower())
105+
resp = requests.post(fn_routes_url, json={
106+
"route": {
107+
"path": "/{}".format(fn_path),
108+
"image": fn_image,
109+
"memory": fn_memory if fn_memory else 256,
110+
"type": fn_type if fn_type else "sync",
111+
"format": fn_format if fn_format else "default",
112+
"timeout": fn_timeout if fn_timeout else 60,
113+
"idle_timeout": (fn_idle_timeout if
114+
fn_idle_timeout else 120),
115+
},
116+
})
117+
118+
try:
119+
resp.raise_for_status()
120+
except requests.HTTPError:
121+
resp.close()
122+
return None, Exception(resp.content)
123+
124+
setattr(action, "__path_created", True)
125+
126+
fn_exec_url = "{}/r/{}/{}".format(
127+
fn_api_url, self.__class__.__name__.lower(), fn_path)
128+
req = requests.Request(method=fn_method,
129+
url=fn_exec_url,
130+
json=f_kwargs)
131+
session = requests.Session()
132+
resp = session.send(req.prepare())
133+
134+
try:
135+
resp.raise_for_status()
136+
except requests.HTTPError:
137+
resp.close()
138+
return None, errors.FnError(
139+
"{}/{}".format(self.__class__.__name__.lower(), fn_path),
140+
resp.content)
141+
142+
f_kwargs.update(fn_data=resp.text)
143+
144+
resp.close()
145+
return action(*f_args, **f_kwargs), None
146+
147+
return inner_wrapper
148+
149+
return ext_wrapper

fdk/application/errors.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
4+
# not use this file except in compliance with the License. You may obtain
5+
# a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12+
# License for the specific language governing permissions and limitations
13+
# under the License.
14+
15+
16+
class FnError(Exception):
17+
18+
def __init__(self, fn_name, fn_raw_error):
19+
self.fn_name = fn_name
20+
self.fn_raw_error = fn_raw_error.decode("utf-8")
21+
self.message = "error at Fn: {}.".format(fn_name)
22+
23+
super(FnError, self).__init__(self.message)
24+
25+
def __str__(self):
26+
return "{}\n{}".format(self.message, self.fn_raw_error)

fdk/http/response.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def __init__(self, http_proto_version=(1, 1), status_code=200,
3535

3636
http_headers = headers if headers else {}
3737
self.http_proto = http_proto_version
38-
self.int_status = status_code
38+
self.status_code = status_code
3939
self.verbose_status = statuses.from_code(status_code)
4040
self.response_data, content_len = self.__encode_data(response_data)
4141
if self.response_data:
@@ -46,7 +46,7 @@ def __init__(self, http_proto_version=(1, 1), status_code=200,
4646
http_headers.update({
4747
"Content-Length": content_len,
4848
})
49-
self.headers = self.__encode_headers(http_headers)
49+
self.headers = http_headers
5050

5151
def __encode_headers(self, headers):
5252
if headers:
@@ -62,13 +62,24 @@ def __encode_data(self, data):
6262
enc = str(data).encode('utf-8')
6363
return enc, len(enc)
6464

65+
def set_response_content(self, data):
66+
self.response_data, content_len = self.__encode_data(data)
67+
if self.response_data:
68+
if not self.headers.get("Content-Type"):
69+
self.headers.update({
70+
"Content-Type": "text/plain; charset=utf-8",
71+
})
72+
self.headers.update({
73+
"Content-Length": content_len,
74+
})
75+
6576
def dump(self, stream, flush=True):
6677
format_map = {
6778
"proto_major": self.http_proto[0],
6879
"proto_minor": self.http_proto[1],
69-
"int_status": self.int_status,
80+
"int_status": self.status_code,
7081
"verbose_status": self.verbose_status,
71-
"headers": self.headers,
82+
"headers": self.__encode_headers(self.headers),
7283
}
7384
result = stream.write(
7485
self.PATTERN.format(**format_map).encode('utf-8') +

fdk/tests/fn/traceback/Dockerfile

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
FROM python:3.6.2
2+
3+
4+
RUN mkdir /code
5+
ADD . /code/
6+
WORKDIR /code/
7+
RUN pip install -r requirements.txt
8+
WORKDIR /code/
9+
10+
ENTRYPOINT ["python3", "func.py"]

fdk/tests/fn/traceback/func.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
4+
# not use this file except in compliance with the License. You may obtain
5+
# a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12+
# License for the specific language governing permissions and limitations
13+
# under the License.
14+
15+
import fdk
16+
import sys
17+
import traceback
18+
19+
from fdk.http import response
20+
21+
22+
def exception_to_string(err):
23+
stack = (traceback.extract_stack()[:-3] +
24+
traceback.extract_tb(err.__traceback__))
25+
pretty = traceback.format_list(stack)
26+
return (''.join(pretty) +
27+
'\n {} {}'.format(err.__class__, err))
28+
29+
30+
def handler(context, data=None, loop=None):
31+
"""
32+
This is just an echo function
33+
:param context: request context
34+
:type context: hotfn.http.request.RequestContext
35+
:param data: request body
36+
:type data: object
37+
:param loop: asyncio event loop
38+
:type loop: asyncio.AbstractEventLoop
39+
:return: echo of request body
40+
:rtype: object
41+
"""
42+
headers = {
43+
"Content-Type": "text/plain",
44+
}
45+
rs = response.RawResponse(
46+
http_proto_version=context.version,
47+
status_code=200,
48+
headers=headers,
49+
response_data="OK"
50+
)
51+
print("response created", file=sys.stderr, flush=True)
52+
try:
53+
raise Exception("test-exception")
54+
except Exception as ex:
55+
print("exception raised", file=sys.stderr, flush=True)
56+
rs.status_code = 500
57+
rs.set_response_content(exception_to_string(ex))
58+
59+
return rs
60+
61+
62+
if __name__ == "__main__":
63+
fdk.handle(handler)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
fdk-python

0 commit comments

Comments
 (0)