aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--firefly_cli/client.py35
-rw-r--r--tests/unit/test_client.py47
2 files changed, 82 insertions, 0 deletions
diff --git a/firefly_cli/client.py b/firefly_cli/client.py
new file mode 100644
index 0000000..719e43e
--- /dev/null
+++ b/firefly_cli/client.py
@@ -0,0 +1,35 @@
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz> GPL-2.0-only
+import json
+import urllib.request
+import urllib.parse
+import urllib.error
+from firefly_cli.errors import ApiError
+
+
+class Client:
+ def __init__(self, url, token, timeout=30):
+ self.url = url.rstrip("/")
+ self.token = token
+ self.timeout = timeout
+
+ def request(self, method, path, params=None, body=None):
+ full = self.url + path
+ if params:
+ full += "?" + urllib.parse.urlencode(params)
+ data = json.dumps(body).encode() if body is not None else None
+ req = urllib.request.Request(full, data=data, method=method)
+ req.add_header("Authorization", f"Bearer {self.token}")
+ req.add_header("Accept", "application/vnd.api+json")
+ if data is not None:
+ req.add_header("Content-Type", "application/json")
+ try:
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
+ raw = resp.read().decode()
+ return json.loads(raw) if raw else {}
+ except urllib.error.HTTPError as e:
+ raw = e.read().decode()
+ try:
+ parsed = json.loads(raw)
+ except ValueError:
+ parsed = {"message": raw or e.reason}
+ raise ApiError(e.code, parsed) from None
diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py
new file mode 100644
index 0000000..e63be08
--- /dev/null
+++ b/tests/unit/test_client.py
@@ -0,0 +1,47 @@
+import json, unittest
+from unittest.mock import patch, MagicMock
+from io import BytesIO
+import urllib.error
+from firefly_cli.client import Client
+from firefly_cli.errors import ApiError
+
+def fake_response(payload, status=200):
+ r = MagicMock()
+ r.read.return_value = json.dumps(payload).encode()
+ r.status = status
+ r.__enter__.return_value = r
+ r.__exit__.return_value = False
+ return r
+
+class TestClient(unittest.TestCase):
+ def setUp(self):
+ self.c = Client("https://f.example", "tok")
+
+ @patch("firefly_cli.client.urllib.request.urlopen")
+ def test_get_builds_url_and_headers(self, urlopen):
+ urlopen.return_value = fake_response({"data": []})
+ out = self.c.request("GET", "/api/v1/accounts", params={"type": "asset"})
+ req = urlopen.call_args[0][0]
+ self.assertEqual(req.full_url, "https://f.example/api/v1/accounts?type=asset")
+ self.assertEqual(req.get_header("Authorization"), "Bearer tok")
+ self.assertEqual(req.get_header("Accept"), "application/vnd.api+json")
+ self.assertEqual(out, {"data": []})
+
+ @patch("firefly_cli.client.urllib.request.urlopen")
+ def test_post_sends_json_body(self, urlopen):
+ urlopen.return_value = fake_response({"data": {"id": "9"}})
+ self.c.request("POST", "/api/v1/transactions", body={"x": 1})
+ req = urlopen.call_args[0][0]
+ self.assertEqual(req.get_method(), "POST")
+ self.assertEqual(json.loads(req.data), {"x": 1})
+ self.assertEqual(req.get_header("Content-type"), "application/json")
+
+ @patch("firefly_cli.client.urllib.request.urlopen")
+ def test_http_error_becomes_apierror(self, urlopen):
+ body = json.dumps({"message": "boom"}).encode()
+ urlopen.side_effect = urllib.error.HTTPError(
+ "u", 422, "Unprocessable", {}, BytesIO(body))
+ with self.assertRaises(ApiError) as ctx:
+ self.c.request("GET", "/api/v1/accounts")
+ self.assertEqual(ctx.exception.status, 422)
+ self.assertEqual(ctx.exception.body["message"], "boom")