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

localstack / localstack / 17816178229

17 Sep 2025 07:48PM UTC coverage: 86.849% (-0.03%) from 86.879%
17816178229

push

github

web-flow
CFn: implement list change sets for new provider (#13149)

12 of 13 new or added lines in 1 file covered. (92.31%)

230 existing lines in 7 files now uncovered.

67632 of 77873 relevant lines covered (86.85%)

0.87 hits per line

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

89.97
/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│ │BaseCBORRequestParser│ │BaseRpcV2RequestParser│
25
  └──────────────────┘ └─────────────────────┘ └─────────────────────┘ └─────────────────────┘ └──────────────────────┘
26
          ▲                    ▲            ▲   ▲           ▲             ▲             ▲             ▲
27
  ┌───────┴────────┐ ┌─────────┴──────────┐ │   │  ┌────────┴────────┐    │         ┌───┴─────────────┴────┐
28
  │EC2RequestParser│ │RestXMLRequestParser│ │   │  │JSONRequestParser│    │         │RpcV2CBORRequestParser│
29
  └────────────────┘ └────────────────────┘ │   │  └─────────────────┘    │         └──────────────────────┘
30
                           ┌────────────────┴───┴┐                 ▲      │
31
                           │RestJSONRequestParser│             ┌───┴──────┴──────┐
32
                           └─────────────────────┘             │CBORRequestParser│
33
                                                               └─────────────────┘
34
::
35

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

42
The classes are structured as follows:
43

44
* The ``RequestParser`` contains all the basic logic for the parsing
45
  which is shared among all different protocols.
46
* The ``BaseRestRequestParser`` contains the logic for the REST
47
  protocol specifics (i.e. specific HTTP metadata parsing).
48
* The ``BaseRpcV2RequestParser`` contains the logic for the RPC v2
49
  protocol specifics (special path routing, no logic about body decoding)
50
* The ``BaseJSONRequestParser`` contains the logic for the JSON body
51
  parsing.
52
* The ``BaseCBORRequestParser`` contains the logic for the CBOR body
53
  parsing.
54
* The ``RestJSONRequestParser`` inherits the ReST specific logic from
55
  the ``BaseRestRequestParser`` and the JSON body parsing from the
56
  ``BaseJSONRequestParser``.
57
* The ``CBORRequestParser`` inherits the ``json``-protocol specific
58
  logic from the ``JSONRequestParser`` and the CBOR body parsing
59
  from the ``BaseCBORRequestParser``.
60
* The ``QueryRequestParser``, ``RestXMLRequestParser``,
61
  ``RpcV2CBORRequestParser`` and ``JSONRequestParser`` have a
62
  conventional inheritance structure.
63

64
The services and their protocols are defined by using AWS's Smithy
65
(a language to define services in a - somewhat - protocol-agnostic
66
way). The "peculiarities" in this parser code usually correspond
67
to certain so-called "traits" in Smithy.
68

69
The result of the parser methods are the operation model of the
70
service's action which the request was aiming for, as well as the
71
parsed parameters for the service's function invocation.
72
"""
73

74
import abc
1✔
75
import base64
1✔
76
import datetime
1✔
77
import functools
1✔
78
import io
1✔
79
import os
1✔
80
import re
1✔
81
import struct
1✔
82
from abc import ABC
1✔
83
from collections.abc import Mapping
1✔
84
from email.utils import parsedate_to_datetime
1✔
85
from typing import IO, Any
1✔
86
from xml.etree import ElementTree as ETree
1✔
87

88
import dateutil.parser
1✔
89
from botocore.model import (
1✔
90
    ListShape,
91
    MapShape,
92
    OperationModel,
93
    OperationNotFoundError,
94
    ServiceModel,
95
    Shape,
96
    StructureShape,
97
)
98

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

103
from localstack.aws.protocol.op_router import RestServiceOperationRouter
1✔
104
from localstack.aws.spec import ProtocolName
1✔
105
from localstack.http import Request
1✔
106

107

108
def _text_content(func):
1✔
109
    """
110
    This decorator hides the difference between an XML node with text or a plain string.
111
    It's used to ensure that scalar processing operates only on text strings, which
112
    allows the same scalar handlers to be used for XML nodes from the body, HTTP headers,
113
    and across different protocols.
114

115
    :param func: function which should be wrapped
116
    :return: wrapper function which can be called with a node or a string, where the
117
             wrapped function is always called with a string
118
    """
119

120
    def _get_text_content(
1✔
121
        self,
122
        request: Request,
123
        shape: Shape,
124
        node_or_string: ETree.Element | str,
125
        uri_params: Mapping[str, Any] = None,
126
    ):
127
        if hasattr(node_or_string, "text"):
1✔
128
            text = node_or_string.text
1✔
129
            if text is None:
1✔
130
                # If an XML node is empty <foo></foo>, we want to parse that as an empty string,
131
                # not as a null/None value.
132
                text = ""
1✔
133
        else:
134
            text = node_or_string
1✔
135
        return func(self, request, shape, text, uri_params)
1✔
136

137
    return _get_text_content
1✔
138

139

140
class RequestParserError(Exception):
1✔
141
    """
142
    Error which is thrown if the request parsing fails.
143
    Super class of all exceptions raised by the parser.
144
    """
145

146
    pass
1✔
147

148

149
class UnknownParserError(RequestParserError):
1✔
150
    """
151
    Error which indicates that the raised exception of the parser could be caused by invalid data or by any other
152
    (unknown) issue. Errors like this should be reported and indicate an issue in the parser itself.
153
    """
154

155
    pass
1✔
156

157

158
class ProtocolParserError(RequestParserError):
1✔
159
    """
160
    Error which indicates that the given data is not compliant with the service's specification and cannot be parsed.
161
    This usually results in a response with an HTTP 4xx status code (client error).
162
    """
163

164
    pass
1✔
165

166

167
class OperationNotFoundParserError(ProtocolParserError):
1✔
168
    """
169
    Error which indicates that the given data cannot be matched to a specific operation.
170
    The request is likely _not_ meant to be handled by the ASF service provider itself.
171
    """
172

173
    pass
1✔
174

175

176
def _handle_exceptions(func):
1✔
177
    """
178
    Decorator which handles the exceptions raised by the parser. It ensures that all exceptions raised by the public
179
    methods of the parser are instances of RequestParserError.
180
    :param func: to wrap in order to add the exception handling
181
    :return: wrapped function
182
    """
183

184
    @functools.wraps(func)
1✔
185
    def wrapper(*args, **kwargs):
1✔
186
        try:
1✔
187
            return func(*args, **kwargs)
1✔
188
        except RequestParserError:
1✔
189
            raise
1✔
190
        except Exception as e:
1✔
191
            raise UnknownParserError(
1✔
192
                "An unknown error occurred when trying to parse the request."
193
            ) from e
194

195
    return wrapper
1✔
196

197

198
class RequestParser(abc.ABC):
1✔
199
    """
200
    The request parser is responsible for parsing an incoming HTTP request.
201
    It determines which operation the request was aiming for and parses the incoming request such that the resulting
202
    dictionary can be used to invoke the service's function implementation.
203
    It is the base class for all parsers and therefore contains the basic logic which is used among all of them.
204
    """
205

206
    service: ServiceModel
1✔
207
    DEFAULT_ENCODING = "utf-8"
1✔
208
    # The default timestamp format is ISO8601, but this can be overwritten by subclasses.
209
    TIMESTAMP_FORMAT = "iso8601"
1✔
210
    # The default timestamp format for header fields
211
    HEADER_TIMESTAMP_FORMAT = "rfc822"
1✔
212
    # The default timestamp format for query fields
213
    QUERY_TIMESTAMP_FORMAT = "iso8601"
1✔
214

215
    def __init__(self, service: ServiceModel) -> None:
1✔
216
        super().__init__()
1✔
217
        self.service = service
1✔
218

219
    @_handle_exceptions
1✔
220
    def parse(self, request: Request) -> tuple[OperationModel, Any]:
1✔
221
        """
222
        Determines which operation the request was aiming for and parses the incoming request such that the resulting
223
        dictionary can be used to invoke the service's function implementation.
224

225
        :param request: to parse
226
        :return: a tuple with the operation model (defining the action / operation which the request aims for),
227
                 and the parsed service parameters
228
        :raises: RequestParserError (either a ProtocolParserError or an UnknownParserError)
229
        """
230
        raise NotImplementedError
231

232
    def _parse_shape(
1✔
233
        self, request: Request, shape: Shape, node: Any, uri_params: Mapping[str, Any] = None
234
    ) -> Any:
235
        """
236
        Main parsing method which dynamically calls the parsing function for the specific shape.
237

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

262
                else:
263
                    payload = request.headers.get(header_name)
1✔
264

265
            elif location == "headers":
1✔
266
                payload = self._parse_header_map(shape, request.headers)
1✔
267
                # shapes with the location trait "headers" only contain strings and are not further processed
268
                return payload
1✔
269
            elif location == "querystring":
1✔
270
                query_name = shape.serialization.get("name")
1✔
271
                parsed_query = request.args
1✔
272
                if shape.type_name == "list":
1✔
273
                    payload = parsed_query.getlist(query_name)
1✔
274
                else:
275
                    payload = parsed_query.get(query_name)
1✔
276
            elif location == "uri":
1✔
277
                uri_param_name = shape.serialization.get("name")
1✔
278
                if uri_param_name in uri_params:
1✔
279
                    payload = uri_params[uri_param_name]
1✔
280
            else:
UNCOV
281
                raise UnknownParserError(f"Unknown shape location '{location}'.")
×
282
        else:
283
            # If we don't have to use a specific location, we use the node
284
            payload = node
1✔
285

286
        fn_name = f"_parse_{shape.type_name}"
1✔
287
        handler = getattr(self, fn_name, self._noop_parser)
1✔
288
        try:
1✔
289
            return handler(request, shape, payload, uri_params) if payload is not None else None
1✔
290
        except (TypeError, ValueError, AttributeError) as e:
1✔
291
            raise ProtocolParserError(
1✔
292
                f"Invalid type when parsing {shape.name}: '{payload}' cannot be parsed to {shape.type_name}."
293
            ) from e
294

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

297
    def _parse_list(
1✔
298
        self,
299
        request: Request,
300
        shape: ListShape,
301
        node: list,
302
        uri_params: Mapping[str, Any] = None,
303
    ):
304
        parsed = []
1✔
305
        member_shape = shape.member
1✔
306
        for item in node:
1✔
307
            parsed.append(self._parse_shape(request, member_shape, item, uri_params))
1✔
308
        return parsed
1✔
309

310
    @_text_content
1✔
311
    def _parse_integer(self, _, __, node: str, ___) -> int:
1✔
312
        return int(node)
1✔
313

314
    @_text_content
1✔
315
    def _parse_float(self, _, __, node: str, ___) -> float:
1✔
316
        return float(node)
1✔
317

318
    @_text_content
1✔
319
    def _parse_blob(self, _, __, node: str, ___) -> bytes:
1✔
320
        return base64.b64decode(node)
1✔
321

322
    @_text_content
1✔
323
    def _parse_timestamp(self, _, shape: Shape, node: str, ___) -> datetime.datetime:
1✔
324
        timestamp_format = shape.serialization.get("timestampFormat")
1✔
325
        if not timestamp_format and shape.serialization.get("location") == "header":
1✔
326
            timestamp_format = self.HEADER_TIMESTAMP_FORMAT
1✔
327
        elif not timestamp_format and shape.serialization.get("location") == "querystring":
1✔
328
            timestamp_format = self.QUERY_TIMESTAMP_FORMAT
1✔
329
        return self._convert_str_to_timestamp(node, timestamp_format)
1✔
330

331
    @_text_content
1✔
332
    def _parse_boolean(self, _, __, node: str, ___) -> bool:
1✔
333
        value = node.lower()
1✔
334
        if value == "true":
1✔
335
            return True
1✔
336
        if value == "false":
1✔
337
            return False
1✔
UNCOV
338
        raise ValueError(f"cannot parse boolean value {node}")
×
339

340
    @_text_content
1✔
341
    def _noop_parser(self, _, __, node: Any, ___):
1✔
342
        return node
1✔
343

344
    _parse_character = _parse_string = _noop_parser
1✔
345
    _parse_double = _parse_float
1✔
346
    _parse_long = _parse_integer
1✔
347

348
    def _convert_str_to_timestamp(self, value: str, timestamp_format=None) -> datetime.datetime:
1✔
349
        if timestamp_format is None:
1✔
350
            timestamp_format = self.TIMESTAMP_FORMAT
1✔
351
        timestamp_format = timestamp_format.lower()
1✔
352
        converter = getattr(self, f"_timestamp_{timestamp_format}")
1✔
353
        final_value = converter(value)
1✔
354
        return final_value
1✔
355

356
    @staticmethod
1✔
357
    def _timestamp_iso8601(date_string: str) -> datetime.datetime:
1✔
358
        return dateutil.parser.isoparse(date_string)
1✔
359

360
    @staticmethod
1✔
361
    def _timestamp_unixtimestamp(timestamp_string: str) -> datetime.datetime:
1✔
362
        dt = datetime.datetime.fromtimestamp(int(timestamp_string), tz=datetime.UTC)
1✔
363
        return dt.replace(tzinfo=None)
1✔
364

365
    @staticmethod
1✔
366
    def _timestamp_unixtimestampmillis(timestamp_string: str) -> datetime.datetime:
1✔
367
        dt = datetime.datetime.fromtimestamp(float(timestamp_string) / 1000, tz=datetime.UTC)
1✔
368
        return dt.replace(tzinfo=None)
1✔
369

370
    @staticmethod
1✔
371
    def _timestamp_rfc822(datetime_string: str) -> datetime.datetime:
1✔
372
        return parsedate_to_datetime(datetime_string)
1✔
373

374
    @staticmethod
1✔
375
    def _parse_header_map(shape: Shape, headers: dict) -> dict:
1✔
376
        # Note that headers are case insensitive, so we .lower() all header names and header prefixes.
377
        parsed = {}
1✔
378
        prefix = shape.serialization.get("name", "").lower()
1✔
379
        for header_name, header_value in headers.items():
1✔
380
            if header_name.lower().startswith(prefix):
1✔
381
                # The key name inserted into the parsed hash strips off the prefix.
382
                name = header_name[len(prefix) :]
1✔
383
                parsed[name] = header_value
1✔
384
        return parsed
1✔
385

386

387
class QueryRequestParser(RequestParser):
1✔
388
    """
389
    The ``QueryRequestParser`` is responsible for parsing incoming requests for services which use the ``query``
390
    protocol. The requests for these services encode the majority of their parameters in the URL query string.
391
    """
392

393
    @_handle_exceptions
1✔
394
    def parse(self, request: Request) -> tuple[OperationModel, Any]:
1✔
395
        instance = request.values
1✔
396
        if "Action" not in instance:
1✔
UNCOV
397
            raise ProtocolParserError(
×
398
                f"Operation detection failed. "
399
                f"Missing Action in request for query-protocol service {self.service}."
400
            )
401
        action = instance["Action"]
1✔
402
        try:
1✔
403
            operation: OperationModel = self.service.operation_model(action)
1✔
404
        except OperationNotFoundError as e:
1✔
405
            raise OperationNotFoundParserError(
1✔
406
                f"Operation detection failed."
407
                f"Operation {action} could not be found for service {self.service}."
408
            ) from e
409
        # There are no uri params in the query protocol (all ops are POST on "/")
410
        uri_params = {}
1✔
411
        input_shape: StructureShape = operation.input_shape
1✔
412
        parsed = self._parse_shape(request, input_shape, instance, uri_params)
1✔
413
        if parsed is None:
1✔
414
            return operation, {}
1✔
415
        return operation, parsed
1✔
416

417
    def _process_member(
1✔
418
        self,
419
        request: Request,
420
        member_name: str,
421
        member_shape: Shape,
422
        node: dict,
423
        uri_params: Mapping[str, Any] = None,
424
    ):
425
        if isinstance(member_shape, (MapShape, ListShape, StructureShape)):
1✔
426
            # If we have a complex type, we filter the node and change it's keys to craft a new "context" for the
427
            # new hierarchy level
428
            sub_node = self._filter_node(member_name, node)
1✔
429
        else:
430
            # If it is a primitive type we just get the value from the dict
431
            sub_node = node.get(member_name)
1✔
432
        # The filtered node is processed and returned (or None if the sub_node is None)
433
        return (
1✔
434
            self._parse_shape(request, member_shape, sub_node, uri_params)
435
            if sub_node is not None
436
            else None
437
        )
438

439
    def _parse_structure(
1✔
440
        self,
441
        request: Request,
442
        shape: StructureShape,
443
        node: dict,
444
        uri_params: Mapping[str, Any] = None,
445
    ) -> dict:
446
        result = {}
1✔
447

448
        for member, member_shape in shape.members.items():
1✔
449
            # The key in the node is either the serialization config "name" of the shape, or the name of the member
450
            member_name = self._get_serialized_name(member_shape, member, node)
1✔
451
            # BUT, if it's flattened and a list, the name is defined by the list's member's name
452
            if member_shape.serialization.get("flattened"):
1✔
453
                if isinstance(member_shape, ListShape):
1✔
454
                    member_name = self._get_serialized_name(member_shape.member, member, node)
1✔
455
            value = self._process_member(request, member_name, member_shape, node, uri_params)
1✔
456
            if value is not None or member in shape.required_members:
1✔
457
                # If the member is required, but not existing, we explicitly set None
458
                result[member] = value
1✔
459

460
        return result if len(result) > 0 else None
1✔
461

462
    def _parse_map(
1✔
463
        self, request: Request, shape: MapShape, node: dict, uri_params: Mapping[str, Any]
464
    ) -> dict:
465
        """
466
        This is what the node looks like for a flattened map::
467
        ::
468
          {
469
              "Attribute.1.Name": "MyKey",
470
              "Attribute.1.Value": "MyValue",
471
              "Attribute.2.Name": ...,
472
              ...
473
          }
474
        ::
475
        This function expects an already filtered / pre-processed node. The node dict would therefore look like:
476
        ::
477
          {
478
              "1.Name": "MyKey",
479
              "1.Value": "MyValue",
480
              "2.Name": ...
481
          }
482
        ::
483
        """
484
        key_prefix = ""
1✔
485
        # Non-flattened maps have an additional hierarchy level named "entry"
486
        # https://awslabs.github.io/smithy/1.0/spec/core/xml-traits.html#xmlflattened-trait
487
        if not shape.serialization.get("flattened"):
1✔
488
            key_prefix += "entry."
1✔
489
        result = {}
1✔
490

491
        i = 0
1✔
492
        while True:
1✔
493
            i += 1
1✔
494
            # The key and value can be renamed (with their serialization config's "name").
495
            # By default they are called "key" and "value".
496
            key_name = f"{key_prefix}{i}.{self._get_serialized_name(shape.key, 'key', node)}"
1✔
497
            value_name = f"{key_prefix}{i}.{self._get_serialized_name(shape.value, 'value', node)}"
1✔
498

499
            # We process the key and value individually
500
            k = self._process_member(request, key_name, shape.key, node)
1✔
501
            v = self._process_member(request, value_name, shape.value, node)
1✔
502
            if k is None or v is None:
1✔
503
                # technically, if one exists but not the other, then that would be an invalid request
504
                break
1✔
505
            result[k] = v
1✔
506

507
        return result if len(result) > 0 else None
1✔
508

509
    def _parse_list(
1✔
510
        self,
511
        request: Request,
512
        shape: ListShape,
513
        node: dict,
514
        uri_params: Mapping[str, Any] = None,
515
    ) -> list:
516
        """
517
        Some actions take lists of parameters. These lists are specified using the param.[member.]n notation.
518
        The "member" is used if the list is not flattened.
519
        Values of n are integers starting from 1.
520
        For example, a list with two elements looks like this:
521
        - Flattened: &AttributeName.1=first&AttributeName.2=second
522
        - Non-flattened: &AttributeName.member.1=first&AttributeName.member.2=second
523
        This function expects an already filtered / processed node. The node dict would therefore look like:
524
        ::
525
          {
526
              "1": "first",
527
              "2": "second",
528
              "3": ...
529
          }
530
        ::
531
        """
532
        # The keys might be prefixed (f.e. for flattened lists)
533
        key_prefix = self._get_list_key_prefix(shape, node)
1✔
534

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

539
        i = 0
1✔
540
        while True:
1✔
541
            i += 1
1✔
542
            key_name = f"{key_prefix}{i}"
1✔
543
            value = self._process_member(request, key_name, shape.member, node)
1✔
544
            if value is None:
1✔
545
                break
1✔
546
            result.append((i, value))
1✔
547

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

550
    @staticmethod
1✔
551
    def _filter_node(name: str, node: dict) -> dict:
1✔
552
        """Filters the node dict for entries where the key starts with the given name."""
553
        filtered = {k[len(name) + 1 :]: v for k, v in node.items() if k.startswith(name)}
1✔
554
        return filtered if len(filtered) > 0 else None
1✔
555

556
    def _get_serialized_name(self, shape: Shape, default_name: str, node: dict) -> str:
1✔
557
        """
558
        Returns the serialized name for the shape if it exists.
559
        Otherwise, it will return the given default_name.
560
        """
561
        return shape.serialization.get("name", default_name)
1✔
562

563
    def _get_list_key_prefix(self, shape: ListShape, node: dict):
1✔
564
        key_prefix = ""
1✔
565
        # Non-flattened lists have an additional hierarchy level:
566
        # https://awslabs.github.io/smithy/1.0/spec/core/xml-traits.html#xmlflattened-trait
567
        # The hierarchy level's name is the serialization name of its member or (by default) "member".
568
        if not shape.serialization.get("flattened"):
1✔
569
            key_prefix += f"{self._get_serialized_name(shape.member, 'member', node)}."
1✔
570
        return key_prefix
1✔
571

572

573
class BaseRestRequestParser(RequestParser):
1✔
574
    """
575
    The ``BaseRestRequestParser`` is the base class for all "resty" AWS service protocols.
576
    The operation which should be invoked is determined based on the HTTP method and the path suffix.
577
    The body encoding is done in the respective subclasses.
578
    """
579

580
    def __init__(self, service: ServiceModel) -> None:
1✔
581
        super().__init__(service)
1✔
582
        self.ignore_get_body_errors = False
1✔
583
        self._operation_router = RestServiceOperationRouter(service)
1✔
584

585
    @_handle_exceptions
1✔
586
    def parse(self, request: Request) -> tuple[OperationModel, Any]:
1✔
587
        try:
1✔
588
            operation, uri_params = self._operation_router.match(request)
1✔
589
        except NotFound as e:
1✔
590
            raise OperationNotFoundParserError(
1✔
591
                f"Unable to find operation for request to service "
592
                f"{self.service.service_name}: {request.method} {request.path}"
593
            ) from e
594

595
        shape: StructureShape = operation.input_shape
1✔
596
        final_parsed = {}
1✔
597
        if shape is not None:
1✔
598
            self._parse_payload(request, shape, shape.members, uri_params, final_parsed)
1✔
599
        return operation, final_parsed
1✔
600

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

647
        # even if the payload has been parsed, the rest of the shape needs to be processed as well
648
        # (for members which are located outside of the body, like uri or header)
649
        non_payload_parsed = self._parse_shape(request, shape, non_payload_parsed, uri_params)
1✔
650
        # update the final result with the parsed body and the parsed payload (where the payload has precedence)
651
        final_parsed.update(non_payload_parsed)
1✔
652
        final_parsed.update(payload_parsed)
1✔
653

654
    def _initial_body_parse(self, request: Request) -> Any:
1✔
655
        """
656
        This method executes the initial parsing of the body (XML, JSON, or CBOR).
657
        The parsed body will afterwards still be walked through and the nodes will be converted to the appropriate
658
        types, but this method does the first round of parsing.
659

660
        :param request: of which the body should be parsed
661
        :return: depending on the actual implementation
662
        """
663
        raise NotImplementedError("_initial_body_parse")
664

665
    def _create_event_stream(self, request: Request, shape: Shape) -> Any:
1✔
666
        # TODO handle event streams
667
        raise NotImplementedError("_create_event_stream")
668

669
    def create_input_stream(self, request: Request) -> IO[bytes]:
1✔
670
        """
671
        Returns an IO object that makes the payload of the Request available for streaming.
672

673
        :param request: the http request
674
        :return: the input stream that allows services to consume the request payload
675
        """
676
        # for now _get_stream_for_parsing seems to be a good compromise. it can be used even after `request.data` was
677
        # previously called. however the reverse doesn't work. once the stream has been consumed, `request.data` will
678
        # return b''
679
        return request._get_stream_for_parsing()
1✔
680

681

682
class RestXMLRequestParser(BaseRestRequestParser):
1✔
683
    """
684
    The ``RestXMLRequestParser`` is responsible for parsing incoming requests for services which use the ``rest-xml``
685
    protocol. The requests for these services encode the majority of their parameters as XML in the request body.
686
    """
687

688
    def __init__(self, service_model: ServiceModel):
1✔
689
        super().__init__(service_model)
1✔
690
        self.ignore_get_body_errors = True
1✔
691
        self._namespace_re = re.compile("{.*}")
1✔
692

693
    def _initial_body_parse(self, request: Request) -> ETree.Element:
1✔
694
        body = request.data
1✔
695
        if not body:
1✔
696
            return ETree.Element("")
1✔
697
        return self._parse_xml_string_to_dom(body)
1✔
698

699
    def _parse_structure(
1✔
700
        self,
701
        request: Request,
702
        shape: StructureShape,
703
        node: ETree.Element,
704
        uri_params: Mapping[str, Any] = None,
705
    ) -> dict:
706
        parsed = {}
1✔
707
        xml_dict = self._build_name_to_xml_node(node)
1✔
708
        for member_name, member_shape in shape.members.items():
1✔
709
            xml_name = self._member_key_name(member_shape, member_name)
1✔
710
            member_node = xml_dict.get(xml_name)
1✔
711
            # If a shape defines a location trait, the node might be None (since these are extracted from the request's
712
            # metadata like headers or the URI)
713
            if (
1✔
714
                member_node is not None
715
                or "location" in member_shape.serialization
716
                or member_shape.serialization.get("eventheader")
717
            ):
718
                parsed[member_name] = self._parse_shape(
1✔
719
                    request, member_shape, member_node, uri_params
720
                )
721
            elif member_shape.serialization.get("xmlAttribute"):
1✔
722
                attributes = {}
1✔
723
                location_name = member_shape.serialization["name"]
1✔
724
                for key, value in node.attrib.items():
1✔
725
                    new_key = self._namespace_re.sub(location_name.split(":")[0] + ":", key)
1✔
726
                    attributes[new_key] = value
1✔
727
                if location_name in attributes:
1✔
728
                    parsed[member_name] = attributes[location_name]
1✔
729
            elif member_name in shape.required_members:
1✔
730
                # If the member is required, but not existing, we explicitly set None
731
                parsed[member_name] = None
1✔
732
        return parsed
1✔
733

734
    def _parse_map(
1✔
735
        self,
736
        request: Request,
737
        shape: MapShape,
738
        node: dict,
739
        uri_params: Mapping[str, Any] = None,
740
    ) -> dict:
741
        parsed = {}
×
UNCOV
742
        key_shape = shape.key
×
743
        value_shape = shape.value
×
744
        key_location_name = key_shape.serialization.get("name", "key")
×
745
        value_location_name = value_shape.serialization.get("name", "value")
×
UNCOV
746
        if shape.serialization.get("flattened") and not isinstance(node, list):
×
UNCOV
747
            node = [node]
×
UNCOV
748
        for keyval_node in node:
×
UNCOV
749
            key_name = val_name = None
×
UNCOV
750
            for single_pair in keyval_node:
×
751
                # Within each <entry> there's a <key> and a <value>
UNCOV
752
                tag_name = self._node_tag(single_pair)
×
UNCOV
753
                if tag_name == key_location_name:
×
UNCOV
754
                    key_name = self._parse_shape(request, key_shape, single_pair, uri_params)
×
UNCOV
755
                elif tag_name == value_location_name:
×
UNCOV
756
                    val_name = self._parse_shape(request, value_shape, single_pair, uri_params)
×
757
                else:
UNCOV
758
                    raise ProtocolParserError(f"Unknown tag: {tag_name}")
×
UNCOV
759
            parsed[key_name] = val_name
×
UNCOV
760
        return parsed
×
761

762
    def _parse_list(
1✔
763
        self,
764
        request: Request,
765
        shape: ListShape,
766
        node: dict,
767
        uri_params: Mapping[str, Any] = None,
768
    ) -> list:
769
        # When we use _build_name_to_xml_node, repeated elements are aggregated
770
        # into a list. However, we can't tell the difference between a scalar
771
        # value and a single element flattened list. So before calling the
772
        # real _handle_list, we know that "node" should actually be a list if
773
        # it's flattened, and if it's not, then we make it a one element list.
774
        if shape.serialization.get("flattened") and not isinstance(node, list):
1✔
775
            node = [node]
1✔
776
        return super()._parse_list(request, shape, node, uri_params)
1✔
777

778
    def _node_tag(self, node: ETree.Element) -> str:
1✔
779
        return self._namespace_re.sub("", node.tag)
1✔
780

781
    @staticmethod
1✔
782
    def _member_key_name(shape: Shape, member_name: str) -> str:
1✔
783
        # This method is needed because we have to special case flattened list
784
        # with a serialization name.  If this is the case we use the
785
        # locationName from the list's member shape as the key name for the
786
        # surrounding structure.
787
        if isinstance(shape, ListShape) and shape.serialization.get("flattened"):
1✔
788
            list_member_serialized_name = shape.member.serialization.get("name")
1✔
789
            if list_member_serialized_name is not None:
1✔
790
                return list_member_serialized_name
1✔
791
        serialized_name = shape.serialization.get("name")
1✔
792
        if serialized_name is not None:
1✔
793
            return serialized_name
1✔
794
        return member_name
1✔
795

796
    @staticmethod
1✔
797
    def _parse_xml_string_to_dom(xml_string: str) -> ETree.Element:
1✔
798
        try:
1✔
799
            parser = ETree.XMLParser(target=ETree.TreeBuilder())
1✔
800
            parser.feed(xml_string)
1✔
801
            root = parser.close()
1✔
802
        except ETree.ParseError as e:
1✔
803
            raise ProtocolParserError(
1✔
804
                f"Unable to parse request ({e}), invalid XML received:\n{xml_string}"
805
            ) from e
806
        return root
1✔
807

808
    def _build_name_to_xml_node(self, parent_node: list | ETree.Element) -> dict:
1✔
809
        # If the parent node is actually a list. We should not be trying
810
        # to serialize it to a dictionary. Instead, return the first element
811
        # in the list.
812
        if isinstance(parent_node, list):
1✔
UNCOV
813
            return self._build_name_to_xml_node(parent_node[0])
×
814
        xml_dict = {}
1✔
815
        for item in parent_node:
1✔
816
            key = self._node_tag(item)
1✔
817
            if key in xml_dict:
1✔
818
                # If the key already exists, the most natural
819
                # way to handle this is to aggregate repeated
820
                # keys into a single list.
821
                # <foo>1</foo><foo>2</foo> -> {'foo': [Node(1), Node(2)]}
822
                if isinstance(xml_dict[key], list):
1✔
823
                    xml_dict[key].append(item)
1✔
824
                else:
825
                    # Convert from a scalar to a list.
826
                    xml_dict[key] = [xml_dict[key], item]
1✔
827
            else:
828
                xml_dict[key] = item
1✔
829
        return xml_dict
1✔
830

831
    def _create_event_stream(self, request: Request, shape: Shape) -> Any:
1✔
832
        # TODO handle event streams
833
        raise NotImplementedError("_create_event_stream")
834

835

836
class BaseJSONRequestParser(RequestParser, ABC):
1✔
837
    """
838
    The ``BaseJSONRequestParser`` is the base class for all JSON-based AWS service protocols.
839
    This base-class handles parsing the payload / body as JSON.
840
    """
841

842
    # default timestamp format for JSON requests
843
    TIMESTAMP_FORMAT = "unixtimestamp"
1✔
844
    # timestamp format for requests with CBOR content type
845
    CBOR_TIMESTAMP_FORMAT = "unixtimestampmillis"
1✔
846

847
    def _parse_structure(
1✔
848
        self,
849
        request: Request,
850
        shape: StructureShape,
851
        value: dict | None,
852
        uri_params: Mapping[str, Any] = None,
853
    ) -> dict | None:
854
        if shape.is_document_type:
1✔
UNCOV
855
            final_parsed = value
×
856
        else:
857
            if value is None:
1✔
858
                # If the comes across the wire as "null" (None in python),
859
                # we should be returning this unchanged, instead of as an
860
                # empty dict.
UNCOV
861
                return None
×
862
            final_parsed = {}
1✔
863
            for member_name, member_shape in shape.members.items():
1✔
864
                json_name = member_shape.serialization.get("name", member_name)
1✔
865
                raw_value = value.get(json_name)
1✔
866
                parsed = self._parse_shape(request, member_shape, raw_value, uri_params)
1✔
867
                if parsed is not None or member_name in shape.required_members:
1✔
868
                    # If the member is required, but not existing, we set it to None anyways
869
                    final_parsed[member_name] = parsed
1✔
870
        return final_parsed
1✔
871

872
    def _parse_map(
1✔
873
        self,
874
        request: Request,
875
        shape: MapShape,
876
        value: dict | None,
877
        uri_params: Mapping[str, Any] = None,
878
    ) -> dict | None:
879
        if value is None:
1✔
UNCOV
880
            return None
×
881
        parsed = {}
1✔
882
        key_shape = shape.key
1✔
883
        value_shape = shape.value
1✔
884
        for key, val in value.items():
1✔
885
            actual_key = self._parse_shape(request, key_shape, key, uri_params)
1✔
886
            actual_value = self._parse_shape(request, value_shape, val, uri_params)
1✔
887
            parsed[actual_key] = actual_value
1✔
888
        return parsed
1✔
889

890
    def _parse_body_as_json(self, request: Request) -> dict:
1✔
891
        body_contents = request.data
1✔
892
        if not body_contents:
1✔
893
            return {}
1✔
894
        if request.mimetype.startswith("application/x-amz-cbor"):
1✔
895
            try:
1✔
896
                return cbor2_loads(body_contents)
1✔
UNCOV
897
            except ValueError as e:
×
UNCOV
898
                raise ProtocolParserError("HTTP body could not be parsed as CBOR.") from e
×
899
        else:
900
            try:
1✔
901
                return request.get_json(force=True)
1✔
902
            except BadRequest as e:
1✔
903
                raise ProtocolParserError("HTTP body could not be parsed as JSON.") from e
1✔
904

905
    def _parse_boolean(
1✔
906
        self, request: Request, shape: Shape, node: bool, uri_params: Mapping[str, Any] = None
907
    ) -> bool:
908
        return super()._noop_parser(request, shape, node, uri_params)
1✔
909

910
    def _parse_timestamp(
1✔
911
        self, request: Request, shape: Shape, node: str, uri_params: Mapping[str, Any] = None
912
    ) -> datetime.datetime:
913
        if not shape.serialization.get("timestampFormat") and request.mimetype.startswith(
1✔
914
            "application/x-amz-cbor"
915
        ):
916
            # cbor2 has native support for timestamp decoding, so this node could already have the right type
917
            if isinstance(node, datetime.datetime):
1✔
918
                return node
1✔
919
            # otherwise parse the timestamp using the AWS CBOR timestamp format
920
            # (non-CBOR-standard conform, uses millis instead of floating-point-millis)
921
            return self._convert_str_to_timestamp(node, self.CBOR_TIMESTAMP_FORMAT)
1✔
922
        return super()._parse_timestamp(request, shape, node, uri_params)
1✔
923

924
    def _parse_blob(
1✔
925
        self, request: Request, shape: Shape, node: bool, uri_params: Mapping[str, Any] = None
926
    ) -> bytes:
927
        if isinstance(node, bytes) and request.mimetype.startswith("application/x-amz-cbor"):
1✔
928
            # CBOR does not base64 encode binary data
929
            return bytes(node)
1✔
930
        else:
931
            return super()._parse_blob(request, shape, node, uri_params)
1✔
932

933

934
class JSONRequestParser(BaseJSONRequestParser):
1✔
935
    """
936
    The ``JSONRequestParser`` is responsible for parsing incoming requests for services which use the ``json``
937
    protocol.
938
    The requests for these services encode the majority of their parameters as JSON in the request body.
939
    The operation is defined in an HTTP header field.
940
    """
941

942
    @_handle_exceptions
1✔
943
    def parse(self, request: Request) -> tuple[OperationModel, Any]:
1✔
944
        target = request.headers["X-Amz-Target"]
1✔
945
        # assuming that the last part of the target string (e.g., "x.y.z.MyAction") contains the operation name
946
        operation_name = target.rpartition(".")[2]
1✔
947
        operation = self.service.operation_model(operation_name)
1✔
948
        shape = operation.input_shape
1✔
949
        # There are no uri params in the query protocol
950
        uri_params = {}
1✔
951
        final_parsed = self._do_parse(request, shape, uri_params)
1✔
952
        return operation, final_parsed
1✔
953

954
    def _do_parse(
1✔
955
        self, request: Request, shape: Shape, uri_params: Mapping[str, Any] = None
956
    ) -> dict:
957
        parsed = {}
1✔
958
        if shape is not None:
1✔
959
            event_name = shape.event_stream_name
1✔
960
            if event_name:
1✔
UNCOV
961
                parsed = self._handle_event_stream(request, shape, event_name)
×
962
            else:
963
                parsed = self._handle_json_body(request, shape, uri_params)
1✔
964
        return parsed
1✔
965

966
    def _handle_event_stream(self, request: Request, shape: Shape, event_name: str):
1✔
967
        # TODO handle event streams
968
        raise NotImplementedError
969

970
    def _handle_json_body(
1✔
971
        self, request: Request, shape: Shape, uri_params: Mapping[str, Any] = None
972
    ) -> Any:
973
        # The json.loads() gives us the primitive JSON types, but we need to traverse the parsed JSON data to convert
974
        # to richer types (blobs, timestamps, etc.)
975
        parsed_json = self._parse_body_as_json(request)
1✔
976
        return self._parse_shape(request, shape, parsed_json, uri_params)
1✔
977

978

979
class RestJSONRequestParser(BaseRestRequestParser, BaseJSONRequestParser):
1✔
980
    """
981
    The ``RestJSONRequestParser`` is responsible for parsing incoming requests for services which use the ``rest-json``
982
    protocol.
983
    The requests for these services encode the majority of their parameters as JSON in the request body.
984
    The operation is defined by the HTTP method and the path suffix.
985
    """
986

987
    def _initial_body_parse(self, request: Request) -> dict:
1✔
988
        return self._parse_body_as_json(request)
1✔
989

990
    def _create_event_stream(self, request: Request, shape: Shape) -> Any:
1✔
991
        raise NotImplementedError
992

993

994
class BaseCBORRequestParser(RequestParser, ABC):
1✔
995
    """
996
    The ``BaseCBORRequestParser`` is the base class for all CBOR-based AWS service protocols.
997
    This base-class handles parsing the payload / body as CBOR.
998
    """
999

1000
    INDEFINITE_ITEM_ADDITIONAL_INFO = 31
1✔
1001
    BREAK_CODE = 0xFF
1✔
1002
    # timestamp format for requests with CBOR content type
1003
    TIMESTAMP_FORMAT = "unixtimestamp"
1✔
1004

1005
    @functools.cached_property
1✔
1006
    def major_type_to_parsing_method_map(self):
1✔
1007
        return {
1✔
1008
            0: self._parse_type_unsigned_integer,
1009
            1: self._parse_type_negative_integer,
1010
            2: self._parse_type_byte_string,
1011
            3: self._parse_type_text_string,
1012
            4: self._parse_type_array,
1013
            5: self._parse_type_map,
1014
            6: self._parse_type_tag,
1015
            7: self._parse_type_simple_and_float,
1016
        }
1017

1018
    @staticmethod
1✔
1019
    def get_peekable_stream_from_bytes(_bytes: bytes) -> io.BufferedReader:
1✔
1020
        return io.BufferedReader(io.BytesIO(_bytes))
1✔
1021

1022
    def parse_data_item(self, stream: io.BufferedReader) -> Any:
1✔
1023
        # CBOR data is divided into "data items", and each data item starts
1024
        # with an initial byte that describes how the following bytes should be parsed
1025
        initial_byte = self._read_bytes_as_int(stream, 1)
1✔
1026
        # The highest order three bits of the initial byte describe the CBOR major type
1027
        major_type = initial_byte >> 5
1✔
1028
        # The lowest order 5 bits of the initial byte tells us more information about
1029
        # how the bytes should be parsed that will be used
1030
        additional_info: int = initial_byte & 0b00011111
1✔
1031

1032
        if major_type in self.major_type_to_parsing_method_map:
1✔
1033
            method = self.major_type_to_parsing_method_map[major_type]
1✔
1034
            return method(stream, additional_info)
1✔
1035
        else:
UNCOV
1036
            raise ProtocolParserError(
×
1037
                f"Unsupported inital byte found for data item- "
1038
                f"Major type:{major_type}, Additional info: "
1039
                f"{additional_info}"
1040
            )
1041

1042
    # Major type 0 - unsigned integers
1043
    def _parse_type_unsigned_integer(self, stream: io.BufferedReader, additional_info: int) -> int:
1✔
1044
        additional_info_to_num_bytes = {
1✔
1045
            24: 1,
1046
            25: 2,
1047
            26: 4,
1048
            27: 8,
1049
        }
1050
        # Values under 24 don't need a full byte to be stored; their values are
1051
        # instead stored as the "additional info" in the initial byte
1052
        if additional_info < 24:
1✔
1053
            return additional_info
1✔
1054
        elif additional_info in additional_info_to_num_bytes:
1✔
1055
            num_bytes = additional_info_to_num_bytes[additional_info]
1✔
1056
            return self._read_bytes_as_int(stream, num_bytes)
1✔
1057
        else:
UNCOV
1058
            raise ProtocolParserError(
×
1059
                "Invalid CBOR integer returned from the service; unparsable "
1060
                f"additional info found for major type 0 or 1: {additional_info}"
1061
            )
1062

1063
    # Major type 1 - negative integers
1064
    def _parse_type_negative_integer(self, stream: io.BufferedReader, additional_info: int) -> int:
1✔
UNCOV
1065
        return -1 - self._parse_type_unsigned_integer(stream, additional_info)
×
1066

1067
    # Major type 2 - byte string
1068
    def _parse_type_byte_string(self, stream: io.BufferedReader, additional_info: int) -> bytes:
1✔
1069
        if additional_info != self.INDEFINITE_ITEM_ADDITIONAL_INFO:
1✔
1070
            length = self._parse_type_unsigned_integer(stream, additional_info)
1✔
1071
            return self._read_from_stream(stream, length)
1✔
1072
        else:
1073
            chunks = []
×
UNCOV
1074
            while True:
×
UNCOV
1075
                if self._handle_break_code(stream):
×
UNCOV
1076
                    break
×
UNCOV
1077
                initial_byte = self._read_bytes_as_int(stream, 1)
×
UNCOV
1078
                additional_info = initial_byte & 0b00011111
×
UNCOV
1079
                length = self._parse_type_unsigned_integer(stream, additional_info)
×
UNCOV
1080
                chunks.append(self._read_from_stream(stream, length))
×
UNCOV
1081
            return b"".join(chunks)
×
1082

1083
    # Major type 3 - text string
1084
    def _parse_type_text_string(self, stream: io.BufferedReader, additional_info: int) -> str:
1✔
1085
        return self._parse_type_byte_string(stream, additional_info).decode("utf-8")
1✔
1086

1087
    # Major type 4 - lists
1088
    def _parse_type_array(self, stream: io.BufferedReader, additional_info: int) -> list:
1✔
1089
        if additional_info != self.INDEFINITE_ITEM_ADDITIONAL_INFO:
1✔
1090
            length = self._parse_type_unsigned_integer(stream, additional_info)
1✔
1091
            return [self.parse_data_item(stream) for _ in range(length)]
1✔
1092
        else:
UNCOV
1093
            items = []
×
UNCOV
1094
            while not self._handle_break_code(stream):
×
UNCOV
1095
                items.append(self.parse_data_item(stream))
×
UNCOV
1096
            return items
×
1097

1098
    # Major type 5 - maps
1099
    def _parse_type_map(self, stream: io.BufferedReader, additional_info: int) -> dict:
1✔
1100
        items = {}
1✔
1101
        if additional_info != self.INDEFINITE_ITEM_ADDITIONAL_INFO:
1✔
1102
            length = self._parse_type_unsigned_integer(stream, additional_info)
1✔
1103
            for _ in range(length):
1✔
1104
                self._parse_type_key_value_pair(stream, items)
1✔
1105
            return items
1✔
1106

1107
        else:
1108
            while not self._handle_break_code(stream):
1✔
1109
                self._parse_type_key_value_pair(stream, items)
1✔
1110
            return items
1✔
1111

1112
    def _parse_type_key_value_pair(self, stream: io.BufferedReader, items: dict) -> None:
1✔
1113
        key = self.parse_data_item(stream)
1✔
1114
        value = self.parse_data_item(stream)
1✔
1115
        if value is not None:
1✔
1116
            items[key] = value
1✔
1117

1118
    # Major type 6 is tags.  The only tag we currently support is tag 1 for unix
1119
    # timestamps
1120
    def _parse_type_tag(self, stream: io.BufferedReader, additional_info: int):
1✔
UNCOV
1121
        tag = self._parse_type_unsigned_integer(stream, additional_info)
×
UNCOV
1122
        value = self.parse_data_item(stream)
×
UNCOV
1123
        if tag == 1:  # Epoch-based date/time in milliseconds
×
UNCOV
1124
            return self._parse_type_datetime(value)
×
1125
        else:
UNCOV
1126
            raise ProtocolParserError(f"Found CBOR tag not supported by botocore: {tag}")
×
1127

1128
    def _parse_type_datetime(self, value: int | float) -> datetime.datetime:
1✔
UNCOV
1129
        if isinstance(value, (int, float)):
×
UNCOV
1130
            return self._convert_str_to_timestamp(str(value))
×
1131
        else:
UNCOV
1132
            raise ProtocolParserError(f"Unable to parse datetime value: {value}")
×
1133

1134
    # Major type 7 includes floats and "simple" types.  Supported simple types are
1135
    # currently boolean values, CBOR's null, and CBOR's undefined type.  All other
1136
    # values are either floats or invalid.
1137
    def _parse_type_simple_and_float(
1✔
1138
        self, stream: io.BufferedReader, additional_info: int
1139
    ) -> bool | float | None:
1140
        # For major type 7, values 20-23 correspond to CBOR "simple" values
1141
        additional_info_simple_values = {
1✔
1142
            20: False,  # CBOR false
1143
            21: True,  # CBOR true
1144
            22: None,  # CBOR null
1145
            23: None,  # CBOR undefined
1146
        }
1147
        # First we check if the additional info corresponds to a supported simple value
1148
        if additional_info in additional_info_simple_values:
1✔
UNCOV
1149
            return additional_info_simple_values[additional_info]
×
1150

1151
        # If it's not a simple value, we need to parse it into the correct format and
1152
        # number fo bytes
1153
        float_formats = {
1✔
1154
            25: (">e", 2),
1155
            26: (">f", 4),
1156
            27: (">d", 8),
1157
        }
1158

1159
        if additional_info in float_formats:
1✔
1160
            float_format, num_bytes = float_formats[additional_info]
1✔
1161
            return struct.unpack(float_format, self._read_from_stream(stream, num_bytes))[0]
1✔
UNCOV
1162
        raise ProtocolParserError(
×
1163
            f"Invalid additional info found for major type 7: {additional_info}.  "
1164
            f"This indicates an unsupported simple type or an indefinite float value"
1165
        )
1166

1167
    @_text_content
1✔
1168
    def _parse_blob(self, _, __, node: bytes, ___) -> bytes:
1✔
1169
        return node
1✔
1170

1171
    # This helper method is intended for use when parsing indefinite length items.
1172
    # It does nothing if the next byte is not the break code.  If the next byte is
1173
    # the break code, it advances past that byte and returns True so the calling
1174
    # method knows to stop parsing that data item.
1175
    def _handle_break_code(self, stream: io.BufferedReader) -> bool | None:
1✔
1176
        if int.from_bytes(stream.peek(1)[:1], "big") == self.BREAK_CODE:
1✔
1177
            stream.seek(1, os.SEEK_CUR)
1✔
1178
            return True
1✔
1179

1180
    def _read_bytes_as_int(self, stream: IO[bytes], num_bytes: int) -> int:
1✔
1181
        byte = self._read_from_stream(stream, num_bytes)
1✔
1182
        return int.from_bytes(byte, "big")
1✔
1183

1184
    @staticmethod
1✔
1185
    def _read_from_stream(stream: IO[bytes], num_bytes: int) -> bytes:
1✔
1186
        value = stream.read(num_bytes)
1✔
1187
        if len(value) != num_bytes:
1✔
UNCOV
1188
            raise ProtocolParserError(
×
1189
                "End of stream reached; this indicates a "
1190
                "malformed CBOR response from the server or an "
1191
                "issue in botocore"
1192
            )
1193
        return value
1✔
1194

1195

1196
class CBORRequestParser(BaseCBORRequestParser, JSONRequestParser):
1✔
1197
    """
1198
    The ``CBORRequestParser`` is responsible for parsing incoming requests for services which use the ``cbor``
1199
    protocol.
1200
    The requests for these services encode the majority of their parameters as CBOR in the request body.
1201
    The operation is defined in an HTTP header field.
1202
    This protocol is not properly defined in the specs, but it is derived from the ``json`` protocol. Only Kinesis uses
1203
    it for now.
1204
    """
1205

1206
    # timestamp format is different from traditional CBOR, and is encoded as a milliseconds integer
1207
    TIMESTAMP_FORMAT = "unixtimestampmillis"
1✔
1208

1209
    def _do_parse(
1✔
1210
        self, request: Request, shape: Shape, uri_params: Mapping[str, Any] = None
1211
    ) -> dict:
1212
        parsed = {}
1✔
1213
        if shape is not None:
1✔
1214
            event_name = shape.event_stream_name
1✔
1215
            if event_name:
1✔
UNCOV
1216
                parsed = self._handle_event_stream(request, shape, event_name)
×
1217
            else:
1218
                self._parse_payload(request, shape, parsed, uri_params)
1✔
1219
        return parsed
1✔
1220

1221
    def _handle_event_stream(self, request: Request, shape: Shape, event_name: str):
1✔
1222
        # TODO handle event streams
1223
        raise NotImplementedError
1224

1225
    def _parse_payload(
1✔
1226
        self,
1227
        request: Request,
1228
        shape: Shape,
1229
        final_parsed: dict,
1230
        uri_params: Mapping[str, Any] = None,
1231
    ) -> None:
1232
        original_parsed = self._initial_body_parse(request)
1✔
1233
        body_parsed = self._parse_shape(request, shape, original_parsed, uri_params)
1✔
1234
        final_parsed.update(body_parsed)
1✔
1235

1236
    def _initial_body_parse(self, request: Request) -> Any:
1✔
1237
        body_contents = request.data
1✔
1238
        if body_contents == b"":
1✔
UNCOV
1239
            return body_contents
×
1240
        body_contents_stream = self.get_peekable_stream_from_bytes(body_contents)
1✔
1241
        return self.parse_data_item(body_contents_stream)
1✔
1242

1243
    def _parse_timestamp(
1✔
1244
        self, request: Request, shape: Shape, node: str, uri_params: Mapping[str, Any] = None
1245
    ) -> datetime.datetime:
1246
        # TODO: remove once CBOR support has been removed from `JSONRequestParser`
1247
        return super()._parse_timestamp(request, shape, node, uri_params)
1✔
1248

1249

1250
class BaseRpcV2RequestParser(RequestParser):
1✔
1251
    """
1252
    The ``BaseRpcV2RequestParser`` is the base class for all RPC V2-based AWS service protocols.
1253
    This base class handles the routing of the request, which is specific based on the path.
1254
    The body decoding is done in the respective subclasses.
1255
    """
1256

1257
    @_handle_exceptions
1✔
1258
    def parse(self, request: Request) -> tuple[OperationModel, Any]:
1✔
1259
        # see https://smithy.io/2.0/additional-specs/protocols/smithy-rpc-v2.html
1260
        if request.method != "POST":
1✔
UNCOV
1261
            raise ProtocolParserError("RPC v2 only accepts POST requests.")
×
1262

1263
        headers = request.headers
1✔
1264
        if "X-Amz-Target" in headers or "X-Amzn-Target" in headers:
1✔
UNCOV
1265
            raise ProtocolParserError(
×
1266
                "RPC v2 does not accept 'X-Amz-Target' or 'X-Amzn-Target'. "
1267
                "Such requests are rejected for security reasons."
1268
            )
1269
        # TODO: add this special path handling to the ServiceNameParser to allow RPC v2 service to be properly extracted
1270
        #  path = '/service/{service_name}/operation/{operation_name}'
1271
        # The Smithy RPCv2 CBOR protocol will only use the last four segments of the URL when routing requests.
1272
        rpc_v2_params = request.path.lstrip("/").split("/")
1✔
1273
        if len(rpc_v2_params) < 4 or not (
1✔
1274
            operation := self.service.operation_model(rpc_v2_params[-1])
1275
        ):
UNCOV
1276
            raise OperationNotFoundParserError(
×
1277
                f"Unable to find operation for request to service "
1278
                f"{self.service.service_name}: {request.method} {request.path}"
1279
            )
1280

1281
        # there are no URI params in RPC v2
1282
        uri_params = {}
1✔
1283
        shape: StructureShape = operation.input_shape
1✔
1284
        final_parsed = self._do_parse(request, shape, uri_params)
1✔
1285
        return operation, final_parsed
1✔
1286

1287
    @_handle_exceptions
1✔
1288
    def _do_parse(
1✔
1289
        self, request: Request, shape: Shape, uri_params: Mapping[str, Any] = None
1290
    ) -> dict[str, Any]:
1291
        parsed = {}
1✔
1292
        if shape is not None:
1✔
1293
            event_stream_name = shape.event_stream_name
1✔
1294
            if event_stream_name:
1✔
UNCOV
1295
                parsed = self._handle_event_stream(request, shape, event_stream_name)
×
1296
            else:
1297
                parsed = {}
1✔
1298
                self._parse_payload(request, shape, parsed, uri_params)
1✔
1299

1300
        return parsed
1✔
1301

1302
    def _handle_event_stream(self, request: Request, shape: Shape, event_name: str):
1✔
1303
        # TODO handle event streams
1304
        raise NotImplementedError
1305

1306
    def _parse_structure(
1✔
1307
        self,
1308
        request: Request,
1309
        shape: StructureShape,
1310
        node: dict | None,
1311
        uri_params: Mapping[str, Any] = None,
1312
    ):
1313
        if shape.is_document_type:
1✔
UNCOV
1314
            final_parsed = node
×
1315
        else:
1316
            if node is None:
1✔
1317
                # If the comes across the wire as "null" (None in python),
1318
                # we should be returning this unchanged, instead of as an
1319
                # empty dict.
UNCOV
1320
                return None
×
1321
            final_parsed = {}
1✔
1322
            members = shape.members
1✔
1323
            if shape.is_tagged_union:
1✔
1324
                cleaned_value = node.copy()
1✔
1325
                cleaned_value.pop("__type", None)
1✔
1326
                cleaned_value = {k: v for k, v in cleaned_value.items() if v is not None}
1✔
1327
                if len(cleaned_value) != 1:
1✔
UNCOV
1328
                    raise ProtocolParserError(
×
1329
                        f"Invalid service response: {shape.name} must have one and only one member set."
1330
                    )
1331

1332
            for member_name, member_shape in members.items():
1✔
1333
                member_value = node.get(member_name)
1✔
1334
                if member_value is not None:
1✔
1335
                    final_parsed[member_name] = self._parse_shape(
1✔
1336
                        request, member_shape, member_value, uri_params
1337
                    )
1338

1339
        return final_parsed
1✔
1340

1341
    def _parse_payload(
1✔
1342
        self,
1343
        request: Request,
1344
        shape: Shape,
1345
        final_parsed: dict,
1346
        uri_params: Mapping[str, Any] = None,
1347
    ) -> None:
1348
        original_parsed = self._initial_body_parse(request)
1✔
1349
        body_parsed = self._parse_shape(request, shape, original_parsed, uri_params)
1✔
1350
        final_parsed.update(body_parsed)
1✔
1351

1352
    def _initial_body_parse(self, request: Request):
1✔
1353
        # This method should do the initial parsing of the
1354
        # body.  We still need to walk the parsed body in order
1355
        # to convert types, but this method will do the first round
1356
        # of parsing.
1357
        raise NotImplementedError("_initial_body_parse")
1358

1359

1360
class RpcV2CBORRequestParser(BaseRpcV2RequestParser, BaseCBORRequestParser):
1✔
1361
    """
1362
    The ``RpcV2CBORRequestParser`` is responsible for parsing incoming requests for services which use the
1363
    ``rpc-v2-cbor`` protocol. The requests for these services encode all of their parameters as CBOR in the
1364
    request body.
1365
    """
1366

1367
    # TODO: investigate datetime format for RpcV2CBOR protocol, which might be different than Kinesis CBOR
1368
    def _initial_body_parse(self, request: Request):
1✔
1369
        body_contents = request.data
1✔
1370
        if body_contents == b"":
1✔
UNCOV
1371
            return body_contents
×
1372
        body_contents_stream = self.get_peekable_stream_from_bytes(body_contents)
1✔
1373
        return self.parse_data_item(body_contents_stream)
1✔
1374

1375

1376
class EC2RequestParser(QueryRequestParser):
1✔
1377
    """
1378
    The ``EC2RequestParser`` is responsible for parsing incoming requests for services which use the ``ec2``
1379
    protocol (which only is EC2). Protocol is quite similar to the ``query`` protocol with some small differences.
1380
    """
1381

1382
    def _get_serialized_name(self, shape: Shape, default_name: str, node: dict) -> str:
1✔
1383
        # Returns the serialized name for the shape if it exists.
1384
        # Otherwise it will return the passed in default_name.
1385
        if "queryName" in shape.serialization:
1✔
UNCOV
1386
            return shape.serialization["queryName"]
×
1387
        elif "name" in shape.serialization:
1✔
1388
            # A locationName is always capitalized on input for the ec2 protocol.
1389
            name = shape.serialization["name"]
1✔
1390
            return name[0].upper() + name[1:]
1✔
1391
        else:
1392
            return default_name
1✔
1393

1394
    def _get_list_key_prefix(self, shape: ListShape, node: dict):
1✔
1395
        # The EC2 protocol does not use a prefix notation for flattened lists
1396
        return ""
1✔
1397

1398

1399
class S3RequestParser(RestXMLRequestParser):
1✔
1400
    class VirtualHostRewriter:
1✔
1401
        """
1402
        Context Manager which rewrites the request object parameters such that - within the context - it looks like a
1403
        normal S3 request.
1404
        FIXME: this is not optimal because it mutates the Request object. Once we have better utility to create/copy
1405
        a request instead of EnvironBuilder, we should copy it before parsing (except the stream).
1406
        """
1407

1408
        def __init__(self, request: Request):
1✔
1409
            self.request = request
1✔
1410
            self.old_host = None
1✔
1411
            self.old_path = None
1✔
1412

1413
        def __enter__(self):
1✔
1414
            # only modify the request if it uses the virtual host addressing
1415
            if bucket_name := self._is_vhost_address_get_bucket(self.request):
1✔
1416
                # save the original path and host for restoring on context exit
1417
                self.old_path = self.request.path
1✔
1418
                self.old_host = self.request.host
1✔
1419
                self.old_raw_uri = self.request.environ.get("RAW_URI")
1✔
1420

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

1424
                # put the bucket name at the front
1425
                new_path = "/" + bucket_name + self.old_path or "/"
1✔
1426

1427
                # create a new RAW_URI for the WSGI environment, this is necessary because of our `get_raw_path` utility
1428
                if self.old_raw_uri:
1✔
1429
                    new_raw_uri = "/" + bucket_name + self.old_raw_uri or "/"
1✔
1430
                    if qs := self.request.query_string:
1✔
1431
                        new_raw_uri += "?" + qs.decode("utf-8")
1✔
1432
                else:
1433
                    new_raw_uri = None
1✔
1434

1435
                # set the new path and host
1436
                self._set_request_props(self.request, new_path, new_host, new_raw_uri)
1✔
1437
            return self.request
1✔
1438

1439
        def __exit__(self, exc_type, exc_value, exc_traceback):
1✔
1440
            # reset the original request properties on exit of the context
1441
            if self.old_host or self.old_path:
1✔
1442
                self._set_request_props(
1✔
1443
                    self.request, self.old_path, self.old_host, self.old_raw_uri
1444
                )
1445

1446
        @staticmethod
1✔
1447
        def _set_request_props(request: Request, path: str, host: str, raw_uri: str | None = None):
1✔
1448
            """Sets the HTTP request's path and host and clears the cache in the request object."""
1449
            request.path = path
1✔
1450
            request.headers["Host"] = host
1✔
1451
            if raw_uri:
1✔
1452
                request.environ["RAW_URI"] = raw_uri
1✔
1453

1454
            try:
1✔
1455
                # delete the werkzeug request property cache that depends on path, but make sure all of them are
1456
                # initialized first, otherwise `del` will raise a key error
1457
                request.host = None  # noqa
1✔
1458
                request.url = None  # noqa
1✔
1459
                request.base_url = None  # noqa
1✔
1460
                request.full_path = None  # noqa
1✔
1461
                request.host_url = None  # noqa
1✔
1462
                request.root_url = None  # noqa
1✔
1463
                del request.host  # noqa
1✔
1464
                del request.url  # noqa
1✔
1465
                del request.base_url  # noqa
1✔
1466
                del request.full_path  # noqa
1✔
1467
                del request.host_url  # noqa
1✔
1468
                del request.root_url  # noqa
1✔
UNCOV
1469
            except AttributeError:
×
UNCOV
1470
                pass
×
1471

1472
        @staticmethod
1✔
1473
        def _is_vhost_address_get_bucket(request: Request) -> str | None:
1✔
1474
            from localstack.services.s3.utils import uses_host_addressing
1✔
1475

1476
            return uses_host_addressing(request.headers)
1✔
1477

1478
    @_handle_exceptions
1✔
1479
    def parse(self, request: Request) -> tuple[OperationModel, Any]:
1✔
1480
        """Handle virtual-host-addressing for S3."""
1481
        with self.VirtualHostRewriter(request):
1✔
1482
            return super().parse(request)
1✔
1483

1484
    def _parse_shape(
1✔
1485
        self, request: Request, shape: Shape, node: Any, uri_params: Mapping[str, Any] = None
1486
    ) -> Any:
1487
        """
1488
        Special handling of parsing the shape for s3 object-names (=key):
1489
        Trailing '/' are valid and need to be preserved, however, the url-matcher removes it from the key.
1490
        We need special logic to compare the parsed Key parameter against the path and add back the missing slashes
1491
        """
1492
        if (
1✔
1493
            shape is not None
1494
            and uri_params is not None
1495
            and shape.serialization.get("location") == "uri"
1496
            and shape.serialization.get("name") == "Key"
1497
            and (
1498
                (trailing_slashes := request.path.rpartition(uri_params["Key"])[2])
1499
                and all(char == "/" for char in trailing_slashes)
1500
            )
1501
        ):
1502
            uri_params = dict(uri_params)
1✔
1503
            uri_params["Key"] = uri_params["Key"] + trailing_slashes
1✔
1504
        return super()._parse_shape(request, shape, node, uri_params)
1✔
1505

1506
    @_text_content
1✔
1507
    def _parse_integer(self, _, shape, node: str, ___) -> int | None:
1✔
1508
        # S3 accepts empty query string parameters that should be integer
1509
        # to not break other cases, validate that the shape is in the querystring
1510
        if node == "" and shape.serialization.get("location") == "querystring":
1✔
1511
            return None
1✔
1512
        return int(node)
1✔
1513

1514

1515
class SQSQueryRequestParser(QueryRequestParser):
1✔
1516
    def _get_serialized_name(self, shape: Shape, default_name: str, node: dict) -> str:
1✔
1517
        """
1518
        SQS allows using both - the proper serialized name of a map as well as the member name - as name for maps.
1519
        For example, both works for the TagQueue operation:
1520
        - Using the proper serialized name "Tag": Tag.1.Key=key&Tag.1.Value=value
1521
        - Using the member name "Tag" in the parent structure: Tags.1.Key=key&Tags.1.Value=value
1522
        - Using "Name" to represent the Key for a nested dict: MessageAttributes.1.Name=key&MessageAttributes.1.Value.StringValue=value
1523
            resulting in {MessageAttributes: {key : {StringValue: value}}}
1524
        The Java SDK implements the second variant: https://github.com/aws/aws-sdk-java-v2/issues/2524
1525
        This has been approved to be a bug and against the spec, but since the client has a lot of users, and AWS SQS
1526
        supports both, we need to handle it here.
1527
        """
1528
        # ask the super implementation for the proper serialized name
1529
        primary_name = super()._get_serialized_name(shape, default_name, node)
1✔
1530

1531
        # determine potential suffixes for the name of the member in the node
1532
        suffixes = []
1✔
1533
        if shape.type_name == "map":
1✔
1534
            if not shape.serialization.get("flattened"):
1✔
UNCOV
1535
                suffixes = [".entry.1.Key", ".entry.1.Name"]
×
1536
            else:
1537
                suffixes = [".1.Key", ".1.Name"]
1✔
1538
        if shape.type_name == "list":
1✔
1539
            if not shape.serialization.get("flattened"):
1✔
UNCOV
1540
                suffixes = [".member.1"]
×
1541
            else:
1542
                suffixes = [".1"]
1✔
1543

1544
        # if the primary name is _not_ available in the node, but the default name is, we use the default name
1545
        if not any(f"{primary_name}{suffix}" in node for suffix in suffixes) and any(
1✔
1546
            f"{default_name}{suffix}" in node for suffix in suffixes
1547
        ):
1548
            return default_name
1✔
1549
        # otherwise we use the primary name
1550
        return primary_name
1✔
1551

1552

1553
@functools.cache
1✔
1554
def create_parser(service: ServiceModel, protocol: ProtocolName | None = None) -> RequestParser:
1✔
1555
    """
1556
    Creates the right parser for the given service model.
1557

1558
    :param service: to create the parser for
1559
    :param protocol: the protocol for the parser. If not provided, fallback to the service's default protocol
1560
    :return: RequestParser which can handle the protocol of the service
1561
    """
1562
    # Unfortunately, some services show subtle differences in their parsing or operation detection behavior, even though
1563
    # their specification states they implement the same protocol.
1564
    # In order to avoid bundling the whole complexity in the specific protocols, or even have service-distinctions
1565
    # within the parser implementations, the service-specific parser implementations (basically the implicit /
1566
    # informally more specific protocol implementation) has precedence over the more general protocol-specific parsers.
1567
    service_specific_parsers = {
1✔
1568
        "s3": {"rest-xml": S3RequestParser},
1569
        "sqs": {"query": SQSQueryRequestParser},
1570
    }
1571
    protocol_specific_parsers = {
1✔
1572
        "query": QueryRequestParser,
1573
        "json": JSONRequestParser,
1574
        "rest-json": RestJSONRequestParser,
1575
        "rest-xml": RestXMLRequestParser,
1576
        "ec2": EC2RequestParser,
1577
        "smithy-rpc-v2-cbor": RpcV2CBORRequestParser,
1578
        # TODO: implement multi-protocol support for Kinesis, so that it can uses the `cbor` protocol and remove
1579
        #  CBOR handling from JSONRequestParser
1580
        # this is not an "official" protocol defined from the spec, but is derived from ``json``
1581
    }
1582

1583
    # TODO: do we want to add a check if the user-defined protocol is part of the available ones in the ServiceModel?
1584
    #  or should it be checked once
1585
    service_protocol = protocol or service.protocol
1✔
1586

1587
    # Try to select a service- and protocol-specific parser implementation
1588
    if (
1✔
1589
        service.service_name in service_specific_parsers
1590
        and service_protocol in service_specific_parsers[service.service_name]
1591
    ):
1592
        return service_specific_parsers[service.service_name][service_protocol](service)
1✔
1593
    else:
1594
        # Otherwise, pick the protocol-specific parser for the protocol of the service
1595
        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