mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
feat(api): expose owning project on entity resolve
Expose project_external_id on v2 entity resolve responses so cloud callers can authorize cross-project targets without a second tenant DB lookup.
This commit is contained in:
@@ -18,6 +18,7 @@ from basic_memory.deps import (
|
||||
EntityServiceV2ExternalDep,
|
||||
SearchServiceV2ExternalDep,
|
||||
LinkResolverV2ExternalDep,
|
||||
ProjectRepositoryDep,
|
||||
ProjectConfigV2ExternalDep,
|
||||
AppConfigDep,
|
||||
EntityRepositoryV2ExternalDep,
|
||||
@@ -152,6 +153,7 @@ async def resolve_identifier(
|
||||
data: EntityResolveRequest,
|
||||
link_resolver: LinkResolverV2ExternalDep,
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
project_repository: ProjectRepositoryDep,
|
||||
) -> EntityResolveResponse:
|
||||
"""Resolve a string identifier (external_id, permalink, title, or path) to entity info.
|
||||
|
||||
@@ -175,6 +177,7 @@ async def resolve_identifier(
|
||||
{
|
||||
"external_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"entity_id": 123,
|
||||
"project_external_id": "4b9b7a10-7a63-48d2-ae3f-0d6a2c69313f",
|
||||
"permalink": "specs/search",
|
||||
"file_path": "specs/search.md",
|
||||
"title": "Search Specification",
|
||||
@@ -209,9 +212,17 @@ async def resolve_identifier(
|
||||
if not entity:
|
||||
raise HTTPException(status_code=404, detail=f"Entity not found: '{data.identifier}'")
|
||||
|
||||
owner_project = await project_repository.get_by_id(entity.project_id)
|
||||
if not owner_project: # pragma: no cover
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Resolved entity references an unknown project",
|
||||
)
|
||||
|
||||
result = EntityResolveResponse(
|
||||
external_id=entity.external_id,
|
||||
entity_id=entity.id,
|
||||
project_external_id=owner_project.external_id,
|
||||
permalink=entity.permalink,
|
||||
file_path=entity.file_path,
|
||||
title=entity.title,
|
||||
|
||||
@@ -45,6 +45,7 @@ class EntityResolveResponse(BaseModel):
|
||||
|
||||
external_id: str = Field(..., description="External UUID (primary API identifier)")
|
||||
entity_id: int = Field(..., description="Numeric entity ID (internal identifier)")
|
||||
project_external_id: str = Field(..., description="External UUID of the owning project")
|
||||
permalink: Optional[str] = Field(None, description="Entity permalink")
|
||||
file_path: str = Field(..., description="Relative file path")
|
||||
title: str = Field(..., description="Entity title")
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""Tests for V2 knowledge graph API routes (ID-based endpoints)."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from basic_memory.models import Project
|
||||
from basic_memory.models import Entity as EntityModel, Project
|
||||
from basic_memory.repository.entity_repository import EntityRepository
|
||||
from basic_memory.repository.project_repository import ProjectRepository
|
||||
from basic_memory.schemas import DeleteEntitiesResponse
|
||||
from basic_memory.schemas.response import DirectoryMoveResult, DirectoryDeleteResult
|
||||
from basic_memory.schemas.v2 import EntityResponseV2, EntityResolveResponse
|
||||
@@ -40,10 +43,55 @@ async def test_resolve_identifier_by_permalink(
|
||||
assert response.status_code == 200
|
||||
resolved = EntityResolveResponse.model_validate(response.json())
|
||||
assert resolved.entity_id == entity_id
|
||||
assert resolved.project_external_id == test_project.external_id
|
||||
assert resolved.permalink == created_entity.permalink
|
||||
assert resolved.resolution_method == "permalink"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_identifier_returns_target_project_external_id_for_cross_project_link(
|
||||
client: AsyncClient,
|
||||
session_maker,
|
||||
tmp_path,
|
||||
v2_project_url,
|
||||
):
|
||||
"""Cross-project resolves should expose the owning project external ID."""
|
||||
project_repository = ProjectRepository(session_maker)
|
||||
other_project = await project_repository.create(
|
||||
{
|
||||
"name": "other-project",
|
||||
"description": "Secondary project",
|
||||
"path": str(tmp_path / "other-project"),
|
||||
"is_active": True,
|
||||
"is_default": False,
|
||||
}
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
other_entity_repository = EntityRepository(session_maker, project_id=other_project.id)
|
||||
target = await other_entity_repository.add(
|
||||
EntityModel(
|
||||
title="Cross Project Note",
|
||||
note_type="note",
|
||||
content_type="text/markdown",
|
||||
file_path="docs/Cross Project Note.md",
|
||||
permalink=f"{other_project.permalink}/docs/cross-project-note",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
project_id=other_project.id,
|
||||
)
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/knowledge/resolve",
|
||||
json={"identifier": "other-project::Cross Project Note", "strict": True},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
resolved = EntityResolveResponse.model_validate(response.json())
|
||||
assert resolved.entity_id == target.id
|
||||
assert resolved.project_external_id == other_project.external_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_identifier_not_found(client: AsyncClient, v2_project_url):
|
||||
"""Test resolving a non-existent identifier returns 404."""
|
||||
|
||||
Reference in New Issue
Block a user