Skip to content

Commit 0519fa3

Browse files
feat(event_handler): add support for multiValueQueryStringParameters in OpenAPI schema (#3667)
* Initial code for multivalue querystring * Adding tests and improving code * Adding tests and improving code * Refactoging to avoid abstraction leaky * Making Pydanticv2 happy * Adding documentation * Addressing Ruben's feedback * Addressing Ruben's feedback * Mypy....
1 parent f98ead0 commit 0519fa3

File tree

13 files changed

+544
-4
lines changed

13 files changed

+544
-4
lines changed

aws_lambda_powertools/event_handler/middlewares/openapi_validation.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
_regenerate_error_with_loc,
1717
get_missing_field_error,
1818
)
19+
from aws_lambda_powertools.event_handler.openapi.dependant import is_scalar_field
1920
from aws_lambda_powertools.event_handler.openapi.encoders import jsonable_encoder
2021
from aws_lambda_powertools.event_handler.openapi.exceptions import RequestValidationError
2122
from aws_lambda_powertools.event_handler.openapi.params import Param
@@ -68,10 +69,16 @@ def handler(self, app: EventHandlerInstance, next_middleware: NextMiddleware) ->
6869
app.context["_route_args"],
6970
)
7071

72+
# Normalize query values before validate this
73+
query_string = _normalize_multi_query_string_with_param(
74+
app.current_event.resolved_query_string_parameters,
75+
route.dependant.query_params,
76+
)
77+
7178
# Process query values
7279
query_values, query_errors = _request_params_to_args(
7380
route.dependant.query_params,
74-
app.current_event.query_string_parameters or {},
81+
query_string,
7582
)
7683

