Question on API Design
Posted by OrganizationOnly8016@reddit | learnprogramming | View on Reddit | 3 comments
Hi, I've been working on building an API for a very simple project-management system just to teach myself the basics and I've stumbled upon a confusing use-case.
The world of the system looks like this
Organizations contain ProjectsProjects contain BucketsBuckets contain TasksTasks contain Subtasks + Comments
[]()
I've got the following roles:
1. ORG_MEMBER: Organization members are allowed to
- Creation of projects
2. ORG_ADMIN: Organization admins are allowed to
- CRUD of organization members - the C in CRUD here refers to "inviting" members...
atop all access rights of organization members
3. PROJ_MEMBER: Project members are allowed to
- CRUD of tasks
- Comments on all tasks within project
- View project history
4. PROJ_MANAGER: Project managers are allowed to
- RUD of projects
- CRUD of buckets
- CRUD of project members (add organization members into project, remove project users from project)
Since the "creation of a project" rests at the scope of an organization, and not at the scope of a project (because it doesn't exist yet), I'm having a hard time figuring out which dependency to inject into the route.
def get_current_user(token: HTTPAuthorizationCredentials = Depends(token_auth_scheme)):
try:
user_response = supabase.auth.get_user(token.credentials)
supabase_user = user_response.user
if not supabase_user:
raise HTTPException(
status_code=401,
detail="Invalid token or user not found."
)
auth_id = supabase_user.id
user_data = supabase.table("users").select("*").eq("user_id", str(auth_id)).execute()
if not user_data.data:
raise HTTPException(
status_code=404,
detail="User not found in database."
)
user_data = user_data.data[0]
return User(
user_id=user_data["user_id"],
user_name=user_data["user_name"],
email_id=user_data["email_id"],
full_name=user_data["full_name"]
)
except Exception as e:
raise HTTPException(
status_code=401,
detail=f"Invalid token or user not found: {e}"
)
def get_org_user(org_id: str, user: User = Depends(get_current_user)):
res = supabase.table("org_users").select("*").eq("user_id", user.user_id).eq("org_id", org_id).single().execute()
if not res.data:
raise HTTPException(
status_code=403,
detail="User is not a member of this organization."
)
return OrgUser(
user_id=res.data["user_id"],
org_id=res.data["org_id"],
role=res.data["role"]
)
def get_proj_user(proj_id: str, user: User = Depends(get_current_user)):
res = supabase.table("proj_users").select("*").eq("user_id", user.user_id).eq("proj_id", proj_id).single().execute()
if not res.data:
raise HTTPException(
status_code=403,
detail="User is not a member of this project."
)
return ProjUser(
user_id=res.data["user_id"],
proj_id=res.data["proj_id"],
role=res.data["role"]
)
Above are what my dependencies are...
this is essentially my dependency factory
# rbac dependency factory
class EntityPermissionChecker:
def __init__(self, required_permission: str, entity_type: str):
self.required_permission = required_permission
self.entity_type = entity_type
self.db = supabase
def __call__(self, request: Request, user: User = Depends(get_current_user)):
if self.entity_type == "org":
view_name = "org_permissions_view"
id_param = "org_id"
elif self.entity_type == "project":
view_name = "proj_permissions_view"
id_param = "proj_id"
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Invalid entity type for permission checking."
)
entity_id = request.path_params.get(id_param)
if not entity_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Missing {id_param} in request path."
)
response = self.db.table(view_name).select("permission_name").eq("user_id", user.user_id).eq(id_param, entity_id).eq("permission_name", self.required_permission).execute()
if not response.data:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="you do not have permission to perform this action."
)
return True
i've got 3 ways to write the POST/ route for creating a project...
- Either i inject the normal User dependency @/router.post( "/", response_model=APIResponse[ProjectResponse], status_code=status.HTTP_201_CREATED ) def create_project( org_id: str, project_data: ProjectCreate, user: User= Depends(get_current_user) ): data = ProjectService().create_project(project_data, user.user_id) return { "message": "Project created successfully", "data": data }
so the route would be POST: projects/ with a body :
class ProjectCreate(BaseModel):
proj_name: str
org_id: str
and here i let the ProjectService handle the verification of the user's permissions
-
or i inject an OrgUser instead
@/router.post( "/org/{org_id}", response_model=APIResponse[ProjectResponse], status_code=status.HTTP_201_CREATED, dependencies=[Depends(EntityPermissionChecker("create:organization", "org"))] ) def create_project( project_data: ProjectCreate, user: OrgUser = Depends(get_org_user) # has to depend on an OrgUser, because creating a project is at the scope of an org (proj hasn't been created yet!) ): data = ProjectService().create_project(project_data, user.user_id) return { "message": "Project created successfully", "data": data }
and have the route look like POST:/projects/org/{org_id} which looks nasty, and have the body be
class ProjectCreate(BaseModel):
proj_name: str
-
or i just create the route within the organizations_router.py (where i have the CRUD routes for the organizations...)
@/router.post( "/{org_id}/project", response_model=APIResponse[ProjectResponse], status_code=status.HTTP_201_CREATED, dependencies=[Depends(EntityPermissionChecker("create:project", "org"))] ) def create_project_in_org( org_id: str, project_data: ProjectCreate, user: OrgUser = Depends(get_org_user) ): data = ProjectService().create_project(project_data, user.user_id) return { "message": "Project created successfully within organization.", "data": data }
and the route looks like POST:/organizations/{org_id}/projects ....
but then all project related routes don't fall under the projects_router.py and the POST/ one alone falls under organizations_router.py
I personally think the 3rd one is best, but is there a better alternative?
Opposite-Dance-8264@reddit
Your third option is definitely the best approach here. It makes total sense to have POST /organizations/{org_id}/projects since you're creating a project within context of an organization
The fact that this one route lives in organizations_router while others are in projects_router isn't really a problem - it's actually good REST design. You're modeling the resource hierarchy correctly. Think about it like how GitHub does it - you create repositories under organizations but manage them separately
For what it's worth, you could also consider having both routes if you want - keep the nested one for creation and have projects_router handle everything else. Some APIs do this pattern where creation happens in context of parent resource but management happens at resource level
Your dependency injection logic looks solid too, just make sure your get_org_user function can handle the org_id from path params properly
OrganizationOnly8016@reddit (OP)
Thank you for the input! I went ahead with the 3rd one with another one of my routers too, seems to be the best option here :)
Able-Preparation843@reddit
Good question! This is a classic nested resource routing problem. Since you're building this to learn, here's my take:
**Your 3rd approach is indeed the best among the options.** Here's why:
**POST /organizations/{org_id}/projects** is the right choice because creating a project inherently requires an organization context. The project belongs to the org, so the org_id is a required path parameter.
**For other project routes, keep them under projects_router.py** - e.g.,
- GET /projects/{project_id}
- PUT /projects/{project_id}
- DELETE /projects/{project_id}
This keeps your routing clean and RESTful. The project_id uniquely identifies a project across the entire system.
- When CREATING a child resource, include the parent in the path (POST /orgs/{org_id}/projects)
- When accessing an EXISTING child resource, just use its own ID (GET /projects/{proj_id})
**A better alternative pattern you could consider:**
Use **scoped lookups** for the create endpoint but still route it under projects:
```
POST /projects?org_id={org_id}
```
Or stick with your current approach which is totally fine:
```
POST /organizations/{org_id}/projects (under orgs_router)
GET/PUT/DELETE /projects/{id} (under projects_router)
```
This hybrid approach is what many real-world APIs use (think GitHub API: POST /repos/{owner}/{repo}/issues but GET /issues/{issue_id}).
One more thing - since you're using Supabase, make sure your RLS (Row Level Security) policies handle the org/project/bucket hierarchy correctly. That's usually where the real complexity lies!
What's your backend stack beyond FastAPI + Supabase?