This document captures the significant design decisions made in building
opennms-api-wrapper, the rationale behind each, and their tradeoffs. It
is intended to serve both as institutional memory and as a guide for
contributors evaluating future changes.
Format loosely follows Nygard ADRs: Context → Decision → Consequences.
Accepted
The OpenNMS REST API covers 35+ resource groups (alarms, nodes, flows, metadata, …) yielding several hundred public methods. Placing all methods in one class produces a 5 000-line file that is hard to navigate, hard to test in isolation, and impossible to maintain incrementally. The alternatives were:
| Option | Description |
|---|---|
| Flat module functions | opennms.get_alarms(client, ...) — functional style, no class |
| One class, one file | OpenNMS with all 250 methods in a single source file |
| Resource sub-clients | client.alarms.get(...), client.nodes.get(...) |
| Mixin-per-resource | One mixin class per resource group, assembled via multiple inheritance into one OpenNMS class |
Mixin-per-resource. Each API resource group lives in its own file
(_alarms.py, _nodes.py, …). client.py assembles them with multiple
inheritance, exposing a single flat namespace to callers.
Pros
- Each mixin is 50–230 lines and covers exactly one coherent concern.
- Adding a new resource group is additive: new file, new mixin, one line in
client.py. No existing code changes. - Tests map 1-to-1:
test_alarms.pytests onlyAlarmsMixin. - The public API is a flat, discoverable namespace:
client.get_alarms(),client.get_nodes()— no sub-object navigation required.
Cons
- Python's MRO is non-obvious to contributors unfamiliar with multiple inheritance. A name collision between two mixins silently shadows one method; there is no compile-time detection.
- The large inheritance chain can confuse IDE introspection and some
documentation generators (though mkdocstrings handles it correctly via
inherited_members: true). - Discipline on method naming (
get_alarm_*,get_node_*) is enforced only by convention, not the language.
Mitigations in place
- Method names are prefixed by resource group, making accidental collision very unlikely.
CLAUDE.mddocuments the convention explicitly for future contributors.
Accepted
The OpenNMS v1 REST API historically required XML for many write operations
(create node, create foreign source, create user, …) and returned XML by
default. The v2 API is JSON-first. Modern Horizon releases (30+) accept
JSON on all endpoints via Accept: application/json /
Content-Type: application/json.
Set Accept: application/json and Content-Type: application/json as
session-level defaults. Emit and parse JSON exclusively. No XML handling
code anywhere in the library.
Pros
- Eliminates an entire class of code (
xml.etree,lxml, schema validation, namespace handling). - Callers work with plain Python dicts and lists — no DOM traversal.
- Tests are straightforward: mock responses return Python dicts; assertions compare plain values.
- Keeps the library at one runtime dependency (
requests).
Cons
- Callers on Horizon < 30 may receive HTTP 415 (Unsupported Media Type) on certain write endpoints. This is documented in the affected method docstrings with a suggested workaround.
- No fallback path: there is no way to opt into XML without forking the library.
Risk level Low. All Horizon 30+ instances (released 2022) support JSON fully. Horizon < 30 is end-of-life.
Accepted
The library needs to make authenticated HTTP calls, parse responses, and handle redirects and SSL. Options included:
| Option | Notes |
|---|---|
urllib3 / http.client |
Stdlib-adjacent; verbose; no session/auth helpers |
requests |
Mature, ubiquitous, synchronous |
httpx |
Modern, supports both sync and async |
aiohttp |
Async-only |
requests only, pinned to >= 2.28 (2022).
Pros
requestsis already present in virtually every Python environment.- No dependency conflicts for callers who also use
requests. - Well-understood by contributors; extensive documentation and Stack Overflow coverage.
requests.Sessionprovides connection pooling, auth, and default headers for free.
Cons
- Synchronous only: callers who need concurrency must use
ThreadPoolExecutoror run multiple client instances. httpxwould have enabled async support with minimal additional complexity; that door is now harder to open without a breaking API change.- If the ecosystem migrates to
httpx-based tooling, this becomes a soft incompatibility (two HTTP stacks in one environment).
Accepted
REST API wrappers are commonly available in both sync and async variants.
Async variants add implementation complexity (two code paths or a sync-to-
async bridge like anyio) and require callers to run an event loop.
Synchronous only. No async def, no asyncio, no anyio.
Pros
- Zero async complexity in the implementation.
- Works in any context: scripts, Django views, Flask routes, Jupyter notebooks, CLI tools — without an event loop.
- Easier to test: no
pytest-asyncio, noasyncio.run()boilerplate.
Cons
- A caller issuing 50 API calls sequentially pays 50× the round-trip latency.
The workaround (
ThreadPoolExecutor) is effective but requires caller knowledge. - Libraries built on
asyncio(FastAPI background tasks, etc.) cannotawaitthese calls naturally.
Intended use case Ops scripts, monitoring integrations, and data pipelines — workloads where sequential simplicity outweighs async throughput.
Accepted
OpenNMS endpoints return three distinct response shapes:
application/json— a dict or listtext/plain— a plain integer (count endpoints like/rest/alarms/count)- Empty body with HTTP 204 — write operations that return no content
Without a central handler, each method would need to inspect Content-Type
and handle error status codes individually.
All HTTP verbs route through a single _parse(resp) method on the base
class. It calls raise_for_status(), then branches on Content-Type and
body presence. Callers receive a Python object or None; they never see a
Response object.
Pros
- Error handling (
raise_for_status) is guaranteed for every call — callers cannot accidentally ignore HTTP error status codes. - Integer parsing (count endpoints) is handled transparently.
- 204 responses return
Nonerather than raising or returning an empty string; callers can testif result is not None. - The entire parsing contract is tested in one place.
Cons
- Callers cannot access response headers, status codes, or raw bytes. If an
endpoint needs header inspection (e.g.
Locationon a 201 Created), the library cannot surface it without adding a new return path. - The fallback (
try: resp.json()) masks cases where a server returns unexpectedtext/htmlerror pages with a 200 status; the dict will be returned but will look wrong to the caller.
Accepted
OpenNMS v1 list endpoints accept an open-ended set of Hibernate property
filters (severity, alarm.uei, node.label, ipInterface.ipAddress, …)
plus a comparator modifier. The full set of valid keys is not enumerated
in the API documentation and varies by resource.
List methods accept **filters and merge them directly into the query
parameter dict without validation.
client.get_alarms(severity="MAJOR", node.label="router01")Pros
- No maintenance burden of enumerating and validating filter keys per resource — a list that would grow stale with each OpenNMS release.
- Any filter key OpenNMS accepts works immediately without a library update.
- Callers with deep OpenNMS knowledge can use advanced filters
(
comparator=ilike) that the library author may not have anticipated.
Cons
- Typos are silently swallowed.
get_alarms(severiy="MAJOR")returns all alarms with no error. - IDE auto-complete cannot suggest valid filter keys.
- The
**filterssignature communicates nothing about what filters exist; callers must consult OpenNMS documentation.
Mitigations in place
- Docstrings list the most common filter keys for each resource group.
- The smoke test exercises real servers and will surface bad filters implicitly (wrong result counts).
Accepted
OpenNMS exposes two API generations at different base paths:
- v1:
/opennms/rest/ - v2:
/opennms/api/v2/
Several resources appear in both (alarms, IP interfaces, SNMP interfaces); a few are v2-only (situations, business services, discovery, metadata).
A single _OpenNMSBase instance holds both base URLs. All private HTTP
helpers (_get, _post, …) accept an optional v2: bool = False parameter
that selects the base URL. v2-specific methods call their helper with
v2=True; the caller never sees the URL.
Pros
- One client object serves the entire API surface; callers do not need to instantiate two clients.
- URL construction is centralised in
_url(); changing the path structure requires editing one method. - v2 methods are clearly identified in the source by their
v2=Truecall and by the_v2suffix in their names (get_alarms_v2,get_alarm_v2).
Cons
- The
v2flag leaks an implementation detail into every private method signature. - If a future v3 API emerges, this pattern extends awkwardly (a
v3=Trueflag alongsidev2=True).
Alternative not taken
Separate _v1_get / _v2_get helpers were rejected as more verbose with no
meaningful benefit at the current API scale.
Accepted
Testing an HTTP wrapper requires either a live server, a local stub server (e.g. WireMock), or an HTTP mocking library. Each approach has different tradeoffs for speed, reliability, and setup overhead.
Use the responses library to intercept requests calls at the adapter
level. Every test registers expected URLs and response bodies; no network
traffic occurs. Fixture shapes in tests/fixtures.py are derived from the
actual OpenNMS Horizon 35 JavaDoc field names.
Pros
- Tests run in ~0.2 s with no external dependencies.
- Tests are fully deterministic; they cannot fail due to server state.
- CI requires no OpenNMS instance; any Python environment can run the suite.
- Fixture shapes document the real API response contracts.
Cons
- Fixtures can drift from the live API as OpenNMS evolves; the test suite will still pass even if a field is renamed upstream.
- Tests verify the library's request construction and response parsing but not whether the server actually accepts those requests.
- There is no contract test: if OpenNMS changes a response shape in a minor release, the library breaks silently until a human notices.
Mitigation
smoke_test.py runs all getters against a real server. It is intended to
be run against a dev or staging instance before each release.
Accepted / Implemented in v0.1.0
requests does not set a default socket timeout. A call to a slow or
unresponsive server will block the calling thread indefinitely.
Added a timeout parameter to _OpenNMSBase.__init__ and OpenNMS.__init__
defaulting to 30 seconds. Every _session.get/post/put/delete call passes
timeout=self._timeout.
def __init__(self, url, username, password, verify_ssl=True, timeout=30):
...
self._timeout = timeout
def _get(self, path, params=None, v2=False):
resp = self._session.get(self._url(path, v2),
params=params, timeout=self._timeout)
return self._parse(resp)Pros
- Threads cannot hang indefinitely on a slow or unresponsive server.
- Callers who need a longer timeout can pass
timeout=N;timeout=Nonerestores unbounded behaviour.
Cons
- Long-running bulk operations or slow servers may now surface
requests.exceptions.Timeout; callers must set a highertimeoutif needed.
Accepted — supersedes earlier "No retry" decision
HTTP clients in production environments commonly need to retry on transient
errors (500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable,
504 Gateway Timeout, connection resets). The OpenNMS REST API occasionally
returns transient 500s under load. requests uses urllib3 under the hood,
which provides a built-in Retry class with exponential backoff — no new
dependencies required.
_OpenNMSBase.__init__ accepts a retries parameter (default 3). When
retries > 0, a urllib3.util.retry.Retry adapter is mounted on the session
for both http:// and https://.
if retries > 0:
retry = Retry(
total=retries,
backoff_factor=0.5,
status_forcelist=(500, 502, 503, 504),
allowed_methods=None,
raise_on_status=False,
)
adapter = HTTPAdapter(max_retries=retry)
self._session.mount("https://", adapter)
self._session.mount("http://", adapter)Key parameters:
backoff_factor=0.5→ delays of 0.5 s, 1 s, 2 s (3.5 s total max wait).status_forcelist=(500, 502, 503, 504)→ retry on transient server errors.allowed_methods=None→ retry all HTTP methods (OpenNMS REST operations are functionally idempotent).raise_on_status=False→ after retries are exhausted, the last response is returned so_parse()→raise_for_status()raisesHTTPErroras before (backwards-compatible error behaviour).- Pass
retries=0to disable and get the old one-request-per-call behaviour.
Pros
- Transient 500s and connection resets are handled transparently — all consumers (including the smoke test) benefit automatically.
- No new runtime dependency;
urllib3ships withrequests. - Opt-out is trivial:
retries=0.
Cons
- Non-idempotent side effects (e.g. creating a resource) could in theory execute twice if the server processes the request but the response is lost. In practice, OpenNMS REST endpoints either return the same result or reject duplicates (requisition nodes keyed by foreign-id, acks keyed by alarm-id).
- Adds up to 3.5 s of hidden latency on a truly broken endpoint before the caller sees the error.
Accepted / Implemented in v0.1.0
Removed the hardcoded __version__ string from __init__.py. Version is
now derived at runtime from installed package metadata:
from importlib.metadata import version, PackageNotFoundError
try:
__version__ = version("opennms-api-wrapper")
except PackageNotFoundError:
__version__ = "unknown" # running directly from source, not installedpyproject.toml is the single source of truth.
- One fewer manual step on each release — only
pyproject.tomlneeds updating. __version__is always accurate when the package is installed; returns"unknown"only when running directly from an uninstalled source tree.
| ADR | Decision | Primary benefit | Primary cost |
|---|---|---|---|
| 001 | Mixin per resource | Incremental, isolated | MRO non-obvious |
| 002 | JSON-only | Eliminates XML complexity | HTTP 415 on old servers |
| 003 | requests only |
Ubiquitous, zero conflict | No native async |
| 004 | Sync only | Simple, works everywhere | Latency under concurrency |
| 005 | Unified _parse() |
Consistent error handling | No header/status access |
| 006 | **filters passthrough |
Future-proof, zero maintenance | Typos are silent |
| 007 | v2=True flag |
One client, full surface | Flag leaks into every helper |
| 008 | Mocked HTTP tests | Fast, deterministic, portable | Fixtures can drift |
| 009 | timeout=30 default |
Threads cannot hang indefinitely | Long ops may need higher timeout |
| 010 | Retry w/ backoff (default 3) | Transient 500s handled transparently | Up to 3.5 s hidden latency |
| 011 | importlib.metadata version |
Single source of truth | Returns unknown from uninstalled source |