Skip to content

Commit 078bd65

Browse files
committed
Fix mappers behavior with foreign keys and enums
Fixes #3 Fix foreign keys behavior: now, when a foreign key is detected, the foreign object will be fetched and linked as a new mapper. Black magic is done with properties to fetch this object in a JIT way: accessing the object for the first time will do the request to fetch it, then it will be stored in cache local to the parent. Fix serialization of netbox choices: not ideal for now, could be improved later, but at least works. Handle specific cases when a mapper cannot be generated.
1 parent b3afdae commit 078bd65

File tree

4 files changed

+338
-32
lines changed

4 files changed

+338
-32
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ the changes made in the object attributes:
143143
>>> child_mapper = netbox_mapper.get(1)
144144
>>> child_mapper.name = "another name"
145145
>>> child_mapper.put()
146-
<NetboxMapper> # corresponding to the updated object
146+
<requests> # requests object containing the netbox response
147147
```
148148

149149
### PATCH

netboxapi/exceptions.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
2-
31
class ForbiddenAsChildError(Exception):
42
pass
3+
4+
5+
class ForbiddenAsPassiveMapperError(Exception):
6+
def __init__(self):
7+
super().__init__("No action is possible for this type of mapper")

netboxapi/mapper.py

Lines changed: 128 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1+
import logging
2+
import re
3+
import requests
14

25
from .api import NetboxAPI
3-
from .exceptions import ForbiddenAsChildError
6+
from .exceptions import ForbiddenAsChildError, ForbiddenAsPassiveMapperError
7+
8+
9+
logger = logging.getLogger("netboxapi")
410

511

612
class NetboxMapper():
@@ -9,6 +15,10 @@ def __init__(self, netbox_api, app_name, model, route=None):
915
self.__app_name__ = app_name
1016
self.__model__ = model
1117
self.__upstream_attrs__ = []
18+
self.__foreign_keys__ = []
19+
20+
#: cache for foreign keys properties
21+
self._fk_cache = {}
1222

1323
self._route = (
1424
route or
@@ -31,6 +41,10 @@ def get(self, *args, **kwargs):
3141
>>> netbox_mapper.get("1", "racks", q="name_to_filter")
3242
3343
Will do a request to "/dcim/sites/1/racks/?q=name_to_filter"
44+
45+
Some specific routes will not return objects with ID (this one for
46+
example: `/ipam/prefixes/{id}/available-prefixes/`). In this case, no
47+
mapper will be built from the result and it will be yield as received.
3448
"""
3549
if args:
3650
route = self._route + "/".join(str(a) for a in args) + "/"
@@ -43,10 +57,15 @@ def get(self, *args, **kwargs):
4357

4458
if isinstance(new_mappers_dict, dict):
4559
new_mappers_dict = [new_mappers_dict]
46-
for d in new_mappers_dict:
47-
yield self._build_new_mapper_from(
48-
d, route + "{}/".format(d["id"])
49-
)
60+
try:
61+
for d in new_mappers_dict:
62+
yield self._build_new_mapper_from(
63+
d, route + "{}/".format(d["id"])
64+
)
65+
except KeyError:
66+
# Result objects have no id, cannot build a mapper from them,
67+
# yield them as received
68+
yield from new_mappers_dict
5069

5170
def post(self, **json):
5271
"""
@@ -66,9 +85,23 @@ def post(self, **json):
6685
:returns: child_mapper: Mapper containing the created object
6786
"""
6887
new_mapper_dict = self.netbox_api.post(self._route, json=json)
69-
route = self._route + "{}/".format(new_mapper_dict["id"])
70-
71-
return self._build_new_mapper_from(new_mapper_dict, route)
88+
try:
89+
return next(self.get(new_mapper_dict["id"]))
90+
except requests.exceptions.HTTPError as e:
91+
if e.response.status_code == 404:
92+
logger.debug(
93+
"Rare case of weird endpoint where object cannot be fetch "
94+
"after POST by using the same endpoint. Returning a "
95+
"mapper based on this answer instead of fetching the "
96+
"entire object."
97+
""
98+
"Do not try to put this mapper as it will fail."
99+
)
100+
return self._build_new_mapper_from(
101+
new_mapper_dict,
102+
self._route + "{}/".format(new_mapper_dict["id"]),
103+
passive_mapper=True
104+
)
72105

73106
def put(self):
74107
"""
@@ -83,17 +116,34 @@ def put(self):
83116
>>> child_mapper = netbox_mapper.get(1)
84117
>>> child_mapper.name = "another name"
85118
>>> child_mapper.put()
86-
<child_mapper>
87119
88-
:returns: child_mapper: Mapper containing the updated object
120+
:returns: request_reponse: requests object containing the netbox
121+
response
89122
"""
90123
assert getattr(self, "id", None) is not None, "self.id does not exist"
91124

92-
new_mapper_dict = self.netbox_api.put(self._route, json=self.to_dict())
93-
return self._build_new_mapper_from(new_mapper_dict, self._route)
125+
return self.netbox_api.put(self._route, json=self.to_dict())
94126

95127
def to_dict(self):
96-
return {a: getattr(self, a, None) for a in self.__upstream_attrs__}
128+
serialize = {}
129+
for a in self.__upstream_attrs__:
130+
val = getattr(self, a, None)
131+
if isinstance(val, dict):
132+
if "value" in val and "label" in val:
133+
val = val["value"]
134+
135+
serialize[a] = val
136+
137+
for fk in self.__foreign_keys__:
138+
attr = getattr(self, fk, None)
139+
try:
140+
# check that attr is iterable
141+
iter(attr)
142+
serialize[fk] = [getattr(i, "id", None) for i in attr]
143+
except TypeError:
144+
serialize[fk] = getattr(attr, "id", None)
145+
146+
return serialize
97147

98148
def delete(self, id=None):
99149
"""
@@ -128,12 +178,73 @@ def delete(self, id=None):
128178
delete_route = self._route + "{}/".format(id) if id else self._route
129179
return self.netbox_api.delete(delete_route)
130180

131-
def _build_new_mapper_from(self, mapper_attributes, new_route):
132-
mapper = type(self)(
181+
def _build_new_mapper_from(
182+
self, mapper_attributes, new_route, passive_mapper=False
183+
):
184+
if not passive_mapper:
185+
cls = type(self)
186+
else:
187+
cls = NetboxPassiveMapper
188+
mapper_class = type(
189+
"NetboxMapper_{}_{}".format(
190+
re.sub("_|-", "", self.__model__.title()),
191+
re.sub("_|-", "", "".join(
192+
s.title() for s in new_route.split("/")
193+
))
194+
), (cls,), {}
195+
)
196+
197+
mapper = mapper_class(
133198
self.netbox_api, self.__app_name__, self.__model__, new_route
134199
)
135-
mapper.__upstream_attrs__ = list(mapper_attributes.keys())
200+
mapper.__upstream_attrs__ = []
201+
mapper.__foreign_keys__ = []
136202
for attr, val in mapper_attributes.items():
137-
setattr(mapper, attr, val)
203+
if isinstance(val, dict) and "id" in val:
204+
mapper.__foreign_keys__.append(attr)
205+
mapper._set_property_foreign_key(attr, val)
206+
else:
207+
mapper.__upstream_attrs__.append(attr)
208+
setattr(mapper, attr, val)
138209

139210
return mapper
211+
212+
def _set_property_foreign_key(self, attr, value):
213+
def get_foreign_object(*args):
214+
if attr in self._fk_cache:
215+
return self._fk_cache[attr]
216+
217+
url = value["url"]
218+
route = url.replace(self.netbox_api.url, "", 1).lstrip("/")
219+
model, app_name, *params = route.split("/")
220+
221+
fk = list(NetboxMapper(self.netbox_api, model, app_name).get(
222+
*[p for p in params if p]
223+
))
224+
if not fk:
225+
fk = None
226+
elif len(fk) == 1:
227+
fk = fk[0]
228+
229+
self._fk_cache[attr] = fk
230+
return fk
231+
232+
try:
233+
self._fk_cache.pop(attr)
234+
except KeyError:
235+
pass
236+
setattr(type(self), attr, property(get_foreign_object))
237+
238+
239+
class NetboxPassiveMapper(NetboxMapper):
240+
def get(self, *args, **kwargs):
241+
raise ForbiddenAsPassiveMapperError()
242+
243+
def post(self, *args, **kwargs):
244+
raise ForbiddenAsPassiveMapperError()
245+
246+
def put(self, *args, **kwargs):
247+
raise ForbiddenAsPassiveMapperError()
248+
249+
def delete(self, *args, **kwargs):
250+
raise ForbiddenAsPassiveMapperError()

0 commit comments

Comments
 (0)