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

Enable support for multiple Set-Cookie response headers (native & wsgi/asgi) #1004

Merged
merged 25 commits into from
Jul 1, 2022
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
85a5807
initial add cookies attempt
Apr 1, 2022
5efa854
exclude workindexing proto api from this pr
Apr 8, 2022
f9d066e
Add parsing and nullable tests
Apr 11, 2022
113d761
initial add cookies attempt
Apr 1, 2022
3fe9f26
exclude workindexing proto api from this pr
Apr 8, 2022
8f710bb
Merge branch 'wangbill/multi-cookie-resp-headers' of https://github.c…
Apr 11, 2022
1df9789
delete random test
Apr 11, 2022
bf1ced6
Merge branch 'dev' of https://github.com/Azure/azure-functions-python…
May 3, 2022
07e7621
update multi-ccokie tests;add pystein tests
May 3, 2022
b1a6dc1
change to self.assertEqual
May 3, 2022
a57156c
fix flakey8
May 3, 2022
47f51f5
fix flakey8
May 3, 2022
a5e26bf
make dateutil install required
May 4, 2022
48c08d0
skip setting multi cookie headers test for py 3.7
May 6, 2022
7a9e5c4
Merge branch 'dev' into wangbill/multi-cookie-resp-headers
YunchuWang May 6, 2022
f1d411f
Merge branch 'dev' into wangbill/multi-cookie-resp-headers
YunchuWang Jun 10, 2022
3199f94
Merge branch 'dev' into wangbill/multi-cookie-resp-headers
YunchuWang Jun 17, 2022
a0f946d
skip 3.7 multi cookie tests
Jun 17, 2022
6a469e5
Merge branch 'dev' into wangbill/multi-cookie-resp-headers
YunchuWang Jun 17, 2022
6d461d5
skip linux consumption tests until dateutil goes in
Jun 30, 2022
fcafe6a
flakey8 fix
Jun 30, 2022
277f5be
Merge branch 'dev' into wangbill/multi-cookie-resp-headers
YunchuWang Jun 30, 2022
ec9bf5d
Merge branch 'dev' into wangbill/multi-cookie-resp-headers
YunchuWang Jul 1, 2022
a42bed1
update cookie tests
Jul 1, 2022
e845986
fix test
Jul 1, 2022
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
88 changes: 85 additions & 3 deletions azure_functions_worker/bindings/datumdef.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

import logging
from typing import Any, Optional
import json
from .. import protos
from ..logging import logger
from typing import List
try:
from http.cookies import SimpleCookie
except ImportError:
from Cookie import SimpleCookie
from dateutil import parser
from dateutil.parser import ParserError
from .nullable_converters import to_nullable_bool, to_nullable_string, \
to_nullable_double, to_nullable_timestamp


class Datum:
Expand Down Expand Up @@ -99,8 +108,8 @@ def from_rpc_shared_memory(
shmem: protos.RpcSharedMemory,
shmem_mgr) -> Optional['Datum']:
"""
Reads the specified shared memory region and converts the read data into
a datum object of the corresponding type.
Reads the specified shared memory region and converts the read data
into a datum object of the corresponding type.
"""
if shmem is None:
logger.warning('Cannot read from shared memory. '
Expand Down Expand Up @@ -183,10 +192,83 @@ def datum_as_proto(datum: Datum) -> protos.TypedData:
k: v.value
for k, v in datum.value['headers'].items()
},
cookies=parse_to_rpc_http_cookie_list(datum.value['cookies']),
enable_content_negotiation=False,
body=datum_as_proto(datum.value['body']),
))
else:
raise NotImplementedError(
'unexpected Datum type: {!r}'.format(datum.type)
)


def parse_to_rpc_http_cookie_list(cookies: Optional[List[SimpleCookie]]):
if cookies is None:
return cookies

rpc_http_cookies = []

for cookie in cookies:
for name, cookie_entity in cookie.items():
rpc_http_cookies.append(
protos.RpcHttpCookie(name=name,
value=cookie_entity.value,
domain=to_nullable_string(
cookie_entity['domain'],
'cookie.domain'),
path=to_nullable_string(
cookie_entity['path'], 'cookie.path'),
expires=to_nullable_timestamp(
parse_cookie_attr_expires(
cookie_entity), 'cookie.expires'),
secure=to_nullable_bool(
bool(cookie_entity['secure']),
'cookie.secure'),
http_only=to_nullable_bool(
bool(cookie_entity['httponly']),
'cookie.httpOnly'),
same_site=parse_cookie_attr_same_site(
cookie_entity),
max_age=to_nullable_double(
cookie_entity['max-age'],
'cookie.maxAge')))

return rpc_http_cookies


def parse_cookie_attr_expires(cookie_entity):
expires = cookie_entity['expires']

