Skip to content

Commit 1a61145

Browse files
committed
feat(endpoint): add SVG support for rack elevation endpoint
1 parent e3810c1 commit 1a61145

File tree

5 files changed

+281
-14
lines changed

5 files changed

+281
-14
lines changed

docs/advanced.md

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,4 +130,51 @@ with open('/path/to/image.png', 'rb') as f:
130130
)
131131
```
132132

133-
The tuple format is `(filename, file_object)` or `(filename, file_object, content_type)`.
133+
The tuple format is `(filename, file_object)` or `(filename, file_object, content_type)`.
134+
135+
# Multi-Format Responses
136+
137+
Some endpoints support multiple response formats. The rack elevation endpoint can return both JSON data and SVG diagrams.
138+
139+
## Getting Rack Elevation as JSON
140+
141+
By default, the elevation endpoint returns JSON data as a list of rack unit objects:
142+
143+
```python
144+
import pynetbox
145+
146+
nb = pynetbox.api(
147+
'http://localhost:8000',
148+
token='d6f4e314a5b5fefd164995169f28ae32d987704f'
149+
)
150+
151+
rack = nb.dcim.racks.get(123)
152+
153+
# Returns list of RU objects (default JSON response)
154+
units = rack.elevation.list()
155+
for unit in units:
156+
print(unit.id, unit.name)
157+
```
158+
159+
## Getting Rack Elevation as SVG
160+
161+
Use the `render='svg'` parameter to get a graphical SVG diagram:
162+
163+
```python
164+
rack = nb.dcim.racks.get(123)
165+
166+
# Returns raw SVG string
167+
svg_diagram = rack.elevation.list(render='svg')
168+
print(svg_diagram) # '<svg xmlns="http://www.w3.org/2000/svg">...</svg>'
169+
170+
# Save to file
171+
with open('rack-elevation.svg', 'w') as f:
172+
f.write(svg_diagram)
173+
```
174+
175+
## Supported Formats
176+
177+
The elevation endpoint supports:
178+
- `render='json'` - Explicitly request JSON (same as default)
179+
- `render='svg'` - Get SVG diagram
180+
- Unsupported formats raise `ValueError`

pynetbox/core/endpoint.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -668,3 +668,54 @@ def create(self, data=None):
668668
class RODetailEndpoint(DetailEndpoint):
669669
def create(self, data):
670670
raise NotImplementedError("Writes are not supported for this endpoint.")
671+
672+
673+
class ROMultiFormatDetailEndpoint(RODetailEndpoint):
674+
"""Read-only detail endpoint supporting multiple response formats.
675+
676+
Handles endpoints that return data in different formats based on
677+
query parameters. Supports both structured data (JSON) and raw formats
678+
(e.g., SVG).
679+
680+
The endpoint inspects the 'render' parameter to determine response format:
681+
- No parameter or render='json': Returns structured JSON data
682+
- render='svg': Returns raw SVG content
683+
684+
## Examples
685+
686+
```python
687+
rack = nb.dcim.racks.get(123)
688+
rack.elevation.list() # Returns: list of rack unit objects
689+
rack.elevation.list(render='svg') # Returns: SVG string
690+
rack.elevation.list(render='json') # Returns: list of rack unit objects
691+
```
692+
"""
693+
694+
def list(self, **kwargs):
695+
"""Returns data in the requested format.
696+
697+
## Parameters
698+
699+
* **kwargs**: Key/value pairs that get converted into URL
700+
parameters. Supports 'render' parameter for format selection.
701+
702+
## Returns
703+
704+
- If render is non-JSON format: Raw content (string)
705+
- If render is 'json' or absent: Structured data (list/generator)
706+
"""
707+
# Check if non-JSON format requested
708+
render_format = kwargs.get("render")
709+
if render_format == "svg":
710+
# Pass expect_json=False for raw SVG response
711+
req = Request(**self.request_kwargs, expect_json=False).get(
712+
add_params=kwargs
713+
)
714+
# Return raw content for non-JSON formats
715+
return next(req)
716+
717+
if render_format != "json" and render_format is not None:
718+
raise ValueError(f"Unsupported render format: {render_format}")
719+
720+
# Return structured JSON response via parent class
721+
return super().list(**kwargs)

pynetbox/core/query.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ def __init__(
200200
key=None,
201201
token=None,
202202
threading=False,
203+
expect_json=True,
203204
):
204205
"""Instantiates a new Request object.
205206
@@ -211,6 +212,9 @@ def __init__(
211212
In (e.g. /api/dcim/devices/?name='test') 'name': 'test'
212213
would be in the filters dict.
213214
* **key** (int, optional): Database id of the item being queried.
215+
* **expect_json** (bool, optional): If True, expects JSON response
216+
and sets appropriate Accept header. If False, expects raw content
217+
(e.g., SVG, XML) and returns text. Defaults to True.
214218
"""
215219
self.base = self.normalize_url(base)
216220
self.filters = filters or None
@@ -221,6 +225,9 @@ def __init__(
221225
self.threading = threading
222226
self.limit = limit
223227
self.offset = offset
228+
self.expect_json = expect_json
229+
# Response Attributes
230+
self.count = None
224231

225232
def get_openapi(self):
226233
"""Gets the OpenAPI Spec."""
@@ -304,7 +311,12 @@ def _make_call(self, verb="get", url_override=None, add_params=None, data=None):
304311
files = None
305312
# Verbs that support request bodies with file uploads
306313
body_verbs = ("post", "put", "patch")
307-
headers = {"accept": "application/json"}
314+
315+
# Set Accept header based on expected response type
316+
if self.expect_json:
317+
headers = {"accept": "application/json"}
318+
else:
319+
headers = {"accept": "*/*"}
308320

309321
# Extract files from data for applicable verbs
310322
if data is not None and verb in body_verbs:
@@ -316,7 +328,7 @@ def _make_call(self, verb="get", url_override=None, add_params=None, data=None):
316328
)
317329

318330
if should_be_json_body:
319-
headers = {"Content-Type": "application/json"}
331+
headers["Content-Type"] = "application/json"
320332

321333
if self.token:
322334
headers["authorization"] = "Token {}".format(self.token)
@@ -350,10 +362,15 @@ def _make_call(self, verb="get", url_override=None, add_params=None, data=None):
350362
else:
351363
raise RequestError(req)
352364
elif req.ok:
353-
try:
354-
return req.json()
355-
except json.JSONDecodeError:
356-
raise ContentError(req)
365+
# Parse response based on expected type
366+
if self.expect_json:
367+
try:
368+
return req.json()
369+
except json.JSONDecodeError:
370+
raise ContentError(req)
371+
else:
372+
# Return raw text for non-JSON responses
373+
return req.text
357374
else:
358375
raise RequestError(req)
359376

pynetbox/models/dcim.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@
1616

1717
from urllib.parse import urlsplit
1818

19-
from pynetbox.core.endpoint import DetailEndpoint, RODetailEndpoint
19+
from pynetbox.core.endpoint import (
20+
DetailEndpoint,
21+
RODetailEndpoint,
22+
ROMultiFormatDetailEndpoint,
23+
)
2024
from pynetbox.core.query import Request
2125
from pynetbox.core.response import JsonField, Record
2226
from pynetbox.models.circuits import Circuits, CircuitTerminations
@@ -230,22 +234,26 @@ def units(self):
230234
def elevation(self):
231235
"""Represents the ``elevation`` detail endpoint.
232236
233-
Returns a DetailEndpoint object that is the interface for
234-
viewing response from the elevation endpoint updated in
235-
Netbox version 2.8.
237+
Returns a multi-format endpoint supporting both JSON and SVG responses.
238+
The elevation endpoint provides rack unit information and can render
239+
graphical elevation views.
236240
237241
## Returns
238-
DetailEndpoint object.
242+
ROMultiFormatDetailEndpoint object supporting JSON and SVG formats.
239243
240244
## Examples
241245
242246
```python
243247
rack = nb.dcim.racks.get(123)
248+
249+
# Get rack units as JSON (list of RU objects)
244250
rack.elevation.list()
245-
# {"get_facts": {"interface_list": ["ge-0/0/0"]}}
251+
252+
# Get elevation as SVG diagram
253+
svg = rack.elevation.list(render='svg')
246254
```
247255
"""
248-
return RODetailEndpoint(self, "elevation", custom_return=RUs)
256+
return ROMultiFormatDetailEndpoint(self, "elevation", custom_return=RUs)
249257

250258

251259
class Termination(Record):
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""Tests for ROMultiFormatDetailEndpoint."""
2+
3+
import unittest
4+
from unittest.mock import Mock, patch
5+
6+
import pynetbox
7+
from pynetbox.core.endpoint import ROMultiFormatDetailEndpoint
8+
9+
10+
class ROMultiFormatDetailEndpointTestCase(unittest.TestCase):
11+
"""Test cases for ROMultiFormatDetailEndpoint class."""
12+
13+
def setUp(self):
14+
"""Set up test fixtures."""
15+
self.nb = pynetbox.api("http://localhost:8000", token="test-token")
16+
17+
def test_list_returns_json_by_default(self):
18+
"""list() without render parameter returns JSON data."""
19+
with patch(
20+
"pynetbox.core.query.Request._make_call",
21+
return_value={"id": 123, "name": "Test Rack"},
22+
):
23+
rack = self.nb.dcim.racks.get(123)
24+
25+
with patch(
26+
"pynetbox.core.query.Request._make_call",
27+
return_value=[
28+
{"id": 1, "name": "U1"},
29+
{"id": 2, "name": "U2"},
30+
],
31+
):
32+
result = rack.elevation.list()
33+
# Should return generator that yields dict items
34+
result_list = list(result)
35+
self.assertEqual(len(result_list), 2)
36+
# Verify custom_return was applied (RUs objects)
37+
self.assertTrue(hasattr(result_list[0], "id"))
38+
39+
def test_list_with_render_svg_returns_raw_string(self):
40+
"""list(render='svg') returns raw SVG string."""
41+
with patch(
42+
"pynetbox.core.query.Request._make_call",
43+
return_value={"id": 123, "name": "Test Rack"},
44+
):
45+
rack = self.nb.dcim.racks.get(123)
46+
47+
svg_content = '<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>'
48+
with patch(
49+
"pynetbox.core.query.Request._make_call",
50+
return_value=svg_content,
51+
):
52+
result = rack.elevation.list(render="svg")
53+
# Should return raw string, not wrapped in list
54+
self.assertIsInstance(result, str)
55+
self.assertEqual(result, svg_content)
56+
self.assertIn("<svg", result)
57+
58+
def test_list_with_render_json_returns_json_data(self):
59+
"""list(render='json') explicitly returns JSON data."""
60+
with patch(
61+
"pynetbox.core.query.Request._make_call",
62+
return_value={"id": 123, "name": "Test Rack"},
63+
):
64+
rack = self.nb.dcim.racks.get(123)
65+
66+
with patch(
67+
"pynetbox.core.query.Request._make_call",
68+
return_value=[
69+
{"id": 1, "name": "U1"},
70+
{"id": 2, "name": "U2"},
71+
],
72+
):
73+
result = rack.elevation.list(render="json")
74+
# Should return JSON like default behavior
75+
result_list = list(result)
76+
self.assertEqual(len(result_list), 2)
77+
self.assertTrue(hasattr(result_list[0], "id"))
78+
79+
def test_svg_response_not_processed_by_custom_return(self):
80+
"""SVG response bypasses custom_return transformation."""
81+
with patch(
82+
"pynetbox.core.query.Request._make_call",
83+
return_value={"id": 123, "name": "Test Rack"},
84+
):
85+
rack = self.nb.dcim.racks.get(123)
86+
87+
svg_content = "<svg><text>Test</text></svg>"
88+
with patch(
89+
"pynetbox.core.query.Request._make_call",
90+
return_value=svg_content,
91+
):
92+
result = rack.elevation.list(render="svg")
93+
# Result should be raw string, not Record object
94+
self.assertIsInstance(result, str)
95+
self.assertFalse(hasattr(result, "id"))
96+
self.assertFalse(hasattr(result, "serialize"))
97+
98+
def test_empty_response_with_svg(self):
99+
"""Empty SVG response is handled correctly."""
100+
with patch(
101+
"pynetbox.core.query.Request._make_call",
102+
return_value={"id": 123, "name": "Test Rack"},
103+
):
104+
rack = self.nb.dcim.racks.get(123)
105+
106+
with patch(
107+
"pynetbox.core.query.Request._make_call",
108+
return_value="",
109+
):
110+
result = rack.elevation.list(render="svg")
111+
# Should return empty string
112+
self.assertIsInstance(result, str)
113+
self.assertEqual(result, "")
114+
115+
def test_create_raises_not_implemented(self):
116+
"""create() raises NotImplementedError (read-only endpoint)."""
117+
with patch(
118+
"pynetbox.core.query.Request._make_call",
119+
return_value={"id": 123, "name": "Test Rack"},
120+
):
121+
rack = self.nb.dcim.racks.get(123)
122+
123+
# ROMultiFormatDetailEndpoint should be read-only
124+
with self.assertRaises(NotImplementedError):
125+
rack.elevation.create({"data": "test"})
126+
127+
def test_unsupported_render_format_raises_error(self):
128+
"""Unsupported render format raises ValueError."""
129+
with patch(
130+
"pynetbox.core.query.Request._make_call",
131+
return_value={"id": 123, "name": "Test Rack"},
132+
):
133+
rack = self.nb.dcim.racks.get(123)
134+
135+
# Unsupported render formats should raise ValueError
136+
with self.assertRaises(ValueError) as context:
137+
rack.elevation.list(render="png")
138+
139+
self.assertIn("Unsupported render format", str(context.exception))
140+
self.assertIn("png", str(context.exception))
141+
142+
143+
if __name__ == "__main__":
144+
unittest.main()

0 commit comments

Comments
 (0)