Skip to content

Commit f5ef6a2

Browse files
committed
Fix issue with header prefixes, remove ambiguous header processing, add test
1 parent cd888dc commit f5ef6a2

7 files changed

Lines changed: 275 additions & 46 deletions

File tree

README.md

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,55 @@
11
# Function development kit for Python
2+
The python FDK lets you write functions in python 3.6/3.7
23

4+
## Simplest possible function
5+
6+
```python
7+
import io
8+
import logging
9+
10+
from fdk import response
11+
12+
def handler(ctx, data: io.BytesIO = None):
13+
logging.getLogger().info("Got incoming request")
14+
return response.Response(ctx, response_data="hello world")
15+
```
316

4-
While the FDK contract is HTTP, the intention is for that to be somewhat
5-
abstracted from the user - they write some Function code , this library helps them do that.
617

7-
## Handling JSON Functions
18+
## Handling HTTP metadata in HTTP Functions
19+
Functions can implement HTTP services when fronted by an HTTP Gateway
820

21+
When your function is behind an HTTP gateway you can access the inbound HTTP Request via :
22+
23+
- `ctx.HttpHeaders()` : a map of string -> value | list of values , unlike `ctx.Headers()` this only includes headers
24+
passed by the HTTP gateway (with no functions metadata).
25+
- `ctx.RequestURL()` : the incoming request URL passed by the gateway
26+
- `ctx.Method()` : the HTTP method of the incoming request
27+
28+
You can set outbound HTTP headers and the HTTP status of the request using `ctx.SetResponseHeaders` or the `Response`
29+
- e.g. `ctx.SetResponseHeaders({"Location","http://example.com/","My-Header2": ["v1","v2"]}, 302)`
30+
- or by passing these to the Response object :
31+
```python
32+
return new Response(ctx,
33+
headers={"Location","http://example.com/","My-Header2": ["v1","v2"]},
34+
response_data="Page moved",
35+
status_code=302)
36+
```
37+
38+
e.g. to redirect users to a different page :
39+
```python
40+
import io
41+
import logging
42+
43+
from fdk import response
44+
45+
def handler(ctx, data: io.BytesIO = None):
46+
logging.getLogger().info("Got incoming request for URL %s with headers %s", ctx.RequestURL(), ctx.HTTPHeaders())
47+
ctx.SetResponseHeaders({"Location": "http://www.example.com"}, 302)
48+
return response.Response(ctx, response_data="Page moved from %s")
49+
```
50+
51+
52+
## Handling JSON in Functions
953

1054
A main loop is supplied that can repeatedly call a user function with a series of requests.
1155
In order to utilise this, you can write your `func.py` as follows:
@@ -16,7 +60,6 @@ import io
1660

1761
from fdk import response
1862

19-
2063
def handler(ctx, data: io.BytesIO=None):
2164
name = "World"
2265
try:
@@ -34,8 +77,8 @@ def handler(ctx, data: io.BytesIO=None):
3477