if expires is not None and len(expires) != 0:
try:
return parser.parse(expires)
except ParserError:
logging.error(
f"Can not parse value {expires} of expires in the cookie "
f"due to invalid format.")
raise
except OverflowError:
logging.error(
f"Can not parse value {expires} of expires in the cookie "
f"because the parsed date exceeds the largest valid C "
f"integer on your system.")
raise

return None


def parse_cookie_attr_same_site(cookie_entity):
same_site = getattr(protos.RpcHttpCookie.SameSite, "None")
try:
raw_same_site_str = cookie_entity['samesite'].lower()

if raw_same_site_str == 'lax':
same_site = protos.RpcHttpCookie.SameSite.Lax
elif raw_same_site_str == 'strict':
same_site = protos.RpcHttpCookie.SameSite.Strict
elif raw_same_site_str == 'none':
same_site = protos.RpcHttpCookie.SameSite.ExplicitNone
except Exception:
return same_site

return same_site
111 changes: 111 additions & 0 deletions azure_functions_worker/bindings/nullable_converters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
from datetime import datetime
from typing import Optional, Union

from google.protobuf.timestamp_pb2 import Timestamp

from azure_functions_worker import protos


def to_nullable_string(nullable: Optional[str], property_name: str) -> \
Optional[protos.NullableString]:
"""Converts string input to an 'NullableString' to be sent through the
RPC layer. Input that is not a string but is also not null or undefined
logs a function app level warning.

:param nullable Input to be converted to an NullableString if it is a
valid string
:param property_name The name of the property that the caller will
assign the output to. Used for debugging.
"""
if isinstance(nullable, str):
return protos.NullableString(value=nullable)

if nullable is not None:
raise TypeError(
f"A 'str' type was expected instead of a '{type(nullable)}' "
f"type. Cannot parse value {nullable} of '{property_name}'.")

return None


def to_nullable_bool(nullable: Optional[bool], property_name: str) -> \
Optional[protos.NullableBool]:
"""Converts boolean input to an 'NullableBool' to be sent through the
RPC layer. Input that is not a boolean but is also not null or undefined
logs a function app level warning.

:param nullable Input to be converted to an NullableBool if it is a
valid boolean
:param property_name The name of the property that the caller will
assign the output to. Used for debugging.
"""
if isinstance(nullable, bool):
return protos.NullableBool(value=nullable)

if nullable is not None:
raise TypeError(
f"A 'bool' type was expected instead of a '{type(nullable)}' "
f"type. Cannot parse value {nullable} of '{property_name}'.")

return None


def to_nullable_double(nullable: Optional[Union[str, int, float]],
property_name: str) -> \
Optional[protos.NullableDouble]:
"""Converts int or float or str that parses to a number to an
'NullableDouble' to be sent through the RPC layer. Input that is not a
valid number but is also not null or undefined logs a function app level
warning.
:param nullable Input to be converted to an NullableDouble if it is a
valid number
:param property_name The name of the property that the caller will
assign the output to. Used for debugging.
"""
if isinstance(nullable, int) or isinstance(nullable, float):
return protos.NullableDouble(value=nullable)
elif isinstance(nullable, str):
if len(nullable) == 0:
return None

YunchuWang marked this conversation as resolved.
Show resolved Hide resolved
try:
return protos.NullableDouble(value=float(nullable))
except Exception:
raise TypeError(
f"Cannot parse value {nullable} of '{property_name}' to "
f"float.")

if nullable is not None:
raise TypeError(
f"A 'int' or 'float'"
f" type was expected instead of a '{type(nullable)}' "
f"type. Cannot parse value {nullable} of '{property_name}'.")

return None


def to_nullable_timestamp(date_time: Optional[Union[datetime, int]],
property_name: str) -> protos.NullableTimestamp:
"""Converts Date or number input to an 'NullableTimestamp' to be sent
through the RPC layer. Input that is not a Date or number but is also
not null or undefined logs a function app level warning.

:param date_time Input to be converted to an NullableTimestamp if it is
valid input
:param property_name The name of the property that the caller will
assign the output to. Used for debugging.
"""
if date_time is not None:
try:
time_in_seconds = date_time if isinstance(date_time,
int) else \
date_time.timestamp()

return protos.NullableTimestamp(
value=Timestamp(seconds=int(time_in_seconds)))
except Exception:
raise TypeError(
f"A 'datetime' or 'int'"
f" type was expected instead of a '{type(date_time)}' "
f"type. Cannot parse value {date_time} of '{property_name}'.")
return None
6 changes: 0 additions & 6 deletions azure_functions_worker/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,12 +206,6 @@ def on_logging(self, record: logging.LogRecord, formatted_msg: str) -> None:
if invocation_id is not None:
log['invocation_id'] = invocation_id

# XXX: When an exception field is set in RpcLog, WebHost doesn't
# wait for the call result and simply aborts the execution.
#
# if record.exc_info and record.exc_info[1] is not None:
# log['exception'] = self._serialize_exception(record.exc_info[1])

