Skip to content

Commit ccdb87b

Browse files
jope-bmclaude
andcommitted
feat: Add v2 project management endpoints
Create ID-based project management endpoints to complete Phase 1 v2 API: - GET /v2/projects/{project_id} - Get project by numeric ID - PATCH /v2/projects/{project_id} - Update project by ID - DELETE /v2/projects/{project_id} - Delete project by ID - PUT /v2/projects/{project_id}/default - Set default project by ID These endpoints provide stable references using integer IDs instead of string names/permalinks, consistent with v2 entity operations. Changes: - Create v2 project router with ID-based CRUD operations - Add ProjectRepository.get_by_id() method for direct ID lookups - Register v2 project router in app.py at /v2/projects - All endpoints use ProjectIdPathDep for validation - Maintain consistency with v2 knowledge endpoints The v2 API is now fully ID-based for both entities and projects. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 7c0ad90 commit ccdb87b

4 files changed

Lines changed: 286 additions & 2 deletions

File tree

src/basic_memory/api/app.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
search,
2121
prompt_router,
2222
)
23-
from basic_memory.api.v2.routers import knowledge_router as v2_knowledge
23+
from basic_memory.api.v2.routers import knowledge_router as v2_knowledge, project_router as v2_project
2424
from basic_memory.api.middleware import DeprecationMiddleware, DeprecationMetrics
2525
from basic_memory.config import ConfigManager
2626
from basic_memory.services.initialization import initialize_file_sync, initialize_app
@@ -93,6 +93,7 @@ async def lifespan(app: FastAPI): # pragma: no cover
9393

9494
# Include v2 routers (current)
9595
app.include_router(v2_knowledge, prefix="/v2/{project_id}")
96+
app.include_router(v2_project, prefix="/v2")
9697

