Skip to content
This repository was archived by the owner on May 3, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion TASK.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

## 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.

- [x] Decode command responses into dataclasses in EmergencyManagement client.
- [x] Implement MessagePack decoding in EmergencyManagement client.
- [x] Add loopback link tests for client requests and resource transfer.
- [x] Update generator docs to use Python tooling and note post-generation tweaks.
- [x] Update codec Msgpack test to import from reticulum_openapi.


84 changes: 84 additions & 0 deletions examples/filmology/Server/controllers_filmology.py
Original file line number Diff line number Diff line change
@@ -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
16 changes: 16 additions & 0 deletions examples/filmology/Server/database.py
Original file line number Diff line number Diff line change
@@ -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)
40 changes: 40 additions & 0 deletions examples/filmology/Server/models_filmology.py
Original file line number Diff line number Diff line change
@@ -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"],
}
16 changes: 16 additions & 0 deletions examples/filmology/Server/server_filmology.py
Original file line number Diff line number Diff line change
@@ -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())
20 changes: 20 additions & 0 deletions examples/filmology/Server/service_filmology.py
Original file line number Diff line number Diff line change
@@ -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)
26 changes: 26 additions & 0 deletions examples/filmology/client/client_filmology.py
Original file line number Diff line number Diff line change
@@ -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())
40 changes: 31 additions & 9 deletions examples/filmology/readme.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -21,6 +30,18 @@ The API contract lives in [`API/FilmologyManagement-OAS.yaml`](API/FilmologyMana
pip install openapi-generator-cli
```


2. Start the server in one terminal:

```bash
python Server/server_filmology.py
```

The service prints its identity hash on startup and expects the auth token
`secret` for incoming requests.

3. In another terminal, run the client and supply the hash when prompted:

Or run with Docker:

```bash
Expand Down Expand Up @@ -49,10 +70,11 @@ python server.py

6. In another terminal, run the generated client:


```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.
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ RNS
LXMF
SQLAlchemy
jsonschema
msgpack
pytest
pytest-asyncio
flake8
Expand Down
19 changes: 12 additions & 7 deletions reticulum_openapi/link_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,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


Expand Down Expand Up @@ -109,6 +112,8 @@ def __init__(
closed_callback=self._on_closed,
)
self.link.set_packet_callback(self._handle_packet)
self.packet_queue: asyncio.Queue[bytes] = asyncio.Queue()


def _on_established(self, _link: RNS.Link) -> None:
"""Internal callback when link is established."""
Expand Down
Loading
Loading