pyoaev 1.18.20__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- docs/conf.py +65 -0
- pyoaev/__init__.py +26 -0
- pyoaev/_version.py +6 -0
- pyoaev/apis/__init__.py +20 -0
- pyoaev/apis/attack_pattern.py +28 -0
- pyoaev/apis/collector.py +29 -0
- pyoaev/apis/cve.py +18 -0
- pyoaev/apis/document.py +29 -0
- pyoaev/apis/endpoint.py +38 -0
- pyoaev/apis/inject.py +29 -0
- pyoaev/apis/inject_expectation/__init__.py +1 -0
- pyoaev/apis/inject_expectation/inject_expectation.py +118 -0
- pyoaev/apis/inject_expectation/model/__init__.py +7 -0
- pyoaev/apis/inject_expectation/model/expectation.py +173 -0
- pyoaev/apis/inject_expectation_trace.py +36 -0
- pyoaev/apis/injector.py +26 -0
- pyoaev/apis/injector_contract.py +56 -0
- pyoaev/apis/inputs/__init__.py +0 -0
- pyoaev/apis/inputs/search.py +72 -0
- pyoaev/apis/kill_chain_phase.py +22 -0
- pyoaev/apis/me.py +17 -0
- pyoaev/apis/organization.py +11 -0
- pyoaev/apis/payload.py +27 -0
- pyoaev/apis/security_platform.py +33 -0
- pyoaev/apis/tag.py +19 -0
- pyoaev/apis/team.py +25 -0
- pyoaev/apis/user.py +31 -0
- pyoaev/backends/__init__.py +14 -0
- pyoaev/backends/backend.py +136 -0
- pyoaev/backends/protocol.py +32 -0
- pyoaev/base.py +320 -0
- pyoaev/client.py +596 -0
- pyoaev/configuration/__init__.py +3 -0
- pyoaev/configuration/configuration.py +188 -0
- pyoaev/configuration/sources.py +44 -0
- pyoaev/contracts/__init__.py +5 -0
- pyoaev/contracts/contract_builder.py +44 -0
- pyoaev/contracts/contract_config.py +292 -0
- pyoaev/contracts/contract_utils.py +22 -0
- pyoaev/contracts/variable_helper.py +124 -0
- pyoaev/daemons/__init__.py +4 -0
- pyoaev/daemons/base_daemon.py +131 -0
- pyoaev/daemons/collector_daemon.py +91 -0
- pyoaev/exceptions.py +219 -0
- pyoaev/helpers.py +451 -0
- pyoaev/mixins.py +242 -0
- pyoaev/signatures/__init__.py +0 -0
- pyoaev/signatures/signature_match.py +12 -0
- pyoaev/signatures/signature_type.py +51 -0
- pyoaev/signatures/types.py +17 -0
- pyoaev/utils.py +211 -0
- pyoaev-1.18.20.dist-info/METADATA +134 -0
- pyoaev-1.18.20.dist-info/RECORD +72 -0
- pyoaev-1.18.20.dist-info/WHEEL +5 -0
- pyoaev-1.18.20.dist-info/licenses/LICENSE +201 -0
- pyoaev-1.18.20.dist-info/top_level.txt +4 -0
- scripts/release.py +127 -0
- test/__init__.py +0 -0
- test/apis/__init__.py +0 -0
- test/apis/expectation/__init__.py +0 -0
- test/apis/expectation/test_expectation.py +338 -0
- test/apis/injector_contract/__init__.py +0 -0
- test/apis/injector_contract/test_injector_contract.py +58 -0
- test/configuration/__init__.py +0 -0
- test/configuration/test_configuration.py +257 -0
- test/configuration/test_sources.py +69 -0
- test/daemons/__init__.py +0 -0
- test/daemons/test_base_daemon.py +109 -0
- test/daemons/test_collector_daemon.py +39 -0
- test/signatures/__init__.py +0 -0
- test/signatures/test_signature_match.py +25 -0
- test/signatures/test_signature_type.py +57 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from pyoaev.contracts.contract_utils import (
|
|
2
|
+
ContractCardinality,
|
|
3
|
+
ContractVariable,
|
|
4
|
+
VariableType,
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
USER = "user"
|
|
8
|
+
EXERCISE = "exercise"
|
|
9
|
+
TEAMS = "teams"
|
|
10
|
+
COMCHECK = "comcheck"
|
|
11
|
+
PLAYER_URI = "player_uri"
|
|
12
|
+
CHALLENGES_URI = "challenges_uri"
|
|
13
|
+
SCOREBOARD_URI = "scoreboard_uri"
|
|
14
|
+
LESSONS_URI = "lessons_uri"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class VariableHelper:
|
|
18
|
+
@staticmethod
|
|
19
|
+
def user_variable():
|
|
20
|
+
return ContractVariable(
|
|
21
|
+
key=USER,
|
|
22
|
+
label="User that will receive the injection",
|
|
23
|
+
type=VariableType.String,
|
|
24
|
+
cardinality=ContractCardinality.One,
|
|
25
|
+
children=[
|
|
26
|
+
ContractVariable(
|
|
27
|
+
key=USER + ".id",
|
|
28
|
+
label="Id of the user in the platform",
|
|
29
|
+
type=VariableType.String,
|
|
30
|
+
cardinality=ContractCardinality.One,
|
|
31
|
+
),
|
|
32
|
+
ContractVariable(
|
|
33
|
+
key=USER + ".email",
|
|
34
|
+
label="Email of the user",
|
|
35
|
+
type=VariableType.String,
|
|
36
|
+
cardinality=ContractCardinality.One,
|
|
37
|
+
),
|
|
38
|
+
ContractVariable(
|
|
39
|
+
key=USER + ".firstname",
|
|
40
|
+
label="Firstname of the user",
|
|
41
|
+
type=VariableType.String,
|
|
42
|
+
cardinality=ContractCardinality.One,
|
|
43
|
+
),
|
|
44
|
+
ContractVariable(
|
|
45
|
+
key=USER + ".lastname",
|
|
46
|
+
label="Lastname of the user",
|
|
47
|
+
type=VariableType.String,
|
|
48
|
+
cardinality=ContractCardinality.One,
|
|
49
|
+
),
|
|
50
|
+
ContractVariable(
|
|
51
|
+
key=USER + ".lang",
|
|
52
|
+
label="Lang of the user",
|
|
53
|
+
type=VariableType.String,
|
|
54
|
+
cardinality=ContractCardinality.One,
|
|
55
|
+
),
|
|
56
|
+
],
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def exercise_variable():
|
|
61
|
+
return ContractVariable(
|
|
62
|
+
key=EXERCISE,
|
|
63
|
+
label="Exercise of the current injection",
|
|
64
|
+
type=VariableType.Object,
|
|
65
|
+
cardinality=ContractCardinality.One,
|
|
66
|
+
children=[
|
|
67
|
+
ContractVariable(
|
|
68
|
+
key=EXERCISE + ".id",
|
|
69
|
+
label="Id of the exercise in the platform",
|
|
70
|
+
type=VariableType.String,
|
|
71
|
+
cardinality=ContractCardinality.One,
|
|
72
|
+
),
|
|
73
|
+
ContractVariable(
|
|
74
|
+
key=EXERCISE + ".name",
|
|
75
|
+
label="Name of the exercise",
|
|
76
|
+
type=VariableType.String,
|
|
77
|
+
cardinality=ContractCardinality.One,
|
|
78
|
+
),
|
|
79
|
+
ContractVariable(
|
|
80
|
+
key=EXERCISE + ".description",
|
|
81
|
+
label="Description of the exercise",
|
|
82
|
+
type=VariableType.String,
|
|
83
|
+
cardinality=ContractCardinality.One,
|
|
84
|
+
),
|
|
85
|
+
],
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def team_variable():
|
|
90
|
+
return ContractVariable(
|
|
91
|
+
key=TEAMS,
|
|
92
|
+
label="List of team name for the injection",
|
|
93
|
+
type=VariableType.String,
|
|
94
|
+
cardinality=ContractCardinality.Multiple,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def uri_variables():
|
|
99
|
+
return [
|
|
100
|
+
ContractVariable(
|
|
101
|
+
key=PLAYER_URI,
|
|
102
|
+
label="Player interface platform link",
|
|
103
|
+
type=VariableType.String,
|
|
104
|
+
cardinality=ContractCardinality.One,
|
|
105
|
+
),
|
|
106
|
+
ContractVariable(
|
|
107
|
+
key=CHALLENGES_URI,
|
|
108
|
+
label="Challenges interface platform link",
|
|
109
|
+
type=VariableType.String,
|
|
110
|
+
cardinality=ContractCardinality.One,
|
|
111
|
+
),
|
|
112
|
+
ContractVariable(
|
|
113
|
+
key=SCOREBOARD_URI,
|
|
114
|
+
label="Scoreboard interface platform link",
|
|
115
|
+
type=VariableType.String,
|
|
116
|
+
cardinality=ContractCardinality.One,
|
|
117
|
+
),
|
|
118
|
+
ContractVariable(
|
|
119
|
+
key=LESSONS_URI,
|
|
120
|
+
label="Lessons learned interface platform link",
|
|
121
|
+
type=VariableType.String,
|
|
122
|
+
cardinality=ContractCardinality.One,
|
|
123
|
+
),
|
|
124
|
+
]
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from inspect import signature
|
|
3
|
+
from types import FunctionType
|
|
4
|
+
|
|
5
|
+
from pyoaev.client import OpenAEV
|
|
6
|
+
from pyoaev.configuration import Configuration
|
|
7
|
+
from pyoaev.exceptions import OpenAEVError
|
|
8
|
+
from pyoaev.utils import logger
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BaseDaemon(ABC):
|
|
12
|
+
"""A base class for implementing a kind of daemon that periodically polls
|
|
13
|
+
a given callback.
|
|
14
|
+
|
|
15
|
+
:param configuration: configuration to provide the daemon
|
|
16
|
+
(allowing for looking up values within the callback for example)
|
|
17
|
+
:type configuration: Configuration
|
|
18
|
+
:param callback: a method or function to periodically call, defaults to None.
|
|
19
|
+
:type callback: callable, optional
|
|
20
|
+
:param logger: a logger object, to log events. if not supplied, a default logger
|
|
21
|
+
will be spawned to provide this functionality.
|
|
22
|
+
:type logger: Any
|
|
23
|
+
:param api_client: an API client that will provide connectivity with other systems.
|
|
24
|
+
:type api_client: Any
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
configuration: Configuration,
|
|
30
|
+
callback: callable = None,
|
|
31
|
+
logger=None,
|
|
32
|
+
api_client=None,
|
|
33
|
+
):
|
|
34
|
+
self._configuration = configuration
|
|
35
|
+
self._callback = callback
|
|
36
|
+
self.api = api_client or BaseDaemon.__get_default_api_client(
|
|
37
|
+
url=self._configuration.get("openaev_url"),
|
|
38
|
+
token=self._configuration.get("openaev_token"),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# logging
|
|
42
|
+
# compatibility layer: in order for older configs to still work, search for legacy names
|
|
43
|
+
actual_log_level = (
|
|
44
|
+
self._configuration.get("log_level")
|
|
45
|
+
or self._configuration.get("collector_log_level")
|
|
46
|
+
or self._configuration.get("injector_log_level")
|
|
47
|
+
or "info"
|
|
48
|
+
)
|
|
49
|
+
actual_log_name = (
|
|
50
|
+
self._configuration.get("name")
|
|
51
|
+
or self._configuration.get("collector_name")
|
|
52
|
+
or self._configuration.get("injector_name")
|
|
53
|
+
or "daemon"
|
|
54
|
+
)
|
|
55
|
+
self.logger = logger or BaseDaemon.__get_default_logger(
|
|
56
|
+
actual_log_level.upper(),
|
|
57
|
+
actual_log_name,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
@abstractmethod
|
|
61
|
+
def _setup(self):
|
|
62
|
+
"""A run-once method that inheritors must implement. This serves to instantiate
|
|
63
|
+
all useful objects and functionality for the implementor to run.
|
|
64
|
+
"""
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
@abstractmethod
|
|
68
|
+
def _start_loop(self):
|
|
69
|
+
"""Starts the daemon's main execution loop. Implementors should implement
|
|
70
|
+
the main execution logic in here.
|
|
71
|
+
"""
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
def _try_callback(self):
|
|
75
|
+
"""Tries to call the configured callback. Note that if any error is thrown,
|
|
76
|
+
it is immediately swallowed (but still logged) allowing the collector to keep
|
|
77
|
+
running. This is useful for any transient issue (e.g. API endpoint down...).
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
# this is some black magic to allow injecting the collector daemon instance
|
|
81
|
+
# into an arbitrary callback that has a specific argument name
|
|
82
|
+
# this allow for avoiding subclassing the CollectorDaemon class just to provide the callback
|
|
83
|
+
# Example:
|
|
84
|
+
#
|
|
85
|
+
# def standalone_func(collector):
|
|
86
|
+
# collector.api.call_openaev()
|
|
87
|
+
#
|
|
88
|
+
# CollectorDaemon(config=<pyboas.configuration.Configuration>, standalone_func).start()
|
|
89
|
+
if (
|
|
90
|
+
isinstance(self._callback, FunctionType)
|
|
91
|
+
and "collector" in signature(self._callback).parameters
|
|
92
|
+
):
|
|
93
|
+
self._callback(collector=self)
|
|
94
|
+
else:
|
|
95
|
+
self._callback()
|
|
96
|
+
except Exception as err: # pylint: disable=broad-except
|
|
97
|
+
self.logger.error(f"Error calling: {err}")
|
|
98
|
+
|
|
99
|
+
def start(self):
|
|
100
|
+
"""Start the daemon. This will run the implementor's run-once setup method and
|
|
101
|
+
follow-up with the main execution loop. Note that at this point, if there is no
|
|
102
|
+
configured callback, the method will abort and kill the daemon.
|
|
103
|
+
"""
|
|
104
|
+
if self._callback is None:
|
|
105
|
+
raise OpenAEVError("This daemon has no configured callback.")
|
|
106
|
+
self._setup()
|
|
107
|
+
self._start_loop()
|
|
108
|
+
|
|
109
|
+
def set_callback(self, callback: callable):
|
|
110
|
+
"""Configures a callback to call in the main execution loop. If the callback
|
|
111
|
+
was not provided in the daemon's ctor, this should be set before calling start().
|
|
112
|
+
"""
|
|
113
|
+
self._callback = callback
|
|
114
|
+
|
|
115
|
+
def get_id(self):
|
|
116
|
+
"""Returns the daemon instance's ID contained in configuration. Configuration
|
|
117
|
+
must define any of these keys: `id`, `collector_id`, `injector_id`.
|
|
118
|
+
"""
|
|
119
|
+
return (
|
|
120
|
+
self._configuration.get("id")
|
|
121
|
+
or self._configuration.get("collector_id")
|
|
122
|
+
or self._configuration.get("injector_id")
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
@classmethod
|
|
126
|
+
def __get_default_api_client(cls, url, token):
|
|
127
|
+
return OpenAEV(url=url, token=token)
|
|
128
|
+
|
|
129
|
+
@classmethod
|
|
130
|
+
def __get_default_logger(cls, log_level, name):
|
|
131
|
+
return logger(log_level)(name)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import sched
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
from pyoaev import Configuration
|
|
5
|
+
from pyoaev.daemons import BaseDaemon
|
|
6
|
+
from pyoaev.utils import PingAlive
|
|
7
|
+
|
|
8
|
+
DEFAULT_PERIOD_SECONDS = 60
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CollectorDaemon(BaseDaemon):
|
|
12
|
+
"""Implementation of a daemon of Collector type. Note that it requires
|
|
13
|
+
specific configuration keys to run its setup.
|
|
14
|
+
`collector_icon_filepath`: relative path to an icon image (preferably PNG)
|
|
15
|
+
`collector_id`: unique identifier for the collector (UUIDv4)
|
|
16
|
+
`collector_period`: time to wait in seconds between each loop execution; note
|
|
17
|
+
that this time is added to the time the loop takes to run, so the actual total
|
|
18
|
+
time between each loop start is time_of_loop+period.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
configuration: Configuration,
|
|
24
|
+
callback: callable = None,
|
|
25
|
+
logger=None,
|
|
26
|
+
api_client=None,
|
|
27
|
+
collector_type=None,
|
|
28
|
+
):
|
|
29
|
+
super().__init__(configuration, callback, logger, api_client)
|
|
30
|
+
if collector_type is None:
|
|
31
|
+
raise ValueError("Must define a value for collector type")
|
|
32
|
+
self.collector_type = collector_type
|
|
33
|
+
|
|
34
|
+
def _setup(self):
|
|
35
|
+
if self._configuration.get("collector_period") is None:
|
|
36
|
+
self._configuration.set("collector_period", DEFAULT_PERIOD_SECONDS)
|
|
37
|
+
icon_path = self._configuration.get("collector_icon_filepath")
|
|
38
|
+
icon_name = self._configuration.get("collector_id") + ".png"
|
|
39
|
+
with open(icon_path, "rb") as icon_file_handle:
|
|
40
|
+
collector_icon = (icon_name, icon_file_handle, "image/png")
|
|
41
|
+
document = self.api.document.upsert(document={}, file=collector_icon)
|
|
42
|
+
if self._configuration.get("collector_platform") is not None:
|
|
43
|
+
security_platform = self.api.security_platform.upsert(
|
|
44
|
+
{
|
|
45
|
+
"asset_name": self._configuration.get("collector_name"),
|
|
46
|
+
"asset_external_reference": self._configuration.get(
|
|
47
|
+
"collector_id"
|
|
48
|
+
),
|
|
49
|
+
"security_platform_type": self._configuration.get(
|
|
50
|
+
"collector_platform"
|
|
51
|
+
),
|
|
52
|
+
"security_platform_logo_light": document.get("document_id"),
|
|
53
|
+
"security_platform_logo_dark": document.get("document_id"),
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
else:
|
|
57
|
+
security_platform = {}
|
|
58
|
+
security_platform_id = security_platform.get("asset_id")
|
|
59
|
+
config = {
|
|
60
|
+
"collector_id": self._configuration.get("collector_id"),
|
|
61
|
+
"collector_name": self._configuration.get("collector_name"),
|
|
62
|
+
"collector_type": self.collector_type,
|
|
63
|
+
"collector_period": self._configuration.get("collector_period"),
|
|
64
|
+
"collector_security_platform": security_platform_id,
|
|
65
|
+
}
|
|
66
|
+
with open(icon_path, "rb") as icon_file_handle:
|
|
67
|
+
collector_icon = (icon_name, icon_file_handle, "image/png")
|
|
68
|
+
self.api.collector.create(config, collector_icon)
|
|
69
|
+
|
|
70
|
+
PingAlive(self.api, config, self.logger, "collector").start()
|
|
71
|
+
|
|
72
|
+
def _start_loop(self):
|
|
73
|
+
scheduler = sched.scheduler(time.time, time.sleep)
|
|
74
|
+
delay = self._configuration.get("collector_period")
|
|
75
|
+
self._try_callback()
|
|
76
|
+
scheduler.enter(
|
|
77
|
+
delay=delay,
|
|
78
|
+
priority=1,
|
|
79
|
+
action=self.__schedule,
|
|
80
|
+
argument=(scheduler, self._try_callback, delay),
|
|
81
|
+
)
|
|
82
|
+
scheduler.run()
|
|
83
|
+
|
|
84
|
+
def __schedule(self, scheduler, callback, delay):
|
|
85
|
+
callback()
|
|
86
|
+
scheduler.enter(
|
|
87
|
+
delay=delay,
|
|
88
|
+
priority=1,
|
|
89
|
+
action=self.__schedule,
|
|
90
|
+
argument=(scheduler, callback, delay),
|
|
91
|
+
)
|
pyoaev/exceptions.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
from typing import TYPE_CHECKING, Any, Callable, Optional, Type, TypeVar, Union, cast
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class OpenAEVError(Exception):
|
|
6
|
+
def __init__(
|
|
7
|
+
self,
|
|
8
|
+
error_message: Union[str, bytes] = "",
|
|
9
|
+
response_code: Optional[int] = None,
|
|
10
|
+
response_body: Optional[bytes] = None,
|
|
11
|
+
) -> None:
|
|
12
|
+
Exception.__init__(self, error_message)
|
|
13
|
+
# Http status code
|
|
14
|
+
self.response_code = response_code
|
|
15
|
+
# Full http response
|
|
16
|
+
self.response_body = response_body
|
|
17
|
+
try:
|
|
18
|
+
# if we receive str/bytes we try to convert to unicode/str to have
|
|
19
|
+
# consistent message types (see #616)
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
assert isinstance(error_message, bytes)
|
|
22
|
+
self.error_message = error_message.decode()
|
|
23
|
+
except Exception:
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
assert isinstance(error_message, str)
|
|
26
|
+
self.error_message = error_message
|
|
27
|
+
|
|
28
|
+
def __str__(self) -> str:
|
|
29
|
+
# Start with the provided error message
|
|
30
|
+
message = self.error_message
|
|
31
|
+
|
|
32
|
+
# List of generic HTTP status messages that indicate we should look deeper
|
|
33
|
+
generic_messages = (
|
|
34
|
+
"Internal Server Error",
|
|
35
|
+
"Bad Request",
|
|
36
|
+
"Not Found",
|
|
37
|
+
"Unauthorized",
|
|
38
|
+
"Forbidden",
|
|
39
|
+
"Service Unavailable",
|
|
40
|
+
"Gateway Timeout",
|
|
41
|
+
"Unknown error",
|
|
42
|
+
"Validation Failed",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Only try to extract from response body if message is truly generic
|
|
46
|
+
# Don't override if we already have a specific error message
|
|
47
|
+
if (
|
|
48
|
+
not message or (message in generic_messages and len(message) < 30)
|
|
49
|
+
) and self.response_body is not None:
|
|
50
|
+
try:
|
|
51
|
+
import json
|
|
52
|
+
|
|
53
|
+
body = self.response_body.decode(errors="ignore")
|
|
54
|
+
data = json.loads(body)
|
|
55
|
+
extracted_msg = None
|
|
56
|
+
|
|
57
|
+
if isinstance(data, dict):
|
|
58
|
+
# Try various common error fields
|
|
59
|
+
if "error" in data:
|
|
60
|
+
err = data.get("error")
|
|
61
|
+
if isinstance(err, dict) and "message" in err:
|
|
62
|
+
extracted_msg = err.get("message")
|
|
63
|
+
elif isinstance(err, str):
|
|
64
|
+
extracted_msg = err
|
|
65
|
+
elif "message" in data:
|
|
66
|
+
extracted_msg = data.get("message")
|
|
67
|
+
elif "execution_message" in data:
|
|
68
|
+
extracted_msg = data.get("execution_message")
|
|
69
|
+
elif "detail" in data:
|
|
70
|
+
extracted_msg = data.get("detail")
|
|
71
|
+
elif "errors" in data:
|
|
72
|
+
errs = data.get("errors")
|
|
73
|
+
if isinstance(errs, list) and errs:
|
|
74
|
+
# Join any messages in the list
|
|
75
|
+
parts = []
|
|
76
|
+
for item in errs:
|
|
77
|
+
if isinstance(item, dict) and "message" in item:
|
|
78
|
+
parts.append(str(item.get("message")))
|
|
79
|
+
else:
|
|
80
|
+
parts.append(str(item))
|
|
81
|
+
extracted_msg = "; ".join(parts)
|
|
82
|
+
elif isinstance(errs, dict):
|
|
83
|
+
# Handle nested validation errors structure
|
|
84
|
+
if "children" in errs:
|
|
85
|
+
validation_errors = []
|
|
86
|
+
children = errs.get("children", {})
|
|
87
|
+
for field, field_errors in children.items():
|
|
88
|
+
if (
|
|
89
|
+
isinstance(field_errors, dict)
|
|
90
|
+
and "errors" in field_errors
|
|
91
|
+
):
|
|
92
|
+
field_error_list = field_errors.get(
|
|
93
|
+
"errors", []
|
|
94
|
+
)
|
|
95
|
+
if field_error_list:
|
|
96
|
+
for err_msg in field_error_list:
|
|
97
|
+
validation_errors.append(
|
|
98
|
+
f"{field}: {err_msg}"
|
|
99
|
+
)
|
|
100
|
+
if validation_errors:
|
|
101
|
+
base_msg = data.get("message", "Validation Failed")
|
|
102
|
+
extracted_msg = (
|
|
103
|
+
f"{base_msg}: {'; '.join(validation_errors)}"
|
|
104
|
+
)
|
|
105
|
+
else:
|
|
106
|
+
# Try to get any string representation
|
|
107
|
+
parts = []
|
|
108
|
+
for key, value in errs.items():
|
|
109
|
+
if value:
|
|
110
|
+
parts.append(f"{key}: {value}")
|
|
111
|
+
if parts:
|
|
112
|
+
extracted_msg = "; ".join(parts)
|
|
113
|
+
elif isinstance(errs, str):
|
|
114
|
+
extracted_msg = errs
|
|
115
|
+
|
|
116
|
+
# Use extracted message if it's better than what we have
|
|
117
|
+
if extracted_msg and extracted_msg not in generic_messages:
|
|
118
|
+
message = str(extracted_msg)
|
|
119
|
+
elif not message:
|
|
120
|
+
# Last resort: use the raw body
|
|
121
|
+
message = body[:500]
|
|
122
|
+
|
|
123
|
+
except json.JSONDecodeError:
|
|
124
|
+
# Not JSON, use raw text if we don't have a good message
|
|
125
|
+
if not message or message in generic_messages:
|
|
126
|
+
try:
|
|
127
|
+
decoded = self.response_body.decode(errors="ignore")[:500]
|
|
128
|
+
if decoded and decoded not in generic_messages:
|
|
129
|
+
message = decoded
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
except Exception:
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
# Final fallback
|
|
136
|
+
if not message:
|
|
137
|
+
message = "Unknown error"
|
|
138
|
+
|
|
139
|
+
# Clean up the message - remove extra whitespace and newlines
|
|
140
|
+
message = " ".join(message.split())
|
|
141
|
+
|
|
142
|
+
if self.response_code is not None:
|
|
143
|
+
return f"{self.response_code}: {message}"
|
|
144
|
+
return f"{message}"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class OpenAEVAuthenticationError(OpenAEVError):
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class OpenAEVHttpError(OpenAEVError):
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class OpenAEVParsingError(OpenAEVError):
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class RedirectError(OpenAEVError):
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class OpenAEVHeadError(OpenAEVError):
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class OpenAEVGetError(OpenAEVError):
|
|
168
|
+
pass
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class OpenAEVUpdateError(OpenAEVError):
|
|
172
|
+
pass
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class OpenAEVListError(OpenAEVError):
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class OpenAEVCreateError(OpenAEVError):
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class ConfigurationError(OpenAEVError):
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# For an explanation of how these type-hints work see:
|
|
188
|
+
# https://mypy.readthedocs.io/en/stable/generics.html#declaring-decorators
|
|
189
|
+
#
|
|
190
|
+
# The goal here is that functions which get decorated will retain their types.
|
|
191
|
+
__F = TypeVar("__F", bound=Callable[..., Any])
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def on_http_error(error: Type[Exception]) -> Callable[[__F], __F]:
|
|
195
|
+
def wrap(f: __F) -> __F:
|
|
196
|
+
@functools.wraps(f)
|
|
197
|
+
def wrapped_f(*args: Any, **kwargs: Any) -> Any:
|
|
198
|
+
try:
|
|
199
|
+
return f(*args, **kwargs)
|
|
200
|
+
except OpenAEVHttpError as e:
|
|
201
|
+
raise error(e.error_message, e.response_code, e.response_body) from e
|
|
202
|
+
|
|
203
|
+
return cast(__F, wrapped_f)
|
|
204
|
+
|
|
205
|
+
return wrap
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# Export manually to keep mypy happy
|
|
209
|
+
__all__ = [
|
|
210
|
+
"ConfigurationError",
|
|
211
|
+
"OpenAEVAuthenticationError",
|
|
212
|
+
"OpenAEVHttpError",
|
|
213
|
+
"OpenAEVParsingError",
|
|
214
|
+
"RedirectError",
|
|
215
|
+
"OpenAEVHeadError",
|
|
216
|
+
"OpenAEVListError",
|
|
217
|
+
"OpenAEVGetError",
|
|
218
|
+
"OpenAEVUpdateError",
|
|
219
|
+
]
|