9798
# Project resource router works across projects
9899
app.include_router(project.project_resource_router)
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""V2 API routers."""
22

33
from basic_memory.api.v2.routers.knowledge_router import router as knowledge_router
4+
from basic_memory.api.v2.routers.project_router import router as project_router
45

5-
__all__ = ["knowledge_router"]
6+
__all__ = ["knowledge_router", "project_router"]
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
"""V2 Project Router - ID-based project management operations.
2+
3+
This router provides ID-based CRUD operations for projects, replacing the
4+
name-based identifiers used in v1 with direct integer ID lookups.
5+
6+
Key improvements:
7+
- Direct database lookups via integer primary keys
8+
- Stable references that don't change with project renames
9+
- Better performance through indexed queries
10+
- Consistent with v2 entity operations
11+
"""
12+
13+
import os
14+
from typing import Optional
15+
16+
from fastapi import APIRouter, HTTPException, Body, Query
17+
from loguru import logger
18+
19+
from basic_memory.deps import (
20+
ProjectServiceDep,
21+
ProjectRepositoryDep,
22+
ProjectIdPathDep,
23+
)
24+
from basic_memory.schemas.project_info import (
25+
ProjectItem,
26+
ProjectStatusResponse,
27+
)
28+
from basic_memory.utils import normalize_project_path
29+
30+
router = APIRouter(prefix="/projects", tags=["project_management-v2"])
31+
32+
33+
@router.get("/{project_id}", response_model=ProjectItem)
34+
async def get_project_by_id(
35+
project_id: ProjectIdPathDep,
36+
project_repository: ProjectRepositoryDep,
37+
) -> ProjectItem:
38+
"""Get project by its numeric ID.
39+
40+
This is the primary project retrieval method in v2, using direct database
41+
lookups for maximum performance.
42+
43+
Args:
44+
project_id: Numeric project ID
45+
46+
Returns:
47+
Project information
48+
49+
Raises:
50+
HTTPException: 404 if project not found
51+
52+
Example:
53+
GET /v2/projects/3
54+
"""
55+
logger.info(f"API v2 request: get_project_by_id for project_id={project_id}")
56+
57+
project = await project_repository.get_by_id(project_id)
58+
if not project:
59+
raise HTTPException(status_code=404, detail=f"Project with ID {project_id} not found")
60+
61+
return ProjectItem(
62+
id=project.id,
63+
name=project.name,
64+
path=normalize_project_path(project.path),
65+
is_default=project.is_default or False,
66+
)
67+
68+
69+
@router.patch("/{project_id}", response_model=ProjectStatusResponse)
70+
async def update_project_by_id(
71+
project_id: ProjectIdPathDep,
72+
project_service: ProjectServiceDep,
73+
project_repository: ProjectRepositoryDep,
74+
path: Optional[str] = Body(None, description="New absolute path for the project"),
75+
is_active: Optional[bool] = Body(None, description="Status of the project (active/inactive)"),
76+
) -> ProjectStatusResponse:
77+
"""Update a project's information by ID.
78+
79+
Args:
80+
project_id: Numeric project ID
81+
path: Optional new absolute path for the project
82+
is_active: Optional status update for the project
83+
84+
Returns:
85+
Response confirming the project was updated
86+
87+
Raises:
88+
HTTPException: 400 if validation fails, 404 if project not found
89+
90+
Example:
91+
PATCH /v2/projects/3
92+
{"path": "/new/path"}
93+
"""
94+
logger.info(f"API v2 request: update_project_by_id for project_id={project_id}")
95+
96+
try:
97+
# Validate that path is absolute if provided
98+
if path and not os.path.isabs(path):
99+
raise HTTPException(status_code=400, detail="Path must be absolute")
100+
101+
# Get original project info for the response
102+
old_project = await project_repository.get_by_id(project_id)
103+
if not old_project:
104+
raise HTTPException(
105+
status_code=404, detail=f"Project with ID {project_id} not found"
106+
)
107+
108+
old_project_info = ProjectItem(
109+
id=old_project.id,
110+
name=old_project.name,
111+
path=old_project.path,
112+
is_default=old_project.is_default or False,
113+
)
114+
115+
# Update using project name (service layer still uses names internally)
116+
if path:
117+
await project_service.move_project(old_project.name, path)
118+
elif is_active is not None:
119+
await project_service.update_project(old_project.name, is_active=is_active)
120+
121+
# Get updated project info
122+
updated_project = await project_repository.get_by_id(project_id)
123+
if not updated_project:
124+
raise HTTPException(
125+
status_code=404, detail=f"Project with ID {project_id} not found after update"
126+
)
127+
128+
return ProjectStatusResponse(
129+
message=f"Project '{updated_project.name}' updated successfully",
130+
status="success",
131+
default=(old_project.name == project_service.default_project),
132+
old_project=old_project_info,
133+
new_project=ProjectItem(
134+
id=updated_project.id,
135+
name=updated_project.name,
136+
path=updated_project.path,
137+
is_default=updated_project.is_default or False,
138+
),
139+
)
140+
except ValueError as e:
141+
raise HTTPException(status_code=400, detail=str(e))
142+
143+
144+
@router.delete("/{project_id}", response_model=ProjectStatusResponse)
145+
async def delete_project_by_id(
146+
project_id: ProjectIdPathDep,
147+
project_service: ProjectServiceDep,
148+
project_repository: ProjectRepositoryDep,
149+
delete_notes: bool = Query(
150+
False, description="If True, delete project directory from filesystem"
151+
),
152+
) -> ProjectStatusResponse:
153+
"""Delete a project by ID.
154+
155+
Args:
156+
project_id: Numeric project ID
157+
delete_notes: If True, delete the project directory from the filesystem
158+
159+
Returns:
160+
Response confirming the project was deleted
161+
162+
Raises:
163+
HTTPException: 400 if trying to delete default project, 404 if not found
164+
165+
Example:
166+
DELETE /v2/projects/3?delete_notes=false
167+
"""
168+
logger.info(
169+
f"API v2 request: delete_project_by_id for project_id={project_id}, delete_notes={delete_notes}"
170+
)
171+
172+
try:
173+
old_project = await project_repository.get_by_id(project_id)
174+
if not old_project:
175+
raise HTTPException(
176+
status_code=404, detail=f"Project with ID {project_id} not found"
177+
)
178+
179+
# Check if trying to delete the default project
180+
if old_project.name == project_service.default_project:
181+
available_projects = await project_service.list_projects()
182+
other_projects = [p.name for p in available_projects if p.id != project_id]
183+
detail = f"Cannot delete default project '{old_project.name}'. "
184+
if other_projects:
185+
detail += (
186+
f"Set another project as default first. Available: {', '.join(other_projects)}"
187+
)
188+
else:
189+
detail += "This is the only project in your configuration."
190+
raise HTTPException(status_code=400, detail=detail)
191+
192+
# Delete using project name (service layer still uses names internally)
193+
await project_service.remove_project(old_project.name, delete_notes=delete_notes)
194+
195+
return ProjectStatusResponse(
196+
message=f"Project '{old_project.name}' removed successfully",
197+
status="success",
198+
default=False,
199+
old_project=ProjectItem(
200+
id=old_project.id,
201+
name=old_project.name,
202+
path=old_project.path,
203+
is_default=old_project.is_default or False,
204+
),
205+
new_project=None,
206+
)
207+
except ValueError as e:
208+
raise HTTPException(status_code=400, detail=str(e))
209+
210+
211+
@router.put("/{project_id}/default", response_model=ProjectStatusResponse)
212+
async def set_default_project_by_id(
213+
project_id: ProjectIdPathDep,
214+
project_service: ProjectServiceDep,
215+
project_repository: ProjectRepositoryDep,
216+
) -> ProjectStatusResponse:
217+
"""Set a project as the default project by ID.
218+
219+
Args:
220+
project_id: Numeric project ID to set as default
221+
222+
Returns:
223+
Response confirming the project was set as default
224+
225+
Raises:
226+
HTTPException: 404 if project not found
227+
228+
Example:
229+
PUT /v2/projects/3/default
230+
"""
231+
logger.info(f"API v2 request: set_default_project_by_id for project_id={project_id}")
232+
233+
try:
234+
# Get the old default project
235+
default_name = project_service.default_project
236+
default_project = await project_service.get_project(default_name)
237+
if not default_project:
238+
raise HTTPException(
239+
status_code=404, detail=f"Default Project: '{default_name}' does not exist"
240+
)
241+
242+
# Get the new default project
243+
new_default_project = await project_repository.get_by_id(project_id)
244+
if not new_default_project:
245+
raise HTTPException(
246+
status_code=404, detail=f"Project with ID {project_id} not found"
247+
)
248+
249+
# Set as default using project name (service layer still uses names internally)
250+
await project_service.set_default_project(new_default_project.name)
251+
252+
return ProjectStatusResponse(
253+
message=f"Project '{new_default_project.name}' set as default successfully",
254+
status="success",
255+
default=True,
256+
old_project=ProjectItem(
257+
id=default_project.id,
258+
name=default_name,
259+
path=default_project.path,
260+
is_default=False,
261+
),
262+
new_project=ProjectItem(
263+
id=new_default_project.id,
264+
name=new_default_project.name,
265+
path=new_default_project.path,
266+
is_default=True,
267+
),
268+
)
269+
except ValueError as e:
270+
raise HTTPException(status_code=400, detail=str(e))

src/basic_memory/repository/project_repository.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,18 @@ async def get_by_path(self, path: Union[Path, str]) -> Optional[Project]:
4949
query = self.select().where(Project.path == Path(path).as_posix())
5050
return await self.find_one(query)
5151

52+
async def get_by_id(self, project_id: int) -> Optional[Project]:
53+
"""Get project by numeric ID.
54+
55+
Args:
56+
project_id: Numeric project ID
57+
58+
Returns:
59+
Project if found, None otherwise
60+
"""
61+
async with db.scoped_session(self.session_maker) as session:
62+
return await self.select_by_id(session, project_id)
63+
5264
async def get_default_project(self) -> Optional[Project]:
5365
"""Get the default project (the one marked as is_default=True)."""
5466
query = self.select().where(Project.is_default.is_not(None))

0 commit comments

Comments
 (0)