From b0d30d861e3653504f524cb1382b7e6811f8af4c Mon Sep 17 00:00:00 2001 From: Corvo <60719165+brothercorvo@users.noreply.github.com> Date: Mon, 11 Aug 2025 15:32:07 -0300 Subject: [PATCH] Switch serialization to MessagePack --- README.md | 2 + TASK.md | 2 + .../filmology/Server/controllers_filmology.py | 84 ++++++++++++++ examples/filmology/Server/database.py | 16 +++ examples/filmology/Server/models_filmology.py | 40 +++++++ examples/filmology/Server/server_filmology.py | 16 +++ .../filmology/Server/service_filmology.py | 20 ++++ examples/filmology/client/client_filmology.py | 26 +++++ examples/filmology/readme.md | 51 ++++----- requirements.txt | 1 + reticulum_openapi/link_client.py | 24 ++-- reticulum_openapi/link_service.py | 10 +- reticulum_openapi/model.py | 39 ++----- reticulum_openapi/service.py | 31 ++--- tests/test_client.py | 6 +- tests/test_client_extra.py | 15 +-- tests/test_example_filmology.py | 106 ++++++++++++++++++ tests/test_model_extra.py | 9 +- tests/test_service.py | 9 +- tests/test_service_extra.py | 32 +++--- 20 files changed, 405 insertions(+), 134 deletions(-) create mode 100644 examples/filmology/Server/controllers_filmology.py create mode 100644 examples/filmology/Server/database.py create mode 100644 examples/filmology/Server/models_filmology.py create mode 100644 examples/filmology/Server/server_filmology.py create mode 100644 examples/filmology/Server/service_filmology.py create mode 100644 examples/filmology/client/client_filmology.py create mode 100644 tests/test_example_filmology.py diff --git a/README.md b/README.md index e74536c..14ee14b 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Reticulum OpenAPI is an experimental framework for building lightweight APIs on This repository contains the Python implementation of the framework as well as documentation, a full featured example and generator templates. The goal is to provide an easy way to build applications that communicate over Reticulum using structured messages. +All payloads are encoded using MessagePack for compact transfer. + The project now also exposes primitives for maintaining persistent links via ``LinkClient`` and ``LinkService`` which allow direct communication over an ``RNS.Link`` in addition to LXMF messaging. diff --git a/TASK.md b/TASK.md index 9ae7664..a2580b9 100644 --- a/TASK.md +++ b/TASK.md @@ -2,3 +2,5 @@ ## 2025-08-11 - [x] Fix flake8 errors across the codebase. +- [x] Add Filmology example service and client with auth tokens and schema validation. +- [x] Replace JSON serialization with MessagePack across service and tests. diff --git a/examples/filmology/Server/controllers_filmology.py b/examples/filmology/Server/controllers_filmology.py new file mode 100644 index 0000000..f8083ca --- /dev/null +++ b/examples/filmology/Server/controllers_filmology.py @@ -0,0 +1,84 @@ +from dataclasses import asdict + +from reticulum_openapi.controller import Controller +from reticulum_openapi.controller import handle_exceptions + +from .database import async_session +from .models_filmology import Movie + + +class MovieController(Controller): + """Handlers for movie operations.""" + + @handle_exceptions + async def CreateMovie(self, req: dict): + """Store a new movie. + + Args: + req (dict): Incoming movie data. + + Returns: + Movie: Persisted movie record. + """ + movie = Movie( + **{k: v for k, v in req.items() if k in Movie.__dataclass_fields__} + ) + async with async_session() as session: + await Movie.create(session, **asdict(movie)) + return movie + + @handle_exceptions + async def RetrieveMovie(self, movie_id: int): + """Fetch a movie by identifier. + + Args: + movie_id (int): Movie identifier. + + Returns: + Movie | None: Retrieved record or None. + """ + async with async_session() as session: + item = await Movie.get(session, movie_id) + return item + + @handle_exceptions + async def DeleteMovie(self, movie_id: int): + """Remove a movie. + + Args: + movie_id (int): Movie identifier. + + Returns: + dict: Deletion status. + """ + async with async_session() as session: + deleted = await Movie.delete(session, movie_id) + return {"status": "deleted" if deleted else "not_found", "id": movie_id} + + @handle_exceptions + async def ListMovie(self): + """List all movies. + + Returns: + list[Movie]: Stored movies. + """ + async with async_session() as session: + items = await Movie.list(session) + return items + + @handle_exceptions + async def PatchMovie(self, req: dict): + """Update an existing movie. + + Args: + req (dict): Movie fields with existing id. + + Returns: + Movie | None: Updated record or None if missing. + """ + movie = Movie( + **{k: v for k, v in req.items() if k in Movie.__dataclass_fields__} + ) + async with async_session() as session: + updated = await Movie.update(session, movie.id, **asdict(movie)) + return updated diff --git a/examples/filmology/Server/database.py b/examples/filmology/Server/database.py new file mode 100644 index 0000000..0c53ab7 --- /dev/null +++ b/examples/filmology/Server/database.py @@ -0,0 +1,16 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.ext.asyncio import async_sessionmaker +from sqlalchemy.ext.asyncio import create_async_engine + +from .models_filmology import Base + +DATABASE_URL = "sqlite+aiosqlite:///filmology.db" + +engine = create_async_engine(DATABASE_URL, echo=False) +async_session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) + + +async def init_db() -> None: + """Create database tables.""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) diff --git a/examples/filmology/Server/models_filmology.py b/examples/filmology/Server/models_filmology.py new file mode 100644 index 0000000..6fac4e7 --- /dev/null +++ b/examples/filmology/Server/models_filmology.py @@ -0,0 +1,40 @@ +from dataclasses import dataclass +from typing import Optional + +from reticulum_openapi.model import BaseModel +from sqlalchemy import Column +from sqlalchemy import Integer +from sqlalchemy import String +from sqlalchemy.orm import declarative_base + +Base = declarative_base() + + +class MovieORM(Base): + __tablename__ = "movies" + id = Column(Integer, primary_key=True) + title = Column(String, nullable=False) + description = Column(String, nullable=True) + + +@dataclass +class Movie(BaseModel): + """Representation of a film.""" + + id: int + title: str + description: Optional[str] = None + + __orm_model__ = MovieORM + + +movie_schema = { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "title": {"type": "string"}, + "description": {"type": "string"}, + "auth_token": {"type": "string"}, + }, + "required": ["id", "title"], +} diff --git a/examples/filmology/Server/server_filmology.py b/examples/filmology/Server/server_filmology.py new file mode 100644 index 0000000..ad6338c --- /dev/null +++ b/examples/filmology/Server/server_filmology.py @@ -0,0 +1,16 @@ +import asyncio + +from .database import init_db +from .service_filmology import FilmologyService + + +async def main() -> None: + """Start the filmology service.""" + await init_db() + async with FilmologyService(auth_token="secret") as svc: + svc.announce() + await asyncio.sleep(30) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/filmology/Server/service_filmology.py b/examples/filmology/Server/service_filmology.py new file mode 100644 index 0000000..042ca43 --- /dev/null +++ b/examples/filmology/Server/service_filmology.py @@ -0,0 +1,20 @@ +from reticulum_openapi.service import LXMFService + +from .controllers_filmology import MovieController +from .models_filmology import movie_schema + + +class FilmologyService(LXMFService): + """Service exposing filmology routes.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + controller = MovieController() + self.add_route( + "CreateMovie", controller.CreateMovie, payload_schema=movie_schema + ) + self.add_route("RetrieveMovie", controller.RetrieveMovie) + self.add_route("DeleteMovie", controller.DeleteMovie) + self.add_route("ListMovie", controller.ListMovie) + self.add_route("PatchMovie", controller.PatchMovie, payload_schema=movie_schema) diff --git a/examples/filmology/client/client_filmology.py b/examples/filmology/client/client_filmology.py new file mode 100644 index 0000000..a842f74 --- /dev/null +++ b/examples/filmology/client/client_filmology.py @@ -0,0 +1,26 @@ +import asyncio + +from reticulum_openapi.client import LXMFClient + +from examples.filmology.Server.models_filmology import Movie + + +async def main() -> None: + """Send sample movie requests.""" + client = LXMFClient(auth_token="secret") + server_id = input("Server Identity Hash: ") + + movie = Movie(id=1, title="Example", description="Demo film") + resp = await client.send_command( + server_id, "CreateMovie", movie, await_response=True + ) + print("Create response:", resp) + + retrieved = await client.send_command( + server_id, "RetrieveMovie", movie.id, await_response=True + ) + print("Retrieve response:", retrieved) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/filmology/readme.md b/examples/filmology/readme.md index 11b9a2c..92df09e 100644 --- a/examples/filmology/readme.md +++ b/examples/filmology/readme.md @@ -1,11 +1,20 @@ # Filmology Example -This directory contains the OpenAPI specification for **Filmology**, a sample -movie catalog service built for the Reticulum mesh network. The specification -demonstrates how to describe CRUD operations for a `Movie` resource before -generating a working service with Reticulum OpenAPI templates. +This example provides a minimal movie catalog built with **Reticulum OpenAPI**. +It demonstrates dataclass models, controllers, and an `LXMFService` that +enforces optional features like authentication tokens and JSON schema +validation. -The API contract lives in [`API/FilmologyManagement-OAS.yaml`](API/FilmologyManagement-OAS.yaml). +The API contract resides in +[`API/FilmologyManagement-OAS.yaml`](API/FilmologyManagement-OAS.yaml). + +## Components + +| Folder | Description | +|-------|-------------| +| `Server/` | Service implementation with dataclasses, controllers and SQLite persistence. | +| `client/` | Simple `LXMFClient` usage that creates and retrieves a movie. | +| `API/` | OpenAPI specification. | ## Running the example @@ -15,35 +24,21 @@ The API contract lives in [`API/FilmologyManagement-OAS.yaml`](API/FilmologyMana pip install -r requirements.txt ``` -2. Ensure you have `openapi-generator-cli` available. Install it via npm: - -```bash -npm install @openapitools/openapi-generator-cli -g -``` - -3. Generate the service and client using the provided templates: +2. Start the server in one terminal: ```bash -openapi-generator-cli generate \ - -g python \ - -i examples/filmology/API/FilmologyManagement-OAS.yaml \ - -t templates \ - -o examples/filmology/FilmologyService +python Server/server_filmology.py ``` -4. Start the generated server: + The service prints its identity hash on startup and expects the auth token + `secret` for incoming requests. -```bash -cd examples/filmology/FilmologyService -python server.py -``` - -5. In another terminal, run the generated client: +3. In another terminal, run the client and supply the hash when prompted: ```bash -python client.py +python client/client_filmology.py ``` -The client sends requests to the server over LXMF messages, showing how movie -records can be created and retrieved across the Reticulum network. - +The client sends a `CreateMovie` request followed by `RetrieveMovie`, showing +both persistence and server-side schema validation. The authentication token is +included in the request payload and must match the server's token. diff --git a/requirements.txt b/requirements.txt index ea02624..bf9773e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ RNS LXMF SQLAlchemy jsonschema +msgpack pytest pytest-asyncio flake8 diff --git a/reticulum_openapi/link_client.py b/reticulum_openapi/link_client.py index c577d68..2880374 100644 --- a/reticulum_openapi/link_client.py +++ b/reticulum_openapi/link_client.py @@ -1,8 +1,5 @@ - """Utilities for working with Reticulum links.""" -import os -from typing import Callable import asyncio import os from dataclasses import asdict @@ -12,12 +9,11 @@ from typing import Optional import RNS -from .model import dataclass_to_json from .model import dataclass_to_json -class LinkFileClient: +class LinkFileClient: """Client helper for sending resources over an established link.""" def __init__( @@ -62,13 +58,16 @@ def _wrapped_callback(resource: RNS.Resource) -> None: if self.on_upload_complete: self.on_upload_complete(resource) - resource = RNS.Resource( - path, - self.link, - metadata=metadata, - callback=_wrapped_callback, - progress_callback=progress_callback, - ) + try: + resource = RNS.Resource( + path, + self.link, + metadata=metadata, + callback=_wrapped_callback, + progress_callback=progress_callback, + ) + except Exception as exc: + raise ValueError(str(exc)) from exc return resource @@ -116,7 +115,6 @@ def __init__( self.packet_queue: asyncio.Queue[bytes] = asyncio.Queue() - def _on_established(self, _link: RNS.Link) -> None: """Internal callback when link is established.""" self.established.set() diff --git a/reticulum_openapi/link_service.py b/reticulum_openapi/link_service.py index 3938acf..64b13ae 100644 --- a/reticulum_openapi/link_service.py +++ b/reticulum_openapi/link_service.py @@ -2,16 +2,18 @@ import asyncio import os +import shutil +from typing import Any +from typing import Awaitable from typing import Callable +from typing import Dict +from typing import Optional -import asyncio -from typing import Any, Awaitable, Dict, Optional import RNS class LinkResourceService: - """Service utilities for receiving resources on a link.""" def __init__( @@ -138,5 +140,3 @@ async def stop(self) -> None: for task in self._keepalive_tasks.values(): task.cancel() self._keepalive_tasks.clear() - - \ No newline at end of file diff --git a/reticulum_openapi/model.py b/reticulum_openapi/model.py index d433bfb..f6faa1c 100644 --- a/reticulum_openapi/model.py +++ b/reticulum_openapi/model.py @@ -3,8 +3,7 @@ from dataclasses import dataclass from dataclasses import fields from dataclasses import is_dataclass -import json -import zlib +import msgpack from typing import Type from typing import TypeVar from typing import Union @@ -27,41 +26,19 @@ def dataclass_to_json(data_obj: T) -> bytes: - """ - Serialize a dataclass instance to a compressed JSON byte string. - """ - # Convert dataclass to dict, then to JSON string + """Serialize a dataclass instance to MessagePack bytes.""" + if is_dataclass(data_obj): data_dict = asdict(data_obj) else: - # If it's already a dict (or primitive), use as is data_dict = data_obj - json_str = json.dumps(data_dict) - # Compress the JSON bytes to minimize payload size - - json_bytes = json_str.encode("utf-8") - # shouldn't this be done at the edge, also probably not great to have the compression logic baked into - - # the logic to go to/from json - compressed = zlib.compress(json_bytes) - return compressed + return msgpack.packb(data_dict, use_bin_type=True) def dataclass_from_json(cls: Type[T], data: bytes) -> T: - """ - Deserialize a dataclass instance from a compressed JSON byte string. - """ - try: - json_bytes = zlib.decompress(data) - except zlib.error: - # Data might not be compressed; use raw bytes if decompression fails - - # Using exception handling as a fallback for an inconsistent and/or + """Deserialize a dataclass instance from MessagePack bytes.""" - # poorly defined interface is bad practice - json_bytes = data - json_str = json_bytes.decode("utf-8") - obj_dict = json.loads(json_str) + obj_dict = msgpack.unpackb(data, raw=False) def _construct(tp, value): origin = get_origin(tp) @@ -97,12 +74,12 @@ class BaseModel: __orm_model__ = None def to_json_bytes(self) -> bytes: - """Serialize this dataclass to compressed JSON bytes.""" + """Serialize this dataclass to MessagePack bytes.""" return dataclass_to_json(self) @classmethod def from_json_bytes(cls: Type[T], data: bytes) -> T: - """Deserialize compressed JSON bytes to a dataclass instance.""" + """Deserialize MessagePack bytes to a dataclass instance.""" return dataclass_from_json(cls, data) def to_orm(self): diff --git a/reticulum_openapi/service.py b/reticulum_openapi/service.py index a544ed5..962b7ff 100644 --- a/reticulum_openapi/service.py +++ b/reticulum_openapi/service.py @@ -1,7 +1,6 @@ # reticulum_openapi/service.py import asyncio -import json -import zlib +import msgpack import RNS import LXMF from typing import Callable @@ -126,21 +125,15 @@ def _lxmf_delivery_callback(self, message: LXMF.LXMessage): return if payload_type: try: - # Parse bytes into the expected dataclass payload_obj = dataclass_from_json(payload_type, payload_bytes) except Exception as e: RNS.log(f"Failed to parse payload for {cmd}: {e}") return else: - # If no type provided, just decode JSON to dict try: - json_bytes = zlib.decompress(payload_bytes) - payload_obj = json.loads(json_bytes.decode("utf-8")) - except zlib.error: - # If not compressed, try directly - payload_obj = json.loads(payload_bytes.decode("utf-8")) + payload_obj = msgpack.unpackb(payload_bytes, raw=False) except Exception as e: - RNS.log(f"Invalid JSON payload for {cmd}: {e}") + RNS.log(f"Invalid MessagePack payload for {cmd}: {e}") return if payload_schema is not None: try: @@ -170,25 +163,19 @@ async def handle_and_reply(): RNS.log(f"Exception in handler for {cmd}: {e}") # If handler returned a result, attempt to send a response back to sender if result is not None: - # Prepare response payload (assume result is serializable or a dataclass) if isinstance(result, bytes): - resp_bytes = result # assume already bytes (e.g., if handler did its own serialization) + resp_bytes = result elif hasattr(result, "__dict__") or isinstance(result, dict): - # Convert dataclass or dict to JSON bytes try: resp_bytes = dataclass_to_json(result) except Exception as e: - # Fallback: just JSON dump the object (it might not be a dataclass) RNS.log(f"Failed to serialize result dataclass: {e}") - resp_bytes = zlib.compress(json.dumps(result).encode("utf-8")) + resp_bytes = msgpack.packb(result, use_bin_type=True) else: - # If result is a simple value (str, number, etc.), wrap it in JSON - resp_bytes = zlib.compress(json.dumps(result).encode("utf-8")) - # Determine response command name (could be something like "_response" or a generic) + resp_bytes = msgpack.packb(result, use_bin_type=True) resp_title = f"{cmd}_response" - dest_identity = message.source # the sender's identity (if available) + dest_identity = message.source if dest_identity: - # Send the response message try: self._send_lxmf(dest_identity, resp_title, resp_bytes) RNS.log(f"Sent response for {cmd} back to sender.") @@ -211,7 +198,7 @@ def _send_lxmf( Internal helper to create and dispatch an LXMF message. :param dest_identity: Destination identity for the message. :param title: Title (command) for the message. - :param content_bytes: Compressed content bytes to send. + :param content_bytes: Content bytes to send. :param propagate: If True, send via propagation (store-and-forward); if False, direct where possible. """ # Create an RNS Destination for the recipient (using LXMF "delivery" namespace) @@ -267,7 +254,7 @@ async def send_message( elif isinstance(payload_obj, bytes): content_bytes = payload_obj else: - # Use dataclass utility to get compressed JSON bytes + # Use dataclass utility to get MessagePack bytes content_bytes = dataclass_to_json(payload_obj) # Use internal send helper self._send_lxmf(dest_identity, command, content_bytes, propagate=propagate) diff --git a/tests/test_client.py b/tests/test_client.py index 4f88692..2e97757 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,6 +1,7 @@ import asyncio from dataclasses import dataclass from types import SimpleNamespace +import msgpack import pytest from reticulum_openapi import client as client_module @@ -137,10 +138,7 @@ def fake_dataclass_to_json(obj): await cli.send_command("aa", "CMD", Sample(text="hello"), await_response=False) - import json - import zlib - - payload = json.loads(zlib.decompress(captured["content"]).decode()) + payload = msgpack.unpackb(captured["content"], raw=False) assert payload.get("auth_token") == "secret" assert payload.get("text") == "hello" assert call_counter["count"] == 1 diff --git a/tests/test_client_extra.py b/tests/test_client_extra.py index 11e103a..4744de0 100644 --- a/tests/test_client_extra.py +++ b/tests/test_client_extra.py @@ -1,6 +1,6 @@ import asyncio from types import SimpleNamespace - +import msgpack import pytest from reticulum_openapi import client as client_module @@ -72,7 +72,9 @@ async def fast_sleep(_): monkeypatch.setattr(client_module.RNS.Transport, "has_path", has_path) monkeypatch.setattr(client_module.RNS.Transport, "request_path", lambda d: None) monkeypatch.setattr(client_module.asyncio, "sleep", fast_sleep) - monkeypatch.setattr(client_module.RNS.Identity, "recall", lambda h, create=False: object()) + monkeypatch.setattr( + client_module.RNS.Identity, "recall", lambda h, create=False: object() + ) class FakeDestination: OUT = object() @@ -108,7 +110,9 @@ async def test_send_command_dict_payload(monkeypatch): cli.timeout = 0.2 monkeypatch.setattr(client_module.RNS.Transport, "has_path", lambda dest: True) - monkeypatch.setattr(client_module.RNS.Identity, "recall", lambda h, create=False: object()) + monkeypatch.setattr( + client_module.RNS.Identity, "recall", lambda h, create=False: object() + ) class FakeDestination: OUT = object() @@ -137,10 +141,7 @@ def fake_dataclass_to_json(obj): await cli.send_command("aa", "CMD", {"x": 1}, await_response=False) - import json - import zlib - - payload = json.loads(zlib.decompress(captured["content"]).decode()) + payload = msgpack.unpackb(captured["content"], raw=False) assert payload["x"] == 1 assert payload["auth_token"] == "secret" assert captured["obj"]["x"] == 1 diff --git a/tests/test_example_filmology.py b/tests/test_example_filmology.py new file mode 100644 index 0000000..a3181a2 --- /dev/null +++ b/tests/test_example_filmology.py @@ -0,0 +1,106 @@ +import asyncio +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from reticulum_openapi.model import dataclass_to_json +from reticulum_openapi.service import LXMFService + +from examples.filmology.Server.models_filmology import Movie +from examples.filmology.Server.models_filmology import movie_schema + + +@pytest.mark.asyncio +async def test_create_movie_success() -> None: + """Valid payload is dispatched to the handler.""" + loop = asyncio.get_running_loop() + svc = LXMFService.__new__(LXMFService) + svc._loop = loop + svc.auth_token = "secret" + svc.max_payload_size = 32000 + svc._send_lxmf = Mock() + + received = {} + + async def handler(payload): + received["movie"] = Movie( + **{k: v for k, v in payload.items() if k in Movie.__dataclass_fields__} + ) + + svc._routes = {"CreateMovie": (handler, None, movie_schema)} + + payload = {"id": 1, "title": "Test", "auth_token": "secret"} + message = SimpleNamespace( + title="CreateMovie", + content=dataclass_to_json(payload), + source=None, + ) + + svc._lxmf_delivery_callback(message) + await asyncio.sleep(0.1) + + assert isinstance(received["movie"], Movie) + assert received["movie"].id == 1 + + +@pytest.mark.asyncio +async def test_create_movie_schema_validation() -> None: + """Payload failing schema is rejected.""" + loop = asyncio.get_running_loop() + svc = LXMFService.__new__(LXMFService) + svc._loop = loop + svc.auth_token = "secret" + svc.max_payload_size = 32000 + svc._send_lxmf = Mock() + + called = False + + async def handler(_payload): + nonlocal called + called = True + + svc._routes = {"CreateMovie": (handler, None, movie_schema)} + + invalid = {"id": "bad", "title": "Test", "auth_token": "secret"} + message = SimpleNamespace( + title="CreateMovie", + content=dataclass_to_json(invalid), + source=None, + ) + + svc._lxmf_delivery_callback(message) + await asyncio.sleep(0.1) + + assert not called + + +@pytest.mark.asyncio +async def test_create_movie_auth_failure() -> None: + """Missing or wrong auth token prevents dispatch.""" + loop = asyncio.get_running_loop() + svc = LXMFService.__new__(LXMFService) + svc._loop = loop + svc.auth_token = "secret" + svc.max_payload_size = 32000 + svc._send_lxmf = Mock() + + called = False + + async def handler(_payload): + nonlocal called + called = True + + svc._routes = {"CreateMovie": (handler, None, movie_schema)} + + payload = {"id": 1, "title": "Test", "auth_token": "wrong"} + message = SimpleNamespace( + title="CreateMovie", + content=dataclass_to_json(payload), + source=None, + ) + + svc._lxmf_delivery_callback(message) + await asyncio.sleep(0.1) + + assert not called diff --git a/tests/test_model_extra.py b/tests/test_model_extra.py index ddf0a8d..6311205 100644 --- a/tests/test_model_extra.py +++ b/tests/test_model_extra.py @@ -1,4 +1,3 @@ -import json from dataclasses import dataclass from typing import Union @@ -57,8 +56,8 @@ class Bike: Vehicle = Union[Car, Bike] -def test_dataclass_from_json_uncompressed(): - data = json.dumps({"name": "foo", "value": 1}).encode() +def test_dataclass_from_json_roundtrip(): + data = dataclass_to_json({"name": "foo", "value": 1}) obj = dataclass_from_json(Simple, data) assert obj == Simple(name="foo", value=1) @@ -101,7 +100,9 @@ async def test_methods_without_orm_raise(): @pytest.mark.asyncio async def test_crud_edge_cases(): engine = create_async_engine("sqlite+aiosqlite:///:memory:") - session_maker = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) + session_maker = async_sessionmaker( + engine, expire_on_commit=False, class_=AsyncSession + ) async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) async with session_maker() as session: diff --git a/tests/test_service.py b/tests/test_service.py index 4a1d21b..4f470e7 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -1,10 +1,9 @@ import asyncio -import json -import zlib from dataclasses import dataclass from types import SimpleNamespace from unittest.mock import Mock +import msgpack import pytest from reticulum_openapi.service import LXMFService @@ -70,7 +69,7 @@ async def handler(payload): valid_msg = SimpleNamespace( title="SCHEMA", - content=zlib.compress(json.dumps({"num": 5}).encode()), + content=dataclass_to_json({"num": 5}), source=None, ) service._lxmf_delivery_callback(valid_msg) @@ -80,7 +79,7 @@ async def handler(payload): called = False invalid_msg = SimpleNamespace( title="SCHEMA", - content=zlib.compress(json.dumps({"num": "bad"}).encode()), + content=dataclass_to_json({"num": "bad"}), source=None, ) service._lxmf_delivery_callback(invalid_msg) @@ -115,7 +114,7 @@ async def handler(): dest, title, payload_bytes = send_mock.call_args.args[:3] assert dest is src assert title == "PING_response" - assert json.loads(zlib.decompress(payload_bytes).decode()) == {"status": "ok"} + assert msgpack.unpackb(payload_bytes, raw=False) == {"status": "ok"} def test_get_api_specification_returns_registered_routes(): diff --git a/tests/test_service_extra.py b/tests/test_service_extra.py index cd9af16..ba565fe 100644 --- a/tests/test_service_extra.py +++ b/tests/test_service_extra.py @@ -1,12 +1,12 @@ import asyncio -import json -import zlib from types import SimpleNamespace from unittest.mock import Mock + import pytest -from reticulum_openapi import service as service_module from dataclasses import dataclass +from reticulum_openapi import service as service_module +from reticulum_openapi.model import dataclass_to_json @dataclass @@ -93,6 +93,7 @@ def __init__(self, dest, src, content, title): self.src = src self.content = content self.title = title + monkeypatch.setattr(service_module.RNS, "Destination", FakeDestination) monkeypatch.setattr(service_module.LXMF, "LXMessage", FakeLXMessage) svc._send_lxmf(object(), "CMD", b"data") @@ -140,7 +141,7 @@ async def test_context_manager(monkeypatch): @pytest.mark.asyncio async def test_init_and_add_route(monkeypatch): class FakeReticulum: - storagepath = '/tmp' + storagepath = "/tmp" def __init__(self, config_path=None): pass @@ -149,7 +150,7 @@ class FakeIdentity: def __init__(self): - self.hash = b'h' + self.hash = b"h" class FakeRNS: Reticulum = FakeReticulum @@ -161,7 +162,7 @@ def log(*a, **k): @staticmethod def prettyhexrep(x): - return 'h' + return "h" class FakeLXMRouter: @@ -177,12 +178,13 @@ def register_delivery_identity(self, ident, display_name=None, stamp_cost=0): class FakeLXMF: LXMRouter = FakeLXMRouter LXMessage = object - monkeypatch.setattr(service_module, 'RNS', FakeRNS) - monkeypatch.setattr(service_module, 'LXMF', FakeLXMF) + + monkeypatch.setattr(service_module, "RNS", FakeRNS) + monkeypatch.setattr(service_module, "LXMF", FakeLXMF) svc = service_module.LXMFService() assert isinstance(svc.router, FakeLXMRouter) - svc.add_route('PING', lambda: None) - assert 'PING' in svc._routes + svc.add_route("PING", lambda: None) + assert "PING" in svc._routes @pytest.mark.asyncio @@ -205,7 +207,7 @@ def test_lxmf_delivery_callback_no_route(monkeypatch): assert any("No route" in m for m in logs) -def test_lxmf_delivery_invalid_json(monkeypatch): +def test_lxmf_delivery_invalid_msgpack(monkeypatch): async def handler(payload): return None @@ -216,10 +218,10 @@ async def handler(payload): svc.auth_token = None logs = [] monkeypatch.setattr(service_module.RNS, "log", lambda msg: logs.append(msg)) - bad = zlib.compress(b"not-json") + bad = b"not-msgpack" message = SimpleNamespace(title="CMD", content=bad) svc._lxmf_delivery_callback(message) - assert any("Invalid JSON" in m for m in logs) + assert any("Invalid MessagePack" in m for m in logs) @pytest.mark.asyncio @@ -236,7 +238,7 @@ async def handler(payload): monkeypatch.setattr( svc._loop, "call_soon_threadsafe", lambda fn: called.update(flag=True) ) - payload = zlib.compress(json.dumps({"auth_token": "wrong"}).encode()) + payload = dataclass_to_json({"auth_token": "wrong"}) message = SimpleNamespace(title="CMD", content=payload) svc._lxmf_delivery_callback(message) assert called["flag"] is False @@ -255,7 +257,7 @@ async def handler(payload): monkeypatch.setattr(service_module.RNS, "log", lambda *a, **k: None) message = SimpleNamespace( title="CMD", - content=zlib.compress(json.dumps({}).encode()), + content=dataclass_to_json({}), source=None, ) svc._send_lxmf = Mock()