3578
```
3679

37-
## Unittest your functions
3880

81+
## Unit testing your functions
3982

4083
Starting v0.0.33 FDK-Python provides a testing framework that allows performing unit tests of your function's code.
4184
The unit test framework is the [pytest](https://pytest.org/). Coding style remain the same, so, write your tests as you've got used to.

fdk/context.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ def __init__(self, app_id, fn_id, call_id,
5757
self.__call_id = call_id
5858
self.__config = config if config else {}
5959
self.__headers = headers if headers else {}
60+
self.__http_headers = {}
6061
self.__deadline = deadline
6162
self.__content_type = content_type
6263
self._request_url = request_url
@@ -66,8 +67,10 @@ def __init__(self, app_id, fn_id, call_id,
6667

6768
log.log("request headers. gateway: {0} {1}"
6869
.format(self.__is_gateway(), headers))
70+
6971
if self.__is_gateway():
70-
self.__headers = hs.decap_headers(self.__headers)
72+
self.__headers = hs.decap_headers(headers, True)
73+
self.__http_headers = hs.decap_headers(headers, False)
7174

7275
def AppID(self):
7376
return self.__app_id
@@ -84,6 +87,9 @@ def Config(self):
8487
def Headers(self):
8588
return self.__headers
8689

90+
def HTTPHeaders(self):
91+
return self.__http_headers
92+
8793
def Format(self):
8894
return self.__fn_format
8995

fdk/fixtures.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ async def process_response(fn_call_coro):
2424
response_data = resp.body()
2525
response_status = resp.status()
2626
response_headers = resp.context().GetResponseHeaders()
27-
print(response_headers)
2827

2928
return response_data, response_status, response_headers
3029

@@ -83,10 +82,17 @@ async def setup_fn_call(
8382
method=method, request_url=request_url,
8483
gateway=gateway
8584
)
85+
return await setup_fn_call_raw(handle_func, content, new_headers)
86+
87+
88+
async def setup_fn_call_raw(handle_func, content=None, headers=None):
89+
90+
if headers is None:
91+
headers = {}
8692

8793
# don't decap headers, so we can test them
8894
# (just like they come out of fdk)
8995
return process_response(runner.handle_request(
9096
code(handle_func), constants.HTTPSTREAM,
91-
headers=new_headers, data=content,
97+
headers=headers, data=content,
9298
))

fdk/headers.py

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,29 +15,58 @@
1515
from fdk import constants
1616

1717

18-
def decap_headers(hdsr):
18+
def decap_headers(hdsr, merge=True):
1919
ctx_headers = {}
2020
if hdsr is not None:
2121
for k, v in hdsr.items():
2222
k = k.lower()
2323
if k.startswith(constants.FN_HTTP_PREFIX):
24-
ctx_headers[k.lstrip(constants.FN_HTTP_PREFIX)] = v
25-
else:
26-
ctx_headers[k] = v
24+
push_header(ctx_headers, k[len(constants.FN_HTTP_PREFIX):], v)
25+
elif merge:
26+
# http headers override functions headers in context
27+
# this is not ideal but it's the more correct view from the
28+
# consumer perspective than random choice and for things
29+
# like host headers
30+
if k not in ctx_headers:
31+
ctx_headers[k] = v
2732
return ctx_headers
2833

2934

35+
def push_header(input_map, key, value):
36+
if key not in input_map:
37+
input_map[key] = value
38+
return
39+
40+
current_val = input_map[key]
41+
42+
if isinstance(current_val, list):
43+
if isinstance(value, list): # both lists concat
44+
input_map[key] = current_val + value
45+
else: # copy and append current value
46+
new_val = current_val.copy()
47+
new_val.append(value)
48+
input_map[key] = new_val
49+
else:
50+
if isinstance(value, list): # copy new list value and prepend current
51+
new_value = value.copy()
52+
new_value.insert(0, current_val)
53+
input_map[key] = new_value
54+
else: # both non-lists create a new list
55+
input_map[key] = [current_val, value]
56+
57+
3058
def encap_headers(headers, status=None):
3159
new_headers = {}
3260
if headers is not None:
3361
for k, v in headers.items():
3462
k = k.lower()
63+
if k.startswith(constants.FN_HTTP_PREFIX): # by default merge
64+
push_header(new_headers, k, v)
3565
if (k == constants.CONTENT_TYPE or
36-
k == constants.FN_FDK_VERSION or
37-
k.startswith(constants.FN_HTTP_PREFIX)):
66+
k == constants.FN_FDK_VERSION): # but don't merge these
3867
new_headers[k] = v
3968
else:
40-
new_headers[constants.FN_HTTP_PREFIX + k] = v
69+
push_header(new_headers, constants.FN_HTTP_PREFIX + k, v)
4170

4271
if status is not None:
4372
new_headers[constants.FN_HTTP_STATUS] = str(status)

fdk/tests/funcs.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717

1818
from fdk import response
1919

20-
2120
xml = """<!DOCTYPE mensaje SYSTEM "record.dtd">
2221
<record>
2322
<player_birthday>1979-09-23</player_birthday>
@@ -78,7 +77,6 @@ def none_func(ctx, data=None):
7877

7978

8079
def timed_sleepr(timeout):
81-
8280
def sleeper(ctx, data=None):
8381
time.sleep(timeout)
8482

@@ -122,3 +120,24 @@ def access_request_url(ctx, **kwargs):
122120
"Request-Method": method,
123121
}
124122
)
123+
124+
125+
captured_context = None
126+
127+
128+
def setup_context_capture():
129+
global captured_context
130+
captured_context = None
131+
132+
133+
def get_captured_context():
134+
global captured_context
135+
my_context = captured_context
136+
captured_context = None
137+
return my_context
138+
139+
140+
def capture_request_ctx(ctx, **kwargs):
141+
global captured_context
142+
captured_context = ctx
143+
return response.Response(ctx, response_data="OK")

fdk/tests/test_headers.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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+
from fdk import headers
16+
17+
18+
def test_push_header():
19+
cases = [
20+
({}, "k", "v", {"k": "v"}),
21+
({}, "k", ["v1", "v2"], {"k": ["v1", "v2"]}),
22+
({"k": "v1"}, "k", "v2", {"k": ["v1", "v2"]}),
23+
({"k": ["v1"]}, "k", "v2", {"k": ["v1", "v2"]}),
24+
({"k": ["v1"]}, "k", ["v2"], {"k": ["v1", "v2"]}),
25+
({"k": []}, "k", [], {"k": []}),
26+
({"k": ["v1"]}, "k", [], {"k": ["v1"]}),
27+
({"k": []}, "k", ["v1"], {"k": ["v1"]}),
28+
({"k": "v1"}, "k", ["v2", "v3"], {"k": ["v1", "v2", "v3"]}),
29+
({"k1": "v1"}, "k2", "v2", {"k1": "v1", "k2": "v2"}),
30+
31+
]
32+
33+
for case in cases:
34+
initial = case[0]
35+
working = initial.copy()
36+
key = case[1]
37+
value = case[2]
38+
result = case[3]
39+
headers.push_header(working, key, value)
40+
assert working == result, "Adding %s:%s to %s" \
41+
% (key, value, initial)
42+
43+
44+
def test_encap_no_headers():
45+
encap = headers.encap_headers({})
46+
assert not encap, "headers should be empty"
47+
48+
49+
def test_encap_simple_headers():
50+
encap = headers.encap_headers({
51+
"Test-header": "foo",
52+
"name-Conflict": "h1",
53+
"name-conflict": "h2",
54+
"nAme-conflict": ["h3", "h4"],
55+
"fn-http-h-name-conflict": "h5",
56+
"multi-header": ["bar", "baz"]
57+
})
58+
assert "fn-http-h-test-header" in encap
59+
assert "fn-http-h-name-conflict" in encap
60+
assert "fn-http-h-multi-header" in encap
61+
62+
assert encap["fn-http-h-test-header"] == "foo"
63+
assert set(encap["fn-http-h-name-conflict"]) == {"h1", "h2",
64+
"h3", "h4", "h5"}
65+
assert encap["fn-http-h-multi-header"] == ["bar", "baz"]
66+
67+
68+
def test_encap_status():
69+
encap = headers.encap_headers({}, 202)
70+
assert "fn-http-status" in encap
71+
assert encap["fn-http-status"] == "202"
72+
73+
74+
def test_encap_status_override():
75+
encap = headers.encap_headers({"fn-http-status": 412}, 202)
76+
assert "fn-http-status" in encap
77+
assert encap["fn-http-status"] == "202"
78+
79+
80+
def test_content_type_version():
81+
encap = headers.encap_headers({"content-type": "text/plain",
82+
"fn-fdk-version": "1.2.3"})
83+
84+
assert encap == {"content-type": "text/plain", "fn-fdk-version": "1.2.3"}
85+
86+
87+
def test_decap_headers_merge():
88+
decap = headers.decap_headers({"fn-http-h-Foo-Header": "v1",
89+
"fn-http-h-merge-header": "v2",
90+
"fn-http-h-merge-Header": ["v3"],
91+
"Foo-Header": "ignored",
92+
"other-header": "bob"}, True)
93+
assert "foo-header" in decap
94+
assert decap["foo-header"] == "v1"
95+
96+
assert "other-header" in decap
97+
assert decap["other-header"] == "bob"
98+
99+
assert "merge-header" in decap
100+
assert set(decap["merge-header"]) == {"v2", "v3"}
101+
102+
103+
def test_decap_headers_strip():
104+
decap = headers.decap_headers({"fn-http-h-Foo-Header": "v1",
105+
"fn-http-h-merge-header": ["v2"],
106+
"Foo-Header": "ignored",
107+
"merge-header": "v3",
108+
"other-header": "bad"}, False)
109+
assert decap == {"foo-header": "v1", "merge-header": ["v2"]}

0 commit comments

Comments
 (0)