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

localstack / localstack / 17086927072

19 Aug 2025 10:02PM UTC coverage: 86.889% (+0.01%) from 86.875%
17086927072

push

github

web-flow
APIGW: fix TestInvokeMethod path logic (#13030)

4 of 23 new or added lines in 1 file covered. (17.39%)

264 existing lines in 17 files now uncovered.

67018 of 77131 relevant lines covered (86.89%)

0.87 hits per line

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

93.41
/localstack-core/localstack/aws/protocol/parser.py
1
"""
2
Request parsers for the different AWS service protocols.
3

4
The module contains classes that take an HTTP request to a service, and
5
given an operation model, parse the HTTP request according to the
6
specified input shape.
7

8
It can be seen as the counterpart to the ``serialize`` module in
9
``botocore`` (which serializes the request before sending it to this
10
parser). It has a lot of similarities with the ``parse`` module in
11
``botocore``, but serves a different purpose (parsing requests
12
instead of responses).
13

14
The different protocols have many similarities. The class hierarchy is
15
designed such that the parsers share as much logic as possible.
16
The class hierarchy looks as follows:
17
::
18
                          ┌─────────────┐
19
                          │RequestParser│
20
                          └─────────────┘
21
                             ▲   ▲   ▲
22
           ┌─────────────────┘   │   └────────────────────┐
23
  ┌────────┴─────────┐ ┌─────────┴───────────┐ ┌──────────┴──────────┐
24
  │QueryRequestParser│ │BaseRestRequestParser│ │BaseJSONRequestParser│
25
  └──────────────────┘ └─────────────────────┘ └─────────────────────┘
26
          ▲                    ▲            ▲   ▲           ▲
27
  ┌───────┴────────┐ ┌─────────┴──────────┐ │   │           │
28
  │EC2RequestParser│ │RestXMLRequestParser│ │   │           │
29
  └────────────────┘ └────────────────────┘ │   │           │
30
                           ┌────────────────┴───┴┐ ┌────────┴────────┐
31
                           │RestJSONRequestParser│ │JSONRequestParser│
32
                           └─────────────────────┘ └─────────────────┘
33
::
34

35
The ``RequestParser`` contains the logic that is used among all the
36
different protocols (``query``, ``json``, ``rest-json``, ``rest-xml``,
37
and ``ec2``).
38
The relation between the different protocols is described in the
39
``serializer``.
40

41
The classes are structured as follows:
42

43
* The ``RequestParser`` contains all the basic logic for the parsing
44
  which is shared among all different protocols.
45
* The ``BaseRestRequestParser`` contains the logic for the REST
46
  protocol specifics (i.e. specific HTTP metadata parsing).
47
* The ``BaseJSONRequestParser`` contains the logic for the JSON body
48
  parsing.
49
* The ``RestJSONRequestParser`` inherits the ReST specific logic from
50
  the ``BaseRestRequestParser`` and the JSON body parsing from the
51
  ``BaseJSONRequestParser``.
52
* The ``QueryRequestParser``, ``RestXMLRequestParser``, and the
53
  ``JSONRequestParser`` have a conventional inheritance structure.
54

55
The services and their protocols are defined by using AWS's Smithy
56
(a language to define services in a - somewhat - protocol-agnostic
57
way). The "peculiarities" in this parser code usually correspond
58
to certain so-called "traits" in Smithy.
59

60
The result of the parser methods are the operation model of the
61
service's action which the request was aiming for, as well as the
62
parsed parameters for the service's function invocation.
63
"""
64

65
import abc
1✔
66
import base64
1✔
67
import datetime
1✔
68
import functools
1✔
69
import re
1✔
70
from abc import ABC
1✔
71
from collections.abc import Mapping
1✔
72
from email.utils import parsedate_to_datetime
1✔
73
from typing import IO, Any
1✔
74
from xml.etree import ElementTree as ETree
1✔
75

76
import dateutil.parser
1✔
77
from botocore.model import (
1✔
78
    ListShape,
79
    MapShape,
80
    OperationModel,
81
    OperationNotFoundError,
82
    ServiceModel,
83
    Shape,
84
    StructureShape,
85
)
86

87
# cbor2: explicitly load from private _decoder module to avoid using the (non-patched) C-version
88
from cbor2._decoder import loads as cbor2_loads
1✔
89
from werkzeug.exceptions import BadRequest, NotFound
1✔
90

91
from localstack.aws.protocol.op_router import RestServiceOperationRouter
1✔
92
from localstack.http import Request
1✔
93

94

95
def _text_content(func):
1✔
96
    """
97
    This decorator hides the difference between an XML node with text or a plain string.
98
    It's used to ensure that scalar processing operates only on text strings, which
99
    allows the same scalar handlers to be used for XML nodes from the body, HTTP headers,
100
    and across different protocols.
101

102
    :param func: function which should be wrapped
103
    :return: wrapper function which can be called with a node or a string, where the
104
             wrapped function is always called with a string
105
    """
106

107
    def _get_text_content(
1✔
108
        self,
109
        request: Request,
110
        shape: Shape,
111
        node_or_string: ETree.Element | str,
112
        uri_params: Mapping[str, Any] = None,
113
    ):
114
        if hasattr(node_or_string, "text"):
1✔
115
            text = node_or_string.text
1✔
116
            if text is None:
1✔
117
                # If an XML node is empty <foo></foo>, we want to parse that as an empty string,
118
                # not as a null/None value.
119
                text = ""
1✔
120
        else:
121
            text = node_or_string
1✔
122
        return func(self, request, shape, text, uri_params)
1✔
123

124
    return _get_text_content
1✔
125

126

127
class RequestParserError(Exception):
1✔
128
    """
129
    Error which is thrown if the request parsing fails.
130
    Super class of all exceptions raised by the parser.
131
    """
132

133
    pass
1✔
134

135

136
class UnknownParserError(RequestParserError):
1✔
137
    """
138
    Error which indicates that the raised exception of the parser could be caused by invalid data or by any other
139
    (unknown) issue. Errors like this should be reported and indicate an issue in the parser itself.
140
    """
141

142
    pass
1✔
143

144

145
class ProtocolParserError(RequestParserError):
1✔
146
    """
147
    Error which indicates that the given data is not compliant with the service's specification and cannot be parsed.
148
    This usually results in a response with an HTTP 4xx status code (client error).
149
    """
150

151
    pass
1✔
152

153

154
class OperationNotFoundParserError(ProtocolParserError):
1✔
155
    """
156
    Error which indicates that the given data cannot be matched to a specific operation.
157
    The request is likely _not_ meant to be handled by the ASF service provider itself.
158
    """
159

160
    pass
1✔
161

162

163
def _handle_exceptions(func):
1✔
164
    """
165
    Decorator which handles the exceptions raised by the parser. It ensures that all exceptions raised by the public
166
    methods of the parser are instances of RequestParserError.
167
    :param func: to wrap in order to add the exception handling
168
    :return: wrapped function
169
    """
170

171
    @functools.wraps(func)
1✔
172
    def wrapper(*args, **kwargs):
1✔
173
        try:
1✔
174
            return func(*args, **kwargs)
1✔
175
        except RequestParserError:
1✔
176
            raise
1✔
177
        except Exception as e:
1✔
178
            raise UnknownParserError(
1✔
179
                "An unknown error occurred when trying to parse the request."
180
            ) from e
181

182
    return wrapper
1✔
183

184

185
class RequestParser(abc.ABC):
1✔
186
    """
187
    The request parser is responsible for parsing an incoming HTTP request.
188
    It determines which operation the request was aiming for and parses the incoming request such that the resulting
189
    dictionary can be used to invoke the service's function implementation.
190
    It is the base class for all parsers and therefore contains the basic logic which is used among all of them.
191
    """
192

193
    service: ServiceModel
1✔
194
    DEFAULT_ENCODING = "utf-8"
1✔
195
    # The default timestamp format is ISO8601, but this can be overwritten by subclasses.
196
    TIMESTAMP_FORMAT = "iso8601"
1✔
197
    # The default timestamp format for header fields
198
    HEADER_TIMESTAMP_FORMAT = "rfc822"
1✔
199
    # The default timestamp format for query fields
200
    QUERY_TIMESTAMP_FORMAT = "iso8601"
1✔
201

202
    def __init__(self, service: ServiceModel) -> None:
1✔
203
        super().__init__()
1✔
204
        self.service = service
1✔
205

206
    @_handle_exceptions
1✔
207
    def parse(self, request: Request) -> tuple[OperationModel, Any]:
1✔
208
        """
209
        Determines which operation the request was aiming for and parses the incoming request such that the resulting
210
        dictionary can be used to invoke the service's function implementation.
211

212
        :param request: to parse
213
        :return: a tuple with the operation model (defining the action / operation which the request aims for),
214
                 and the parsed service parameters
215
        :raises: RequestParserError (either a ProtocolParserError or an UnknownParserError)
216
        """
217
        raise NotImplementedError
218

219
    def _parse_shape(
1✔
220
        self, request: Request, shape: Shape, node: Any, uri_params: Mapping[str, Any] = None
221
    ) -> Any:
222
        """
223
        Main parsing method which dynamically calls the parsing function for the specific shape.
224

225
        :param request: the complete Request
226
        :param shape: of the node
227
        :param node: the single part of the HTTP request to parse
228
        :param uri_params: the extracted URI path params
229
        :return: result of the parsing operation, the type depends on the shape
230
        """
231
        if shape is None:
1✔
232
            return None
1✔
233
        location = shape.serialization.get("location")
1✔
234
        if location is not None:
1✔
235
            if location == "header":
1✔
236
                header_name = shape.serialization.get("name")
1✔
237
                if shape.type_name == "list":
1✔
238
                    # headers may contain a comma separated list of values (e.g., the ObjectAttributes member in
239
                    # s3.GetObjectAttributes), so we prepare it here for the handler, which will be `_parse_list`.
240
                    # Header lists can contain optional whitespace, so we strip it
241
                    # https://www.rfc-editor.org/rfc/rfc9110.html#name-lists-rule-abnf-extension
242
                    # It can also directly contain a list of headers
243
                    # See https://datatracker.ietf.org/doc/html/rfc2616
244
                    payload = request.headers.getlist(header_name) or None
1✔
245
                    if payload:
1✔
246
                        headers = ",".join(payload)
1✔
247
                        payload = [value.strip() for value in headers.split(",")]
1✔
248

249
                else:
250
                    payload = request.headers.get(header_name)
1✔
251

252
            elif location == "headers":
1✔
253
                payload = self._parse_header_map(shape, request.headers)
1✔
254
                # shapes with the location trait "headers" only contain strings and are not further processed
255
                return payload
1✔
256
            elif location == "querystring":
1✔
257
                query_name = shape.serialization.get("name")
1✔
258
                parsed_query = request.args
1✔
259
                if shape.type_name == "list":
1✔
260
                    payload = parsed_query.getlist(query_name)
1✔
261
                else:
262
                    payload = parsed_query.get(query_name)
1✔
263
            elif location == "uri":
1✔
264
                uri_param_name = shape.serialization.get("name")
1✔
265
                if uri_param_name in uri_params:
1✔
266
                    payload = uri_params[uri_param_name]
1✔
267
            else:
UNCOV
268
                raise UnknownParserError("Unknown shape location '%s'." % location)
×
269
        else:
270
            # If we don't have to use a specific location, we use the node
271
            payload = node
1✔
272

273
        fn_name = "_parse_%s" % shape.type_name
1✔
274
        handler = getattr(self, fn_name, self._noop_parser)
1✔
275
        try:
1✔
276
            return handler(request, shape, payload, uri_params) if payload is not None else None
1✔
277
        except (TypeError, ValueError, AttributeError) as e:
1✔
278
            raise ProtocolParserError(
1✔
279
                f"Invalid type when parsing {shape.name}: '{payload}' cannot be parsed to {shape.type_name}."
280
            ) from e
281

282
    # The parsing functions for primitive types, lists, and timestamps are shared among subclasses.
283

284
    def _parse_list(
1✔
285
        self,
286
        request: Request,
287
        shape: ListShape,
288
        node: list,
289
        uri_params: Mapping[str, Any] = None,
290
    ):
291
        parsed = []
1✔
292
        member_shape = shape.member
1✔
293
        for item in node:
1✔
294
            parsed.append(self._parse_shape(request, member_shape, item, uri_params))
1✔
295
        return parsed
1✔
296

297
    @_text_content
1✔
298
    def _parse_integer(self, _, __, node: str, ___) -> int:
1✔
299
        return int(node)
1✔
300

301
    @_text_content
1✔
302
    def _parse_float(self, _, __, node: str, ___) -> float:
1✔
303
        return float(node)
1✔
304

305
    @_text_content
1✔
306
    def _parse_blob(self, _, __, node: str, ___) -> bytes:
1✔
307
        return base64.b64decode(node)
1✔
308

309
    @_text_content
1✔
310
    def _parse_timestamp(self, _, shape: Shape, node: str, ___) -> datetime.datetime:
1✔
311
        timestamp_format = shape.serialization.get("timestampFormat")
1✔
312
        if not timestamp_format and shape.serialization.get("location") == "header":
1✔
313
            timestamp_format = self.HEADER_TIMESTAMP_FORMAT
1✔
314
        elif not timestamp_format and shape.serialization.get("location") == "querystring":
1✔
315
            timestamp_format = self.QUERY_TIMESTAMP_FORMAT
1✔
316
        return self._convert_str_to_timestamp(node, timestamp_format)
1✔
317

318
    @_text_content
1✔
319
    def _parse_boolean(self, _, __, node: str, ___) -> bool:
1✔
320
        value = node.lower()
1✔
321
        if value == "true":
1✔
322
            return True
1✔
323
        if value == "false":
1✔
324
            return False
1✔
UNCOV
325
        raise ValueError("cannot parse boolean value %s" % node)
×
326

327
    @_text_content
1✔
328
    def _noop_parser(self, _, __, node: Any, ___):
1✔
329
        return node
1✔
330

331
    _parse_character = _parse_string = _noop_parser
1✔
332
    _parse_double = _parse_float
1✔
333
    _parse_long = _parse_integer
1✔
334

335
    def _convert_str_to_timestamp(self, value: str, timestamp_format=None):
1✔
336
        if timestamp_format is None:
1✔
337
            timestamp_format = self.TIMESTAMP_FORMAT
1✔
338
        timestamp_format = timestamp_format.lower()
1✔
339
        converter = getattr(self, "_timestamp_%s" % timestamp_format)
1✔
340
        final_value = converter(value)
1✔
341
        return final_value
1✔
342

343
    @staticmethod
1✔
344
    def _timestamp_iso8601(date_string: str) -> datetime.datetime:
1✔
345
        return dateutil.parser.isoparse(date_string)
1✔
346

347
    @staticmethod
1✔
348
    def _timestamp_unixtimestamp(timestamp_string: str) -> datetime.datetime:
1✔
349
        return datetime.datetime.utcfromtimestamp(int(timestamp_string))
1✔
350

351
    @staticmethod
1✔
352
    def _timestamp_unixtimestampmillis(timestamp_string: str) -> datetime.datetime:
1✔
353
        return datetime.datetime.utcfromtimestamp(float(timestamp_string) / 1000)
1✔
354

355
    @staticmethod
1✔
356
    def _timestamp_rfc822(datetime_string: str) -> datetime.datetime:
1✔
357
        return parsedate_to_datetime(datetime_string)
1✔
358

359
    @staticmethod
1✔
360
    def _parse_header_map(shape: Shape, headers: dict) -> dict:
1✔
361
        # Note that headers are case insensitive, so we .lower() all header names and header prefixes.
362
        parsed = {}
1✔
363
        prefix = shape.serialization.get("name", "").lower()
1✔
364
        for header_name, header_value in headers.items():
1✔
365
            if header_name.lower().startswith(prefix):
1✔
366
                # The key name inserted into the parsed hash strips off the prefix.
367
                name = header_name[len(prefix) :]
1✔
368
                parsed[name] = header_value
1✔
369
        return parsed
1✔
370

371

372
class QueryRequestParser(RequestParser):
1✔
373
    """
374
    The ``QueryRequestParser`` is responsible for parsing incoming requests for services which use the ``query``
375
    protocol. The requests for these services encode the majority of their parameters in the URL query string.
376
    """
377

378
    @_handle_exceptions
1✔
379
    def parse(self, request: Request) -> tuple[OperationModel, Any]:
1✔
380
        instance = request.values
1✔
381
        if "Action" not in instance:
1✔
UNCOV
382
            raise ProtocolParserError(
×
383
                f"Operation detection failed. "
384
                f"Missing Action in request for query-protocol service {self.service}."
385
            )
386
        action = instance["Action"]
1✔
387
        try:
1✔
388
            operation: OperationModel = self.service.operation_model(action)
1✔
389
        except OperationNotFoundError as e:
1✔
390
            raise OperationNotFoundParserError(
1✔
391
                f"Operation detection failed."
392
                f"Operation {action} could not be found for service {self.service}."
393
            ) from e
394
        # There are no uri params in the query protocol (all ops are POST on "/")
395
        uri_params = {}
1✔
396
        input_shape: StructureShape = operation.input_shape
1✔
397
        parsed = self._parse_shape(request, input_shape, instance, uri_params)
1✔
398
        if parsed is None:
1✔
399
            return operation, {}
1✔
400
        return operation, parsed
1✔
401

402
    def _process_member(
1✔
403
        self,
404
        request: Request,
405
        member_name: str,
406
        member_shape: Shape,
407
        node: dict,
408
        uri_params: Mapping[str, Any] = None,
409
    ):
410
        if isinstance(member_shape, (MapShape, ListShape, StructureShape)):
1✔
411
            # If we have a complex type, we filter the node and change it's keys to craft a new "context" for the
412
            # new hierarchy level
413
            sub_node = self._filter_node(member_name, node)
1✔
414
        else:
415
            # If it is a primitive type we just get the value from the dict
416
            sub_node = node.get(member_name)
1✔
417
        # The filtered node is processed and returned (or None if the sub_node is None)
418
        return (
1✔
419
            self._parse_shape(request, member_shape, sub_node, uri_params)
420
            if sub_node is not None
421
            else None
422
        )
423

424
    def _parse_structure(
1✔
425
        self,
426
        request: Request,
427
        shape: StructureShape,
428
        node: dict,
429
        uri_params: Mapping[str, Any] = None,
430
    ) -> dict:
431
        result = {}
1✔
432

433
        for member, member_shape in shape.members.items():
1✔
434
            # The key in the node is either the serialization config "name" of the shape, or the name of the member
435
            member_name = self._get_serialized_name(member_shape, member, node)
1✔
436
            # BUT, if it's flattened and a list, the name is defined by the list's member's name
437
            if member_shape.serialization.get("flattened"):
1✔
438
                if isinstance(member_shape, ListShape):
1✔
439
                    member_name = self._get_serialized_name(member_shape.member, member, node)
1✔
440
            value = self._process_member(request, member_name, member_shape, node, uri_params)
1✔
441
            if value is not None or member in shape.required_members:
1✔
442
                # If the member is required, but not existing, we explicitly set None
443
                result[member] = value
1✔
444

445
        return result if len(result) > 0 else None
1✔
446

447
    def _parse_map(
1✔
448
        self, request: Request, shape: MapShape, node: dict, uri_params: Mapping[str, Any]
449
    ) -> dict:
450
        """
451
        This is what the node looks like for a flattened map::
452
        ::
453
          {
454
              "Attribute.1.Name": "MyKey",
455
              "Attribute.1.Value": "MyValue",
456
              "Attribute.2.Name": ...,
457
              ...
458
          }
459
        ::
460
        This function expects an already filtered / pre-processed node. The node dict would therefore look like:
461
        ::
462
          {
463
              "1.Name": "MyKey",
464
              "1.Value": "MyValue",
465
              "2.Name": ...
466
          }
467
        ::
468
        """
469
        key_prefix = ""
1✔
470
        # Non-flattened maps have an additional hierarchy level named "entry"
471
        # https://awslabs.github.io/smithy/1.0/spec/core/xml-traits.html#xmlflattened-trait
472
        if not shape.serialization.get("flattened"):
1✔
473
            key_prefix += "entry."
1✔
474
        result = {}
1✔
475

476
        i = 0
1✔
477
        while True:
1✔
478
            i += 1
1✔
479
            # The key and value can be renamed (with their serialization config's "name").
480
            # By default they are called "key" and "value".
481
            key_name = f"{key_prefix}{i}.{self._get_serialized_name(shape.key, 'key', node)}"
1✔
482
            value_name = f"{key_prefix}{i}.{self._get_serialized_name(shape.value, 'value', node)}"
1✔
483

484
            # We process the key and value individually
485
            k = self._process_member(request, key_name, shape.key, node)
1✔
486
            v = self._process_member(request, value_name, shape.value, node)
1✔
487
            if k is None or v is None:
1✔
488
                # technically, if one exists but not the other, then that would be an invalid request
489
                break
1✔
490
            result[k] = v
1✔
491

492
        return result if len(result) > 0 else None
1✔
493

494
    def _parse_list(
1✔
495
        self,
496
        request: Request,
497
        shape: ListShape,
498
        node: dict,
499
        uri_params: Mapping[str, Any] = None,
500
    ) -> list:
501
        """
502
        Some actions take lists of parameters. These lists are specified using the param.[member.]n notation.
503
        The "member" is used if the list is not flattened.
504
        Values of n are integers starting from 1.
505
        For example, a list with two elements looks like this:
506
        - Flattened: &AttributeName.1=first&AttributeName.2=second
507
        - Non-flattened: &AttributeName.member.1=first&AttributeName.member.2=second
508
        This function expects an already filtered / processed node. The node dict would therefore look like:
509
        ::
510
          {
511
              "1": "first",
512
              "2": "second",
513
              "3": ...
514
          }
515
        ::
516
        """
517
        # The keys might be prefixed (f.e. for flattened lists)
518
        key_prefix = self._get_list_key_prefix(shape, node)
1✔
519

520
        # We collect the list value as well as the integer indicating the list position so we can
521
        # later sort the list by the position, in case they attribute values are unordered
522
        result: list[tuple[int, Any]] = []
1✔
523

524
        i = 0
1✔
525
        while True:
1✔
526
            i += 1
1✔
527
            key_name = f"{key_prefix}{i}"
1✔
528
            value = self._process_member(request, key_name, shape.member, node)
1✔
529
            if value is None:
1✔
530
                break
1✔
531
            result.append((i, value))
1✔
532

533
        return [r[1] for r in sorted(result)] if len(result) > 0 else None
1✔
534

535
    @staticmethod
1✔
536
    def _filter_node(name: str, node: dict) -> dict:
1✔
537
        """Filters the node dict for entries where the key starts with the given name."""
538
        filtered = {k[len(name) + 1 :]: v for k, v in node.items() if k.startswith(name)}
1✔
539
        return filtered if len(filtered) > 0 else None
1✔
540

541
    def _get_serialized_name(self, shape: Shape, default_name: str, node: dict) -> str:
1✔
542
        """
543
        Returns the serialized name for the shape if it exists.
544
        Otherwise, it will return the given default_name.
545
        """
546
        return shape.serialization.get("name", default_name)
1✔
547

548
    def _get_list_key_prefix(self, shape: ListShape, node: dict):
1✔
549
        key_prefix = ""
1✔
550
        # Non-flattened lists have an additional hierarchy level:
551
        # https://awslabs.github.io/smithy/1.0/spec/core/xml-traits.html#xmlflattened-trait
552
        # The hierarchy level's name is the serialization name of its member or (by default) "member".
553
        if not shape.serialization.get("flattened"):
1✔
554
            key_prefix += f"{self._get_serialized_name(shape.member, 'member', node)}."
1✔
555
        return key_prefix
1✔
556

557

558
class BaseRestRequestParser(RequestParser):
1✔
559
    """
560
    The ``BaseRestRequestParser`` is the base class for all "resty" AWS service protocols.
561
    The operation which should be invoked is determined based on the HTTP method and the path suffix.
562
    The body encoding is done in the respective subclasses.
563
    """
564

565
    def __init__(self, service: ServiceModel) -> None:
1✔
566
        super().__init__(service)
1✔
567
        self.ignore_get_body_errors = False
1✔
568
        self._operation_router = RestServiceOperationRouter(service)
1✔
569

570
    @_handle_exceptions
1✔
571
    def parse(self, request: Request) -> tuple[OperationModel, Any]:
1✔
572
        try:
1✔
573
            operation, uri_params = self._operation_router.match(request)
1✔
574
        except NotFound as e:
1✔
575
            raise OperationNotFoundParserError(
1✔
576
                f"Unable to find operation for request to service "
577
                f"{self.service.service_name}: {request.method} {request.path}"
578
            ) from e
579

580
        shape: StructureShape = operation.input_shape
1✔
581
        final_parsed = {}
1✔
582
        if shape is not None:
1✔
583
            self._parse_payload(request, shape, shape.members, uri_params, final_parsed)
1✔
584
        return operation, final_parsed
1✔
585

586
    def _parse_payload(
1✔
587
        self,
588
        request: Request,
589
        shape: Shape,
590
        member_shapes: dict[str, Shape],
591
        uri_params: Mapping[str, Any],
592
        final_parsed: dict,
593
    ) -> None:
594
        """Parses all attributes which are located in the payload / body of the incoming request."""
595
        payload_parsed = {}
1✔
596
        non_payload_parsed = {}
1✔
597
        if "payload" in shape.serialization:
1✔
598
            # If a payload is specified in the output shape, then only that shape is used for the body payload.
599
            payload_member_name = shape.serialization["payload"]
1✔
600
            body_shape = member_shapes[payload_member_name]
1✔
601
            if body_shape.serialization.get("eventstream"):
1✔
UNCOV
602
                body = self._create_event_stream(request, body_shape)
×
UNCOV
603
                payload_parsed[payload_member_name] = body
×
604
            elif body_shape.type_name == "string":
1✔
605
                # Only set the value if it's not empty (the request's data is an empty binary by default)
606
                if request.data:
1✔
607
                    body = request.data
1✔
608
                    if isinstance(body, bytes):
1✔
609
                        body = body.decode(self.DEFAULT_ENCODING)
1✔
610
                    payload_parsed[payload_member_name] = body
1✔
611
            elif body_shape.type_name == "blob":
1✔
612
                # This control path is equivalent to operation.has_streaming_input (shape has a payload which is a blob)
613
                # in which case we assume essentially an IO[bytes] to be passed. Since the payload can be optional, we
614
                # only set the parameter if content_length=0, which indicates an empty request. If the content length is
615
                # not set, it could be a streaming response.
616
                if request.content_length != 0:
1✔
617
                    payload_parsed[payload_member_name] = self.create_input_stream(request)
1✔
618
            else:
619
                original_parsed = self._initial_body_parse(request)
1✔
620
                payload_parsed[payload_member_name] = self._parse_shape(
1✔
621
                    request, body_shape, original_parsed, uri_params
622
                )
623
        else:
624
            # The payload covers the whole body. We only parse the body if it hasn't been handled by the payload logic.
625
            try:
1✔
626
                non_payload_parsed = self._initial_body_parse(request)
1✔
627
            except ProtocolParserError:
1✔
628
                # GET requests should ignore the body, so we just let them pass
629
                if not (request.method in ["GET", "HEAD"] and self.ignore_get_body_errors):
1✔
630
                    raise
1✔
631

632
        # even if the payload has been parsed, the rest of the shape needs to be processed as well
633
        # (for members which are located outside of the body, like uri or header)
634
        non_payload_parsed = self._parse_shape(request, shape, non_payload_parsed, uri_params)
1✔
635
        # update the final result with the parsed body and the parsed payload (where the payload has precedence)
636
        final_parsed.update(non_payload_parsed)
1✔
637
        final_parsed.update(payload_parsed)
1✔
638

639
    def _initial_body_parse(self, request: Request) -> Any:
1✔
640
        """
641
        This method executes the initial parsing of the body (XML, JSON, or CBOR).
642
        The parsed body will afterwards still be walked through and the nodes will be converted to the appropriate
643
        types, but this method does the first round of parsing.
644

645
        :param request: of which the body should be parsed
646
        :return: depending on the actual implementation
647
        """
648
        raise NotImplementedError("_initial_body_parse")
649

650
    def _create_event_stream(self, request: Request, shape: Shape) -> Any:
1✔
651
        # TODO handle event streams
652
        raise NotImplementedError("_create_event_stream")
653

654
    def create_input_stream(self, request: Request) -> IO[bytes]:
1✔
655
        """
656
        Returns an IO object that makes the payload of the Request available for streaming.
657

658
        :param request: the http request
659
        :return: the input stream that allows services to consume the request payload
660
        """
661
        # for now _get_stream_for_parsing seems to be a good compromise. it can be used even after `request.data` was
662
        # previously called. however the reverse doesn't work. once the stream has been consumed, `request.data` will
663
        # return b''
664
        return request._get_stream_for_parsing()
1✔
665

666

667
class RestXMLRequestParser(BaseRestRequestParser):
1✔
668
    """
669
    The ``RestXMLRequestParser`` is responsible for parsing incoming requests for services which use the ``rest-xml``
670
    protocol. The requests for these services encode the majority of their parameters as XML in the request body.
671
    """
672

673
    def __init__(self, service_model: ServiceModel):
1✔
674
        super(RestXMLRequestParser, self).__init__(service_model)
1✔
675
        self.ignore_get_body_errors = True
1✔
676
        self._namespace_re = re.compile("{.*}")
1✔
677

678
    def _initial_body_parse(self, request: Request) -> ETree.Element:
1✔
679
        body = request.data
1✔
680
        if not body:
1✔
681
            return ETree.Element("")
1✔
682
        return self._parse_xml_string_to_dom(body)
1✔
683

684
    def _parse_structure(
1✔
685
        self,
686
        request: Request,
687
        shape: StructureShape,
688
        node: ETree.Element,
689
        uri_params: Mapping[str, Any] = None,
690
    ) -> dict:
691
        parsed = {}
1✔
692
        xml_dict = self._build_name_to_xml_node(node)
1✔
693
        for member_name, member_shape in shape.members.items():
1✔
694
            xml_name = self._member_key_name(member_shape, member_name)
1✔
695
            member_node = xml_dict.get(xml_name)
1✔
696
            # If a shape defines a location trait, the node might be None (since these are extracted from the request's
697
            # metadata like headers or the URI)
698
            if (
1✔
699
                member_node is not None
700
                or "location" in member_shape.serialization
701
                or member_shape.serialization.get("eventheader")
702
            ):
703
                parsed[member_name] = self._parse_shape(
1✔
704
                    request, member_shape, member_node, uri_params
705
                )
706
            elif member_shape.serialization.get("xmlAttribute"):
1✔
707
                attributes = {}
1✔
708
                location_name = member_shape.serialization["name"]
1✔
709
                for key, value in node.attrib.items():
1✔
710
                    new_key = self._namespace_re.sub(location_name.split(":")[0] + ":", key)
1✔
711
                    attributes[new_key] = value
1✔
712
                if location_name in attributes:
1✔
713
                    parsed[member_name] = attributes[location_name]
1✔
714
            elif member_name in shape.required_members:
1✔
715
                # If the member is required, but not existing, we explicitly set None
716
                parsed[member_name] = None
1✔
717
        return parsed
1✔
718

719
    def _parse_map(
1✔
720
        self,
721
        request: Request,
722
        shape: MapShape,
723
        node: dict,
724
        uri_params: Mapping[str, Any] = None,
725
    ) -> dict:
726
        parsed = {}
×
727
        key_shape = shape.key
×
UNCOV
728
        value_shape = shape.value
×
729
        key_location_name = key_shape.serialization.get("name", "key")
×
730
        value_location_name = value_shape.serialization.get("name", "value")
×
731
        if shape.serialization.get("flattened") and not isinstance(node, list):
×
732
            node = [node]
×
733
        for keyval_node in node:
×
UNCOV
734
            key_name = val_name = None
×
735
            for single_pair in keyval_node:
×
736
                # Within each <entry> there's a <key> and a <value>
737
                tag_name = self._node_tag(single_pair)
×
UNCOV
738
                if tag_name == key_location_name:
×
UNCOV
739
                    key_name = self._parse_shape(request, key_shape, single_pair, uri_params)
×
UNCOV
740
                elif tag_name == value_location_name:
×
UNCOV
741
                    val_name = self._parse_shape(request, value_shape, single_pair, uri_params)
×
742
                else:
UNCOV
743
                    raise ProtocolParserError("Unknown tag: %s" % tag_name)
×
UNCOV
744
            parsed[key_name] = val_name
×
UNCOV
745
        return parsed
×
746

747
    def _parse_list(
1✔
748
        self,
749
        request: Request,
750
        shape: ListShape,
751
        node: dict,
752
        uri_params: Mapping[str, Any] = None,
753
    ) -> list:
754
        # When we use _build_name_to_xml_node, repeated elements are aggregated
755
        # into a list. However, we can't tell the difference between a scalar
756
        # value and a single element flattened list. So before calling the
757
        # real _handle_list, we know that "node" should actually be a list if
758
        # it's flattened, and if it's not, then we make it a one element list.
759
        if shape.serialization.get("flattened") and not isinstance(node, list):
1✔
760
            node = [node]
1✔
761
        return super(RestXMLRequestParser, self)._parse_list(request, shape, node, uri_params)
1✔
762

763
    def _node_tag(self, node: ETree.Element) -> str:
1✔
764
        return self._namespace_re.sub("", node.tag)
1✔
765

766
    @staticmethod
1✔
767
    def _member_key_name(shape: Shape, member_name: str) -> str:
1✔
768
        # This method is needed because we have to special case flattened list
769
        # with a serialization name.  If this is the case we use the
770
        # locationName from the list's member shape as the key name for the
771
        # surrounding structure.
772
        if isinstance(shape, ListShape) and shape.serialization.get("flattened"):
1✔
773
            list_member_serialized_name = shape.member.serialization.get("name")
1✔
774
            if list_member_serialized_name is not None:
1✔
775
                return list_member_serialized_name
1✔
776
        serialized_name = shape.serialization.get("name")
1✔
777
        if serialized_name is not None:
1✔
778
            return serialized_name
1✔
779
        return member_name
1✔
780

781
    @staticmethod
1✔
782
    def _parse_xml_string_to_dom(xml_string: str) -> ETree.Element:
1✔
783
        try:
1✔
784
            parser = ETree.XMLParser(target=ETree.TreeBuilder())
1✔
785
            parser.feed(xml_string)
1✔
786
            root = parser.close()
1✔
787
        except ETree.ParseError as e:
1✔
788
            raise ProtocolParserError(
1✔
789
                "Unable to parse request (%s), invalid XML received:\n%s" % (e, xml_string)
790
            ) from e
791
        return root
1✔
792

793
    def _build_name_to_xml_node(self, parent_node: list | ETree.Element) -> dict:
1✔
794
        # If the parent node is actually a list. We should not be trying
795
        # to serialize it to a dictionary. Instead, return the first element
796
        # in the list.
797
        if isinstance(parent_node, list):
1✔
UNCOV
798
            return self._build_name_to_xml_node(parent_node[0])
×
799
        xml_dict = {}
1✔
800
        for item in parent_node:
1✔
801
            key = self._node_tag(item)
1✔
802
            if key in xml_dict:
1✔
803
                # If the key already exists, the most natural
804
                # way to handle this is to aggregate repeated
805
                # keys into a single list.
806
                # <foo>1</foo><foo>2</foo> -> {'foo': [Node(1), Node(2)]}
807
                if isinstance(xml_dict[key], list):
1✔
808
                    xml_dict[key].append(item)
1✔
809
                else:
810
                    # Convert from a scalar to a list.
811
                    xml_dict[key] = [xml_dict[key], item]
1✔
812
            else:
813
                xml_dict[key] = item
1✔
814
        return xml_dict
1✔
815

816
    def _create_event_stream(self, request: Request, shape: Shape) -> Any:
1✔
817
        # TODO handle event streams
818
        raise NotImplementedError("_create_event_stream")
819

820

821
class BaseJSONRequestParser(RequestParser, ABC):
1✔
822
    """
823
    The ``BaseJSONRequestParser`` is the base class for all JSON-based AWS service protocols.
824
    This base-class handles parsing the payload / body as JSON.
825
    """
826

827
    # default timestamp format for JSON requests
828
    TIMESTAMP_FORMAT = "unixtimestamp"
1✔
829
    # timestamp format for requests with CBOR content type
830
    CBOR_TIMESTAMP_FORMAT = "unixtimestampmillis"
1✔
831

832
    def _parse_structure(
1✔
833
        self,
834
        request: Request,
835
        shape: StructureShape,
836
        value: dict | None,
837
        uri_params: Mapping[str, Any] = None,
838
    ) -> dict | None:
839
        if shape.is_document_type:
1✔
UNCOV
840
            final_parsed = value
×
841
        else:
842
            if value is None:
1✔
843
                # If the comes across the wire as "null" (None in python),
844
                # we should be returning this unchanged, instead of as an
845
                # empty dict.
UNCOV
846
                return None
×
847
            final_parsed = {}
1✔
848
            for member_name, member_shape in shape.members.items():
1✔
849
                json_name = member_shape.serialization.get("name", member_name)
1✔
850
                raw_value = value.get(json_name)
1✔
851
                parsed = self._parse_shape(request, member_shape, raw_value, uri_params)
1✔
852
                if parsed is not None or member_name in shape.required_members:
1✔
853
                    # If the member is required, but not existing, we set it to None anyways
854
                    final_parsed[member_name] = parsed
1✔
855
        return final_parsed
1✔
856

857
    def _parse_map(
1✔
858
        self,
859
        request: Request,
860
        shape: MapShape,
861
        value: dict | None,
862
        uri_params: Mapping[str, Any] = None,
863
    ) -> dict | None:
864
        if value is None:
1✔
UNCOV
865
            return None
×
866
        parsed = {}
1✔
867
        key_shape = shape.key
1✔
868
        value_shape = shape.value
1✔
869
        for key, val in value.items():
1✔
870
            actual_key = self._parse_shape(request, key_shape, key, uri_params)
1✔
871
            actual_value = self._parse_shape(request, value_shape, val, uri_params)
1✔
872
            parsed[actual_key] = actual_value
1✔
873
        return parsed
1✔
874

875
    def _parse_body_as_json(self, request: Request) -> dict:
1✔
876
        body_contents = request.data
1✔
877
        if not body_contents:
1✔
878
            return {}
1✔
879
        if request.mimetype.startswith("application/x-amz-cbor"):
1✔
880
            try:
1✔
881
                return cbor2_loads(body_contents)
1✔
UNCOV
882
            except ValueError as e:
×
UNCOV
883
                raise ProtocolParserError("HTTP body could not be parsed as CBOR.") from e
×
884
        else:
885
            try:
1✔
886
                return request.get_json(force=True)
1✔
887
            except BadRequest as e:
1✔
888
                raise ProtocolParserError("HTTP body could not be parsed as JSON.") from e
1✔
889

890
    def _parse_boolean(
1✔
891
        self, request: Request, shape: Shape, node: bool, uri_params: Mapping[str, Any] = None
892
    ) -> bool:
893
        return super()._noop_parser(request, shape, node, uri_params)
1✔
894

895
    def _parse_timestamp(
1✔
896
        self, request: Request, shape: Shape, node: str, uri_params: Mapping[str, Any] = None
897
    ) -> datetime.datetime:
898
        if not shape.serialization.get("timestampFormat") and request.mimetype.startswith(
1✔
899
            "application/x-amz-cbor"
900
        ):
901
            # cbor2 has native support for timestamp decoding, so this node could already have the right type
902
            if isinstance(node, datetime.datetime):
1✔
903
                return node
1✔
904
            # otherwise parse the timestamp using the AWS CBOR timestamp format
905
            # (non-CBOR-standard conform, uses millis instead of floating-point-millis)
906
            return self._convert_str_to_timestamp(node, self.CBOR_TIMESTAMP_FORMAT)
1✔
907
        return super()._parse_timestamp(request, shape, node, uri_params)
1✔
908

909
    def _parse_blob(
1✔
910
        self, request: Request, shape: Shape, node: bool, uri_params: Mapping[str, Any] = None
911
    ) -> bytes:
912
        if isinstance(node, bytes) and request.mimetype.startswith("application/x-amz-cbor"):
1✔
913
            # CBOR does not base64 encode binary data
914
            return bytes(node)
1✔
915
        else:
916
            return super()._parse_blob(request, shape, node, uri_params)
1✔
917

918

919
class JSONRequestParser(BaseJSONRequestParser):
1✔
920
    """
921
    The ``JSONRequestParser`` is responsible for parsing incoming requests for services which use the ``json``
922
    protocol.
923
    The requests for these services encode the majority of their parameters as JSON in the request body.
924
    The operation is defined in an HTTP header field.
925
    """
926

927
    @_handle_exceptions
1✔
928
    def parse(self, request: Request) -> tuple[OperationModel, Any]:
1✔
929
        target = request.headers["X-Amz-Target"]
1✔
930
        # assuming that the last part of the target string (e.g., "x.y.z.MyAction") contains the operation name
931
        operation_name = target.rpartition(".")[2]
1✔
932
        operation = self.service.operation_model(operation_name)
1✔
933
        shape = operation.input_shape
1✔
934
        # There are no uri params in the query protocol
935
        uri_params = {}
1✔
936
        final_parsed = self._do_parse(request, shape, uri_params)
1✔
937
        return operation, final_parsed
1✔
938

939
    def _do_parse(
1✔
940
        self, request: Request, shape: Shape, uri_params: Mapping[str, Any] = None
941
    ) -> dict:
942
        parsed = {}
1✔
943
        if shape is not None:
1✔
944
            event_name = shape.event_stream_name
1✔
945
            if event_name:
1✔
UNCOV
946
                parsed = self._handle_event_stream(request, shape, event_name)
×
947
            else:
948
                parsed = self._handle_json_body(request, shape, uri_params)
1✔
949
        return parsed
1✔
950

951
    def _handle_event_stream(self, request: Request, shape: Shape, event_name: str):
1✔
952
        # TODO handle event streams
953
        raise NotImplementedError
954

955
    def _handle_json_body(
1✔
956
        self, request: Request, shape: Shape, uri_params: Mapping[str, Any] = None
957
    ) -> Any:
958
        # The json.loads() gives us the primitive JSON types, but we need to traverse the parsed JSON data to convert
959
        # to richer types (blobs, timestamps, etc.)
960
        parsed_json = self._parse_body_as_json(request)
1✔
961
        return self._parse_shape(request, shape, parsed_json, uri_params)
1✔
962

963

964
class RestJSONRequestParser(BaseRestRequestParser, BaseJSONRequestParser):
1✔
965
    """
966
    The ``RestJSONRequestParser`` is responsible for parsing incoming requests for services which use the ``rest-json``
967
    protocol.
968
    The requests for these services encode the majority of their parameters as JSON in the request body.
969
    The operation is defined by the HTTP method and the path suffix.
970
    """
971

972
    def _initial_body_parse(self, request: Request) -> dict:
1✔
973
        return self._parse_body_as_json(request)
1✔
974

975
    def _create_event_stream(self, request: Request, shape: Shape) -> Any:
1✔
976
        raise NotImplementedError
977

978

979
class EC2RequestParser(QueryRequestParser):
1✔
980
    """
981
    The ``EC2RequestParser`` is responsible for parsing incoming requests for services which use the ``ec2``
982
    protocol (which only is EC2). Protocol is quite similar to the ``query`` protocol with some small differences.
983
    """
984

985
    def _get_serialized_name(self, shape: Shape, default_name: str, node: dict) -> str:
1✔
986
        # Returns the serialized name for the shape if it exists.
987
        # Otherwise it will return the passed in default_name.
988
        if "queryName" in shape.serialization:
1✔
UNCOV
989
            return shape.serialization["queryName"]
×
990
        elif "name" in shape.serialization:
1✔
991
            # A locationName is always capitalized on input for the ec2 protocol.
992
            name = shape.serialization["name"]
1✔
993
            return name[0].upper() + name[1:]
1✔
994
        else:
995
            return default_name
1✔
996

997
    def _get_list_key_prefix(self, shape: ListShape, node: dict):
1✔
998
        # The EC2 protocol does not use a prefix notation for flattened lists
999
        return ""
1✔
1000

1001

1002
class S3RequestParser(RestXMLRequestParser):
1✔
1003
    class VirtualHostRewriter:
1✔
1004
        """
1005
        Context Manager which rewrites the request object parameters such that - within the context - it looks like a
1006
        normal S3 request.
1007
        FIXME: this is not optimal because it mutates the Request object. Once we have better utility to create/copy
1008
        a request instead of EnvironBuilder, we should copy it before parsing (except the stream).
1009
        """
1010

1011
        def __init__(self, request: Request):
1✔
1012
            self.request = request
1✔
1013
            self.old_host = None
1✔
1014
            self.old_path = None
1✔
1015

1016
        def __enter__(self):
1✔
1017
            # only modify the request if it uses the virtual host addressing
1018
            if bucket_name := self._is_vhost_address_get_bucket(self.request):
1✔
1019
                # save the original path and host for restoring on context exit
1020
                self.old_path = self.request.path
1✔
1021
                self.old_host = self.request.host
1✔
1022
                self.old_raw_uri = self.request.environ.get("RAW_URI")
1✔
1023

1024
                # remove the bucket name from the host part of the request
1025
                new_host = self.old_host.removeprefix(f"{bucket_name}.")
1✔
1026

1027
                # put the bucket name at the front
1028
                new_path = "/" + bucket_name + self.old_path or "/"
1✔
1029

1030
                # create a new RAW_URI for the WSGI environment, this is necessary because of our `get_raw_path` utility
1031
                if self.old_raw_uri:
1✔
1032
                    new_raw_uri = "/" + bucket_name + self.old_raw_uri or "/"
1✔
1033
                    if qs := self.request.query_string:
1✔
1034
                        new_raw_uri += "?" + qs.decode("utf-8")
1✔
1035
                else:
1036
                    new_raw_uri = None
1✔
1037

1038
                # set the new path and host
1039
                self._set_request_props(self.request, new_path, new_host, new_raw_uri)
1✔
1040
            return self.request
1✔
1041

1042
        def __exit__(self, exc_type, exc_value, exc_traceback):
1✔
1043
            # reset the original request properties on exit of the context
1044
            if self.old_host or self.old_path:
1✔
1045
                self._set_request_props(
1✔
1046
                    self.request, self.old_path, self.old_host, self.old_raw_uri
1047
                )
1048

1049
        @staticmethod
1✔
1050
        def _set_request_props(request: Request, path: str, host: str, raw_uri: str | None = None):
1✔
1051
            """Sets the HTTP request's path and host and clears the cache in the request object."""
1052
            request.path = path
1✔
1053
            request.headers["Host"] = host
1✔
1054
            if raw_uri:
1✔
1055
                request.environ["RAW_URI"] = raw_uri
1✔
1056

1057
            try:
1✔
1058
                # delete the werkzeug request property cache that depends on path, but make sure all of them are
1059
                # initialized first, otherwise `del` will raise a key error
1060
                request.host = None  # noqa
1✔
1061
                request.url = None  # noqa
1✔
1062
                request.base_url = None  # noqa
1✔
1063
                request.full_path = None  # noqa
1✔
1064
                request.host_url = None  # noqa
1✔
1065
                request.root_url = None  # noqa
1✔
1066
                del request.host  # noqa
1✔
1067
                del request.url  # noqa
1✔
1068
                del request.base_url  # noqa
1✔
1069
                del request.full_path  # noqa
1✔
1070
                del request.host_url  # noqa
1✔
1071
                del request.root_url  # noqa
1✔
UNCOV
1072
            except AttributeError:
×
UNCOV
1073
                pass
×
1074

1075
        @staticmethod
1✔
1076
        def _is_vhost_address_get_bucket(request: Request) -> str | None:
1✔
1077
            from localstack.services.s3.utils import uses_host_addressing
1✔
1078

1079
            return uses_host_addressing(request.headers)
1✔
1080

1081
    @_handle_exceptions
1✔
1082
    def parse(self, request: Request) -> tuple[OperationModel, Any]:
1✔
1083
        """Handle virtual-host-addressing for S3."""
1084
        with self.VirtualHostRewriter(request):
1✔
1085
            return super().parse(request)
1✔
1086

1087
    def _parse_shape(
1✔
1088
        self, request: Request, shape: Shape, node: Any, uri_params: Mapping[str, Any] = None
1089
    ) -> Any:
1090
        """
1091
        Special handling of parsing the shape for s3 object-names (=key):
1092
        Trailing '/' are valid and need to be preserved, however, the url-matcher removes it from the key.
1093
        We need special logic to compare the parsed Key parameter against the path and add back the missing slashes
1094
        """
1095
        if (
1✔
1096
            shape is not None
1097
            and uri_params is not None
1098
            and shape.serialization.get("location") == "uri"
1099
            and shape.serialization.get("name") == "Key"
1100
            and (
1101
                (trailing_slashes := request.path.rpartition(uri_params["Key"])[2])
1102
                and all(char == "/" for char in trailing_slashes)
1103
            )
1104
        ):
1105
            uri_params = dict(uri_params)
1✔
1106
            uri_params["Key"] = uri_params["Key"] + trailing_slashes
1✔
1107
        return super()._parse_shape(request, shape, node, uri_params)
1✔
1108

1109
    @_text_content
1✔
1110
    def _parse_integer(self, _, shape, node: str, ___) -> int | None:
1✔
1111
        # S3 accepts empty query string parameters that should be integer
1112
        # to not break other cases, validate that the shape is in the querystring
1113
        if node == "" and shape.serialization.get("location") == "querystring":
1✔
1114
            return None
1✔
1115
        return int(node)
1✔
1116

1117

1118
class SQSQueryRequestParser(QueryRequestParser):
1✔
1119
    def _get_serialized_name(self, shape: Shape, default_name: str, node: dict) -> str:
1✔
1120
        """
1121
        SQS allows using both - the proper serialized name of a map as well as the member name - as name for maps.
1122
        For example, both works for the TagQueue operation:
1123
        - Using the proper serialized name "Tag": Tag.1.Key=key&Tag.1.Value=value
1124
        - Using the member name "Tag" in the parent structure: Tags.1.Key=key&Tags.1.Value=value
1125
        - Using "Name" to represent the Key for a nested dict: MessageAttributes.1.Name=key&MessageAttributes.1.Value.StringValue=value
1126
            resulting in {MessageAttributes: {key : {StringValue: value}}}
1127
        The Java SDK implements the second variant: https://github.com/aws/aws-sdk-java-v2/issues/2524
1128
        This has been approved to be a bug and against the spec, but since the client has a lot of users, and AWS SQS
1129
        supports both, we need to handle it here.
1130
        """
1131
        # ask the super implementation for the proper serialized name
1132
        primary_name = super()._get_serialized_name(shape, default_name, node)
1✔
1133

1134
        # determine potential suffixes for the name of the member in the node
1135
        suffixes = []
1✔
1136
        if shape.type_name == "map":
1✔
1137
            if not shape.serialization.get("flattened"):
1✔
UNCOV
1138
                suffixes = [".entry.1.Key", ".entry.1.Name"]
×
1139
            else:
1140
                suffixes = [".1.Key", ".1.Name"]
1✔
1141
        if shape.type_name == "list":
1✔
1142
            if not shape.serialization.get("flattened"):
1✔
UNCOV
1143
                suffixes = [".member.1"]
×
1144
            else:
1145
                suffixes = [".1"]
1✔
1146

1147
        # if the primary name is _not_ available in the node, but the default name is, we use the default name
1148
        if not any(f"{primary_name}{suffix}" in node for suffix in suffixes) and any(
1✔
1149
            f"{default_name}{suffix}" in node for suffix in suffixes
1150
        ):
1151
            return default_name
1✔
1152
        # otherwise we use the primary name
1153
        return primary_name
1✔
1154

1155

1156
@functools.cache
1✔
1157
def create_parser(service: ServiceModel) -> RequestParser:
1✔
1158
    """
1159
    Creates the right parser for the given service model.
1160

1161
    :param service: to create the parser for
1162
    :return: RequestParser which can handle the protocol of the service
1163
    """
1164
    # Unfortunately, some services show subtle differences in their parsing or operation detection behavior, even though
1165
    # their specification states they implement the same protocol.
1166
    # In order to avoid bundling the whole complexity in the specific protocols, or even have service-distinctions
1167
    # within the parser implementations, the service-specific parser implementations (basically the implicit /
1168
    # informally more specific protocol implementation) has precedence over the more general protocol-specific parsers.
1169
    service_specific_parsers = {
1✔
1170
        "s3": {"rest-xml": S3RequestParser},
1171
        "sqs": {"query": SQSQueryRequestParser},
1172
    }
1173
    protocol_specific_parsers = {
1✔
1174
        "query": QueryRequestParser,
1175
        "json": JSONRequestParser,
1176
        "rest-json": RestJSONRequestParser,
1177
        "rest-xml": RestXMLRequestParser,
1178
        "ec2": EC2RequestParser,
1179
    }
1180

1181
    # Try to select a service- and protocol-specific parser implementation
1182
    if (
1✔
1183
        service.service_name in service_specific_parsers
1184
        and service.protocol in service_specific_parsers[service.service_name]
1185
    ):
1186
        return service_specific_parsers[service.service_name][service.protocol](service)
1✔
1187
    else:
1188
        # Otherwise, pick the protocol-specific parser for the protocol of the service
1189
        return protocol_specific_parsers[service.protocol](service)
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc