mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-20 21:18:13 +02:00
feat: refine Obsidian plugin routes and schemas for improved device management and API stability
This commit is contained in:
parent
60d9e7ed8c
commit
b5c9388c8a
9 changed files with 182 additions and 385 deletions
|
|
@ -1,31 +1,8 @@
|
|||
"""
|
||||
Obsidian plugin ingestion routes.
|
||||
"""Obsidian plugin ingestion routes (``/api/v1/obsidian/*``).
|
||||
|
||||
This is the public surface that the SurfSense Obsidian plugin
|
||||
(``surfsense_obsidian/``) speaks to. It is a separate router from the
|
||||
legacy server-path Obsidian connector — the legacy code stays in place
|
||||
until the ``obsidian-legacy-cleanup`` plan ships.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
- ``GET /api/v1/obsidian/health`` — version handshake
|
||||
- ``POST /api/v1/obsidian/connect`` — register or get a vault row
|
||||
- ``POST /api/v1/obsidian/sync`` — batch upsert
|
||||
- ``POST /api/v1/obsidian/rename`` — batch rename
|
||||
- ``DELETE /api/v1/obsidian/notes`` — batch soft-delete
|
||||
- ``GET /api/v1/obsidian/manifest`` — reconcile manifest
|
||||
|
||||
Auth contract
|
||||
-------------
|
||||
|
||||
Every endpoint requires ``Depends(current_active_user)`` — the same JWT
|
||||
bearer the rest of the API uses; future PAT migration is transparent.
|
||||
|
||||
API stability is provided by the ``/api/v1/...`` URL prefix and the
|
||||
``capabilities`` array advertised on ``/health`` (additive only). There
|
||||
is no plugin-version gate; "your plugin is out of date" notices are
|
||||
delegated to Obsidian's built-in community-store updater.
|
||||
Wire surface for the ``surfsense_obsidian/`` plugin. API stability is the
|
||||
``/api/v1/`` prefix plus the additive ``capabilities`` array on /health;
|
||||
no plugin-version gate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -67,14 +44,10 @@ logger = logging.getLogger(__name__)
|
|||
router = APIRouter(prefix="/obsidian", tags=["obsidian-plugin"])
|
||||
|
||||
|
||||
# Bumped manually whenever the wire contract gains a non-additive change.
|
||||
# Additive (extra='ignore'-safe) changes do NOT bump this.
|
||||
# Bumped only on non-additive wire changes; additive ones ride extra='ignore'.
|
||||
OBSIDIAN_API_VERSION = "1"
|
||||
|
||||
# Capabilities advertised on /health and /connect. Plugins use this list
|
||||
# for feature gating ("does this server understand attachments_v2?"). Add
|
||||
# new strings, never rename/remove existing ones — older plugins ignore
|
||||
# unknown entries safely.
|
||||
# Plugins feature-gate on these. Add entries, never rename or remove.
|
||||
OBSIDIAN_CAPABILITIES: list[str] = ["sync", "rename", "delete", "manifest"]
|
||||
|
||||
|
||||
|
|
@ -90,18 +63,41 @@ def _build_handshake() -> dict[str, object]:
|
|||
}
|
||||
|
||||
|
||||
def _upsert_device(
|
||||
existing_devices: object,
|
||||
device_id: str,
|
||||
now_iso: str,
|
||||
) -> dict[str, dict[str, str]]:
|
||||
"""Upsert ``device_id`` into ``{device_id: {first_seen_at, last_seen_at}}``.
|
||||
|
||||
Keyed by device_id for O(1) dedup; ``len(devices)`` is the count.
|
||||
Timestamps are kept for a future stale-device pruner.
|
||||
"""
|
||||
devices: dict[str, dict[str, str]] = {}
|
||||
if isinstance(existing_devices, dict):
|
||||
for key, val in existing_devices.items():
|
||||
if not isinstance(key, str) or not key or not isinstance(val, dict):
|
||||
continue
|
||||
devices[key] = {
|
||||
"first_seen_at": str(val.get("first_seen_at") or now_iso),
|
||||
"last_seen_at": str(val.get("last_seen_at") or now_iso),
|
||||
}
|
||||
|
||||
prev = devices.get(device_id)
|
||||
devices[device_id] = {
|
||||
"first_seen_at": prev["first_seen_at"] if prev else now_iso,
|
||||
"last_seen_at": now_iso,
|
||||
}
|
||||
return devices
|
||||
|
||||
|
||||
async def _resolve_vault_connector(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
user: User,
|
||||
vault_id: str,
|
||||
) -> SearchSourceConnector:
|
||||
"""Find the OBSIDIAN_CONNECTOR row that owns ``vault_id`` for this user.
|
||||
|
||||
Looked up by the (user_id, connector_type, config['vault_id']) tuple
|
||||
so users can have multiple vaults each backed by its own connector
|
||||
row (one per search space).
|
||||
"""
|
||||
"""Find the OBSIDIAN_CONNECTOR row that owns ``vault_id`` for this user."""
|
||||
result = await session.execute(
|
||||
select(SearchSourceConnector).where(
|
||||
and_(
|
||||
|
|
@ -136,12 +132,7 @@ async def _ensure_search_space_access(
|
|||
user: User,
|
||||
search_space_id: int,
|
||||
) -> SearchSpace:
|
||||
"""Confirm the user owns the requested search space.
|
||||
|
||||
Plugin currently does not support shared search spaces (RBAC roles)
|
||||
— that's a follow-up. Restricting to owner-only here keeps the
|
||||
surface narrow and avoids leaking other members' connectors.
|
||||
"""
|
||||
"""Owner-only access to the search space (shared spaces are a follow-up)."""
|
||||
result = await session.execute(
|
||||
select(SearchSpace).where(
|
||||
and_(SearchSpace.id == search_space_id, SearchSpace.user_id == user.id)
|
||||
|
|
@ -168,11 +159,7 @@ async def _ensure_search_space_access(
|
|||
async def obsidian_health(
|
||||
user: User = Depends(current_active_user),
|
||||
) -> HealthResponse:
|
||||
"""Return the API contract handshake.
|
||||
|
||||
The plugin calls this once per ``onload`` and caches the result for
|
||||
capability-gating decisions.
|
||||
"""
|
||||
"""Return the API contract handshake; plugin caches it per onload."""
|
||||
return HealthResponse(
|
||||
**_build_handshake(),
|
||||
server_time_utc=datetime.now(UTC),
|
||||
|
|
@ -187,9 +174,9 @@ async def obsidian_connect(
|
|||
) -> ConnectResponse:
|
||||
"""Register a vault, or return the existing connector row.
|
||||
|
||||
Idempotent on the (user_id, OBSIDIAN_CONNECTOR, vault_id) tuple so
|
||||
re-installing the plugin or reconnecting from a new device picks up
|
||||
the same connector — and therefore the same documents.
|
||||
Idempotent on (user_id, OBSIDIAN_CONNECTOR, vault_id). Called on every
|
||||
plugin onload as a heartbeat — upserts ``device_id`` into
|
||||
``config['devices']`` so the web UI can show a "Devices: N" tile.
|
||||
"""
|
||||
await _ensure_search_space_access(
|
||||
session, user=user, search_space_id=payload.search_space_id
|
||||
|
|
@ -215,27 +202,31 @@ async def obsidian_connect(
|
|||
|
||||
if existing is not None:
|
||||
cfg = dict(existing.config or {})
|
||||
devices = _upsert_device(cfg.get("devices"), payload.device_id, now_iso)
|
||||
cfg.update(
|
||||
{
|
||||
"vault_id": payload.vault_id,
|
||||
"vault_name": payload.vault_name,
|
||||
"source": "plugin",
|
||||
"plugin_version": payload.plugin_version,
|
||||
"device_id": payload.device_id,
|
||||
"devices": devices,
|
||||
"device_count": len(devices),
|
||||
"last_connect_at": now_iso,
|
||||
}
|
||||
)
|
||||
if payload.device_label:
|
||||
cfg["device_label"] = payload.device_label
|
||||
cfg.pop("legacy", None)
|
||||
cfg.pop("vault_path", None)
|
||||
existing.config = cfg
|
||||
# Re-stamp on every connect so vault renames in Obsidian propagate;
|
||||
# the web UI hides the Name input for Obsidian connectors.
|
||||
existing.name = f"Obsidian — {payload.vault_name}"
|
||||
existing.is_indexable = False
|
||||
existing.search_space_id = payload.search_space_id
|
||||
await session.commit()
|
||||
await session.refresh(existing)
|
||||
connector = existing
|
||||
else:
|
||||
devices = _upsert_device(None, payload.device_id, now_iso)
|
||||
connector = SearchSourceConnector(
|
||||
name=f"Obsidian — {payload.vault_name}",
|
||||
connector_type=SearchSourceConnectorType.OBSIDIAN_CONNECTOR,
|
||||
|
|
@ -245,8 +236,8 @@ async def obsidian_connect(
|
|||
"vault_name": payload.vault_name,
|
||||
"source": "plugin",
|
||||
"plugin_version": payload.plugin_version,
|
||||
"device_id": payload.device_id,
|
||||
"device_label": payload.device_label,
|
||||
"devices": devices,
|
||||
"device_count": len(devices),
|
||||
"files_synced": 0,
|
||||
"last_connect_at": now_iso,
|
||||
},
|
||||
|
|
@ -271,11 +262,7 @@ async def obsidian_sync(
|
|||
user: User = Depends(current_active_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> dict[str, object]:
|
||||
"""Batch-upsert notes pushed by the plugin.
|
||||
|
||||
Returns per-note ack so the plugin can dequeue successes and retry
|
||||
failures.
|
||||
"""
|
||||
"""Batch-upsert notes; returns per-note ack so the plugin can dequeue/retry."""
|
||||
connector = await _resolve_vault_connector(
|
||||
session, user=user, vault_id=payload.vault_id
|
||||
)
|
||||
|
|
@ -439,11 +426,7 @@ async def obsidian_manifest(
|
|||
user: User = Depends(current_active_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> ManifestResponse:
|
||||
"""Return the server-side ``{path: {hash, mtime}}`` manifest.
|
||||
|
||||
Used by the plugin's ``onload`` reconcile to find files that were
|
||||
edited or deleted while the plugin was offline.
|
||||
"""
|
||||
"""Return ``{path: {hash, mtime}}`` for the plugin's onload reconcile diff."""
|
||||
connector = await _resolve_vault_connector(
|
||||
session, user=user, vault_id=vault_id
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,23 +1,8 @@
|
|||
"""
|
||||
Obsidian Plugin connector schemas.
|
||||
"""Wire schemas spoken between the SurfSense Obsidian plugin and the backend.
|
||||
|
||||
Wire format spoken between the SurfSense Obsidian plugin
|
||||
(``surfsense_obsidian/``) and the FastAPI backend.
|
||||
|
||||
Stability contract
|
||||
------------------
|
||||
Every request and response schema sets ``model_config = ConfigDict(extra='ignore')``.
|
||||
This is the API stability contract — not just hygiene:
|
||||
|
||||
- Old plugins talking to a newer backend silently drop any new response fields
|
||||
they don't understand instead of failing validation.
|
||||
- New plugins talking to an older backend can include forward-looking request
|
||||
fields (e.g. attachments metadata) without the older backend rejecting them.
|
||||
|
||||
Hard breaking changes are reserved for the URL prefix (``/api/v2/...``).
|
||||
Additive evolution is signaled via the ``capabilities`` array on
|
||||
``HealthResponse`` / ``ConnectResponse`` — older plugins ignore unknown
|
||||
capability strings safely.
|
||||
All schemas inherit ``extra='ignore'`` from :class:`_PluginBase` so additive
|
||||
field changes never break either side; hard breaks live behind a new URL
|
||||
prefix (``/api/v2/...``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -31,22 +16,13 @@ _PLUGIN_MODEL_CONFIG = ConfigDict(extra="ignore")
|
|||
|
||||
|
||||
class _PluginBase(BaseModel):
|
||||
"""Base class for all plugin payload schemas.
|
||||
|
||||
Carries the forward-compatibility config so subclasses don't have to
|
||||
repeat it.
|
||||
"""
|
||||
"""Base schema carrying the shared forward-compatibility config."""
|
||||
|
||||
model_config = _PLUGIN_MODEL_CONFIG
|
||||
|
||||
|
||||
class NotePayload(_PluginBase):
|
||||
"""One Obsidian note as pushed by the plugin.
|
||||
|
||||
The plugin is the source of truth: ``content`` is the post-frontmatter
|
||||
body, ``frontmatter``/``tags``/``headings``/etc. are precomputed by the
|
||||
plugin via ``app.metadataCache`` so the backend doesn't have to re-parse.
|
||||
"""
|
||||
"""One Obsidian note as pushed by the plugin (the source of truth)."""
|
||||
|
||||
vault_id: str = Field(..., description="Stable plugin-generated UUID for this vault")
|
||||
path: str = Field(..., description="Vault-relative path, e.g. 'notes/foo.md'")
|
||||
|
|
@ -68,7 +44,7 @@ class NotePayload(_PluginBase):
|
|||
|
||||
|
||||
class SyncBatchRequest(_PluginBase):
|
||||
"""Batch upsert. Plugin sends 10-20 notes per request to amortize HTTP overhead."""
|
||||
"""Batch upsert; plugin sends 10-20 notes per request."""
|
||||
|
||||
vault_id: str
|
||||
notes: list[NotePayload] = Field(default_factory=list, max_length=100)
|
||||
|
|
@ -90,8 +66,6 @@ class DeleteBatchRequest(_PluginBase):
|
|||
|
||||
|
||||
class ManifestEntry(_PluginBase):
|
||||
"""One row of the server-side manifest used by the plugin to reconcile."""
|
||||
|
||||
hash: str
|
||||
mtime: datetime
|
||||
|
||||
|
|
@ -104,26 +78,18 @@ class ManifestResponse(_PluginBase):
|
|||
|
||||
|
||||
class ConnectRequest(_PluginBase):
|
||||
"""First-call handshake to register or look up a vault connector row."""
|
||||
"""Vault registration / heartbeat. Replayed on every plugin onload."""
|
||||
|
||||
vault_id: str
|
||||
vault_name: str
|
||||
search_space_id: int
|
||||
plugin_version: str
|
||||
device_id: str
|
||||
device_label: str | None = Field(
|
||||
default=None,
|
||||
description="User-friendly device name shown in the web UI (e.g. 'iPad Pro').",
|
||||
)
|
||||
|
||||
|
||||
class ConnectResponse(_PluginBase):
|
||||
"""Returned from POST /connect.
|
||||
|
||||
Carries the same handshake fields as ``HealthResponse`` so the plugin
|
||||
learns the contract on its very first call without an extra round-trip
|
||||
to ``GET /health``.
|
||||
"""
|
||||
"""Carries the same handshake fields as ``HealthResponse`` so the plugin
|
||||
learns the contract without a separate ``GET /health`` round-trip."""
|
||||
|
||||
connector_id: int
|
||||
vault_id: str
|
||||
|
|
@ -133,14 +99,7 @@ class ConnectResponse(_PluginBase):
|
|||
|
||||
|
||||
class HealthResponse(_PluginBase):
|
||||
"""API contract handshake.
|
||||
|
||||
The plugin calls ``GET /health`` once per ``onload`` and caches the
|
||||
result. ``capabilities`` is a forward-extensible string list: future
|
||||
additions (``'pat_auth'``, ``'scoped_pat'``, ``'attachments_v2'``,
|
||||
``'shared_search_spaces'``...) ship without breaking older plugins
|
||||
because they only enable extra behavior, never gate existing endpoints.
|
||||
"""
|
||||
"""API contract handshake. ``capabilities`` is additive-only string list."""
|
||||
|
||||
api_version: str
|
||||
capabilities: list[str]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue