Skip to content

Commit 5139258

Browse files
committed
WIP: adding CMS support to breadbox
1 parent 786d970 commit 5139258

File tree

4 files changed

+200
-0
lines changed

4 files changed

+200
-0
lines changed

breadbox/breadbox/api/dependencies.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,9 @@ def get_dataset(
6464
raise DatasetNotFoundError(f"Dataset '{dataset_id}' not found")
6565

6666
return dataset
67+
68+
69+
def get_cms_client(settings=Depends(get_settings),):
70+
from ..service import cms
71+
72+
return cms.PayloadClient(settings.payload_url, settings.payload_api_key)

breadbox/breadbox/api/temp/cms.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from typing import Dict, Any, List
2+
import re
3+
4+
from sqlalchemy.sql.annotation import Annotated
5+
6+
from .router import router
7+
from ...service import cms
8+
from fastapi import Request, Depends
9+
from ..dependencies import get_cms_client
10+
11+
# this endpoint exists to decouple the front end from payload by preventing any direct access. This
12+
# has a few advantages:
13+
#
14+
# 1. The front end doesn't need to worry about access to payload, just access to breadbox. From the perspective
15+
# of the front end, there's only breadbox.
16+
# 2. We can add a caching layer inside breadbox to avoid every page requesting content from payload. This
17+
# will allow us to freely restart payload/taking it offline for periods of time without impacting
18+
# any running portals. As a design principal, we want every portal to only rely on the services running
19+
# on the deployed server as much as possible to isolate ourselves from other services' outages.
20+
21+
22+
@router.get("/cms/{collection_type}", operation_id="get_cms_documents")
23+
async def get_cms_documents(
24+
collection_type: str,
25+
request: Request,
26+
payload_client: Annotated[cms.PayloadClient, Depends(get_cms_client)],
27+
) -> List[Dict[str, Any]]:
28+
"""
29+
Retrieve documents from Payload CMS with filtering.
30+
31+
Query parameters format: prop.<property_name>.eq=<value>
32+
33+
Example:
34+
GET /cms/posts?prop.status.eq=published&prop.author.eq=john
35+
"""
36+
37+
# Parse query parameters
38+
filters = {}
39+
query_params = dict(request.query_params)
40+
41+
for key, value in query_params.items():
42+
# Parse prop.<property_name>.eq format
43+
m = re.match("prop\\.([^.]+)\\.eq")
44+
if m:
45+
# Extract property name
46+
prop_name = m.group(1)
47+
filters[prop_name] = value
48+
49+
# Fetch documents from Payload CMS
50+
result = await payload_client.fetch(collection_type, filters)
51+
52+
return result

breadbox/breadbox/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ class Settings(BaseSettings):
3131

3232
LEGACY_CAS_BUCKET: Optional[str] = os.environ.get("LEGACY_CAS_BUCKET")
3333

34+
# the url for the Payload CMS which is used by the "cms" endpoints
35+
payload_url: str = ""
36+
payload_api_key: str = ""
37+
3438
model_config = SettingsConfigDict(
3539
env_file=os.environ.get("BREADBOX_SETTINGS_PATH", ".env"),
3640
)

breadbox/breadbox/service/cms.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
from dataclasses import dataclass, replace
2+
from typing import Any, Dict, Protocol, List
3+
import httpx
4+
from fastapi import HTTPException
5+
6+
7+
class ContentClient(Protocol):
8+
async def fetch(
9+
self, collection_type: str, filters: Dict[str, str]
10+
) -> List[Dict[str, Any]]:
11+
...
12+
13+
14+
class PayloadClient:
15+
url: str
16+
api_key: str
17+
18+
def __init__(self, url: str, api_key: str):
19+
self.url = url
20+
self.api_key = api_key
21+
22+
async def fetch(
23+
self, collection_type: str, filters: Dict[str, str]
24+
) -> List[Dict[str, Any]]:
25+
"""
26+
Fetch documents from Payload CMS with optional filtering.
27+
28+
Args:
29+
collection_type: The Payload collection name
30+
filters: Dictionary of property filters
31+
32+
Returns:
33+
Response from Payload CMS API
34+
"""
35+
url = f"{self.url}/api/{collection_type}"
36+
37+
# Build query parameters for Payload's where clause
38+
where_conditions = {}
39+
for prop, value in filters.items():
40+
where_conditions[prop] = {"equals": value}
41+
42+
params = {}
43+
if where_conditions:
44+
# Payload uses a 'where' parameter with JSON
45+
import json
46+
47+
params["where"] = json.dumps(where_conditions)
48+
49+
headers = {}
50+
if self.api_key:
51+
headers["Authorization"] = f"Bearer {self.api_key}"
52+
53+
async with httpx.AsyncClient() as client:
54+
try:
55+
response = await client.get(
56+
url, params=params, headers=headers, timeout=30.0
57+
)
58+
response.raise_for_status()
59+
return response.json()
60+
except httpx.HTTPError as e:
61+
raise HTTPException(
62+
status_code=500, detail=f"Error fetching from Payload CMS: {str(e)}"
63+
)
64+
65+
66+
class CacheClient:
67+
cache_dir: str
68+
69+
def __init__(self, cache_dir: str):
70+
self.cache_dir = cache_dir
71+
72+
async def fetch(
73+
self, collection_type: str, filters: Dict[str, str]
74+
) -> List[Dict[str, Any]]:
75+
raise NotImplementedError()
76+
77+
def update(
78+
self,
79+
collection_type: str,
80+
documents: List[Dict[str, Any]],
81+
replace_all: bool = False,
82+
):
83+
# used for populating the cache
84+
raise NotImplementedError()
85+
86+
87+
# Turns out none of this is needed. Will delete...
88+
# class WrongNumberOfMatches(Exception):
89+
# pass
90+
#
91+
# class TooManyMatches(WrongNumberOfMatches):
92+
# pass
93+
#
94+
# class NoMatches(WrongNumberOfMatches):
95+
# pass
96+
#
97+
# @dataclass(frozen=True)
98+
# class Query:
99+
# client : ContentClient
100+
# content_type: str
101+
# filters : Dict[str, str]
102+
#
103+
# def filter_by(self, **filters):
104+
# new_filters = dict(self.filters, **filters)
105+
# return replace(self, filters=new_filters)
106+
#
107+
# async def one(self):
108+
# result = await self.client.fetch(self.content_type, self.filters)
109+
# if len(result) == 1:
110+
# return result[0]
111+
# elif len(result) == 0:
112+
# raise NoMatches()
113+
# else:
114+
# raise TooManyMatches()
115+
#
116+
# async def all(self):
117+
# return await self.client.fetch(self.content_type, self.filters)
118+
#
119+
# async def one_or_none(self):
120+
# result = await self.client.fetch(self.content_type, self.filters)
121+
# if len(result) == 1:
122+
# return result[0]
123+
# elif len(result) == 0:
124+
# return None
125+
# else:
126+
# raise TooManyMatches()
127+
#
128+
# class CMS:
129+
# """
130+
# A slim wrapper around a client which provides some sqlalchemy api-like query behavior where we can
131+
# """
132+
#
133+
# client : ContentClient
134+
# def __init__(self,client : ContentClient):
135+
# self.client = client
136+
#
137+
# def query(self, content_type: str):
138+
# return

0 commit comments

Comments
 (0)