diff options
| -rw-r--r-- | firefly_cli/client.py | 35 | ||||
| -rw-r--r-- | tests/unit/test_client.py | 47 |
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") |
