Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
Matrix Directory is a community-driven web application for discovering projects
in the Matrix ecosystem, including bots, frameworks, SDKs, and other tools.

It provides a Vue frontend for discovering and managing listings and a FastAPI
It provides a Vue frontend for discovering and managing projects and a FastAPI
backend for authentication, profiles, project ownership, and directory data.

## Tech stack
Expand Down
7 changes: 6 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware

Expand Down Expand Up @@ -58,6 +58,11 @@ def mount_static_sites(application: FastAPI, static_directory: Path) -> None:
frontend_assets = static_directory / "assets"

if documentation_directory.is_dir():

@application.get("/docs", include_in_schema=False)
def documentation_root_redirect() -> RedirectResponse:
return RedirectResponse(url="/docs/", status_code=307)

application.mount(
"/docs",
StaticFiles(directory=documentation_directory, html=True),
Expand Down
34 changes: 32 additions & 2 deletions backend/app/routers/project_routers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from uuid import UUID

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlmodel import Session

from app.database import get_session
Expand All @@ -23,9 +23,21 @@

@router.get("/", response_model=list[ProjectRead])
def list_projects(
q: str | None = Query(default=None, max_length=200),
project_type: str | None = Query(default=None, max_length=100),
label: str | None = Query(default=None, max_length=100),
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
session: Session = Depends(get_session),
) -> list[Project]:
return projects_service.list_projects(session)
return projects_service.list_projects(
session,
query=q,
project_type=project_type,
label=label,
limit=limit,
offset=offset,
)


@router.get("/mine/", response_model=list[ProjectRead])
Expand All @@ -36,6 +48,24 @@ def list_my_projects(
return projects_service.list_projects_for_user(session, user_id=user.id)


@router.get("/count/", response_model=int)
def count_projects(
session: Session = Depends(get_session),
) -> int:
return projects_service.count_projects(session)


@router.get("/random/", response_model=list[ProjectRead])
def list_random_projects(
limit: int = Query(default=6, ge=1, le=24),
session: Session = Depends(get_session),
) -> list[Project]:
return projects_service.list_random_projects(
session,
limit=limit,
)


@router.get("/{project_id}", response_model=ProjectRead)
def get_project(
project_id: UUID,
Expand Down
87 changes: 84 additions & 3 deletions backend/app/services/projects_service.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,102 @@
from uuid import UUID

from sqlmodel import Session, select
from sqlalchemy import func, or_
from sqlmodel import Session, col, select

from app.models.label import Label
from app.models.profile import Profile
from app.models.project import Project
from app.models.project_label import ProjectLabel
from app.models.project_type import ProjectType
from app.schemas.project import ProjectCreate, ProjectUpdate

from . import labels_service, project_types_service
from .errors import ProjectLinkRequiredError


def list_projects(session: Session) -> list[Project]:
def list_projects(
session: Session,
*,
query: str | None = None,
project_type: str | None = None,
label: str | None = None,
limit: int = 50,
offset: int = 0,
) -> list[Project]:
statement = select(Project)

if query:
pattern = f"%{query.strip()}%"
matching_project_types = select(ProjectType.id).where(
col(ProjectType.name).ilike(pattern)
)
matching_project_labels = (
select(ProjectLabel.project_id)
.join(Label, col(ProjectLabel.label_id) == col(Label.id))
.where(col(Label.name).ilike(pattern))
)
matching_owners = select(Profile.user_id).where(
or_(
col(Profile.display_name).ilike(pattern),
col(Profile.matrix_id).ilike(pattern),
)
)
statement = statement.where(
or_(
col(Project.name).ilike(pattern),
col(Project.short_description).ilike(pattern),
col(Project.description).ilike(pattern),
col(Project.project_type_id).in_(matching_project_types),
col(Project.id).in_(matching_project_labels),
col(Project.user_id).in_(matching_owners),
)
)

if project_type:
matching_project_type = select(ProjectType.id).where(
ProjectType.name == project_type
)
statement = statement.where(
col(Project.project_type_id).in_(matching_project_type)
)

if label:
matching_label = (
select(ProjectLabel.project_id)
.join(Label, col(ProjectLabel.label_id) == col(Label.id))
.where(col(Label.name) == label)
)
statement = statement.where(col(Project.id).in_(matching_label))

statement = (
statement.order_by(col(Project.created_at).desc()).offset(offset).limit(limit)
)
return list(session.exec(statement).all())


def count_projects(session: Session) -> int:
statement = select(func.count()).select_from(Project)
return int(session.exec(statement).one())


def list_random_projects(
session: Session,
*,
limit: int = 6,
) -> list[Project]:
statement = select(Project).order_by(func.random()).limit(limit)
return list(session.exec(statement).all())


def list_projects_for_user(session: Session, *, user_id: UUID) -> list[Project]:
statement = select(Project).where(Project.user_id == user_id)
statement = (
select(Project)
.where(Project.user_id == user_id)
.order_by(
col(Project.updated_at).desc(),
col(Project.created_at).desc(),
)
)
return list(session.exec(statement).all())


Expand Down
70 changes: 70 additions & 0 deletions backend/tests/test_project_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,76 @@ def test_create__expect_project_associated_with_authenticated_user(
assert [label["id"] for label in body["labels"]] == [str(project_client.label_id)]


def test_random_projects__expect_requested_limit_and_total_count(
project_client: ProjectClient,
) -> None:
for index in range(3):
response = project_client.client.post(
"/api/projects/",
json={
"name": f"Test Project {index}",
"description": "A useful project.",
"short_description": "Useful project",
"repository_url": f"https://example.com/project-{index}",
"project_type_id": str(project_client.project_type_id),
"label_ids": [],
},
)
assert response.status_code == 201

random_response = project_client.client.get("/api/projects/random/?limit=2")
count_response = project_client.client.get("/api/projects/count/")

assert random_response.status_code == 200
assert len(random_response.json()) == 2
assert count_response.status_code == 200
assert count_response.json() == 3


@pytest.mark.parametrize("limit", [0, 25])
def test_random_projects_with_invalid_limit__expect_validation_error(
project_client: ProjectClient,
limit: int,
) -> None:
response = project_client.client.get(f"/api/projects/random/?limit={limit}")

assert response.status_code == 422


def test_list_projects__expect_server_side_search_and_filters(
project_client: ProjectClient,
) -> None:
for name, labels in [
("Alpha Bridge", [str(project_client.label_id)]),
("Beta Bot", []),
]:
response = project_client.client.post(
"/api/projects/",
json={
"name": name,
"description": f"Description for {name}",
"short_description": name,
"repository_url": f"https://example.com/{name.lower().replace(' ', '-')}",
"project_type_id": str(project_client.project_type_id),
"label_ids": labels,
},
)
assert response.status_code == 201

response = project_client.client.get(
"/api/projects/",
params={
"q": "alpha",
"project_type": "Bot",
"label": "Utility",
"limit": 24,
},
)

assert response.status_code == 200
assert [project["name"] for project in response.json()] == ["Alpha Bridge"]


@pytest.mark.parametrize(
("project_type_id", "label_ids", "message"),
[
Expand Down
15 changes: 13 additions & 2 deletions backend/tests/test_static_sites.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,21 @@ def test_documentation_static_files__expect_served_under_docs(
encoding="utf-8",
)

application = FastAPI()
application = FastAPI(
docs_url=None,
redoc_url=None,
openapi_url=None,
)
mount_static_sites(application, static_directory)
client = TestClient(application)

home_response = client.get("/docs/")
home_redirect_response = client.get("/docs", follow_redirects=False)
guide_response = client.get("/docs/guide/")

assert home_response.status_code == 200
assert home_redirect_response.status_code == 307
assert home_redirect_response.headers["location"] == "/docs/"
assert "Documentation home" in home_response.text
assert guide_response.status_code == 200
assert "Documentation guide" in guide_response.text
Expand All @@ -57,7 +64,11 @@ def test_reserved_routes__expect_not_fall_through_to_frontend(
encoding="utf-8",
)

application = FastAPI()
application = FastAPI(
docs_url=None,
redoc_url=None,
openapi_url=None,
)
mount_static_sites(application, static_directory)
client = TestClient(application)

Expand Down
34 changes: 17 additions & 17 deletions docs/docs/guides/submitting-a-project.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,31 +16,31 @@ Prepare the following information:
You may also provide a Matrix room and indicate whether the project supports
end-to-end encrypted rooms.

!!! note "Listings are public"
!!! note "Projects are public"
Do not include secrets, access tokens, private room links, or other
information that should not appear in the public directory.

## Publish a listing
## Publish a project

1. Sign in to Matrix Directory.
2. Open your dashboard.
3. Select **Add listing**.
4. Complete the required fields. The listing status panel shows which required
3. Select **Add project**.
4. Complete the required fields. The project status panel shows which required
information is still missing.
5. Review the directory-card preview.
6. Select **Publish listing**.
6. Select **Publish project**.

The listing is associated with your authenticated account and published
A project is associated with your authenticated account and published
immediately. After a successful submission, the application redirects you to
the new public listing.
the new public view of the project.

## Types and labels

A project type describes what the project **is**. Every listing has exactly
A project type describes what the project **is**. Every project has exactly
one of the initial directory types: **Bot**, **SDK**, **Framework**,
**Bridges**, **Clients**, **Server**, or **Integrations**.

Labels describe what the project **does**. A listing may have any number of
Labels describe what the project **does**. A project may have any number of
labels, including none. Examples include **Dev tools** and **Utility**.

This separation lets visitors filter by both the project format and its
Expand All @@ -63,10 +63,10 @@ purpose.
URLs must be absolute `http://` or `https://` URLs no longer than 255
characters.

At least one repository or website must remain on the listing when it is
At least one repository or website must remain on the project when it is
updated later.

!!! note "Listings are public"
!!! note "Projects are public"
Do not include secrets, access tokens, private room links, or other
information that should not appear in the public directory.

Expand All @@ -84,25 +84,25 @@ The **About** editor supports CommonMark formatting, including:

Raw HTML is displayed as text rather than interpreted as page markup.

Images embedded with Markdown are not rendered on saved listings. When the
Images embedded with Markdown are not rendered on saved projects. When the
Markdown is parsed, image nodes are reduced to their alternative text to
prevent a listing from making visitors contact an untrusted image host. Use a
prevent a project from making visitors contact an untrusted image host. Use a
normal link when readers need access to a screenshot or diagram.

## Manage your listings
## Manage your projects

Open the dashboard to see projects owned by your account. Ownership comes from
your authenticated session; it cannot be assigned to another user through the
submission form.

Only an owner can update or delete their listing. Deleting a listing is
Only an owner can update or delete their project. Deleting a project is
permanent, so confirm that you selected the intended project before proceeding.

## Troubleshooting

If a listing cannot be published:
If a project cannot be published:

- Check the listing status panel for missing required fields.
- Check the project status panel for missing required fields.
- Confirm that every URL is absolute and begins with `http://` or `https://`.
- Confirm that at least one repository or website is present.
- Confirm that at least one category is selected.
Expand Down
Loading