trustarc-cli 1.0.0__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.
- ta/__init__.py +1 -0
- ta/commands/__init__.py +0 -0
- ta/commands/auth/__init__.py +1 -0
- ta/commands/auth/command.py +91 -0
- ta/commands/config/__init__.py +1 -0
- ta/commands/config/command.py +57 -0
- ta/commands/self_update/__init__.py +2 -0
- ta/commands/self_update/command.py +36 -0
- ta/commands/services/__init__.py +1 -0
- ta/commands/services/command.py +37 -0
- ta/lib/__init__.py +0 -0
- ta/lib/analytics.py +63 -0
- ta/lib/auth.py +212 -0
- ta/lib/auth_servers.py +56 -0
- ta/lib/catalog_cache.py +72 -0
- ta/lib/config.py +65 -0
- ta/lib/dynamic.py +259 -0
- ta/lib/services.py +13 -0
- ta/lib/spec_cache.py +25 -0
- ta/lib/update_check.py +66 -0
- ta/lib/urls.py +18 -0
- ta/main.py +69 -0
- trustarc_cli-1.0.0.dist-info/METADATA +114 -0
- trustarc_cli-1.0.0.dist-info/RECORD +31 -0
- trustarc_cli-1.0.0.dist-info/WHEEL +4 -0
- trustarc_cli-1.0.0.dist-info/entry_points.txt +2 -0
- trustarc_sdk/openapi/__init__.py +39 -0
- trustarc_sdk/openapi/call.py +204 -0
- trustarc_sdk/openapi/feature_manager.py +106 -0
- trustarc_sdk/openapi/parse.py +207 -0
- trustarc_sdk/openapi/schema.py +92 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from trustarc_sdk.openapi.call import Entrypoint, acall, call
|
|
2
|
+
from trustarc_sdk.openapi.feature_manager import (
|
|
3
|
+
ASK_ARC_CUD_LICENSE_ID,
|
|
4
|
+
ais_feature_enabled,
|
|
5
|
+
is_feature_enabled,
|
|
6
|
+
)
|
|
7
|
+
from trustarc_sdk.openapi.parse import (
|
|
8
|
+
ACTION_VERBS,
|
|
9
|
+
METHOD_ACTION,
|
|
10
|
+
base_url_from_spec,
|
|
11
|
+
detect_namespace_prefix,
|
|
12
|
+
extract_resource_action,
|
|
13
|
+
fetch_spec,
|
|
14
|
+
group_operations_by_resource,
|
|
15
|
+
iter_operations,
|
|
16
|
+
resolve_base_url,
|
|
17
|
+
)
|
|
18
|
+
from trustarc_sdk.openapi.schema import build_responses, build_schema_snapshot, resolve_ref
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"ACTION_VERBS",
|
|
22
|
+
"ASK_ARC_CUD_LICENSE_ID",
|
|
23
|
+
"METHOD_ACTION",
|
|
24
|
+
"Entrypoint",
|
|
25
|
+
"acall",
|
|
26
|
+
"ais_feature_enabled",
|
|
27
|
+
"base_url_from_spec",
|
|
28
|
+
"build_responses",
|
|
29
|
+
"build_schema_snapshot",
|
|
30
|
+
"call",
|
|
31
|
+
"is_feature_enabled",
|
|
32
|
+
"detect_namespace_prefix",
|
|
33
|
+
"extract_resource_action",
|
|
34
|
+
"fetch_spec",
|
|
35
|
+
"group_operations_by_resource",
|
|
36
|
+
"iter_operations",
|
|
37
|
+
"resolve_base_url",
|
|
38
|
+
"resolve_ref",
|
|
39
|
+
]
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
from urllib.parse import quote
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
from trustarc_sdk.openapi.feature_manager import (
|
|
8
|
+
ASK_ARC_CUD_LICENSE_ID,
|
|
9
|
+
ais_feature_enabled,
|
|
10
|
+
is_feature_enabled,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Entrypoint(StrEnum):
|
|
15
|
+
MCP = "mcp"
|
|
16
|
+
CLI = "cli"
|
|
17
|
+
ASK_ARC = "ask-arc"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_PLACEHOLDER = re.compile(r"\{([^/{}]+)\}")
|
|
21
|
+
_DOT_SEGMENTS = frozenset({".", ".."})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _encode_header_value(value: str) -> str:
|
|
25
|
+
return quote(value)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _cud_blocked_response(method: str, url: str) -> httpx.Response:
|
|
29
|
+
return httpx.Response(
|
|
30
|
+
403,
|
|
31
|
+
json={"error": "Create or Update or Delete action is not enabled for this account."},
|
|
32
|
+
request=httpx.Request(method, url),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _prepare(
|
|
37
|
+
operation: dict,
|
|
38
|
+
base_url: str,
|
|
39
|
+
access_token: str,
|
|
40
|
+
args: dict | None,
|
|
41
|
+
user_agent: str | None = None,
|
|
42
|
+
title: str | None = None,
|
|
43
|
+
explanation: str | None = None,
|
|
44
|
+
human_approved: bool = False,
|
|
45
|
+
thread_id: str | None = None,
|
|
46
|
+
entrypoint: Entrypoint | None = None,
|
|
47
|
+
source: str | None = None,
|
|
48
|
+
path_params: dict | None = None,
|
|
49
|
+
query_params: dict | None = None,
|
|
50
|
+
body: dict | list | None = None,
|
|
51
|
+
) -> tuple[str, str, dict, dict | list | None, dict]:
|
|
52
|
+
method = operation["method"]
|
|
53
|
+
if path_params is not None or query_params is not None or body is not None:
|
|
54
|
+
if args:
|
|
55
|
+
raise TypeError("pass either args or path_params/query_params/body, not both")
|
|
56
|
+
path_values = dict(path_params or {})
|
|
57
|
+
query_values = dict(query_params or {})
|
|
58
|
+
body_fields: dict | list | None = body
|
|
59
|
+
else:
|
|
60
|
+
path_values, query_values, body_fields = _classify_by_name(operation, args or {})
|
|
61
|
+
if not body_fields:
|
|
62
|
+
body_fields = None
|
|
63
|
+
if method.upper() == "GET":
|
|
64
|
+
body_fields = None
|
|
65
|
+
|
|
66
|
+
path = _bind_path(operation["path"], path_values)
|
|
67
|
+
url = f"{base_url.rstrip('/')}{path}"
|
|
68
|
+
headers: dict = {}
|
|
69
|
+
if access_token:
|
|
70
|
+
headers["Authorization"] = f"Bearer {access_token}"
|
|
71
|
+
if user_agent:
|
|
72
|
+
headers["User-Agent"] = user_agent
|
|
73
|
+
if entrypoint:
|
|
74
|
+
headers["X-TrustArc-Entrypoint"] = entrypoint
|
|
75
|
+
if source:
|
|
76
|
+
headers["X-TrustArc-Source"] = source
|
|
77
|
+
if title:
|
|
78
|
+
headers["X-TrustArc-Title"] = _encode_header_value(title)
|
|
79
|
+
if explanation:
|
|
80
|
+
headers["X-TrustArc-Explanation"] = _encode_header_value(explanation)
|
|
81
|
+
if human_approved:
|
|
82
|
+
headers["X-TrustArc-Human-Approved"] = "true"
|
|
83
|
+
if thread_id:
|
|
84
|
+
headers["X-TrustArc-Thread-Id"] = thread_id
|
|
85
|
+
headers["X-TrustArc-Request-Time"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
86
|
+
return method, url, query_values, body_fields, headers
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _classify_by_name(operation: dict, args: dict) -> tuple[dict, dict, dict]:
|
|
90
|
+
path_values: dict = {}
|
|
91
|
+
query_values: dict = {}
|
|
92
|
+
body_fields: dict = {}
|
|
93
|
+
declared = {p["name"]: p for p in operation.get("parameters", [])}
|
|
94
|
+
for key, value in args.items():
|
|
95
|
+
param = declared.get(key)
|
|
96
|
+
if param and param.get("in") == "path":
|
|
97
|
+
path_values[key] = value
|
|
98
|
+
elif param and param.get("in") == "query":
|
|
99
|
+
query_values[key] = value
|
|
100
|
+
else:
|
|
101
|
+
body_fields[key] = value
|
|
102
|
+
return path_values, query_values, body_fields
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _bind_path(template: str, path_values: dict) -> str:
|
|
106
|
+
path = template
|
|
107
|
+
for name, value in path_values.items():
|
|
108
|
+
segment = str(value)
|
|
109
|
+
if segment in _DOT_SEGMENTS:
|
|
110
|
+
raise ValueError(f"path parameter {name} cannot be {segment!r}")
|
|
111
|
+
path = path.replace(f"{{{name}}}", quote(segment, safe=""))
|
|
112
|
+
missing = _PLACEHOLDER.findall(path)
|
|
113
|
+
if missing:
|
|
114
|
+
raise ValueError(f"missing path parameter(s): {', '.join(missing)}")
|
|
115
|
+
return path
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def call(
|
|
119
|
+
operation: dict,
|
|
120
|
+
base_url: str,
|
|
121
|
+
access_token: str,
|
|
122
|
+
args: dict | None = None,
|
|
123
|
+
timeout: float = 60.0,
|
|
124
|
+
user_agent: str | None = None,
|
|
125
|
+
title: str | None = None,
|
|
126
|
+
explanation: str | None = None,
|
|
127
|
+
human_approved: bool = False,
|
|
128
|
+
thread_id: str | None = None,
|
|
129
|
+
entrypoint: Entrypoint | None = None,
|
|
130
|
+
source: str | None = None,
|
|
131
|
+
feature_manager_url: str | None = None,
|
|
132
|
+
feature_id: str = ASK_ARC_CUD_LICENSE_ID,
|
|
133
|
+
path_params: dict | None = None,
|
|
134
|
+
query_params: dict | None = None,
|
|
135
|
+
body: dict | list | None = None,
|
|
136
|
+
) -> httpx.Response:
|
|
137
|
+
method, url, query_params, body_fields, headers = _prepare(
|
|
138
|
+
operation,
|
|
139
|
+
base_url,
|
|
140
|
+
access_token,
|
|
141
|
+
args,
|
|
142
|
+
user_agent,
|
|
143
|
+
title,
|
|
144
|
+
explanation,
|
|
145
|
+
human_approved,
|
|
146
|
+
thread_id,
|
|
147
|
+
entrypoint,
|
|
148
|
+
source,
|
|
149
|
+
path_params=path_params,
|
|
150
|
+
query_params=query_params,
|
|
151
|
+
body=body,
|
|
152
|
+
)
|
|
153
|
+
if (
|
|
154
|
+
method.upper() != "GET"
|
|
155
|
+
and feature_manager_url
|
|
156
|
+
and not is_feature_enabled(feature_manager_url, feature_id, access_token)
|
|
157
|
+
):
|
|
158
|
+
return _cud_blocked_response(method, url)
|
|
159
|
+
return httpx.request(method, url, params=query_params or None, json=body_fields, headers=headers, timeout=timeout)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def acall(
|
|
163
|
+
operation: dict,
|
|
164
|
+
base_url: str,
|
|
165
|
+
access_token: str,
|
|
166
|
+
args: dict | None = None,
|
|
167
|
+
timeout: float = 60.0,
|
|
168
|
+
user_agent: str | None = None,
|
|
169
|
+
title: str | None = None,
|
|
170
|
+
explanation: str | None = None,
|
|
171
|
+
human_approved: bool = False,
|
|
172
|
+
thread_id: str | None = None,
|
|
173
|
+
entrypoint: Entrypoint | None = None,
|
|
174
|
+
source: str | None = None,
|
|
175
|
+
feature_manager_url: str | None = None,
|
|
176
|
+
feature_id: str = ASK_ARC_CUD_LICENSE_ID,
|
|
177
|
+
path_params: dict | None = None,
|
|
178
|
+
query_params: dict | None = None,
|
|
179
|
+
body: dict | list | None = None,
|
|
180
|
+
) -> httpx.Response:
|
|
181
|
+
method, url, query_params, body_fields, headers = _prepare(
|
|
182
|
+
operation,
|
|
183
|
+
base_url,
|
|
184
|
+
access_token,
|
|
185
|
+
args,
|
|
186
|
+
user_agent,
|
|
187
|
+
title,
|
|
188
|
+
explanation,
|
|
189
|
+
human_approved,
|
|
190
|
+
thread_id,
|
|
191
|
+
entrypoint,
|
|
192
|
+
source,
|
|
193
|
+
path_params=path_params,
|
|
194
|
+
query_params=query_params,
|
|
195
|
+
body=body,
|
|
196
|
+
)
|
|
197
|
+
if (
|
|
198
|
+
method.upper() != "GET"
|
|
199
|
+
and feature_manager_url
|
|
200
|
+
and not await ais_feature_enabled(feature_manager_url, feature_id, access_token)
|
|
201
|
+
):
|
|
202
|
+
return _cud_blocked_response(method, url)
|
|
203
|
+
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
|
204
|
+
return await client.request(method, url, params=query_params or None, json=body_fields, headers=headers)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
ASK_ARC_CUD_LICENSE_ID = "ARC_ASK_ARC_CUD"
|
|
9
|
+
|
|
10
|
+
_DEFAULT_TTL_S = 5 * 60
|
|
11
|
+
_cache: dict[tuple[str, str, str], tuple[float, bool]] = {}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _account_key(access_token: str) -> str:
|
|
15
|
+
try:
|
|
16
|
+
payload = access_token.split(".")[1]
|
|
17
|
+
payload += "=" * (-len(payload) % 4)
|
|
18
|
+
claims = json.loads(base64.urlsafe_b64decode(payload))
|
|
19
|
+
return str(claims.get("accountId") or claims.get("account_id") or access_token)
|
|
20
|
+
except Exception:
|
|
21
|
+
return access_token
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _cache_key(feature_manager_url: str, feature_id: str, access_token: str) -> tuple[str, str, str]:
|
|
25
|
+
return (feature_manager_url, feature_id, _account_key(access_token))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _cache_get(key: tuple[str, str, str]) -> bool | None:
|
|
29
|
+
hit = _cache.get(key)
|
|
30
|
+
if hit is None:
|
|
31
|
+
return None
|
|
32
|
+
if hit[0] > time.monotonic():
|
|
33
|
+
return hit[1]
|
|
34
|
+
del _cache[key]
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _cache_set(key: tuple[str, str, str], value: bool, ttl_s: float) -> None:
|
|
39
|
+
_cache[key] = (time.monotonic() + ttl_s, value)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _url(feature_manager_url: str, feature_id: str) -> str:
|
|
43
|
+
return f"{feature_manager_url.rstrip('/')}/api/features/{feature_id}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _parse(data: object, feature_id: str) -> bool:
|
|
47
|
+
if not isinstance(data, dict):
|
|
48
|
+
return False
|
|
49
|
+
return str(data.get(feature_id, "")).lower() == "true"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def is_feature_enabled(
|
|
53
|
+
feature_manager_url: str,
|
|
54
|
+
feature_id: str,
|
|
55
|
+
access_token: str,
|
|
56
|
+
ttl_s: float = _DEFAULT_TTL_S,
|
|
57
|
+
timeout: float = 10.0,
|
|
58
|
+
) -> bool:
|
|
59
|
+
if not feature_manager_url or not access_token:
|
|
60
|
+
return False
|
|
61
|
+
key = _cache_key(feature_manager_url, feature_id, access_token)
|
|
62
|
+
cached = _cache_get(key)
|
|
63
|
+
if cached is not None:
|
|
64
|
+
return cached
|
|
65
|
+
try:
|
|
66
|
+
resp = httpx.get(
|
|
67
|
+
_url(feature_manager_url, feature_id), headers={"Authorization": f"Bearer {access_token}"}, timeout=timeout
|
|
68
|
+
)
|
|
69
|
+
if resp.status_code != 200:
|
|
70
|
+
logging.warning("feature_manager %s -> %s", feature_id, resp.status_code)
|
|
71
|
+
return False
|
|
72
|
+
enabled = _parse(resp.json(), feature_id)
|
|
73
|
+
except Exception as e:
|
|
74
|
+
logging.warning("feature_manager lookup failed for %s: %s", feature_id, e)
|
|
75
|
+
return False
|
|
76
|
+
_cache_set(key, enabled, ttl_s)
|
|
77
|
+
return enabled
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def ais_feature_enabled(
|
|
81
|
+
feature_manager_url: str,
|
|
82
|
+
feature_id: str,
|
|
83
|
+
access_token: str,
|
|
84
|
+
ttl_s: float = _DEFAULT_TTL_S,
|
|
85
|
+
timeout: float = 10.0,
|
|
86
|
+
) -> bool:
|
|
87
|
+
if not feature_manager_url or not access_token:
|
|
88
|
+
return False
|
|
89
|
+
key = _cache_key(feature_manager_url, feature_id, access_token)
|
|
90
|
+
cached = _cache_get(key)
|
|
91
|
+
if cached is not None:
|
|
92
|
+
return cached
|
|
93
|
+
try:
|
|
94
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
95
|
+
resp = await client.get(
|
|
96
|
+
_url(feature_manager_url, feature_id), headers={"Authorization": f"Bearer {access_token}"}
|
|
97
|
+
)
|
|
98
|
+
if resp.status_code != 200:
|
|
99
|
+
logging.warning("feature_manager %s -> %s", feature_id, resp.status_code)
|
|
100
|
+
return False
|
|
101
|
+
enabled = _parse(resp.json(), feature_id)
|
|
102
|
+
except Exception as e:
|
|
103
|
+
logging.warning("feature_manager lookup failed for %s: %s", feature_id, e)
|
|
104
|
+
return False
|
|
105
|
+
_cache_set(key, enabled, ttl_s)
|
|
106
|
+
return enabled
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
from urllib.parse import urlparse
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import inflection
|
|
5
|
+
from trustarc_sdk.openapi.feature_manager import ASK_ARC_CUD_LICENSE_ID, is_feature_enabled
|
|
6
|
+
|
|
7
|
+
ACTION_VERBS = {
|
|
8
|
+
"new",
|
|
9
|
+
"create",
|
|
10
|
+
"update",
|
|
11
|
+
"patch",
|
|
12
|
+
"upsert",
|
|
13
|
+
"merge",
|
|
14
|
+
"delete",
|
|
15
|
+
"approve",
|
|
16
|
+
"reject",
|
|
17
|
+
"submit",
|
|
18
|
+
"cancel",
|
|
19
|
+
"complete",
|
|
20
|
+
"activate",
|
|
21
|
+
"deactivate",
|
|
22
|
+
"enable",
|
|
23
|
+
"disable",
|
|
24
|
+
"start",
|
|
25
|
+
"stop",
|
|
26
|
+
"pause",
|
|
27
|
+
"resume",
|
|
28
|
+
"publish",
|
|
29
|
+
"unpublish",
|
|
30
|
+
"lock",
|
|
31
|
+
"unlock",
|
|
32
|
+
"send",
|
|
33
|
+
"trigger",
|
|
34
|
+
"validate",
|
|
35
|
+
"verify",
|
|
36
|
+
"clone",
|
|
37
|
+
"duplicate",
|
|
38
|
+
"import",
|
|
39
|
+
"export",
|
|
40
|
+
"reset",
|
|
41
|
+
"refresh",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
METHOD_ACTION = {
|
|
45
|
+
"POST": "create",
|
|
46
|
+
"PUT": "update",
|
|
47
|
+
"PATCH": "patch",
|
|
48
|
+
"DELETE": "delete",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def base_url_from_spec(spec_url: str) -> str:
|
|
53
|
+
parsed = urlparse(spec_url)
|
|
54
|
+
return f"{parsed.scheme}://{parsed.netloc}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def resolve_base_url(spec: dict, spec_url: str) -> str:
|
|
58
|
+
servers = spec.get("servers") or []
|
|
59
|
+
if servers and servers[0].get("url"):
|
|
60
|
+
url = servers[0]["url"]
|
|
61
|
+
if url.startswith("http"):
|
|
62
|
+
return url.rstrip("/")
|
|
63
|
+
return base_url_from_spec(spec_url).rstrip("/") + "/" + url.lstrip("/")
|
|
64
|
+
return base_url_from_spec(spec_url)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def fetch_spec(spec_url: str) -> dict:
|
|
68
|
+
response = httpx.get(spec_url, follow_redirects=True, timeout=30)
|
|
69
|
+
response.raise_for_status()
|
|
70
|
+
return response.json()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def iter_operations(spec: dict):
|
|
74
|
+
for path, methods in spec.get("paths", {}).items():
|
|
75
|
+
for method, op in methods.items():
|
|
76
|
+
if method.lower() not in {"get", "post", "put", "patch", "delete"}:
|
|
77
|
+
continue
|
|
78
|
+
wire_method = method.upper()
|
|
79
|
+
semantic = (op.get("x-semantic-method") or wire_method).upper()
|
|
80
|
+
yield {
|
|
81
|
+
"method": wire_method,
|
|
82
|
+
"semantic_method": semantic,
|
|
83
|
+
"path": path,
|
|
84
|
+
"summary": op.get("summary", ""),
|
|
85
|
+
"description": op.get("description", ""),
|
|
86
|
+
"parameters": op.get("parameters", []),
|
|
87
|
+
"request_body": op.get("requestBody"),
|
|
88
|
+
"responses": op.get("responses") or {},
|
|
89
|
+
"tags": op.get("tags") or [],
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def group_operations_by_resource(
|
|
94
|
+
spec: dict,
|
|
95
|
+
namespace_prefix: list[str] | None = None,
|
|
96
|
+
include_tags: list[str] | None = None,
|
|
97
|
+
*,
|
|
98
|
+
feature_manager_url: str | None = None,
|
|
99
|
+
access_token: str | None = None,
|
|
100
|
+
) -> dict[str, list[tuple[str, dict]]]:
|
|
101
|
+
prefix = namespace_prefix if namespace_prefix is not None else detect_namespace_prefix(spec)
|
|
102
|
+
tag_filter = set(include_tags) if include_tags else None
|
|
103
|
+
cud_enabled = not feature_manager_url or is_feature_enabled(
|
|
104
|
+
feature_manager_url, ASK_ARC_CUD_LICENSE_ID, access_token or ""
|
|
105
|
+
)
|
|
106
|
+
grouped: dict[str, list[tuple[str, dict]]] = {}
|
|
107
|
+
seen: dict[tuple[str, str], int] = {}
|
|
108
|
+
for op in iter_operations(spec):
|
|
109
|
+
if tag_filter and not tag_filter.intersection(op.get("tags") or []):
|
|
110
|
+
continue
|
|
111
|
+
if not cud_enabled and op["method"].upper() != "GET":
|
|
112
|
+
continue
|
|
113
|
+
resource, action = extract_resource_action(op, namespace_prefix=prefix)
|
|
114
|
+
key = (resource, action)
|
|
115
|
+
seen[key] = seen.get(key, 0) + 1
|
|
116
|
+
if seen[key] > 1:
|
|
117
|
+
action = f"{action}-{seen[key]}"
|
|
118
|
+
grouped.setdefault(resource, []).append((action, op))
|
|
119
|
+
return grouped
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def detect_namespace_prefix(spec: dict) -> list[str]:
|
|
123
|
+
paths = list(spec.get("paths", {}).keys())
|
|
124
|
+
if len(paths) < 2:
|
|
125
|
+
return []
|
|
126
|
+
firsts = set()
|
|
127
|
+
for p in paths:
|
|
128
|
+
segs = [s for s in p.split("/") if s]
|
|
129
|
+
if not segs or segs[0].startswith("{"):
|
|
130
|
+
return []
|
|
131
|
+
firsts.add(segs[0])
|
|
132
|
+
return [firsts.pop()] if len(firsts) == 1 else []
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def extract_resource_action(op: dict, namespace_prefix: list[str] | None = None) -> tuple[str, str]:
|
|
136
|
+
method = op.get("semantic_method", op["method"]).upper()
|
|
137
|
+
raw = [s for s in op["path"].split("/") if s]
|
|
138
|
+
|
|
139
|
+
segs = list(raw)
|
|
140
|
+
for p in namespace_prefix or []:
|
|
141
|
+
if segs and segs[0].lower() == p.lower():
|
|
142
|
+
segs.pop(0)
|
|
143
|
+
else:
|
|
144
|
+
break
|
|
145
|
+
|
|
146
|
+
resource_parts: list[str] = []
|
|
147
|
+
explicit_action: str | None = None
|
|
148
|
+
trailing_param = False
|
|
149
|
+
|
|
150
|
+
i = 0
|
|
151
|
+
while i < len(segs):
|
|
152
|
+
seg = segs[i]
|
|
153
|
+
lower = seg.lower()
|
|
154
|
+
|
|
155
|
+
if seg.startswith("{"):
|
|
156
|
+
trailing_param = True
|
|
157
|
+
i += 1
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
trailing_param = False
|
|
161
|
+
|
|
162
|
+
if lower in ACTION_VERBS and all(segs[j].startswith("{") for j in range(i + 1, len(segs))):
|
|
163
|
+
explicit_action = lower
|
|
164
|
+
trailing_param = any(segs[j].startswith("{") for j in range(i + 1, len(segs)))
|
|
165
|
+
i = len(segs)
|
|
166
|
+
break
|
|
167
|
+
|
|
168
|
+
resource_parts.append(lower)
|
|
169
|
+
i += 1
|
|
170
|
+
|
|
171
|
+
resource_words: list[str] = []
|
|
172
|
+
for part in resource_parts:
|
|
173
|
+
words = _split_words(part)
|
|
174
|
+
if words:
|
|
175
|
+
words[-1] = inflection.singularize(words[-1])
|
|
176
|
+
resource_words.extend(words)
|
|
177
|
+
resource = "-".join(resource_words) or "root"
|
|
178
|
+
|
|
179
|
+
if explicit_action and not trailing_param:
|
|
180
|
+
action = explicit_action
|
|
181
|
+
elif method == "GET":
|
|
182
|
+
action = "get" if trailing_param else "list"
|
|
183
|
+
else:
|
|
184
|
+
action = METHOD_ACTION.get(method, method.lower())
|
|
185
|
+
|
|
186
|
+
return resource, action
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _split_words(name: str) -> list[str]:
|
|
190
|
+
tokens = name.replace("_", " ").replace("-", " ")
|
|
191
|
+
words: list[str] = []
|
|
192
|
+
buf: list[str] = []
|
|
193
|
+
for ch in tokens:
|
|
194
|
+
if ch == " ":
|
|
195
|
+
if buf:
|
|
196
|
+
words.append("".join(buf))
|
|
197
|
+
buf = []
|
|
198
|
+
elif ch.isupper():
|
|
199
|
+
if buf:
|
|
200
|
+
words.append("".join(buf))
|
|
201
|
+
buf = []
|
|
202
|
+
buf.append(ch.lower())
|
|
203
|
+
else:
|
|
204
|
+
buf.append(ch)
|
|
205
|
+
if buf:
|
|
206
|
+
words.append("".join(buf))
|
|
207
|
+
return [w for w in words if w]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
def resolve_ref(node, spec: dict, seen: set[str] | None = None):
|
|
2
|
+
seen = seen if seen is not None else set()
|
|
3
|
+
if isinstance(node, dict):
|
|
4
|
+
if "$ref" in node and isinstance(node["$ref"], str):
|
|
5
|
+
ref = node["$ref"]
|
|
6
|
+
if ref in seen:
|
|
7
|
+
return {"$ref": ref}
|
|
8
|
+
if ref.startswith("#/"):
|
|
9
|
+
parts = ref[2:].split("/")
|
|
10
|
+
target = spec
|
|
11
|
+
for p in parts:
|
|
12
|
+
target = target.get(p, {}) if isinstance(target, dict) else {}
|
|
13
|
+
return resolve_ref(target, spec, seen | {ref})
|
|
14
|
+
return {"$ref": ref}
|
|
15
|
+
return {k: resolve_ref(v, spec, seen) for k, v in node.items()}
|
|
16
|
+
if isinstance(node, list):
|
|
17
|
+
return [resolve_ref(v, spec, seen) for v in node]
|
|
18
|
+
return node
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
JSON_MIME = "application/json"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _preferred_mime(content: dict) -> str | None:
|
|
25
|
+
offered = [mime for mime, media in content.items() if "schema" in media]
|
|
26
|
+
for mime in offered:
|
|
27
|
+
if mime.split(";")[0].strip() == JSON_MIME:
|
|
28
|
+
return mime
|
|
29
|
+
return offered[0] if offered else None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _success_codes(responses: dict) -> list[str]:
|
|
33
|
+
exact = sorted(c for c in responses if c.isdigit() and c.startswith("2"))
|
|
34
|
+
return exact + [c for c in ("2XX", "default") if c in responses]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _is_success(code: str) -> bool:
|
|
38
|
+
return code.startswith("2")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_responses(op: dict, spec: dict) -> dict:
|
|
42
|
+
"""Every documented status with its description; 2xx entries also carry the resolved schema."""
|
|
43
|
+
responses: dict = {}
|
|
44
|
+
for code, response in (op.get("responses") or {}).items():
|
|
45
|
+
code = str(code)
|
|
46
|
+
if code.startswith("x-"):
|
|
47
|
+
continue
|
|
48
|
+
response = resolve_ref(response or {}, spec)
|
|
49
|
+
entry: dict = {"description": response.get("description") or ""}
|
|
50
|
+
if _is_success(code):
|
|
51
|
+
content = response.get("content") or {}
|
|
52
|
+
mime = _preferred_mime(content)
|
|
53
|
+
if mime:
|
|
54
|
+
entry["content_type"] = mime
|
|
55
|
+
entry["schema"] = resolve_ref(content[mime]["schema"], spec)
|
|
56
|
+
responses[code] = entry
|
|
57
|
+
return responses
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_schema_snapshot(op: dict, spec: dict) -> dict:
|
|
61
|
+
snapshot: dict = {
|
|
62
|
+
"method": op["method"],
|
|
63
|
+
"path": op["path"],
|
|
64
|
+
"summary": op.get("summary") or "",
|
|
65
|
+
"description": op.get("description") or "",
|
|
66
|
+
"parameters": [resolve_ref(p, spec) for p in op.get("parameters") or []],
|
|
67
|
+
}
|
|
68
|
+
body = op.get("request_body")
|
|
69
|
+
if body:
|
|
70
|
+
content = body.get("content") or {}
|
|
71
|
+
mime = _preferred_mime(content)
|
|
72
|
+
if mime:
|
|
73
|
+
snapshot["request_body"] = {
|
|
74
|
+
"content_type": mime,
|
|
75
|
+
"required": body.get("required", False),
|
|
76
|
+
"schema": resolve_ref(content[mime]["schema"], spec),
|
|
77
|
+
}
|
|
78
|
+
responses = {str(c): r for c, r in (op.get("responses") or {}).items()}
|
|
79
|
+
for code in _success_codes(responses):
|
|
80
|
+
content = responses[code].get("content") or {}
|
|
81
|
+
for mime, media in content.items():
|
|
82
|
+
if "schema" in media:
|
|
83
|
+
snapshot["response"] = {
|
|
84
|
+
"status": code,
|
|
85
|
+
"content_type": mime,
|
|
86
|
+
"schema": resolve_ref(media["schema"], spec),
|
|
87
|
+
}
|
|
88
|
+
break
|
|
89
|
+
if "response" in snapshot:
|
|
90
|
+
break
|
|
91
|
+
snapshot["responses"] = build_responses(op, spec)
|
|
92
|
+
return snapshot
|