netbox-super-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.
- netbox_super_cli-1.0.0.dist-info/METADATA +182 -0
- netbox_super_cli-1.0.0.dist-info/RECORD +71 -0
- netbox_super_cli-1.0.0.dist-info/WHEEL +4 -0
- netbox_super_cli-1.0.0.dist-info/entry_points.txt +3 -0
- netbox_super_cli-1.0.0.dist-info/licenses/LICENSE +201 -0
- nsc/__init__.py +5 -0
- nsc/__main__.py +6 -0
- nsc/_version.py +3 -0
- nsc/aliases/__init__.py +24 -0
- nsc/aliases/resolver.py +112 -0
- nsc/auth/__init__.py +5 -0
- nsc/auth/verify.py +143 -0
- nsc/builder/__init__.py +5 -0
- nsc/builder/build.py +514 -0
- nsc/cache/__init__.py +5 -0
- nsc/cache/store.py +295 -0
- nsc/cli/__init__.py +1 -0
- nsc/cli/aliases_commands.py +264 -0
- nsc/cli/app.py +291 -0
- nsc/cli/cache_commands.py +159 -0
- nsc/cli/commands_dump.py +57 -0
- nsc/cli/config_commands.py +156 -0
- nsc/cli/globals.py +65 -0
- nsc/cli/handlers.py +660 -0
- nsc/cli/init_commands.py +95 -0
- nsc/cli/login_commands.py +265 -0
- nsc/cli/profiles_commands.py +256 -0
- nsc/cli/registration.py +465 -0
- nsc/cli/runtime.py +290 -0
- nsc/cli/skill_commands.py +186 -0
- nsc/cli/writes/__init__.py +10 -0
- nsc/cli/writes/apply.py +177 -0
- nsc/cli/writes/bulk.py +231 -0
- nsc/cli/writes/coercion.py +9 -0
- nsc/cli/writes/confirmation.py +96 -0
- nsc/cli/writes/input.py +358 -0
- nsc/cli/writes/preflight.py +182 -0
- nsc/config/__init__.py +23 -0
- nsc/config/loader.py +69 -0
- nsc/config/models.py +54 -0
- nsc/config/settings.py +36 -0
- nsc/config/writer.py +207 -0
- nsc/http/__init__.py +6 -0
- nsc/http/audit.py +183 -0
- nsc/http/client.py +365 -0
- nsc/http/errors.py +35 -0
- nsc/http/retry.py +90 -0
- nsc/model/__init__.py +23 -0
- nsc/model/command_model.py +125 -0
- nsc/output/__init__.py +1 -0
- nsc/output/csv_.py +34 -0
- nsc/output/errors.py +346 -0
- nsc/output/explain.py +194 -0
- nsc/output/flatten.py +25 -0
- nsc/output/headers.py +9 -0
- nsc/output/json_.py +21 -0
- nsc/output/jsonl.py +21 -0
- nsc/output/render.py +47 -0
- nsc/output/table.py +50 -0
- nsc/output/yaml_.py +28 -0
- nsc/schema/__init__.py +1 -0
- nsc/schema/hashing.py +24 -0
- nsc/schema/loader.py +66 -0
- nsc/schema/models.py +109 -0
- nsc/schema/source.py +120 -0
- nsc/schemas/__init__.py +1 -0
- nsc/schemas/bundled/__init__.py +1 -0
- nsc/schemas/bundled/manifest.yaml +5 -0
- nsc/schemas/bundled/netbox-4.6.0-beta2.json.gz +0 -0
- nsc/skill/__init__.py +35 -0
- skills/netbox-super-cli/SKILL.md +127 -0
nsc/auth/verify.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Pre-flight verification of a profile's URL+token.
|
|
2
|
+
|
|
3
|
+
`verify(profile)` issues two probes against the candidate NetBox:
|
|
4
|
+
|
|
5
|
+
* `GET /api/status/` — confirms the URL is a NetBox and reports the version.
|
|
6
|
+
* `GET /api/users/tokens/?limit=1` — confirms the token is accepted (the
|
|
7
|
+
endpoint requires authentication) and the response carries the calling
|
|
8
|
+
user's identity in `results[0].user.username`. NetBox does not expose a
|
|
9
|
+
top-level "current user" endpoint, so the token list is the closest signal
|
|
10
|
+
to "authenticated as <user>".
|
|
11
|
+
|
|
12
|
+
Both must succeed. Either failure raises `VerifyError`; the caller maps the
|
|
13
|
+
exception to an `auth_error` envelope (`ErrorType.AUTH`, exit 8). Login is not
|
|
14
|
+
audited — `verify` deliberately bypasses `NetBoxClient` to keep the pre-flight
|
|
15
|
+
out of `audit.jsonl`. There is no retry loop: a single failed probe fails fast.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
from pydantic import BaseModel, ConfigDict
|
|
22
|
+
|
|
23
|
+
from nsc.config.models import Profile
|
|
24
|
+
|
|
25
|
+
_DEFAULT_TIMEOUT = 10.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class VerifyResult(BaseModel):
|
|
29
|
+
"""The success-shape of a pre-flight verification."""
|
|
30
|
+
|
|
31
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
32
|
+
username: str
|
|
33
|
+
netbox_version: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class VerifyError(Exception):
|
|
37
|
+
"""Pre-flight verification failed.
|
|
38
|
+
|
|
39
|
+
`status_code` is the HTTP status of the failing probe, or `None` when the
|
|
40
|
+
failure was a transport error (connection refused, TLS, DNS, etc.).
|
|
41
|
+
`user_check_status` is set to the same status as `status_code` when the
|
|
42
|
+
auth probe (`/api/users/tokens/`) was the one that failed (i.e. `/api/status/`
|
|
43
|
+
returned 2xx but the token was rejected). This distinguishes
|
|
44
|
+
"wrong URL / NetBox down" from "URL fine, token rejected".
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
message: str,
|
|
50
|
+
*,
|
|
51
|
+
status_code: int | None = None,
|
|
52
|
+
user_check_status: int | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
super().__init__(message)
|
|
55
|
+
self.message = message
|
|
56
|
+
self.status_code = status_code
|
|
57
|
+
self.user_check_status = user_check_status
|
|
58
|
+
|
|
59
|
+
def __str__(self) -> str:
|
|
60
|
+
return self.message
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def verify(profile: Profile, *, timeout: float = _DEFAULT_TIMEOUT) -> VerifyResult:
|
|
64
|
+
"""Confirm `profile`'s URL+token reach an authenticated NetBox.
|
|
65
|
+
|
|
66
|
+
Raises `VerifyError` on any failure. Returns a `VerifyResult` on success.
|
|
67
|
+
"""
|
|
68
|
+
if not profile.token:
|
|
69
|
+
raise VerifyError(message="profile has no token; cannot verify")
|
|
70
|
+
base = str(profile.url).rstrip("/")
|
|
71
|
+
headers = {
|
|
72
|
+
"Authorization": f"Token {profile.token}",
|
|
73
|
+
"Accept": "application/json",
|
|
74
|
+
}
|
|
75
|
+
with httpx.Client(
|
|
76
|
+
base_url=base,
|
|
77
|
+
headers=headers,
|
|
78
|
+
verify=profile.verify_ssl,
|
|
79
|
+
timeout=timeout,
|
|
80
|
+
) as client:
|
|
81
|
+
version = _probe_status(client)
|
|
82
|
+
username = _probe_users_me(client)
|
|
83
|
+
return VerifyResult(username=username, netbox_version=version)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _probe_status(client: httpx.Client) -> str:
|
|
87
|
+
try:
|
|
88
|
+
response = client.get("/api/status/")
|
|
89
|
+
except (httpx.RequestError, OSError) as exc:
|
|
90
|
+
raise VerifyError(message=f"could not reach NetBox: {exc}") from exc
|
|
91
|
+
if not response.is_success:
|
|
92
|
+
raise VerifyError(
|
|
93
|
+
message=f"NetBox /api/status/ returned {response.status_code}",
|
|
94
|
+
status_code=response.status_code,
|
|
95
|
+
)
|
|
96
|
+
body = _safe_json(response)
|
|
97
|
+
version = body.get("netbox-version") if isinstance(body, dict) else None
|
|
98
|
+
return str(version) if version else "unknown"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _probe_users_me(client: httpx.Client) -> str:
|
|
102
|
+
"""Verify the token via `GET /api/users/tokens/?limit=1`.
|
|
103
|
+
|
|
104
|
+
NetBox does not expose a top-level "current user" endpoint (`/api/users/me/`
|
|
105
|
+
and `/api/users/users/me/` both return 404 on 4.5+). The token-list endpoint
|
|
106
|
+
is authenticated and its response carries a nested `user.username` for each
|
|
107
|
+
token, which is the closest signal to "authenticated as <user>". A non-admin
|
|
108
|
+
user only sees their own tokens, so `results[0].user.username` is the
|
|
109
|
+
calling user's identity in the common case. If the user has no visible
|
|
110
|
+
tokens (an unusual admin state), we surface "(unknown)" rather than failing.
|
|
111
|
+
"""
|
|
112
|
+
try:
|
|
113
|
+
response = client.get("/api/users/tokens/", params={"limit": 1})
|
|
114
|
+
except (httpx.RequestError, OSError) as exc:
|
|
115
|
+
raise VerifyError(message=f"token probe failed: {exc}") from exc
|
|
116
|
+
if not response.is_success:
|
|
117
|
+
raise VerifyError(
|
|
118
|
+
message=(
|
|
119
|
+
f"NetBox accepted the URL but rejected the token "
|
|
120
|
+
f"(/api/users/tokens/ returned {response.status_code})"
|
|
121
|
+
),
|
|
122
|
+
status_code=response.status_code,
|
|
123
|
+
user_check_status=response.status_code,
|
|
124
|
+
)
|
|
125
|
+
body = _safe_json(response)
|
|
126
|
+
if not isinstance(body, dict):
|
|
127
|
+
return "(unknown)"
|
|
128
|
+
results = body.get("results") or []
|
|
129
|
+
if not results:
|
|
130
|
+
return "(unknown)"
|
|
131
|
+
user = results[0].get("user") if isinstance(results[0], dict) else None
|
|
132
|
+
if isinstance(user, dict):
|
|
133
|
+
username = user.get("username")
|
|
134
|
+
if username:
|
|
135
|
+
return str(username)
|
|
136
|
+
return "(unknown)"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _safe_json(response: httpx.Response) -> object:
|
|
140
|
+
try:
|
|
141
|
+
return response.json()
|
|
142
|
+
except ValueError:
|
|
143
|
+
return None
|
nsc/builder/__init__.py
ADDED
nsc/builder/build.py
ADDED
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
"""Schema → CommandModel.
|
|
2
|
+
|
|
3
|
+
The algorithm is deterministic and depends solely on the OpenAPI document.
|
|
4
|
+
No NetBox-specific knowledge is hard-coded.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
from nsc.model.command_model import (
|
|
13
|
+
CommandModel,
|
|
14
|
+
FieldShape,
|
|
15
|
+
HttpMethod,
|
|
16
|
+
Operation,
|
|
17
|
+
Parameter,
|
|
18
|
+
ParameterLocation,
|
|
19
|
+
PrimitiveType,
|
|
20
|
+
RequestBodyShape,
|
|
21
|
+
Resource,
|
|
22
|
+
Tag,
|
|
23
|
+
)
|
|
24
|
+
from nsc.schema.loader import LoadedSchema
|
|
25
|
+
from nsc.schema.models import OpenAPIDocument, PathItem, SchemaObject
|
|
26
|
+
from nsc.schema.models import Operation as SchemaOperation
|
|
27
|
+
from nsc.schema.models import Parameter as SchemaParameter
|
|
28
|
+
|
|
29
|
+
_PARAM_SEGMENT = re.compile(r"^\{[^}]+\}$")
|
|
30
|
+
_CANONICAL_SENSITIVE_NAMES: frozenset[str] = frozenset(
|
|
31
|
+
{
|
|
32
|
+
"password",
|
|
33
|
+
"secret",
|
|
34
|
+
"token",
|
|
35
|
+
"api_key",
|
|
36
|
+
"apikey",
|
|
37
|
+
"private_key",
|
|
38
|
+
"passphrase",
|
|
39
|
+
"client_secret",
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
_HTTP_METHODS: tuple[tuple[str, HttpMethod], ...] = (
|
|
43
|
+
("get", HttpMethod.GET),
|
|
44
|
+
("post", HttpMethod.POST),
|
|
45
|
+
("patch", HttpMethod.PATCH),
|
|
46
|
+
("put", HttpMethod.PUT),
|
|
47
|
+
("delete", HttpMethod.DELETE),
|
|
48
|
+
("options", HttpMethod.OPTIONS),
|
|
49
|
+
("head", HttpMethod.HEAD),
|
|
50
|
+
)
|
|
51
|
+
# api / <tag> / <resource> = at least 3 non-empty path parts
|
|
52
|
+
_MIN_PATH_PARTS = 3
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_command_model(loaded: LoadedSchema) -> CommandModel:
|
|
56
|
+
doc = loaded.document
|
|
57
|
+
tags: dict[str, _MutableTag] = {}
|
|
58
|
+
|
|
59
|
+
for path, item in doc.paths.items():
|
|
60
|
+
for attr_name, http_method in _HTTP_METHODS:
|
|
61
|
+
schema_op: SchemaOperation | None = getattr(item, attr_name)
|
|
62
|
+
if schema_op is None:
|
|
63
|
+
continue
|
|
64
|
+
_assimilate(tags, path, http_method, schema_op, item, doc)
|
|
65
|
+
|
|
66
|
+
final_tags = {
|
|
67
|
+
name: Tag(
|
|
68
|
+
name=name,
|
|
69
|
+
description=mt.description,
|
|
70
|
+
resources={rname: _finalize_resource(rname, mr) for rname, mr in mt.resources.items()},
|
|
71
|
+
)
|
|
72
|
+
for name, mt in sorted(tags.items())
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return CommandModel(
|
|
76
|
+
info_title=doc.info.title,
|
|
77
|
+
info_version=doc.info.version,
|
|
78
|
+
schema_hash=loaded.hash,
|
|
79
|
+
tags=final_tags,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# --- internals -------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _MutableResource:
|
|
87
|
+
def __init__(self, name: str) -> None:
|
|
88
|
+
self.name = name
|
|
89
|
+
self.list_op: Operation | None = None
|
|
90
|
+
self.get_op: Operation | None = None
|
|
91
|
+
self.create_op: Operation | None = None
|
|
92
|
+
self.update_op: Operation | None = None
|
|
93
|
+
self.replace_op: Operation | None = None
|
|
94
|
+
self.delete_op: Operation | None = None
|
|
95
|
+
self.custom_actions: list[Operation] = []
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class _MutableTag:
|
|
99
|
+
def __init__(self, name: str, description: str | None) -> None:
|
|
100
|
+
self.name = name
|
|
101
|
+
self.description = description
|
|
102
|
+
self.resources: dict[str, _MutableResource] = {}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _assimilate(
|
|
106
|
+
tags: dict[str, _MutableTag],
|
|
107
|
+
path: str,
|
|
108
|
+
http_method: HttpMethod,
|
|
109
|
+
schema_op: SchemaOperation,
|
|
110
|
+
item: PathItem,
|
|
111
|
+
doc: OpenAPIDocument,
|
|
112
|
+
) -> None:
|
|
113
|
+
tag_name = schema_op.tags[0] if schema_op.tags else None
|
|
114
|
+
if tag_name is None:
|
|
115
|
+
return # untagged operations are skipped intentionally
|
|
116
|
+
|
|
117
|
+
resource_name, is_collection_path = _resource_from_path(path, tag_name)
|
|
118
|
+
if resource_name is None:
|
|
119
|
+
return # paths that don't match the /api/<tag>/<resource>/... shape
|
|
120
|
+
|
|
121
|
+
tag = tags.get(tag_name)
|
|
122
|
+
if tag is None:
|
|
123
|
+
tag = _MutableTag(tag_name, _tag_description(tag_name, doc))
|
|
124
|
+
tags[tag_name] = tag
|
|
125
|
+
|
|
126
|
+
resource = tag.resources.get(resource_name)
|
|
127
|
+
if resource is None:
|
|
128
|
+
resource = _MutableResource(resource_name)
|
|
129
|
+
tag.resources[resource_name] = resource
|
|
130
|
+
|
|
131
|
+
op = _to_model_operation(path, http_method, schema_op, item, doc)
|
|
132
|
+
classification = _classify(http_method, path, schema_op, resource_name)
|
|
133
|
+
_attach(resource, classification, op, is_collection_path)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _resource_from_path(path: str, tag: str) -> tuple[str | None, bool]:
|
|
137
|
+
"""Return (resource_name, is_collection_path).
|
|
138
|
+
|
|
139
|
+
A path is a collection path if it has no further parameter segments after
|
|
140
|
+
the resource name. `is_collection_path=True` for `/api/dcim/devices/`
|
|
141
|
+
and `False` for `/api/dcim/devices/{id}/`.
|
|
142
|
+
"""
|
|
143
|
+
parts = [p for p in path.split("/") if p]
|
|
144
|
+
if not parts or parts[0] != "api":
|
|
145
|
+
return None, False
|
|
146
|
+
# Top-level resources (e.g., /api/search/, /api/status/) — single segment after /api/.
|
|
147
|
+
# The segment itself is the resource name and is always a collection path.
|
|
148
|
+
if len(parts) == 2: # noqa: PLR2004
|
|
149
|
+
return parts[1], True
|
|
150
|
+
# Expect: api / <tag-or-tag-with-dashes> / <resource> / [...]
|
|
151
|
+
if len(parts) < _MIN_PATH_PARTS:
|
|
152
|
+
return None, False
|
|
153
|
+
# Some plugin endpoints have nested tags (e.g., ['plugins', '<plugin>']).
|
|
154
|
+
# The tag string from the spec is authoritative; just find <resource>.
|
|
155
|
+
# Strategy: skip leading non-parameter segments until we've consumed the
|
|
156
|
+
# tag's words and reached a resource segment.
|
|
157
|
+
# Easier heuristic that works for NetBox: the resource is the first
|
|
158
|
+
# segment after the segment that begins the tag's path-form.
|
|
159
|
+
tag_form = tag.replace(" ", "-").lower()
|
|
160
|
+
try:
|
|
161
|
+
anchor = parts.index(tag_form)
|
|
162
|
+
except ValueError:
|
|
163
|
+
# Fall back: assume parts[1] is the tag, parts[2] is the resource.
|
|
164
|
+
anchor = 1
|
|
165
|
+
if anchor + 1 >= len(parts):
|
|
166
|
+
return None, False
|
|
167
|
+
resource = parts[anchor + 1]
|
|
168
|
+
if _PARAM_SEGMENT.match(resource):
|
|
169
|
+
return None, False
|
|
170
|
+
remainder = parts[anchor + 2 :]
|
|
171
|
+
is_collection = all(not _PARAM_SEGMENT.match(p) for p in remainder) and not remainder
|
|
172
|
+
# If the only remaining segment is `{id}` it's the per-item path:
|
|
173
|
+
if len(remainder) == 1 and _PARAM_SEGMENT.match(remainder[0]):
|
|
174
|
+
is_collection = False
|
|
175
|
+
return resource, is_collection
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
_CRUD_MAP: dict[tuple[HttpMethod, bool], str] = {
|
|
179
|
+
(HttpMethod.GET, False): "list",
|
|
180
|
+
(HttpMethod.GET, True): "get",
|
|
181
|
+
(HttpMethod.POST, False): "create",
|
|
182
|
+
(HttpMethod.PATCH, True): "update",
|
|
183
|
+
(HttpMethod.PUT, True): "replace",
|
|
184
|
+
(HttpMethod.DELETE, True): "delete",
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _classify(
|
|
189
|
+
method: HttpMethod,
|
|
190
|
+
path: str,
|
|
191
|
+
schema_op: SchemaOperation,
|
|
192
|
+
resource_name: str,
|
|
193
|
+
) -> str:
|
|
194
|
+
has_id = "{id}" in path
|
|
195
|
+
extra_path_segments = path.rstrip("/").split("/")[-1]
|
|
196
|
+
# Path is a custom action endpoint if `{id}` appears AND the last segment
|
|
197
|
+
# is a literal name (not itself a path parameter like `{id}`).
|
|
198
|
+
is_action_endpoint = has_id and not _PARAM_SEGMENT.match(extra_path_segments)
|
|
199
|
+
|
|
200
|
+
if is_action_endpoint:
|
|
201
|
+
return "custom"
|
|
202
|
+
|
|
203
|
+
return _CRUD_MAP.get((method, has_id), "custom")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _attach(
|
|
207
|
+
resource: _MutableResource,
|
|
208
|
+
classification: str,
|
|
209
|
+
op: Operation,
|
|
210
|
+
is_collection_path: bool, # kept for future heuristics
|
|
211
|
+
) -> None:
|
|
212
|
+
match classification:
|
|
213
|
+
case "list":
|
|
214
|
+
resource.list_op = op
|
|
215
|
+
case "get":
|
|
216
|
+
resource.get_op = op
|
|
217
|
+
case "create":
|
|
218
|
+
resource.create_op = op
|
|
219
|
+
case "update":
|
|
220
|
+
resource.update_op = op
|
|
221
|
+
case "replace":
|
|
222
|
+
resource.replace_op = op
|
|
223
|
+
case "delete":
|
|
224
|
+
resource.delete_op = op
|
|
225
|
+
case _:
|
|
226
|
+
resource.custom_actions.append(op)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _to_model_operation(
|
|
230
|
+
path: str,
|
|
231
|
+
http_method: HttpMethod,
|
|
232
|
+
schema_op: SchemaOperation,
|
|
233
|
+
item: PathItem,
|
|
234
|
+
doc: OpenAPIDocument,
|
|
235
|
+
) -> Operation:
|
|
236
|
+
if schema_op.operation_id is None:
|
|
237
|
+
# Synthesize one — operationId is required in practice but we degrade
|
|
238
|
+
# gracefully rather than crashing on hand-rolled schemas.
|
|
239
|
+
synthesized = f"{http_method.value.lower()}_{path.strip('/').replace('/', '_')}"
|
|
240
|
+
operation_id = synthesized
|
|
241
|
+
else:
|
|
242
|
+
operation_id = schema_op.operation_id
|
|
243
|
+
|
|
244
|
+
seen: dict[str, Parameter] = {}
|
|
245
|
+
for source_param in (*item.parameters, *schema_op.parameters):
|
|
246
|
+
seen[source_param.name] = _to_model_parameter(source_param)
|
|
247
|
+
parameters = list(seen.values())
|
|
248
|
+
|
|
249
|
+
return Operation(
|
|
250
|
+
operation_id=operation_id,
|
|
251
|
+
http_method=http_method,
|
|
252
|
+
path=path,
|
|
253
|
+
summary=schema_op.summary,
|
|
254
|
+
description=schema_op.description,
|
|
255
|
+
parameters=parameters,
|
|
256
|
+
request_body=_to_request_body_shape(schema_op, doc),
|
|
257
|
+
default_columns=_default_columns_from_response(schema_op, doc),
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _to_model_parameter(p: SchemaParameter) -> Parameter:
|
|
262
|
+
enum_values: list[str] | None = None
|
|
263
|
+
primitive = PrimitiveType.UNKNOWN
|
|
264
|
+
if p.schema_ is not None:
|
|
265
|
+
primitive = _primitive(p.schema_)
|
|
266
|
+
if p.schema_.enum:
|
|
267
|
+
enum_values = [str(v) for v in p.schema_.enum]
|
|
268
|
+
return Parameter(
|
|
269
|
+
name=p.name,
|
|
270
|
+
location=ParameterLocation(p.in_.value),
|
|
271
|
+
primitive=primitive,
|
|
272
|
+
required=p.required,
|
|
273
|
+
description=p.description,
|
|
274
|
+
enum=enum_values,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _to_request_body_shape(
|
|
279
|
+
schema_op: SchemaOperation, doc: OpenAPIDocument
|
|
280
|
+
) -> RequestBodyShape | None:
|
|
281
|
+
if schema_op.request_body is None:
|
|
282
|
+
return None
|
|
283
|
+
media = schema_op.request_body.content.get("application/json")
|
|
284
|
+
if media is None or media.schema_ is None:
|
|
285
|
+
return None
|
|
286
|
+
schema = _resolve_ref(media.schema_, doc)
|
|
287
|
+
if schema is None:
|
|
288
|
+
return None
|
|
289
|
+
top_level = _classify_top_level(schema, doc)
|
|
290
|
+
if top_level is None:
|
|
291
|
+
return None
|
|
292
|
+
if top_level in ("object", "object_or_array"):
|
|
293
|
+
branch = _object_branch(schema, doc)
|
|
294
|
+
required = list(branch.required or []) if branch is not None else []
|
|
295
|
+
else:
|
|
296
|
+
required = []
|
|
297
|
+
fields = _flat_fields(schema, doc) if top_level in ("object", "object_or_array") else {}
|
|
298
|
+
raw_paths = _collect_sensitive_paths(schema, doc)
|
|
299
|
+
sensitive_paths = tuple(sorted({".".join(p) for p in raw_paths if p}))
|
|
300
|
+
return RequestBodyShape(
|
|
301
|
+
top_level=top_level,
|
|
302
|
+
required=required,
|
|
303
|
+
fields=fields,
|
|
304
|
+
sensitive_paths=sensitive_paths,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _branch_type(raw: object, doc: OpenAPIDocument) -> str | None:
|
|
309
|
+
"""Return the top-level "type" for a oneOf/anyOf branch, resolving $ref once."""
|
|
310
|
+
if not isinstance(raw, dict):
|
|
311
|
+
return None
|
|
312
|
+
direct = raw.get("type")
|
|
313
|
+
if direct in {"object", "array"}:
|
|
314
|
+
return str(direct)
|
|
315
|
+
ref = raw.get("$ref")
|
|
316
|
+
if isinstance(ref, str):
|
|
317
|
+
candidate = SchemaObject.model_validate({"$ref": ref})
|
|
318
|
+
resolved = _resolve_ref(candidate, doc)
|
|
319
|
+
if resolved is not None and resolved.type in ("object", "array"):
|
|
320
|
+
return resolved.type
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _classify_top_level(
|
|
325
|
+
schema: SchemaObject, doc: OpenAPIDocument
|
|
326
|
+
) -> Literal["object", "array", "object_or_array"] | None:
|
|
327
|
+
if schema.type in ("object", "array"):
|
|
328
|
+
return schema.type # type: ignore[return-value]
|
|
329
|
+
one_of = schema.model_extra.get("oneOf") if schema.model_extra else None
|
|
330
|
+
any_of = schema.model_extra.get("anyOf") if schema.model_extra else None
|
|
331
|
+
candidates = one_of or any_of or None
|
|
332
|
+
if not candidates:
|
|
333
|
+
return None
|
|
334
|
+
types = {t for raw in candidates if (t := _branch_type(raw, doc)) is not None}
|
|
335
|
+
if {"object", "array"}.issubset(types):
|
|
336
|
+
return "object_or_array"
|
|
337
|
+
if types == {"object"}:
|
|
338
|
+
return "object"
|
|
339
|
+
return "array" if types == {"array"} else None
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _object_branch(schema: SchemaObject, doc: OpenAPIDocument) -> SchemaObject | None:
|
|
343
|
+
"""Find the object-shaped variant of a oneOf/anyOf, resolving $ref branches."""
|
|
344
|
+
if schema.type == "object":
|
|
345
|
+
return schema
|
|
346
|
+
candidates = (
|
|
347
|
+
(schema.model_extra or {}).get("oneOf") or (schema.model_extra or {}).get("anyOf") or []
|
|
348
|
+
)
|
|
349
|
+
for raw in candidates:
|
|
350
|
+
if not isinstance(raw, dict):
|
|
351
|
+
continue
|
|
352
|
+
if raw.get("type") == "object":
|
|
353
|
+
return SchemaObject.model_validate(raw)
|
|
354
|
+
ref = raw.get("$ref")
|
|
355
|
+
if isinstance(ref, str):
|
|
356
|
+
resolved = _resolve_ref(SchemaObject.model_validate({"$ref": ref}), doc)
|
|
357
|
+
if resolved is not None and resolved.type == "object":
|
|
358
|
+
return resolved
|
|
359
|
+
return None
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _flat_fields(schema: SchemaObject, doc: OpenAPIDocument) -> dict[str, FieldShape]:
|
|
363
|
+
target = _object_branch(schema, doc)
|
|
364
|
+
if target is None or not target.properties:
|
|
365
|
+
return {}
|
|
366
|
+
out: dict[str, FieldShape] = {}
|
|
367
|
+
for name, prop in target.properties.items():
|
|
368
|
+
resolved = _resolve_ref(prop, doc) or prop
|
|
369
|
+
primitive = _primitive(resolved)
|
|
370
|
+
enum = [str(v) for v in resolved.enum] if resolved.enum else None
|
|
371
|
+
out[name] = FieldShape(primitive=primitive, enum=enum)
|
|
372
|
+
return out
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _primitive(schema: SchemaObject) -> PrimitiveType:
|
|
376
|
+
if schema.type is None:
|
|
377
|
+
return PrimitiveType.UNKNOWN
|
|
378
|
+
try:
|
|
379
|
+
return PrimitiveType(schema.type)
|
|
380
|
+
except ValueError:
|
|
381
|
+
return PrimitiveType.UNKNOWN
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
_SCALAR_TYPES = {"string", "integer", "number", "boolean"}
|
|
385
|
+
_PRIORITY_NAMES = ("name", "slug", "display")
|
|
386
|
+
_MAX_DEFAULT_COLUMNS = 6
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _resolve_ref(schema: SchemaObject, doc: OpenAPIDocument) -> SchemaObject | None:
|
|
390
|
+
if schema.ref is None:
|
|
391
|
+
return schema
|
|
392
|
+
# OpenAPI refs look like "#/components/schemas/<Name>" — we only support that form.
|
|
393
|
+
prefix = "#/components/schemas/"
|
|
394
|
+
if not schema.ref.startswith(prefix):
|
|
395
|
+
return None
|
|
396
|
+
name = schema.ref[len(prefix) :]
|
|
397
|
+
return doc.components.schemas.get(name)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _is_sensitive_field(name: str, schema: SchemaObject) -> bool:
|
|
401
|
+
if name.lower() in _CANONICAL_SENSITIVE_NAMES:
|
|
402
|
+
return True
|
|
403
|
+
return getattr(schema, "format", None) == "password"
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _collect_sensitive_paths(
|
|
407
|
+
schema: SchemaObject,
|
|
408
|
+
doc: OpenAPIDocument,
|
|
409
|
+
*,
|
|
410
|
+
prefix: tuple[str, ...] = (),
|
|
411
|
+
seen: frozenset[str] = frozenset(),
|
|
412
|
+
) -> list[tuple[str, ...]]:
|
|
413
|
+
"""Return all dotted paths to sensitive fields under `schema`.
|
|
414
|
+
|
|
415
|
+
`seen` carries already-traversed `$ref` targets to break cycles in
|
|
416
|
+
self-referential schemas (NetBox has a few via `tags`).
|
|
417
|
+
"""
|
|
418
|
+
target = _resolve_ref(schema, doc) or schema
|
|
419
|
+
new_seen = seen
|
|
420
|
+
if target.ref is not None:
|
|
421
|
+
if target.ref in seen:
|
|
422
|
+
return []
|
|
423
|
+
new_seen = seen | {target.ref}
|
|
424
|
+
elif schema.ref is not None:
|
|
425
|
+
if schema.ref in seen:
|
|
426
|
+
return []
|
|
427
|
+
new_seen = seen | {schema.ref}
|
|
428
|
+
|
|
429
|
+
paths: list[tuple[str, ...]] = []
|
|
430
|
+
|
|
431
|
+
if target.type == "object" and target.properties:
|
|
432
|
+
for name, prop in target.properties.items():
|
|
433
|
+
child_prefix = (*prefix, name)
|
|
434
|
+
resolved_prop = _resolve_ref(prop, doc) or prop
|
|
435
|
+
if _is_sensitive_field(name, resolved_prop):
|
|
436
|
+
paths.append(child_prefix)
|
|
437
|
+
continue
|
|
438
|
+
paths.extend(
|
|
439
|
+
_collect_sensitive_paths(resolved_prop, doc, prefix=child_prefix, seen=new_seen)
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
if target.type == "array" and target.items is not None:
|
|
443
|
+
paths.extend(_collect_sensitive_paths(target.items, doc, prefix=prefix, seen=new_seen))
|
|
444
|
+
|
|
445
|
+
candidates = (
|
|
446
|
+
(target.model_extra or {}).get("oneOf") or (target.model_extra or {}).get("anyOf") or []
|
|
447
|
+
)
|
|
448
|
+
for raw in candidates:
|
|
449
|
+
if isinstance(raw, dict):
|
|
450
|
+
branch = SchemaObject.model_validate(raw)
|
|
451
|
+
paths.extend(_collect_sensitive_paths(branch, doc, prefix=prefix, seen=new_seen))
|
|
452
|
+
|
|
453
|
+
return paths
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _record_shape(response_schema: SchemaObject, doc: OpenAPIDocument) -> SchemaObject | None:
|
|
457
|
+
resolved = _resolve_ref(response_schema, doc)
|
|
458
|
+
if resolved is None:
|
|
459
|
+
return None
|
|
460
|
+
props = resolved.properties or {}
|
|
461
|
+
results = props.get("results")
|
|
462
|
+
if results is not None and results.type == "array" and results.items is not None:
|
|
463
|
+
return _resolve_ref(results.items, doc)
|
|
464
|
+
return resolved
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _default_columns_from_response(
|
|
468
|
+
schema_op: SchemaOperation, doc: OpenAPIDocument
|
|
469
|
+
) -> list[str] | None:
|
|
470
|
+
response = schema_op.responses.get("200")
|
|
471
|
+
if response is None:
|
|
472
|
+
return None
|
|
473
|
+
media = response.content.get("application/json")
|
|
474
|
+
if media is None or media.schema_ is None:
|
|
475
|
+
return None
|
|
476
|
+
record = _record_shape(media.schema_, doc)
|
|
477
|
+
if record is None or not record.properties:
|
|
478
|
+
return None
|
|
479
|
+
|
|
480
|
+
cols: list[str] = []
|
|
481
|
+
if "id" in record.properties:
|
|
482
|
+
cols.append("id")
|
|
483
|
+
for name in _PRIORITY_NAMES:
|
|
484
|
+
if name in record.properties and name not in cols:
|
|
485
|
+
cols.append(name)
|
|
486
|
+
for name, prop in record.properties.items():
|
|
487
|
+
if len(cols) >= _MAX_DEFAULT_COLUMNS:
|
|
488
|
+
break
|
|
489
|
+
if name in cols:
|
|
490
|
+
continue
|
|
491
|
+
if (prop.type or "") not in _SCALAR_TYPES:
|
|
492
|
+
continue
|
|
493
|
+
cols.append(name)
|
|
494
|
+
return cols if cols else None
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _tag_description(name: str, doc: OpenAPIDocument) -> str | None:
|
|
498
|
+
for tag in doc.tags:
|
|
499
|
+
if tag.name == name:
|
|
500
|
+
return tag.description
|
|
501
|
+
return None
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _finalize_resource(name: str, m: _MutableResource) -> Resource:
|
|
505
|
+
return Resource(
|
|
506
|
+
name=name,
|
|
507
|
+
list_op=m.list_op,
|
|
508
|
+
get_op=m.get_op,
|
|
509
|
+
create_op=m.create_op,
|
|
510
|
+
update_op=m.update_op,
|
|
511
|
+
replace_op=m.replace_op,
|
|
512
|
+
delete_op=m.delete_op,
|
|
513
|
+
custom_actions=list(m.custom_actions),
|
|
514
|
+
)
|