7784
values.update(path_values)
@@ -344,3 +351,29 @@ def _get_embed_body(
344351
received_body = {field.alias: received_body}
345352

346353
return received_body, field_alias_omitted
354+
355+
356+
def _normalize_multi_query_string_with_param(query_string: Optional[Dict[str, str]], params: Sequence[ModelField]):
357+
"""
358+
Extract and normalize resolved_query_string_parameters
359+
360+
Parameters
361+
----------
362+
query_string: Dict
363+
A dictionary containing the initial query string parameters.
364+
params: Sequence[ModelField]
365+
A sequence of ModelField objects representing parameters.
366+
367+
Returns
368+
-------
369+
A dictionary containing the processed multi_query_string_parameters.
370+
"""
371+
if query_string:
372+
for param in filter(is_scalar_field, params):
373+
try:
374+
# if the target parameter is a scalar, we keep the first value of the query string
375+
# regardless if there are more in the payload
376+
query_string[param.name] = query_string[param.name][0]
377+
except KeyError:
378+
pass
379+
return query_string

aws_lambda_powertools/utilities/data_classes/alb_event.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Dict, List, Optional
1+
from typing import Any, Dict, List, Optional
22

33
from aws_lambda_powertools.shared.headers_serializer import (
44
BaseHeadersSerializer,
@@ -35,6 +35,13 @@ def request_context(self) -> ALBEventRequestContext:
3535
def multi_value_query_string_parameters(self) -> Optional[Dict[str, List[str]]]:
3636
return self.get("multiValueQueryStringParameters")
3737

38+
@property
39+
def resolved_query_string_parameters(self) -> Optional[Dict[str, Any]]:
40+
if self.multi_value_query_string_parameters:
41+
return self.multi_value_query_string_parameters
42+
43+
return self.query_string_parameters
44+
3845
@property
3946
def multi_value_headers(self) -> Optional[Dict[str, List[str]]]:
4047
return self.get("multiValueHeaders")

aws_lambda_powertools/utilities/data_classes/api_gateway_proxy_event.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,13 @@ def multi_value_headers(self) -> Dict[str, List[str]]:
118118
def multi_value_query_string_parameters(self) -> Optional[Dict[str, List[str]]]:
119119
return self.get("multiValueQueryStringParameters")
120120

121+
@property
122+
def resolved_query_string_parameters(self) -> Optional[Dict[str, Any]]:
123+
if self.multi_value_query_string_parameters:
124+
return self.multi_value_query_string_parameters
125+
126+
return self.query_string_parameters
127+
121128
@property
122129
def request_context(self) -> APIGatewayEventRequestContext:
123130
return APIGatewayEventRequestContext(self._data)
@@ -299,3 +306,13 @@ def http_method(self) -> str:
299306

300307
def header_serializer(self):
301308
return HttpApiHeadersSerializer()
309+
310+
@property
311+
def resolved_query_string_parameters(self) -> Optional[Dict[str, Any]]:
312+
if self.query_string_parameters is not None:
313+
query_string = {
314+
key: value.split(",") if "," in value else value for key, value in self.query_string_parameters.items()
315+
}
316+
return query_string
317+
318+
return {}

aws_lambda_powertools/utilities/data_classes/bedrock_agent_event.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,7 @@ def query_string_parameters(self) -> Optional[Dict[str, str]]:
108108
# In Bedrock Agent events, query string parameters are passed as undifferentiated parameters,
109109
# together with the other parameters. So we just return all parameters here.
110110
return {x["name"]: x["value"] for x in self["parameters"]} if self.get("parameters") else None
111+
112+
@property
113+
def resolved_query_string_parameters(self) -> Optional[Dict[str, str]]:
114+
return self.query_string_parameters

aws_lambda_powertools/utilities/data_classes/common.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,17 @@ def headers(self) -> Dict[str, str]:
103103
def query_string_parameters(self) -> Optional[Dict[str, str]]:
104104
return self.get("queryStringParameters")
105105

106+
@property
107+
def resolved_query_string_parameters(self) -> Optional[Dict[str, str]]:
108+
"""
109+
This property determines the appropriate query string parameter to be used
110+
as a trusted source for validating OpenAPI.
111+
112+
This is necessary because different resolvers use different formats to encode
113+
multi query string parameters.
114+
"""
115+
return self.query_string_parameters
116+
106117
@property
107118
def is_base64_encoded(self) -> Optional[bool]:
108119
return self.get("isBase64Encoded")

aws_lambda_powertools/utilities/data_classes/vpc_lattice.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,10 @@ def query_string_parameters(self) -> Dict[str, str]:
141141
"""The request query string parameters."""
142142
return self["query_string_parameters"]
143143

144+
@property
145+
def resolved_query_string_parameters(self) -> Optional[Dict[str, str]]:
146+
return self.query_string_parameters
147+
144148

145149
class vpcLatticeEventV2Identity(DictWrapper):
146150
@property
@@ -251,3 +255,7 @@ def request_context(self) -> vpcLatticeEventV2RequestContext:
251255
def query_string_parameters(self) -> Optional[Dict[str, str]]:
252256
"""The request query string parameters."""
253257
return self.get("queryStringParameters")
258+
259+
@property
260+
def resolved_query_string_parameters(self) -> Optional[Dict[str, str]]:
261+
return self.query_string_parameters

docs/core/event_handler/api_gateway.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,16 @@ In the following example, we use a new `Query` OpenAPI type to add [one out of m
400400

401401
1. `completed` is still the same query string as before, except we simply state it's an string. No `Query` or `Annotated` to validate it.
402402

403+
=== "working_with_multi_query_values.py"
404+
405+
If you need to handle multi-value query parameters, you can create a list of the desired type.
406+
407+
```python hl_lines="23"
408+
--8<-- "examples/event_handler_rest/src/working_with_multi_query_values.py"
409+
```
410+
411+
1. `example_multi_value_param` is a list containing values from the `ExampleEnum` enumeration.
412+
403413
<!-- markdownlint-enable MD013 -->
404414

405415
#### Validating path parameters
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from enum import Enum
2+
from typing import List
3+
4+
from aws_lambda_powertools.event_handler import APIGatewayRestResolver
5+
from aws_lambda_powertools.event_handler.openapi.params import Query
6+
from aws_lambda_powertools.shared.types import Annotated
7+
from aws_lambda_powertools.utilities.typing import LambdaContext
8+
9+
app = APIGatewayRestResolver(enable_validation=True)
10+
11+
12+
class ExampleEnum(Enum):
13+
"""Example of an Enum class."""
14+
15+
ONE = "value_one"
16+
TWO = "value_two"
17+
THREE = "value_three"
18+
19+
20+
@app.get("/todos")
21+
def get(
22+
example_multi_value_param: Annotated[
23+
List[ExampleEnum], # (1)!
24+
Query(
25+
description="This is multi value query parameter.",
26+
),
27+
],
28+
):
29+
"""Return validated multi-value param values."""
30+
return example_multi_value_param
31+
32+
33+
def lambda_handler(event: dict, context: LambdaContext) -> dict:
34+
return app.resolve(event, context)
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"requestContext": {
3+
"elb": {
4+
"targetGroupArn": "arn:aws:elasticloadbalancing:eu-central-1:1234567890:targetgroup/alb-c-Targe-11GDXTPQ7663S/804a67588bfdc10f"
5+
}
6+
},
7+
"httpMethod": "GET",
8+
"path": "/todos",
9+
"multiValueQueryStringParameters": {
10+
"parameter1": ["value1","value2"],
11+
"parameter2": ["value"]
12+
},
13+
"multiValueHeaders": {
14+
"accept": [
15+
"*/*"
16+
],
17+
"host": [
18+
"alb-c-LoadB-14POFKYCLBNSF-1815800096.eu-central-1.elb.amazonaws.com"
19+
],
20+
"user-agent": [
21+
"curl/7.79.1"
22+
],
23+
"x-amzn-trace-id": [
24+
"Root=1-62fa9327-21cdd4da4c6db451490a5fb7"
25+
],
26+
"x-forwarded-for": [
27+
"123.123.123.123"
28+
],
29+
"x-forwarded-port": [
30+
"80"
31+
],
32+
"x-forwarded-proto": [
33+
"http"
34+
]
35+
},
36+
"body": "",
37+
"isBase64Encoded": false
38+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
{
2+
"version":"2.0",
3+
"routeKey":"$default",
4+
"rawPath":"/",
5+
"rawQueryString":"",
6+
"headers":{
7+
"sec-fetch-mode":"navigate",
8+
"x-amzn-tls-version":"TLSv1.2",
9+
"sec-fetch-site":"cross-site",
10+
"accept-language":"pt-BR,pt;q=0.9",
11+
"x-forwarded-proto":"https",
12+
"x-forwarded-port":"443",
13+
"x-forwarded-for":"123.123.123.123",
14+
"sec-fetch-user":"?1",
15+
"accept":"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
16+
"x-amzn-tls-cipher-suite":"ECDHE-RSA-AES128-GCM-SHA256",
17+
"sec-ch-ua":"\" Not A;Brand\";v=\"99\", \"Chromium\";v=\"102\", \"Google Chrome\";v=\"102\"",
18+
"sec-ch-ua-mobile":"?0",
19+
"x-amzn-trace-id":"Root=1-62ecd163-5f302e550dcde3b12402207d",
20+
"sec-ch-ua-platform":"\"Linux\"",
21+
"host":"<url-id>.lambda-url.us-east-1.on.aws",
22+
"upgrade-insecure-requests":"1",
23+
"cache-control":"max-age=0",
24+
"accept-encoding":"gzip, deflate, br",
25+
"sec-fetch-dest":"document",
26+
"user-agent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36"
27+
},
28+
"queryStringParameters": {
29+
"parameter1": "value1,value2",
30+
"parameter2": "value"
31+
},
32+
"requestContext":{
33+
"accountId":"anonymous",
34+
"apiId":"<url-id>",
35+
"domainName":"<url-id>.lambda-url.us-east-1.on.aws",
36+
"domainPrefix":"<url-id>",
37+
"http":{
38+
"method":"GET",
39+
"path":"/",
40+
"protocol":"HTTP/1.1",
41+
"sourceIp":"123.123.123.123",
42+
"userAgent":"agent"
43+
},
44+
"requestId":"id",
45+
"routeKey":"$default",
46+
"stage":"$default",
47+
"time":"05/Aug/2022:08:14:39 +0000",
48+
"timeEpoch":1659687279885
49+
},
50+
"isBase64Encoded":false
51+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"version": "2.0",
3+
"path": "/newpath",
4+
"method": "GET",
5+
"headers": {
6+
"user_agent": "curl/7.64.1",
7+
"x-forwarded-for": "10.213.229.10",
8+
"host": "test-lambda-service-3908sdf9u3u.dkfjd93.vpc-lattice-svcs.us-east-2.on.aws",
9+
"accept": "*/*"
10+
},
11+
"queryStringParameters": {
12+
"parameter1": [
13+
"value1",
14+
"value2"
15+
],
16+
"parameter2": [
17+
"value"
18+
]
19+
},
20+
"body": "{\"message\": \"Hello from Lambda!\"}",
21+
"isBase64Encoded": false,
22+
"requestContext": {
23+
"serviceNetworkArn": "arn:aws:vpc-lattice:us-east-2:123456789012:servicenetwork/sn-0bf3f2882e9cc805a",
24+
"serviceArn": "arn:aws:vpc-lattice:us-east-2:123456789012:service/svc-0a40eebed65f8d69c",
25+
"targetGroupArn": "arn:aws:vpc-lattice:us-east-2:123456789012:targetgroup/tg-6d0ecf831eec9f09",
26+
"identity": {
27+
"sourceVpcArn": "arn:aws:ec2:region:123456789012:vpc/vpc-0b8276c84697e7339",
28+
"type" : "AWS_IAM",
29+
"principal": "arn:aws:sts::123456789012:assumed-role/example-role/057d00f8b51257ba3c853a0f248943cf",
30+
"sessionName": "057d00f8b51257ba3c853a0f248943cf",
31+
"x509SanDns": "example.com"
32+
},
33+
"region": "us-east-2",
34+
"timeEpoch": "1696331543569073"
35+
}
36+
}

tests/functional/event_handler/test_openapi_params.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,20 @@ def handler(page: Annotated[str, Query(include_in_schema=False)]):
184184
assert get.parameters is None
185185

186186

187+
def test_openapi_with_list_param():
188+
app = APIGatewayRestResolver()
189+
190+
@app.get("/")
191+
def handler(page: Annotated[List[str], Query()]):
192+
return page
193+
194+
schema = app.get_openapi_schema()
195+
assert len(schema.paths.keys()) == 1
196+
197+
get = schema.paths["/"].get
198+
assert get.parameters[0].schema_.type == "array"
199+
200+
187201
def test_openapi_with_description():
188202
app = APIGatewayRestResolver()
189203

0 commit comments

Comments
 (0)