self._grpc_resp_queue.put_nowait(
protos.StreamingMessage(
request_id=self.request_id,
Expand Down
8 changes: 8 additions & 0 deletions azure_functions_worker/protos/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,18 @@
ParameterBinding,
TypedData,
RpcHttp,
RpcHttpCookie,
RpcLog,
RpcSharedMemory,
RpcDataType,
CloseSharedMemoryResourcesRequest,
CloseSharedMemoryResourcesResponse,
FunctionsMetadataRequest,
FunctionMetadataResponse)

from .shared.NullableTypes_pb2 import (
NullableString,
NullableBool,
NullableDouble,
NullableTimestamp
)
7 changes: 2 additions & 5 deletions tests/endtoend/test_linux_consumption.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,18 @@
# Licensed under the MIT License.
import os
import sys
from unittest import TestCase, skipIf
from unittest import TestCase, skip

from requests import Request

from azure_functions_worker.testutils_lc import (
LinuxConsumptionWebHostController
)
from azure_functions_worker.utils.common import is_python_version

_DEFAULT_HOST_VERSION = "4"


@skipIf(is_python_version('3.10'),
"Skip the tests for Python 3.10 currently as the mesh images for "
"Python 3.10 aren't available currently.")
@skip
class TestLinuxConsumption(TestCase):
"""Test worker behaviors on specific scenarios.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,3 +318,89 @@ def user_event_loop(req: func.HttpRequest) -> func.HttpResponse:
loop.run_until_complete(try_log())
loop.close()
return 'OK-user-event-loop'


@app.route(route="multiple_set_cookie_resp_headers")
def multiple_set_cookie_resp_headers(
req: func.HttpRequest) -> func.HttpResponse:
logging.info('Python HTTP trigger function processed a request.')
resp = func.HttpResponse(
"This HTTP triggered function executed successfully.")

resp.headers.add("Set-Cookie",
'foo3=42; Domain=example.com; Expires=Thu, 12-Jan-2017 '
'13:55:08 GMT; Path=/; Max-Age=10000000; Secure; '
'HttpOnly')
resp.headers.add("Set-Cookie",
'foo3=43; Domain=example.com; Expires=Thu, 12-Jan-2018 '
'13:55:08 GMT; Path=/; Max-Age=10000000; Secure; '
'HttpOnly')
resp.headers.add("HELLO", 'world')

return resp


@app.route(route="response_cookie_header_nullable_bool_err")
def response_cookie_header_nullable_bool_err(
req: func.HttpRequest) -> func.HttpResponse:
logging.info('Python HTTP trigger function processed a request.')
resp = func.HttpResponse(
"This HTTP triggered function executed successfully.")

resp.headers.add("Set-Cookie",
'foo3=42; Domain=example.com; Expires=Thu, 12-Jan-2017 '
'13:55:08 GMT; Path=/; Max-Age=10000000; SecureFalse; '
'HttpOnly')

return resp


@app.route(route="response_cookie_header_nullable_double_err")
def response_cookie_header_nullable_double_err(
req: func.HttpRequest) -> func.HttpResponse:
logging.info('Python HTTP trigger function processed a request.')
resp = func.HttpResponse(
"This HTTP triggered function executed successfully.")

resp.headers.add("Set-Cookie",
'foo3=42; Domain=example.com; Expires=Thu, 12-Jan-2017 '
'13:55:08 GMT; Path=/; Max-Age=Dummy; SecureFalse; '
'HttpOnly')

return resp


@app.route(route="response_cookie_header_nullable_timestamp_err")
def response_cookie_header_nullable_timestamp_err(
req: func.HttpRequest) -> func.HttpResponse:
logging.info('Python HTTP trigger function processed a request.')
resp = func.HttpResponse(
"This HTTP triggered function executed successfully.")

resp.headers.add("Set-Cookie", 'foo=bar; Domain=123; Expires=Dummy')

return resp


@app.route(route="set_cookie_resp_header_default_values")
def set_cookie_resp_header_default_values(
req: func.HttpRequest) -> func.HttpResponse:
logging.info('Python HTTP trigger function processed a request.')
resp = func.HttpResponse(
"This HTTP triggered function executed successfully.")

resp.headers.add("Set-Cookie", 'foo=bar')

return resp


@app.route(route="set_cookie_resp_header_empty")
def set_cookie_resp_header_empty(
req: func.HttpRequest) -> func.HttpResponse:
logging.info('Python HTTP trigger function processed a request.')
resp = func.HttpResponse(
"This HTTP triggered function executed successfully.")

resp.headers.add("Set-Cookie", '')

return resp
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"scriptFile": "main.py",
"bindings": [
{
"type": "httpTrigger",
"direction": "in",
"name": "req"
},
{
"type": "http",
"direction": "out",
"name": "$return"
}
]
}
Loading