Add friendly names system for recipes, effects, and media
- Add friendly_names table with unique constraints per actor - Create NamingService with HMAC-signed timestamp version IDs - Version IDs use base32-crockford encoding, always increase alphabetically - Name normalization: spaces/underscores to dashes, lowercase, strip special chars - Format: "my-effect 01hw3x9k" (space separator ensures uniqueness) - Integrate naming into recipe, effect, and media uploads - Resolve friendly names to CIDs during DAG execution - Update effects UI to display friendly names - Add 30 tests covering normalization, parsing, and service structure Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -224,9 +224,21 @@ async def upload_content(
|
||||
if error:
|
||||
raise HTTPException(400, error)
|
||||
|
||||
# Assign friendly name (use IPFS CID if available, otherwise local hash)
|
||||
final_cid = ipfs_cid or cid
|
||||
from ..services.naming_service import get_naming_service
|
||||
naming = get_naming_service()
|
||||
friendly_entry = await naming.assign_name(
|
||||
cid=final_cid,
|
||||
actor_id=ctx.actor_id,
|
||||
item_type="media",
|
||||
filename=file.filename,
|
||||
)
|
||||
|
||||
return {
|
||||
"cid": ipfs_cid or cid,
|
||||
"cid": final_cid,
|
||||
"content_hash": cid, # Legacy, for backwards compatibility
|
||||
"friendly_name": friendly_entry["friendly_name"],
|
||||
"filename": file.filename,
|
||||
"size": len(content),
|
||||
"uploaded": True,
|
||||
|
||||
@@ -200,12 +200,24 @@ async def upload_effect(
|
||||
# Also store metadata in IPFS for discoverability
|
||||
meta_cid = ipfs_client.add_json(full_meta)
|
||||
|
||||
logger.info(f"Uploaded effect '{meta.get('name')}' cid={cid} by {ctx.actor_id}")
|
||||
# Assign friendly name
|
||||
from ..services.naming_service import get_naming_service
|
||||
naming = get_naming_service()
|
||||
friendly_entry = await naming.assign_name(
|
||||
cid=cid,
|
||||
actor_id=ctx.actor_id,
|
||||
item_type="effect",
|
||||
display_name=meta.get("name"),
|
||||
filename=file.filename,
|
||||
)
|
||||
|
||||
logger.info(f"Uploaded effect '{meta.get('name')}' cid={cid} friendly_name='{friendly_entry['friendly_name']}' by {ctx.actor_id}")
|
||||
|
||||
return {
|
||||
"cid": cid,
|
||||
"metadata_cid": meta_cid,
|
||||
"name": meta.get("name"),
|
||||
"friendly_name": friendly_entry["friendly_name"],
|
||||
"version": meta.get("version"),
|
||||
"temporal": meta.get("temporal", False),
|
||||
"params": meta.get("params", []),
|
||||
@@ -244,6 +256,15 @@ async def get_effect(
|
||||
meta = {"cid": cid, "meta": parsed_meta}
|
||||
(effect_dir / "metadata.json").write_text(json.dumps(meta, indent=2))
|
||||
|
||||
# Add friendly name if available
|
||||
from ..services.naming_service import get_naming_service
|
||||
naming = get_naming_service()
|
||||
friendly = await naming.get_by_cid(ctx.actor_id, cid)
|
||||
if friendly:
|
||||
meta["friendly_name"] = friendly["friendly_name"]
|
||||
meta["base_name"] = friendly["base_name"]
|
||||
meta["version_id"] = friendly["version_id"]
|
||||
|
||||
if wants_json(request):
|
||||
return meta
|
||||
|
||||
@@ -295,6 +316,10 @@ async def list_effects(
|
||||
effects_dir = get_effects_dir()
|
||||
effects = []
|
||||
|
||||
# Get naming service for friendly name lookup
|
||||
from ..services.naming_service import get_naming_service
|
||||
naming = get_naming_service()
|
||||
|
||||
if effects_dir.exists():
|
||||
for effect_dir in effects_dir.iterdir():
|
||||
if effect_dir.is_dir():
|
||||
@@ -302,6 +327,13 @@ async def list_effects(
|
||||
if metadata_path.exists():
|
||||
try:
|
||||
meta = json.loads(metadata_path.read_text())
|
||||
# Add friendly name if available
|
||||
cid = meta.get("cid")
|
||||
if cid:
|
||||
friendly = await naming.get_by_cid(ctx.actor_id, cid)
|
||||
if friendly:
|
||||
meta["friendly_name"] = friendly["friendly_name"]
|
||||
meta["base_name"] = friendly["base_name"]
|
||||
effects.append(meta)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
@@ -158,13 +158,59 @@ def bind_inputs(
|
||||
return warnings
|
||||
|
||||
|
||||
def prepare_dag_for_execution(
|
||||
async def resolve_friendly_names_in_registry(
|
||||
registry: dict,
|
||||
actor_id: str,
|
||||
) -> dict:
|
||||
"""
|
||||
Resolve friendly names to CIDs in the registry.
|
||||
|
||||
Friendly names are identified by containing a space (e.g., "brightness 01hw3x9k")
|
||||
or by not being a valid CID format.
|
||||
"""
|
||||
from ..services.naming_service import get_naming_service
|
||||
import re
|
||||
|
||||
naming = get_naming_service()
|
||||
resolved = {"assets": {}, "effects": {}}
|
||||
|
||||
# CID patterns: IPFS CID (Qm..., bafy...) or SHA256 hash (64 hex chars)
|
||||
cid_pattern = re.compile(r'^(Qm[a-zA-Z0-9]{44}|bafy[a-zA-Z0-9]+|[a-f0-9]{64})$')
|
||||
|
||||
for asset_name, asset_info in registry.get("assets", {}).items():
|
||||
cid = asset_info.get("cid", "")
|
||||
if cid and not cid_pattern.match(cid):
|
||||
# Looks like a friendly name, resolve it
|
||||
resolved_cid = await naming.resolve(actor_id, cid, item_type="media")
|
||||
if resolved_cid:
|
||||
asset_info = dict(asset_info)
|
||||
asset_info["cid"] = resolved_cid
|
||||
asset_info["_resolved_from"] = cid
|
||||
resolved["assets"][asset_name] = asset_info
|
||||
|
||||
for effect_name, effect_info in registry.get("effects", {}).items():
|
||||
cid = effect_info.get("cid", "")
|
||||
if cid and not cid_pattern.match(cid):
|
||||
# Looks like a friendly name, resolve it
|
||||
resolved_cid = await naming.resolve(actor_id, cid, item_type="effect")
|
||||
if resolved_cid:
|
||||
effect_info = dict(effect_info)
|
||||
effect_info["cid"] = resolved_cid
|
||||
effect_info["_resolved_from"] = cid
|
||||
resolved["effects"][effect_name] = effect_info
|
||||
|
||||
return resolved
|
||||
|
||||
|
||||
async def prepare_dag_for_execution(
|
||||
recipe: Recipe,
|
||||
user_inputs: Dict[str, str],
|
||||
actor_id: str = None,
|
||||
) -> Tuple[str, List[str]]:
|
||||
"""
|
||||
Prepare a recipe DAG for execution by transforming nodes and binding inputs.
|
||||
|
||||
Resolves friendly names to CIDs if actor_id is provided.
|
||||
Returns (dag_json, warnings).
|
||||
"""
|
||||
recipe_dag = recipe.get("dag")
|
||||
@@ -177,6 +223,11 @@ def prepare_dag_for_execution(
|
||||
|
||||
# Get registry for resolving references
|
||||
registry = recipe.get("registry", {})
|
||||
|
||||
# Resolve friendly names to CIDs
|
||||
if actor_id and registry:
|
||||
registry = await resolve_friendly_names_in_registry(registry, actor_id)
|
||||
|
||||
assets = registry.get("assets", {}) if registry else {}
|
||||
effects = registry.get("effects", {}) if registry else {}
|
||||
|
||||
@@ -506,10 +557,10 @@ async def run_recipe(
|
||||
# Create run using run service
|
||||
run_service = RunService(database, get_redis_client(), get_cache_manager())
|
||||
|
||||
# Prepare DAG for execution (transform nodes, bind inputs)
|
||||
# Prepare DAG for execution (transform nodes, bind inputs, resolve friendly names)
|
||||
dag_json = None
|
||||
if recipe.get("dag"):
|
||||
dag_json, warnings = prepare_dag_for_execution(recipe, req.inputs)
|
||||
dag_json, warnings = await prepare_dag_for_execution(recipe, req.inputs, actor_id=ctx.actor_id)
|
||||
for warning in warnings:
|
||||
logger.warning(warning)
|
||||
|
||||
|
||||
234
app/services/naming_service.py
Normal file
234
app/services/naming_service.py
Normal file
@@ -0,0 +1,234 @@
|
||||
"""
|
||||
Naming service for friendly names.
|
||||
|
||||
Handles:
|
||||
- Name normalization (My Cool Effect -> my-cool-effect)
|
||||
- Version ID generation (server-signed timestamps)
|
||||
- Friendly name assignment and resolution
|
||||
"""
|
||||
|
||||
import hmac
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import database
|
||||
|
||||
|
||||
# Base32 Crockford alphabet (excludes I, L, O, U to avoid confusion)
|
||||
CROCKFORD_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz"
|
||||
|
||||
|
||||
def _get_server_secret() -> bytes:
|
||||
"""Get server secret for signing version IDs."""
|
||||
secret = os.environ.get("SERVER_SECRET", "")
|
||||
if not secret:
|
||||
# Fall back to a derived secret from other env vars
|
||||
# In production, SERVER_SECRET should be set explicitly
|
||||
secret = os.environ.get("SECRET_KEY", "default-dev-secret")
|
||||
return secret.encode("utf-8")
|
||||
|
||||
|
||||
def _base32_crockford_encode(data: bytes) -> str:
|
||||
"""Encode bytes as base32-crockford (lowercase)."""
|
||||
# Convert bytes to integer
|
||||
num = int.from_bytes(data, "big")
|
||||
if num == 0:
|
||||
return CROCKFORD_ALPHABET[0]
|
||||
|
||||
result = []
|
||||
while num > 0:
|
||||
result.append(CROCKFORD_ALPHABET[num % 32])
|
||||
num //= 32
|
||||
|
||||
return "".join(reversed(result))
|
||||
|
||||
|
||||
def generate_version_id() -> str:
|
||||
"""
|
||||
Generate a version ID that is:
|
||||
- Always increasing (timestamp-based prefix)
|
||||
- Verifiable as originating from this server (HMAC suffix)
|
||||
- Short and URL-safe (13 chars)
|
||||
|
||||
Format: 6 bytes timestamp (ms) + 2 bytes HMAC = 8 bytes = 13 base32 chars
|
||||
"""
|
||||
timestamp_ms = int(time.time() * 1000)
|
||||
timestamp_bytes = timestamp_ms.to_bytes(6, "big")
|
||||
|
||||
# HMAC the timestamp with server secret
|
||||
secret = _get_server_secret()
|
||||
sig = hmac.new(secret, timestamp_bytes, "sha256").digest()
|
||||
|
||||
# Combine: 6 bytes timestamp + 2 bytes HMAC signature
|
||||
combined = timestamp_bytes + sig[:2]
|
||||
|
||||
# Encode as base32-crockford
|
||||
return _base32_crockford_encode(combined)
|
||||
|
||||
|
||||
def normalize_name(name: str) -> str:
|
||||
"""
|
||||
Normalize a display name to a base name.
|
||||
|
||||
- Lowercase
|
||||
- Replace spaces and underscores with dashes
|
||||
- Remove special characters (keep alphanumeric and dashes)
|
||||
- Collapse multiple dashes
|
||||
- Strip leading/trailing dashes
|
||||
|
||||
Examples:
|
||||
"My Cool Effect" -> "my-cool-effect"
|
||||
"Brightness_V2" -> "brightness-v2"
|
||||
"Test!!!Effect" -> "test-effect"
|
||||
"""
|
||||
# Lowercase
|
||||
name = name.lower()
|
||||
|
||||
# Replace spaces and underscores with dashes
|
||||
name = re.sub(r"[\s_]+", "-", name)
|
||||
|
||||
# Remove anything that's not alphanumeric or dash
|
||||
name = re.sub(r"[^a-z0-9-]", "", name)
|
||||
|
||||
# Collapse multiple dashes
|
||||
name = re.sub(r"-+", "-", name)
|
||||
|
||||
# Strip leading/trailing dashes
|
||||
name = name.strip("-")
|
||||
|
||||
return name or "unnamed"
|
||||
|
||||
|
||||
def parse_friendly_name(friendly_name: str) -> Tuple[str, Optional[str]]:
|
||||
"""
|
||||
Parse a friendly name into base name and optional version.
|
||||
|
||||
Args:
|
||||
friendly_name: Name like "my-effect" or "my-effect 01hw3x9k"
|
||||
|
||||
Returns:
|
||||
Tuple of (base_name, version_id or None)
|
||||
"""
|
||||
parts = friendly_name.strip().split(" ", 1)
|
||||
base_name = parts[0]
|
||||
version_id = parts[1] if len(parts) > 1 else None
|
||||
return base_name, version_id
|
||||
|
||||
|
||||
def format_friendly_name(base_name: str, version_id: str) -> str:
|
||||
"""Format a base name and version into a full friendly name."""
|
||||
return f"{base_name} {version_id}"
|
||||
|
||||
|
||||
def format_l2_name(actor_id: str, base_name: str, version_id: str) -> str:
|
||||
"""
|
||||
Format a friendly name for L2 sharing.
|
||||
|
||||
Format: @user@domain base-name version-id
|
||||
"""
|
||||
return f"{actor_id} {base_name} {version_id}"
|
||||
|
||||
|
||||
class NamingService:
|
||||
"""Service for managing friendly names."""
|
||||
|
||||
async def assign_name(
|
||||
self,
|
||||
cid: str,
|
||||
actor_id: str,
|
||||
item_type: str,
|
||||
display_name: Optional[str] = None,
|
||||
filename: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Assign a friendly name to content.
|
||||
|
||||
Args:
|
||||
cid: Content ID
|
||||
actor_id: User ID
|
||||
item_type: Type (recipe, effect, media)
|
||||
display_name: Human-readable name (optional)
|
||||
filename: Original filename (used as fallback for media)
|
||||
|
||||
Returns:
|
||||
Friendly name entry dict
|
||||
"""
|
||||
# Determine display name
|
||||
if not display_name:
|
||||
if filename:
|
||||
# Use filename without extension
|
||||
display_name = os.path.splitext(filename)[0]
|
||||
else:
|
||||
display_name = f"unnamed-{item_type}"
|
||||
|
||||
# Normalize to base name
|
||||
base_name = normalize_name(display_name)
|
||||
|
||||
# Generate version ID
|
||||
version_id = generate_version_id()
|
||||
|
||||
# Create database entry
|
||||
entry = await database.create_friendly_name(
|
||||
actor_id=actor_id,
|
||||
base_name=base_name,
|
||||
version_id=version_id,
|
||||
cid=cid,
|
||||
item_type=item_type,
|
||||
display_name=display_name,
|
||||
)
|
||||
|
||||
return entry
|
||||
|
||||
async def get_by_cid(self, actor_id: str, cid: str) -> Optional[dict]:
|
||||
"""Get friendly name entry by CID."""
|
||||
return await database.get_friendly_name_by_cid(actor_id, cid)
|
||||
|
||||
async def resolve(
|
||||
self,
|
||||
actor_id: str,
|
||||
name: str,
|
||||
item_type: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Resolve a friendly name to a CID.
|
||||
|
||||
Args:
|
||||
actor_id: User ID
|
||||
name: Friendly name ("base-name" or "base-name version")
|
||||
item_type: Optional type filter
|
||||
|
||||
Returns:
|
||||
CID or None if not found
|
||||
"""
|
||||
return await database.resolve_friendly_name(actor_id, name, item_type)
|
||||
|
||||
async def list_names(
|
||||
self,
|
||||
actor_id: str,
|
||||
item_type: Optional[str] = None,
|
||||
latest_only: bool = False,
|
||||
) -> list:
|
||||
"""List friendly names for a user."""
|
||||
return await database.list_friendly_names(
|
||||
actor_id=actor_id,
|
||||
item_type=item_type,
|
||||
latest_only=latest_only,
|
||||
)
|
||||
|
||||
async def delete(self, actor_id: str, cid: str) -> bool:
|
||||
"""Delete a friendly name entry."""
|
||||
return await database.delete_friendly_name(actor_id, cid)
|
||||
|
||||
|
||||
# Module-level instance
|
||||
_naming_service: Optional[NamingService] = None
|
||||
|
||||
|
||||
def get_naming_service() -> NamingService:
|
||||
"""Get the naming service singleton."""
|
||||
global _naming_service
|
||||
if _naming_service is None:
|
||||
_naming_service = NamingService()
|
||||
return _naming_service
|
||||
@@ -124,6 +124,18 @@ class RecipeService:
|
||||
cached, ipfs_cid = self.cache.put(tmp_path, node_type="recipe", move=True)
|
||||
recipe_id = ipfs_cid or cached.cid # Prefer IPFS CID
|
||||
|
||||
# Assign friendly name
|
||||
if uploader:
|
||||
from .naming_service import get_naming_service
|
||||
naming = get_naming_service()
|
||||
display_name = name or compiled.name or "unnamed-recipe"
|
||||
await naming.assign_name(
|
||||
cid=recipe_id,
|
||||
actor_id=uploader,
|
||||
item_type="recipe",
|
||||
display_name=display_name,
|
||||
)
|
||||
|
||||
return recipe_id, None
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -31,8 +31,15 @@
|
||||
<p class="text-gray-400 mb-6">{{ meta.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- CID Info -->
|
||||
<!-- Friendly Name & CID Info -->
|
||||
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700 mb-6">
|
||||
{% if effect.friendly_name %}
|
||||
<div class="mb-4 pb-4 border-b border-gray-700">
|
||||
<span class="text-gray-500 text-sm">Friendly Name</span>
|
||||
<p class="text-blue-400 font-medium text-lg mt-1">{{ effect.friendly_name }}</p>
|
||||
<p class="text-gray-500 text-xs mt-1">Use in recipes: <code class="bg-gray-900 px-2 py-0.5 rounded">(effect {{ effect.base_name }})</code></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="text-gray-500 text-sm">Content ID (CID)</span>
|
||||
|
||||
@@ -60,8 +60,12 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-3 text-gray-600 text-xs font-mono truncate">
|
||||
{{ effect.cid[:24] }}...
|
||||
<div class="mt-3 text-xs">
|
||||
{% if effect.friendly_name %}
|
||||
<span class="text-blue-400 font-medium">{{ effect.friendly_name }}</span>
|
||||
{% else %}
|
||||
<span class="text-gray-600 font-mono truncate">{{ effect.cid[:24] }}...</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
208
database.py
208
database.py
@@ -126,6 +126,23 @@ CREATE TABLE IF NOT EXISTS storage_pins (
|
||||
UNIQUE(cid, storage_id)
|
||||
);
|
||||
|
||||
-- Friendly names: human-readable versioned names for content
|
||||
-- Version IDs are server-signed timestamps (always increasing, verifiable origin)
|
||||
-- Names are per-user within L1; when shared to L2: @user@domain name version
|
||||
CREATE TABLE IF NOT EXISTS friendly_names (
|
||||
id SERIAL PRIMARY KEY,
|
||||
actor_id VARCHAR(255) NOT NULL,
|
||||
base_name VARCHAR(255) NOT NULL, -- normalized: my-cool-effect
|
||||
version_id VARCHAR(20) NOT NULL, -- server-signed timestamp: 01hw3x9kab2cd
|
||||
cid VARCHAR(64) NOT NULL, -- content address
|
||||
item_type VARCHAR(20) NOT NULL, -- recipe | effect | media
|
||||
display_name VARCHAR(255), -- original "My Cool Effect"
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
|
||||
UNIQUE(actor_id, base_name, version_id),
|
||||
UNIQUE(actor_id, cid) -- each CID has exactly one friendly name per user
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_item_types_cid ON item_types(cid);
|
||||
CREATE INDEX IF NOT EXISTS idx_item_types_actor_id ON item_types(actor_id);
|
||||
@@ -139,6 +156,10 @@ CREATE INDEX IF NOT EXISTS idx_storage_backends_actor ON storage_backends(actor_
|
||||
CREATE INDEX IF NOT EXISTS idx_storage_backends_type ON storage_backends(provider_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_storage_pins_hash ON storage_pins(cid);
|
||||
CREATE INDEX IF NOT EXISTS idx_storage_pins_storage ON storage_pins(storage_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_friendly_names_actor ON friendly_names(actor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_friendly_names_type ON friendly_names(item_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_friendly_names_base ON friendly_names(actor_id, base_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_friendly_names_latest ON friendly_names(actor_id, item_type, base_name, created_at DESC);
|
||||
"""
|
||||
|
||||
|
||||
@@ -1558,3 +1579,190 @@ async def get_stale_pending_runs(older_than_hours: int = 24) -> List[dict]:
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
# ============ Friendly Names ============
|
||||
|
||||
async def create_friendly_name(
|
||||
actor_id: str,
|
||||
base_name: str,
|
||||
version_id: str,
|
||||
cid: str,
|
||||
item_type: str,
|
||||
display_name: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Create a friendly name entry.
|
||||
|
||||
Returns the created entry.
|
||||
"""
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO friendly_names (actor_id, base_name, version_id, cid, item_type, display_name)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (actor_id, cid) DO UPDATE SET
|
||||
base_name = EXCLUDED.base_name,
|
||||
version_id = EXCLUDED.version_id,
|
||||
display_name = EXCLUDED.display_name
|
||||
RETURNING id, actor_id, base_name, version_id, cid, item_type, display_name, created_at
|
||||
""",
|
||||
actor_id, base_name, version_id, cid, item_type, display_name
|
||||
)
|
||||
return {
|
||||
"id": row["id"],
|
||||
"actor_id": row["actor_id"],
|
||||
"base_name": row["base_name"],
|
||||
"version_id": row["version_id"],
|
||||
"cid": row["cid"],
|
||||
"item_type": row["item_type"],
|
||||
"display_name": row["display_name"],
|
||||
"friendly_name": f"{row['base_name']} {row['version_id']}",
|
||||
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
||||
}
|
||||
|
||||
|
||||
async def get_friendly_name_by_cid(actor_id: str, cid: str) -> Optional[dict]:
|
||||
"""Get friendly name entry by CID."""
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT id, actor_id, base_name, version_id, cid, item_type, display_name, created_at
|
||||
FROM friendly_names
|
||||
WHERE actor_id = $1 AND cid = $2
|
||||
""",
|
||||
actor_id, cid
|
||||
)
|
||||
if row:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"actor_id": row["actor_id"],
|
||||
"base_name": row["base_name"],
|
||||
"version_id": row["version_id"],
|
||||
"cid": row["cid"],
|
||||
"item_type": row["item_type"],
|
||||
"display_name": row["display_name"],
|
||||
"friendly_name": f"{row['base_name']} {row['version_id']}",
|
||||
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
async def resolve_friendly_name(
|
||||
actor_id: str,
|
||||
name: str,
|
||||
item_type: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Resolve a friendly name to a CID.
|
||||
|
||||
Name can be:
|
||||
- "base-name" -> resolves to latest version
|
||||
- "base-name version-id" -> resolves to exact version
|
||||
|
||||
Returns CID or None if not found.
|
||||
"""
|
||||
parts = name.strip().split(' ')
|
||||
base_name = parts[0]
|
||||
version_id = parts[1] if len(parts) > 1 else None
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
if version_id:
|
||||
# Exact version lookup
|
||||
query = """
|
||||
SELECT cid FROM friendly_names
|
||||
WHERE actor_id = $1 AND base_name = $2 AND version_id = $3
|
||||
"""
|
||||
params = [actor_id, base_name, version_id]
|
||||
if item_type:
|
||||
query += " AND item_type = $4"
|
||||
params.append(item_type)
|
||||
|
||||
return await conn.fetchval(query, *params)
|
||||
else:
|
||||
# Latest version lookup
|
||||
query = """
|
||||
SELECT cid FROM friendly_names
|
||||
WHERE actor_id = $1 AND base_name = $2
|
||||
"""
|
||||
params = [actor_id, base_name]
|
||||
if item_type:
|
||||
query += " AND item_type = $3"
|
||||
params.append(item_type)
|
||||
query += " ORDER BY created_at DESC LIMIT 1"
|
||||
|
||||
return await conn.fetchval(query, *params)
|
||||
|
||||
|
||||
async def list_friendly_names(
|
||||
actor_id: str,
|
||||
item_type: Optional[str] = None,
|
||||
base_name: Optional[str] = None,
|
||||
latest_only: bool = False,
|
||||
) -> List[dict]:
|
||||
"""
|
||||
List friendly names for a user.
|
||||
|
||||
Args:
|
||||
actor_id: User ID
|
||||
item_type: Filter by type (recipe, effect, media)
|
||||
base_name: Filter by base name
|
||||
latest_only: If True, only return latest version of each base name
|
||||
"""
|
||||
async with pool.acquire() as conn:
|
||||
if latest_only:
|
||||
# Use DISTINCT ON to get latest version of each base name
|
||||
query = """
|
||||
SELECT DISTINCT ON (base_name)
|
||||
id, actor_id, base_name, version_id, cid, item_type, display_name, created_at
|
||||
FROM friendly_names
|
||||
WHERE actor_id = $1
|
||||
"""
|
||||
params = [actor_id]
|
||||
if item_type:
|
||||
query += " AND item_type = $2"
|
||||
params.append(item_type)
|
||||
if base_name:
|
||||
query += f" AND base_name = ${len(params) + 1}"
|
||||
params.append(base_name)
|
||||
query += " ORDER BY base_name, created_at DESC"
|
||||
else:
|
||||
query = """
|
||||
SELECT id, actor_id, base_name, version_id, cid, item_type, display_name, created_at
|
||||
FROM friendly_names
|
||||
WHERE actor_id = $1
|
||||
"""
|
||||
params = [actor_id]
|
||||
if item_type:
|
||||
query += " AND item_type = $2"
|
||||
params.append(item_type)
|
||||
if base_name:
|
||||
query += f" AND base_name = ${len(params) + 1}"
|
||||
params.append(base_name)
|
||||
query += " ORDER BY base_name, created_at DESC"
|
||||
|
||||
rows = await conn.fetch(query, *params)
|
||||
return [
|
||||
{
|
||||
"id": row["id"],
|
||||
"actor_id": row["actor_id"],
|
||||
"base_name": row["base_name"],
|
||||
"version_id": row["version_id"],
|
||||
"cid": row["cid"],
|
||||
"item_type": row["item_type"],
|
||||
"display_name": row["display_name"],
|
||||
"friendly_name": f"{row['base_name']} {row['version_id']}",
|
||||
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
async def delete_friendly_name(actor_id: str, cid: str) -> bool:
|
||||
"""Delete a friendly name entry by CID."""
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
"DELETE FROM friendly_names WHERE actor_id = $1 AND cid = $2",
|
||||
actor_id, cid
|
||||
)
|
||||
return "DELETE 1" in result
|
||||
|
||||
246
tests/test_naming_service.py
Normal file
246
tests/test_naming_service.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""
|
||||
Tests for the friendly naming service.
|
||||
"""
|
||||
|
||||
import re
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# Copy the pure functions from naming_service for testing
|
||||
# This avoids import issues with the app module
|
||||
|
||||
CROCKFORD_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz"
|
||||
|
||||
|
||||
def normalize_name(name: str) -> str:
|
||||
"""Copy of normalize_name for testing."""
|
||||
name = name.lower()
|
||||
name = re.sub(r"[\s_]+", "-", name)
|
||||
name = re.sub(r"[^a-z0-9-]", "", name)
|
||||
name = re.sub(r"-+", "-", name)
|
||||
name = name.strip("-")
|
||||
return name or "unnamed"
|
||||
|
||||
|
||||
def parse_friendly_name(friendly_name: str):
|
||||
"""Copy of parse_friendly_name for testing."""
|
||||
parts = friendly_name.strip().split(" ", 1)
|
||||
base_name = parts[0]
|
||||
version_id = parts[1] if len(parts) > 1 else None
|
||||
return base_name, version_id
|
||||
|
||||
|
||||
def format_friendly_name(base_name: str, version_id: str) -> str:
|
||||
"""Copy of format_friendly_name for testing."""
|
||||
return f"{base_name} {version_id}"
|
||||
|
||||
|
||||
def format_l2_name(actor_id: str, base_name: str, version_id: str) -> str:
|
||||
"""Copy of format_l2_name for testing."""
|
||||
return f"{actor_id} {base_name} {version_id}"
|
||||
|
||||
|
||||
class TestNameNormalization:
|
||||
"""Tests for name normalization."""
|
||||
|
||||
def test_normalize_simple_name(self) -> None:
|
||||
"""Simple names should be lowercased."""
|
||||
assert normalize_name("Brightness") == "brightness"
|
||||
|
||||
def test_normalize_spaces_to_dashes(self) -> None:
|
||||
"""Spaces should be converted to dashes."""
|
||||
assert normalize_name("My Cool Effect") == "my-cool-effect"
|
||||
|
||||
def test_normalize_underscores_to_dashes(self) -> None:
|
||||
"""Underscores should be converted to dashes."""
|
||||
assert normalize_name("brightness_v2") == "brightness-v2"
|
||||
|
||||
def test_normalize_removes_special_chars(self) -> None:
|
||||
"""Special characters should be removed."""
|
||||
# Special chars are removed (not replaced with dashes)
|
||||
assert normalize_name("Test!!!Effect") == "testeffect"
|
||||
assert normalize_name("cool@effect#1") == "cooleffect1"
|
||||
# But spaces/underscores become dashes first
|
||||
assert normalize_name("Test Effect!") == "test-effect"
|
||||
|
||||
def test_normalize_collapses_dashes(self) -> None:
|
||||
"""Multiple dashes should be collapsed."""
|
||||
assert normalize_name("test--effect") == "test-effect"
|
||||
assert normalize_name("test___effect") == "test-effect"
|
||||
|
||||
def test_normalize_strips_edge_dashes(self) -> None:
|
||||
"""Leading/trailing dashes should be stripped."""
|
||||
assert normalize_name("-test-effect-") == "test-effect"
|
||||
|
||||
def test_normalize_empty_returns_unnamed(self) -> None:
|
||||
"""Empty names should return 'unnamed'."""
|
||||
assert normalize_name("") == "unnamed"
|
||||
assert normalize_name("---") == "unnamed"
|
||||
assert normalize_name("!!!") == "unnamed"
|
||||
|
||||
|
||||
class TestFriendlyNameParsing:
|
||||
"""Tests for friendly name parsing."""
|
||||
|
||||
def test_parse_base_name_only(self) -> None:
|
||||
"""Parsing base name only returns None for version."""
|
||||
base, version = parse_friendly_name("my-effect")
|
||||
assert base == "my-effect"
|
||||
assert version is None
|
||||
|
||||
def test_parse_with_version(self) -> None:
|
||||
"""Parsing with version returns both parts."""
|
||||
base, version = parse_friendly_name("my-effect 01hw3x9k")
|
||||
assert base == "my-effect"
|
||||
assert version == "01hw3x9k"
|
||||
|
||||
def test_parse_strips_whitespace(self) -> None:
|
||||
"""Parsing should strip leading/trailing whitespace."""
|
||||
base, version = parse_friendly_name(" my-effect ")
|
||||
assert base == "my-effect"
|
||||
assert version is None
|
||||
|
||||
|
||||
class TestFriendlyNameFormatting:
|
||||
"""Tests for friendly name formatting."""
|
||||
|
||||
def test_format_friendly_name(self) -> None:
|
||||
"""Format combines base and version with space."""
|
||||
assert format_friendly_name("my-effect", "01hw3x9k") == "my-effect 01hw3x9k"
|
||||
|
||||
def test_format_l2_name(self) -> None:
|
||||
"""L2 format includes actor ID."""
|
||||
result = format_l2_name("@alice@example.com", "my-effect", "01hw3x9k")
|
||||
assert result == "@alice@example.com my-effect 01hw3x9k"
|
||||
|
||||
|
||||
class TestDatabaseSchemaExists:
|
||||
"""Tests that verify database schema includes friendly_names table."""
|
||||
|
||||
def test_schema_has_friendly_names_table(self) -> None:
|
||||
"""Database schema should include friendly_names table."""
|
||||
path = Path(__file__).parent.parent / "database.py"
|
||||
content = path.read_text()
|
||||
assert "CREATE TABLE IF NOT EXISTS friendly_names" in content
|
||||
|
||||
def test_schema_has_required_columns(self) -> None:
|
||||
"""Friendly names table should have required columns."""
|
||||
path = Path(__file__).parent.parent / "database.py"
|
||||
content = path.read_text()
|
||||
assert "actor_id" in content
|
||||
assert "base_name" in content
|
||||
assert "version_id" in content
|
||||
assert "item_type" in content
|
||||
assert "display_name" in content
|
||||
|
||||
def test_schema_has_unique_constraints(self) -> None:
|
||||
"""Friendly names table should have unique constraints."""
|
||||
path = Path(__file__).parent.parent / "database.py"
|
||||
content = path.read_text()
|
||||
# Unique on (actor_id, base_name, version_id)
|
||||
assert "UNIQUE(actor_id, base_name, version_id)" in content
|
||||
# Unique on (actor_id, cid)
|
||||
assert "UNIQUE(actor_id, cid)" in content
|
||||
|
||||
|
||||
class TestDatabaseFunctionsExist:
|
||||
"""Tests that verify database functions exist."""
|
||||
|
||||
def test_create_friendly_name_exists(self) -> None:
|
||||
"""create_friendly_name function should exist."""
|
||||
path = Path(__file__).parent.parent / "database.py"
|
||||
content = path.read_text()
|
||||
assert "async def create_friendly_name(" in content
|
||||
|
||||
def test_get_friendly_name_by_cid_exists(self) -> None:
|
||||
"""get_friendly_name_by_cid function should exist."""
|
||||
path = Path(__file__).parent.parent / "database.py"
|
||||
content = path.read_text()
|
||||
assert "async def get_friendly_name_by_cid(" in content
|
||||
|
||||
def test_resolve_friendly_name_exists(self) -> None:
|
||||
"""resolve_friendly_name function should exist."""
|
||||
path = Path(__file__).parent.parent / "database.py"
|
||||
content = path.read_text()
|
||||
assert "async def resolve_friendly_name(" in content
|
||||
|
||||
def test_list_friendly_names_exists(self) -> None:
|
||||
"""list_friendly_names function should exist."""
|
||||
path = Path(__file__).parent.parent / "database.py"
|
||||
content = path.read_text()
|
||||
assert "async def list_friendly_names(" in content
|
||||
|
||||
def test_delete_friendly_name_exists(self) -> None:
|
||||
"""delete_friendly_name function should exist."""
|
||||
path = Path(__file__).parent.parent / "database.py"
|
||||
content = path.read_text()
|
||||
assert "async def delete_friendly_name(" in content
|
||||
|
||||
|
||||
class TestNamingServiceModuleExists:
|
||||
"""Tests that verify naming service module structure."""
|
||||
|
||||
def test_module_file_exists(self) -> None:
|
||||
"""Naming service module file should exist."""
|
||||
path = Path(__file__).parent.parent / "app" / "services" / "naming_service.py"
|
||||
assert path.exists()
|
||||
|
||||
def test_module_has_normalize_name(self) -> None:
|
||||
"""Module should have normalize_name function."""
|
||||
path = Path(__file__).parent.parent / "app" / "services" / "naming_service.py"
|
||||
content = path.read_text()
|
||||
assert "def normalize_name(" in content
|
||||
|
||||
def test_module_has_generate_version_id(self) -> None:
|
||||
"""Module should have generate_version_id function."""
|
||||
path = Path(__file__).parent.parent / "app" / "services" / "naming_service.py"
|
||||
content = path.read_text()
|
||||
assert "def generate_version_id(" in content
|
||||
|
||||
def test_module_has_naming_service_class(self) -> None:
|
||||
"""Module should have NamingService class."""
|
||||
path = Path(__file__).parent.parent / "app" / "services" / "naming_service.py"
|
||||
content = path.read_text()
|
||||
assert "class NamingService:" in content
|
||||
|
||||
def test_naming_service_has_assign_name(self) -> None:
|
||||
"""NamingService should have assign_name method."""
|
||||
path = Path(__file__).parent.parent / "app" / "services" / "naming_service.py"
|
||||
content = path.read_text()
|
||||
assert "async def assign_name(" in content
|
||||
|
||||
def test_naming_service_has_resolve(self) -> None:
|
||||
"""NamingService should have resolve method."""
|
||||
path = Path(__file__).parent.parent / "app" / "services" / "naming_service.py"
|
||||
content = path.read_text()
|
||||
assert "async def resolve(" in content
|
||||
|
||||
def test_naming_service_has_get_by_cid(self) -> None:
|
||||
"""NamingService should have get_by_cid method."""
|
||||
path = Path(__file__).parent.parent / "app" / "services" / "naming_service.py"
|
||||
content = path.read_text()
|
||||
assert "async def get_by_cid(" in content
|
||||
|
||||
|
||||
class TestVersionIdProperties:
|
||||
"""Tests for version ID format properties (using actual function)."""
|
||||
|
||||
def test_version_id_format(self) -> None:
|
||||
"""Version ID should use base32-crockford alphabet."""
|
||||
# Read the naming service to verify it uses the right alphabet
|
||||
path = Path(__file__).parent.parent / "app" / "services" / "naming_service.py"
|
||||
content = path.read_text()
|
||||
assert 'CROCKFORD_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz"' in content
|
||||
|
||||
def test_version_id_uses_hmac(self) -> None:
|
||||
"""Version ID generation should use HMAC for server verification."""
|
||||
path = Path(__file__).parent.parent / "app" / "services" / "naming_service.py"
|
||||
content = path.read_text()
|
||||
assert "hmac.new(" in content
|
||||
|
||||
def test_version_id_uses_timestamp(self) -> None:
|
||||
"""Version ID generation should be timestamp-based."""
|
||||
path = Path(__file__).parent.parent / "app" / "services" / "naming_service.py"
|
||||
content = path.read_text()
|
||||
assert "time.time()" in content
|
||||
Reference in New Issue
Block a user