• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

localstack / localstack / 08a45e28-4998-4845-a88b-f2c425830a31

21 Feb 2025 08:33PM UTC coverage: 86.896% (+0.01%) from 86.883%
08a45e28-4998-4845-a88b-f2c425830a31

push

circleci

web-flow
fix SNS FIFO ordering (#12285)

Co-authored-by: Daniel Fangl <daniel.fangl@localstack.cloud>

70 of 79 new or added lines in 2 files covered. (88.61%)

117 existing lines in 8 files now uncovered.

61670 of 70970 relevant lines covered (86.9%)

0.87 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.57
/localstack-core/localstack/services/apigateway/next_gen/execute_api/integrations/aws.py
1
import base64
1✔
2
import json
1✔
3
import logging
1✔
4
from functools import lru_cache
1✔
5
from http import HTTPMethod
1✔
6
from typing import Literal, Optional, TypedDict
1✔
7
from urllib.parse import urlparse
1✔
8

9
import requests
1✔
10
from botocore.exceptions import ClientError
1✔
11
from werkzeug.datastructures import Headers
1✔
12

13
from localstack import config
1✔
14
from localstack.aws.connect import (
1✔
15
    INTERNAL_REQUEST_PARAMS_HEADER,
16
    InternalRequestParameters,
17
    connect_to,
18
    dump_dto,
19
)
20
from localstack.aws.protocol.service_router import get_service_catalog
1✔
21
from localstack.constants import APPLICATION_JSON, INTERNAL_AWS_ACCESS_KEY_ID
1✔
22
from localstack.utils.aws.arns import extract_region_from_arn
1✔
23
from localstack.utils.aws.client_types import ServicePrincipal
1✔
24
from localstack.utils.strings import to_bytes, to_str
1✔
25

26
from ..context import (
1✔
27
    EndpointResponse,
28
    IntegrationRequest,
29
    InvocationRequest,
30
    RestApiInvocationContext,
31
)
32
from ..gateway_response import IntegrationFailureError, InternalServerError
1✔
33
from ..header_utils import build_multi_value_headers
1✔
34
from ..helpers import (
1✔
35
    get_lambda_function_arn_from_invocation_uri,
36
    get_source_arn,
37
    mime_type_matches_binary_media_types,
38
    render_uri_with_stage_variables,
39
    validate_sub_dict_of_typed_dict,
40
)
41
from ..variables import ContextVariables
1✔
42
from .core import RestApiIntegration
1✔
43

44
LOG = logging.getLogger(__name__)
1✔
45

46
NO_BODY_METHODS = {
1✔
47
    HTTPMethod.OPTIONS,
48
    HTTPMethod.GET,
49
    HTTPMethod.HEAD,
50
}
51

52

53
class LambdaProxyResponse(TypedDict, total=False):
1✔
54
    body: Optional[str]
1✔
55
    statusCode: Optional[int | str]
1✔
56
    headers: Optional[dict[str, str]]
1✔
57
    isBase64Encoded: Optional[bool]
1✔
58
    multiValueHeaders: Optional[dict[str, list[str]]]
1✔
59

60

61
class LambdaInputEvent(TypedDict, total=False):
1✔
62
    body: str
1✔
63
    isBase64Encoded: bool
1✔
64
    httpMethod: str | HTTPMethod
1✔
65
    resource: str
1✔
66
    path: str
1✔
67
    headers: dict[str, str]
1✔
68
    multiValueHeaders: dict[str, list[str]]
1✔
69
    queryStringParameters: dict[str, str]
1✔
70
    multiValueQueryStringParameters: dict[str, list[str]]
1✔
71
    requestContext: ContextVariables
1✔
72
    pathParameters: dict[str, str]
1✔
73
    stageVariables: dict[str, str]
1✔
74

75

76
class ParsedAwsIntegrationUri(TypedDict):
1✔
77
    service_name: str
1✔
78
    region_name: str
1✔
79
    action_type: Literal["path", "action"]
1✔
80
    path: str
1✔
81

82

83
@lru_cache(maxsize=64)
1✔
84
def get_service_factory(region_name: str, role_arn: str):
1✔
85
    if role_arn:
1✔
86
        return connect_to.with_assumed_role(
1✔
87
            role_arn=role_arn,
88
            region_name=region_name,
89
            service_principal=ServicePrincipal.apigateway,
90
            session_name="BackplaneAssumeRoleSession",
91
        )
92
    else:
93
        return connect_to(region_name=region_name)
1✔
94

95

96
@lru_cache(maxsize=64)
1✔
97
def get_internal_mocked_headers(
1✔
98
    service_name: str,
99
    region_name: str,
100
    source_arn: str,
101
    role_arn: str | None,
102
) -> dict[str, str]:
103
    if role_arn:
1✔
104
        access_key_id = (
1✔
105
            connect_to()
106
            .sts.request_metadata(service_principal=ServicePrincipal.apigateway)
107
            .assume_role(RoleArn=role_arn, RoleSessionName="BackplaneAssumeRoleSession")[
108
                "Credentials"
109
            ]["AccessKeyId"]
110
        )
111
    else:
112
        access_key_id = INTERNAL_AWS_ACCESS_KEY_ID
1✔
113

114
    dto = InternalRequestParameters(
1✔
115
        service_principal=ServicePrincipal.apigateway, source_arn=source_arn
116
    )
117
    # TODO: maybe use the localstack.utils.aws.client.SigningHttpClient instead of directly mocking the Authorization
118
    #  header (but will need to select the right signer depending on the service?)
119
    headers = {
1✔
120
        "Authorization": (
121
            "AWS4-HMAC-SHA256 "
122
            + f"Credential={access_key_id}/20160623/{region_name}/{service_name}/aws4_request, "
123
            + "SignedHeaders=content-type;host;x-amz-date;x-amz-target, Signature=1234"
124
        ),
125
        INTERNAL_REQUEST_PARAMS_HEADER: dump_dto(dto),
126
    }
127

128
    return headers
1✔
129

130

131
@lru_cache(maxsize=64)
1✔
132
def get_target_prefix_for_service(service_name: str) -> str | None:
1✔
133
    return get_service_catalog().get(service_name).metadata.get("targetPrefix")
1✔
134

135

136
class RestApiAwsIntegration(RestApiIntegration):
1✔
137
    """
138
    This is a REST API integration responsible to directly interact with AWS services. It uses the `uri` to
139
    map the incoming request to the concerned AWS service, and can have 2 types.
140
    - `path`: the request is targeting the direct URI of the AWS service, like you would with an HTTP client
141
     example: For S3 GetObject call: arn:aws:apigateway:us-west-2:s3:path/{bucket}/{key}
142
    - `action`: this is a simpler way, where you can pass the request parameters like you would do with an SDK, and you
143
     can specify the service action (for ex. here S3 `GetObject`). It seems the request parameters can be pass as query
144
     string parameters, JSON body and maybe more. TODO: verify, 2 documentation pages indicates divergent information.
145
    (one indicates parameters through QS, one through request body)
146
     example: arn:aws:apigateway:us-west-2:s3:action/GetObject&Bucket={bucket}&Key={key}
147

148
    https://docs.aws.amazon.com/apigateway/latest/developerguide/integration-request-basic-setup.html
149

150

151
    TODO: it seems we can global AWS integration type, we should not need to subclass for each service
152
     we just need to separate usage between the `path` URI type and the `action` URI type.
153
     - `path`, we can simply pass along the full rendered request along with specific `mocked` AWS headers
154
     that are dependant of the service (retrieving for the ARN in the uri)
155
     - `action`, we might need either a full Boto call or use the Boto request serializer, as it seems the request
156
     parameters are expected as parameters
157
    """
158

159
    name = "AWS"
1✔
160

161
    # TODO: it seems in AWS, you don't need to manually set the `X-Amz-Target` header when using the `action` type.
162
    #  for now, we know `events` needs the user to manually add the header, but Kinesis and DynamoDB don't.
163
    #  Maybe reverse the list to exclude instead of include.
164
    SERVICES_AUTO_TARGET = ["dynamodb", "kinesis", "ssm", "stepfunctions"]
1✔
165

166
    # TODO: some services still target the Query protocol (validated with AWS), even though SSM for example is JSON for
167
    #  as long as the Boto SDK exists. We will need to emulate the Query protocol and translate it to JSON
168
    SERVICES_LEGACY_QUERY_PROTOCOL = ["ssm"]
1✔
169

170
    SERVICE_MAP = {
1✔
171
        "states": "stepfunctions",
172
    }
173

174
    def __init__(self):
1✔
175
        self._base_domain = config.internal_service_url()
1✔
176
        self._base_host = ""
1✔
177
        self._service_names = get_service_catalog().service_names
1✔
178

179
    def invoke(self, context: RestApiInvocationContext) -> EndpointResponse:
1✔
180
        integration_req: IntegrationRequest = context.integration_request
1✔
181
        method = integration_req["http_method"]
1✔
182
        parsed_uri = self.parse_aws_integration_uri(integration_req["uri"])
1✔
183
        service_name = parsed_uri["service_name"]
1✔
184
        integration_region = parsed_uri["region_name"]
1✔
185

186
        if credentials := context.integration.get("credentials"):
1✔
187
            credentials = render_uri_with_stage_variables(credentials, context.stage_variables)
1✔
188

189
        headers = integration_req["headers"]
1✔
190
        # Some integrations will use a special format for the service in the URI, like AppSync, and so those requests
191
        # are not directed to a service directly, so need to add the Authorization header. It would fail parsing
192
        # by our service name parser anyway
193
        if service_name in self._service_names:
1✔
194
            headers.update(
1✔
195
                get_internal_mocked_headers(
196
                    service_name=service_name,
197
                    region_name=integration_region,
198
                    source_arn=get_source_arn(context),
199
                    role_arn=credentials,
200
                )
201
            )
202
        query_params = integration_req["query_string_parameters"].copy()
1✔
203
        data = integration_req["body"]
1✔
204

205
        if parsed_uri["action_type"] == "path":
1✔
206
            # the Path action type allows you to override the path the request is sent to, like you would send to AWS
207
            path = f"/{parsed_uri['path']}"
1✔
208
        else:
209
            # Action passes the `Action` query string parameter
210
            path = ""
1✔
211
            action = parsed_uri["path"]
1✔
212

213
            if target := self.get_action_service_target(service_name, action):
1✔
214
                headers["X-Amz-Target"] = target
1✔
215

216
            query_params["Action"] = action
1✔
217

218
            if service_name in self.SERVICES_LEGACY_QUERY_PROTOCOL:
1✔
219
                # this has been tested in AWS: for `ssm`, it fully overrides the body because SSM uses the Query
220
                # protocol, so we simulate it that way
221
                data = self.get_payload_from_query_string(query_params)
1✔
222

223
        url = f"{self._base_domain}{path}"
1✔
224
        headers["Host"] = self.get_internal_host_for_service(
1✔
225
            service_name=service_name, region_name=integration_region
226
        )
227

228
        request_parameters = {
1✔
229
            "method": method,
230
            "url": url,
231
            "params": query_params,
232
            "headers": headers,
233
        }
234

235
        if method not in NO_BODY_METHODS:
1✔
236
            request_parameters["data"] = data
1✔
237

238
        request_response = requests.request(**request_parameters)
1✔
239
        response_content = request_response.content
1✔
240

241
        if (
1✔
242
            parsed_uri["action_type"] == "action"
243
            and service_name in self.SERVICES_LEGACY_QUERY_PROTOCOL
244
        ):
245
            response_content = self.format_response_content_legacy(
1✔
246
                payload=response_content,
247
                service_name=service_name,
248
                action=parsed_uri["path"],
249
                request_id=context.context_variables["requestId"],
250
            )
251

252
        return EndpointResponse(
1✔
253
            body=response_content,
254
            status_code=request_response.status_code,
255
            headers=Headers(dict(request_response.headers)),
256
        )
257

258
    def parse_aws_integration_uri(self, uri: str) -> ParsedAwsIntegrationUri:
1✔
259
        """
260
        The URI can be of 2 shapes: Path or Action.
261
        Path  : arn:aws:apigateway:us-west-2:s3:path/{bucket}/{key}
262
        Action: arn:aws:apigateway:us-east-1:kinesis:action/PutRecord
263
        :param uri: the URI of the AWS integration
264
        :return: a ParsedAwsIntegrationUri containing the service name, the region and the type of action
265
        """
266
        arn, _, path = uri.partition("/")
1✔
267
        split_arn = arn.split(":", maxsplit=5)
1✔
268
        *_, region_name, service_name, action_type = split_arn
1✔
269
        boto_service_name = self.SERVICE_MAP.get(service_name, service_name)
1✔
270
        return ParsedAwsIntegrationUri(
1✔
271
            region_name=region_name,
272
            service_name=boto_service_name,
273
            action_type=action_type,
274
            path=path,
275
        )
276

277
    def get_action_service_target(self, service_name: str, action: str) -> str | None:
1✔
278
        if service_name not in self.SERVICES_AUTO_TARGET:
1✔
279
            return None
1✔
280

281
        target_prefix = get_target_prefix_for_service(service_name)
1✔
282
        if not target_prefix:
1✔
283
            return None
×
284

285
        return f"{target_prefix}.{action}"
1✔
286

287
    def get_internal_host_for_service(self, service_name: str, region_name: str):
1✔
288
        url = self._base_domain
1✔
289
        if service_name == "sqs":
1✔
290
            # This follow the new SQS_ENDPOINT_STRATEGY=standard
291
            url = config.external_service_url(subdomains=f"sqs.{region_name}")
1✔
292
        elif "-api" in service_name:
1✔
293
            # this could be an `<subdomain>.<service>-api`, used by some services
294
            url = config.external_service_url(subdomains=service_name)
×
295

296
        return urlparse(url).netloc
1✔
297

298
    @staticmethod
1✔
299
    def get_payload_from_query_string(query_string_parameters: dict) -> str:
1✔
300
        return json.dumps(query_string_parameters)
1✔
301

302
    @staticmethod
1✔
303
    def format_response_content_legacy(
1✔
304
        service_name: str, action: str, payload: bytes, request_id: str
305
    ) -> bytes:
306
        # TODO: not sure how much we need to support this, this supports SSM for now, once we write more tests for
307
        #  `action` type, see if we can generalize more
308
        data = json.loads(payload)
1✔
309
        try:
1✔
310
            # we try to populate the missing fields from the OperationModel of the operation
311
            operation_model = get_service_catalog().get(service_name).operation_model(action)
1✔
312
            for key in operation_model.output_shape.members:
1✔
313
                if key not in data:
1✔
314
                    data[key] = None
×
315

316
        except Exception:
×
317
            # the operation above is only for parity reason, skips if it fails
318
            pass
×
319

320
        wrapped = {
1✔
321
            f"{action}Response": {
322
                f"{action}Result": data,
323
                "ResponseMetadata": {
324
                    "RequestId": request_id,
325
                },
326
            }
327
        }
328
        return to_bytes(json.dumps(wrapped))
1✔
329

330

331
class RestApiAwsProxyIntegration(RestApiIntegration):
1✔
332
    """
333
    This is a custom, simplified REST API integration focused only on the Lambda service, with minimal modification from
334
    API Gateway. It passes the incoming request almost as is, in a custom created event payload, to the configured
335
    Lambda function.
336

337
    https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html
338
    """
339

340
    name = "AWS_PROXY"
1✔
341

342
    def invoke(self, context: RestApiInvocationContext) -> EndpointResponse:
1✔
343
        integration_req: IntegrationRequest = context.integration_request
1✔
344
        method = integration_req["http_method"]
1✔
345

346
        if method != HTTPMethod.POST:
1✔
347
            LOG.warning(
1✔
348
                "The 'AWS_PROXY' integration can only be used with the POST integration method.",
349
            )
350
            raise IntegrationFailureError("Internal server error")
1✔
351

352
        input_event = self.create_lambda_input_event(context)
1✔
353

354
        # TODO: verify stage variables rendering in AWS_PROXY
355
        integration_uri = integration_req["uri"]
1✔
356

357
        function_arn = get_lambda_function_arn_from_invocation_uri(integration_uri)
1✔
358
        source_arn = get_source_arn(context)
1✔
359

360
        # TODO: write test for credentials rendering
361
        if credentials := context.integration.get("credentials"):
1✔
362
            credentials = render_uri_with_stage_variables(credentials, context.stage_variables)
1✔
363

364
        try:
1✔
365
            lambda_payload = self.call_lambda(
1✔
366
                function_arn=function_arn,
367
                event=to_bytes(json.dumps(input_event)),
368
                source_arn=source_arn,
369
                credentials=credentials,
370
            )
371

372
        except ClientError as e:
×
373
            LOG.warning(
×
374
                "Exception during integration invocation: '%s'",
375
                e,
376
            )
377
            status_code = 502
×
378
            if e.response["Error"]["Code"] == "AccessDeniedException":
×
379
                status_code = 500
×
380
            raise IntegrationFailureError("Internal server error", status_code=status_code) from e
×
381

382
        except Exception as e:
×
383
            LOG.warning(
×
384
                "Unexpected exception during integration invocation: '%s'",
385
                e,
386
            )
387
            raise IntegrationFailureError("Internal server error", status_code=502) from e
×
388

389
        lambda_response = self.parse_lambda_response(lambda_payload)
1✔
390

391
        headers = Headers({"Content-Type": APPLICATION_JSON})
1✔
392

393
        response_headers = self._merge_lambda_response_headers(lambda_response)
1✔
394
        headers.update(response_headers)
1✔
395

396
        # TODO: maybe centralize this flag inside the context, when we are also using it for other integration types
397
        #  AWS_PROXY behaves a bit differently, but this could checked only once earlier
398
        binary_response_accepted = mime_type_matches_binary_media_types(
1✔
399
            mime_type=context.invocation_request["headers"].get("Accept"),
400
            binary_media_types=context.deployment.rest_api.rest_api.get("binaryMediaTypes", []),
401
        )
402
        body = self._parse_body(
1✔
403
            body=lambda_response.get("body"),
404
            is_base64_encoded=binary_response_accepted and lambda_response.get("isBase64Encoded"),
405
        )
406

407
        return EndpointResponse(
1✔
408
            headers=headers,
409
            body=body,
410
            status_code=int(lambda_response.get("statusCode") or 200),
411
        )
412

413
    @staticmethod
1✔
414
    def call_lambda(
1✔
415
        function_arn: str,
416
        event: bytes,
417
        source_arn: str,
418
        credentials: str = None,
419
    ) -> bytes:
420
        lambda_client = get_service_factory(
1✔
421
            region_name=extract_region_from_arn(function_arn),
422
            role_arn=credentials,
423
        ).lambda_
424
        inv_result = lambda_client.request_metadata(
1✔
425
            service_principal=ServicePrincipal.apigateway,
426
            source_arn=source_arn,
427
        ).invoke(
428
            FunctionName=function_arn,
429
            Payload=event,
430
            InvocationType="RequestResponse",
431
        )
432
        if payload := inv_result.get("Payload"):
1✔
433
            return payload.read()
1✔
434
        return b""
×
435

436
    def parse_lambda_response(self, payload: bytes) -> LambdaProxyResponse:
1✔
437
        try:
1✔
438
            lambda_response = json.loads(payload)
1✔
439
        except json.JSONDecodeError:
×
440
            LOG.warning(
×
441
                'Lambda output should follow the next JSON format: { "isBase64Encoded": true|false, "statusCode": httpStatusCode, "headers": { "headerName": "headerValue", ... },"body": "..."} but was: %s',
442
                payload,
443
            )
444
            LOG.debug(
×
445
                "Execution failed due to configuration error: Malformed Lambda proxy response"
446
            )
447
            raise InternalServerError("Internal server error", status_code=502)
×
448

449
        # none of the lambda response fields are mandatory, but you cannot return any other fields
450
        if not self._is_lambda_response_valid(lambda_response):
1✔
451
            if "errorMessage" in lambda_response:
1✔
452
                LOG.debug(
1✔
453
                    "Lambda execution failed with status 200 due to customer function error: %s. Lambda request id: %s",
454
                    lambda_response["errorMessage"],
455
                    lambda_response.get("requestId", "<Unknown Request Id>"),
456
                )
457
            else:
458
                LOG.warning(
1✔
459
                    'Lambda output should follow the next JSON format: { "isBase64Encoded": true|false, "statusCode": httpStatusCode, "headers": { "headerName": "headerValue", ... },"body": "..."} but was: %s',
460
                    payload,
461
                )
462
                LOG.debug(
1✔
463
                    "Execution failed due to configuration error: Malformed Lambda proxy response"
464
                )
465
            raise InternalServerError("Internal server error", status_code=502)
1✔
466

467
        def serialize_header(value: bool | str) -> str:
1✔
468
            if isinstance(value, bool):
1✔
469
                return "true" if value else "false"
1✔
470
            return value
1✔
471

472
        if headers := lambda_response.get("headers"):
1✔
473
            lambda_response["headers"] = {k: serialize_header(v) for k, v in headers.items()}
1✔
474

475
        if multi_value_headers := lambda_response.get("multiValueHeaders"):
1✔
476
            lambda_response["multiValueHeaders"] = {
1✔
477
                k: [serialize_header(v) for v in values]
478
                for k, values in multi_value_headers.items()
479
            }
480

481
        return lambda_response
1✔
482

483
    @staticmethod
1✔
484
    def _is_lambda_response_valid(lambda_response: dict) -> bool:
1✔
485
        if not isinstance(lambda_response, dict):
1✔
486
            return False
1✔
487

488
        if not validate_sub_dict_of_typed_dict(LambdaProxyResponse, lambda_response):
1✔
489
            return False
1✔
490

491
        if (headers := lambda_response.get("headers")) is not None:
1✔
492
            if not isinstance(headers, dict):
1✔
493
                return False
×
494
            if any(not isinstance(header_value, (str, bool)) for header_value in headers.values()):
1✔
495
                return False
×
496

497
        if (multi_value_headers := lambda_response.get("multiValueHeaders")) is not None:
1✔
498
            if not isinstance(multi_value_headers, dict):
1✔
499
                return False
×
500
            if any(
1✔
501
                not isinstance(header_value, list) for header_value in multi_value_headers.values()
502
            ):
503
                return False
1✔
504

505
        if "statusCode" in lambda_response:
1✔
506
            try:
1✔
507
                int(lambda_response["statusCode"])
1✔
508
            except ValueError:
1✔
509
                return False
1✔
510

511
        # TODO: add more validations of the values' type
512
        return True
1✔
513

514
    def create_lambda_input_event(self, context: RestApiInvocationContext) -> LambdaInputEvent:
1✔
515
        # https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format
516
        # for building the Lambda Payload, we need access to the Invocation Request, as some data is not available in
517
        # the integration request and does not make sense for it
518
        invocation_req: InvocationRequest = context.invocation_request
1✔
519
        integration_req: IntegrationRequest = context.integration_request
1✔
520

521
        body, is_b64_encoded = self._format_body(integration_req["body"])
1✔
522

523
        if context.base_path:
1✔
UNCOV
524
            path = context.context_variables["path"]
×
525
        else:
526
            path = invocation_req["path"]
1✔
527

528
        input_event = LambdaInputEvent(
1✔
529
            headers=self._format_headers(dict(integration_req["headers"])),
530
            multiValueHeaders=self._format_headers(
531
                build_multi_value_headers(integration_req["headers"])
532
            ),
533
            body=body or None,
534
            isBase64Encoded=is_b64_encoded,
535
            requestContext=context.context_variables,
536
            stageVariables=context.stage_variables,
537
            # still using the InvocationRequest query string parameters as the logic is the same, maybe refactor?
538
            queryStringParameters=invocation_req["query_string_parameters"] or None,
539
            multiValueQueryStringParameters=invocation_req["multi_value_query_string_parameters"]
540
            or None,
541
            pathParameters=invocation_req["path_parameters"] or None,
542
            httpMethod=invocation_req["http_method"],
543
            path=path,
544
            resource=context.resource["path"],
545
        )
546

547
        return input_event
1✔
548

549
    @staticmethod
1✔
550
    def _format_headers(headers: dict[str, str | list[str]]) -> dict[str, str | list[str]]:
1✔
551
        # Some headers get capitalized like in CloudFront, see
552
        # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/add-origin-custom-headers.html#add-origin-custom-headers-forward-authorization
553
        # It seems AWS_PROXY lambda integrations are behind CloudFront, as seen by the returned headers in AWS
554
        to_capitalize: list[str] = ["authorization", "user-agent"]  # some headers get capitalized
1✔
555
        to_filter: list[str] = ["content-length", "connection"]
1✔
556
        headers = {
1✔
557
            k.title() if k.lower() in to_capitalize else k: v
558
            for k, v in headers.items()
559
            if k.lower() not in to_filter
560
        }
561

562
        return headers
1✔
563

564
    @staticmethod
1✔
565
    def _format_body(body: bytes) -> tuple[str, bool]:
1✔
566
        try:
1✔
567
            return body.decode("utf-8"), False
1✔
UNCOV
568
        except UnicodeDecodeError:
×
UNCOV
569
            return to_str(base64.b64encode(body)), True
×
570

571
    @staticmethod
1✔
572
    def _parse_body(body: str | None, is_base64_encoded: bool) -> bytes:
1✔
573
        if not body:
1✔
574
            return b""
1✔
575

576
        if is_base64_encoded:
1✔
577
            try:
1✔
578
                return base64.b64decode(body)
1✔
579
            except Exception:
1✔
580
                raise InternalServerError("Internal server error", status_code=500)
1✔
581

582
        return to_bytes(body)
1✔
583

584
    @staticmethod
1✔
585
    def _merge_lambda_response_headers(lambda_response: LambdaProxyResponse) -> dict:
1✔
586
        headers = lambda_response.get("headers") or {}
1✔
587

588
        if multi_value_headers := lambda_response.get("multiValueHeaders"):
1✔
589
            # multiValueHeaders has the priority and will decide the casing of the final headers, as they are merged
590
            headers_low_keys = {k.lower(): v for k, v in headers.items()}
1✔
591

592
            for k, values in multi_value_headers.items():
1✔
593
                if (k_lower := k.lower()) in headers_low_keys:
1✔
594
                    headers[k] = [*values, headers_low_keys[k_lower]]
1✔
595
                else:
596
                    headers[k] = values
1✔
597

598
        return headers
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc