Skip to content

Commit 200f3ae

Browse files
committed
cache votes
1 parent e1aad39 commit 200f3ae

17 files changed

Lines changed: 606 additions & 22 deletions

File tree

‎.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,3 +221,5 @@ __marimo__/
221221

222222
# Streamlit
223223
.streamlit/secrets.toml
224+
225+
config/data/*

‎runtime/src/api/client.ts‎

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { validateResourceId } from "../feedback/resources.js";
22
import type { Resource } from "../feedback/resources.js";
3+
import type { Vote, VoteResult } from "../protocol/github.js";
34

45
export interface ReactionState {
56
id: string | null;
@@ -111,11 +112,47 @@ export class FeedbackClient {
111112
return { id: payload.id, number: payload.number };
112113
}
113114

114-
async #post(path: string, body: Record<string, unknown>, signal?: AbortSignal): Promise<Record<string, unknown>> {
115+
async vote(
116+
key: string,
117+
requested: Vote,
118+
token: AccessToken,
119+
signal?: AbortSignal,
120+
): Promise<VoteResult> {
121+
validateResourceId(key);
122+
const payload = await this.#post(
123+
"votes",
124+
{ key, vote: requested },
125+
signal,
126+
token.value,
127+
);
128+
const up = payload.up;
129+
const down = payload.down;
130+
const viewer = payload.viewer;
131+
if (
132+
!Number.isSafeInteger(up) || (up as number) < 0 ||
133+
!Number.isSafeInteger(down) || (down as number) < 0 ||
134+
(viewer !== "up" && viewer !== "down" && viewer !== "none")
135+
) {
136+
throw new TypeError("Invalid vote response");
137+
}
138+
return { up: up as number, down: down as number, viewer };
139+
}
140+
141+
async #post(
142+
path: string,
143+
body: Record<string, unknown>,
144+
signal?: AbortSignal,
145+
accessToken?: string,
146+
): Promise<Record<string, unknown>> {
115147
const url = new URL(`/v1/sites/${encodeURIComponent(this.#site)}/${path}`, this.#apiOrigin);
148+
const headers: Record<string, string> = {
149+
Accept: "application/json",
150+
"Content-Type": "application/json",
151+
};
152+
if (accessToken !== undefined) headers.Authorization = `Bearer ${accessToken}`;
116153
const init: RequestInit = {
117154
method: "POST",
118-
headers: { Accept: "application/json", "Content-Type": "application/json" },
155+
headers,
119156
body: JSON.stringify(body),
120157
};
121158
if (signal) init.signal = signal;

‎runtime/src/example/page.ts‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { FeedbackClient } from "../api/client.js";
1+
import { FeedbackClient, FeedbackError } from "../api/client.js";
22
import { Authentication } from "../auth/controller.js";
33
import { PendingVoteStore } from "../auth/pending-vote.js";
44
import { trustedOrigin } from "../auth/origin.js";
55
import { Stars } from "../feedback/stars.js";
6-
import { GitHubRequestError, viewerVote, vote, type Vote } from "../protocol/github.js";
6+
import type { Vote } from "../protocol/github.js";
77

88
const parameters = new URLSearchParams(location.search);
99
const site = parameters.get("site") ?? "feedback-cpp-social";
@@ -65,13 +65,12 @@ async function castVote(requested: Vote): Promise<void> {
6565
const discussion = await client.ensure(resource, token.creationGrant);
6666
discussionId = discussion.id;
6767
}
68-
const current = await viewerVote(token, discussionId);
69-
const result = await vote(token, discussionId, current, pending.vote);
68+
const result = await client.vote(resource.key, pending.vote, token);
7069
render(result.up, result.down);
7170
status.textContent = result.viewer === "none" ? "Vote removed." : `${result.viewer} vote saved.`;
7271
} catch (error) {
7372
pendingVotes.clear();
74-
if (error instanceof GitHubRequestError && error.status === 401) authentication.clear();
73+
if (error instanceof FeedbackError && error.status === 401) authentication.clear();
7574
showError(error);
7675
} finally {
7776
disable(false);

‎runtime/test/client.test.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,30 @@ void test("default browser fetch keeps its global receiver", async () => {
7777

7878
assert.equal(called, true);
7979
});
80+
81+
void test("votes are submitted through the service with the user token", async () => {
82+
let request: Request | undefined;
83+
const fetch = (input: URL | RequestInfo, init?: RequestInit): Promise<Response> => {
84+
request = new Request(input, init);
85+
return Promise.resolve(Response.json({
86+
v: 1,
87+
up: 12,
88+
down: 3,
89+
viewer: "up",
90+
}));
91+
};
92+
const client = new FeedbackClient({
93+
apiOrigin: "https://feedback-api.cpp.social",
94+
site: "cpp-social",
95+
fetch,
96+
});
97+
const token = { value: "ghu_user", expiresAt: 2_000_000_000, creationGrant: "grant" };
98+
99+
const result = await client.vote("feedback/example", "up", token);
100+
101+
assert.ok(request);
102+
assert.equal(request.url, "https://feedback-api.cpp.social/v1/sites/cpp-social/votes");
103+
assert.equal(request.headers.get("authorization"), "Bearer ghu_user");
104+
assert.deepEqual(await request.json(), { key: "feedback/example", vote: "up" });
105+
assert.deepEqual(result, { up: 12, down: 3, viewer: "up" });
106+
});

‎src/feedback/api/routes.py‎

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import hashlib
44
import json
55
import logging
6+
from contextlib import suppress
67
from typing import cast
78

89
from starlette.requests import Request
@@ -22,7 +23,9 @@
2223
from feedback.protocol.github.oauth import OAuthError
2324
from feedback.service.discussions import DiscussionError
2425
from feedback.service.oauth_state import GrantError, StateError
26+
from feedback.service.resources import ResourceError, validate_resource_id
2527
from feedback.service.runtime import FeedbackRuntime
28+
from feedback.service.votes import VoteError
2629

2730
oauth_logger = logging.getLogger("feedback.oauth")
2831
github_logger = logging.getLogger("feedback.github")
@@ -39,9 +42,11 @@ async def reactions(request: Request) -> Response:
3942
assert origin is not None
4043
return preflight(origin, method="GET", headers="If-None-Match")
4144
keys = resource_keys(request.query_params, site.max_batch_size)
42-
now = int(container.clock())
4345
cached = container.databases[site.id].reactions(keys)
44-
container.schedule_refresh(site, cached)
46+
with suppress(GitHubError):
47+
await container.refresh_stale(site, cached)
48+
cached = container.databases[site.id].reactions(keys)
49+
now = int(container.clock())
4550
items: dict[str, dict[str, str | int | bool | None]] = {}
4651
for key in keys:
4752
item = cached.get(key)
@@ -60,7 +65,10 @@ async def reactions(request: Request) -> Response:
6065
body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
6166
etag = f'"{hashlib.sha256(body).hexdigest()}"'
6267
headers = {
63-
"Cache-Control": "public, max-age=30, stale-while-revalidate=300",
68+
# Revalidate with the service so a successful vote is visible after a
69+
# reload. GitHub traffic is still bounded by the server-side freshness
70+
# window and coalesced refresh task.
71+
"Cache-Control": "no-cache",
6472
"ETag": etag,
6573
"Vary": "Origin",
6674
}
@@ -183,6 +191,52 @@ async def ensure_discussion(request: Request) -> Response:
183191
)
184192

185193

194+
async def submit_vote(request: Request) -> Response:
195+
container, site, origin = site_context(request, require_origin=True)
196+
assert origin is not None
197+
if container.votes is None:
198+
raise ApiError("service_unavailable", "Voting is unavailable.", 503)
199+
if request.method == "OPTIONS":
200+
return preflight(origin, method="POST", headers="Authorization, Content-Type")
201+
body = await json_strings(request, frozenset({"key", "vote"}))
202+
try:
203+
resource_id = validate_resource_id(body["key"])
204+
except ResourceError:
205+
raise ApiError("invalid_vote", "Vote parameters are invalid.", 400) from None
206+
if body["vote"] not in {"up", "down"}:
207+
raise ApiError("invalid_vote", "Vote parameters are invalid.", 400)
208+
cached = container.databases[site.id].reactions([resource_id])
209+
with suppress(GitHubError):
210+
await container.refresh_stale(site, cached)
211+
try:
212+
result = await container.votes.vote(
213+
container.databases[site.id],
214+
resource_id=resource_id,
215+
requested=body["vote"],
216+
token=bearer_token(request),
217+
)
218+
except VoteError as exc:
219+
raise ApiError("discussion_not_found", str(exc), 404) from exc
220+
except GitHubError as exc:
221+
github_logger.warning(
222+
"GitHub vote failed: code=%s status=%s github_request_id=%s site=%s origin=%s",
223+
exc.code,
224+
exc.status,
225+
exc.request_id,
226+
site.id,
227+
origin,
228+
)
229+
status = 401 if exc.status == 401 else 502
230+
raise ApiError("github_vote_failed", "GitHub rejected the vote.", status) from exc
231+
return cors(
232+
JSONResponse(
233+
{"v": 1, "up": result.up, "down": result.down, "viewer": result.viewer},
234+
headers={"Cache-Control": "no-store"},
235+
),
236+
origin,
237+
)
238+
239+
186240
async def api_error(request: Request, error: Exception) -> Response:
187241
assert isinstance(error, ApiError)
188242
response = error_response(error)
@@ -195,6 +249,20 @@ def services(request: Request) -> FeedbackRuntime:
195249
return cast(FeedbackRuntime, request.app.state.services)
196250

197251

252+
def bearer_token(request: Request) -> str:
253+
authorization = request.headers.get("authorization", "")
254+
scheme, separator, token = authorization.partition(" ")
255+
if (
256+
separator != " "
257+
or scheme.lower() != "bearer"
258+
or not token.startswith("ghu_")
259+
or not 8 <= len(token) <= 512
260+
or any(character.isspace() for character in token)
261+
):
262+
raise ApiError("invalid_token", "A GitHub user token is required.", 401)
263+
return token
264+
265+
198266
def site_context(
199267
request: Request, *, require_origin: bool = False
200268
) -> tuple[FeedbackRuntime, SiteConfig, str | None]:

‎src/feedback/app.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,19 @@
2222
oauth_authorize,
2323
oauth_exchange,
2424
reactions,
25+
submit_vote,
2526
)
2627
from feedback.config import Config, load_config_from_environment
2728
from feedback.database.sqlite import SiteDatabase
2829
from feedback.protocol.github.client import GitHubClient
2930
from feedback.protocol.github.discussions import GitHubDiscussions
3031
from feedback.protocol.github.oauth import OAuthClient
32+
from feedback.protocol.github.votes import GitHubVotes
3133
from feedback.service.discussions import DiscussionService
3234
from feedback.service.oauth_state import CreationGrantSigner, StateSigner
3335
from feedback.service.reaction_cache import ReactionRefresher
3436
from feedback.service.runtime import FeedbackRuntime
37+
from feedback.service.votes import VoteService
3538

3639
logger = logging.getLogger("feedback.runtime")
3740

@@ -44,6 +47,7 @@ def create_app(
4447
refresher: ReactionRefresher | None = None,
4548
grants: CreationGrantSigner | None = None,
4649
discussions: DiscussionService | None = None,
50+
votes: VoteService | None = None,
4751
) -> Starlette:
4852
if oauth is not None and grants is None:
4953
raise ValueError("OAuth and creation grants must be configured together")
@@ -56,6 +60,7 @@ async def lifespan(application: Starlette) -> AsyncIterator[None]:
5660
resolved_refresher: ReactionRefresher | None
5761
resolved_grants: CreationGrantSigner | None
5862
resolved_discussions: DiscussionService | None
63+
resolved_votes: VoteService | None
5964
if config is None and oauth is None:
6065
owned_http = httpx.AsyncClient(
6166
timeout=httpx.Timeout(
@@ -70,11 +75,13 @@ async def lifespan(application: Starlette) -> AsyncIterator[None]:
7075
github = _github_client(loaded, owned_http, clock)
7176
resolved_refresher = ReactionRefresher(github, clock=clock)
7277
resolved_discussions = DiscussionService(GitHubDiscussions(github))
78+
resolved_votes = VoteService(GitHubVotes(owned_http))
7379
else:
7480
resolved_oauth = oauth
7581
resolved_refresher = refresher
7682
resolved_grants = grants
7783
resolved_discussions = discussions
84+
resolved_votes = votes
7885
databases = {
7986
site_id: SiteDatabase(loaded.service.data_directory / f"{site_id}.sqlite3")
8087
for site_id in loaded.sites
@@ -89,6 +96,7 @@ async def lifespan(application: Starlette) -> AsyncIterator[None]:
8996
refresher=resolved_refresher,
9097
grants=resolved_grants,
9198
discussions=resolved_discussions,
99+
votes=resolved_votes,
92100
)
93101
application.state.services = services
94102
logger.info(
@@ -122,6 +130,11 @@ async def lifespan(application: Starlette) -> AsyncIterator[None]:
122130
ensure_discussion,
123131
methods=["POST", "OPTIONS"],
124132
),
133+
Route(
134+
"/v1/sites/{site}/votes",
135+
submit_vote,
136+
methods=["POST", "OPTIONS"],
137+
),
125138
],
126139
lifespan=lifespan,
127140
exception_handlers={ApiError: api_error},
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
UPDATE discussions
2+
SET
3+
up_count = MAX(0, up_count + ?),
4+
down_count = MAX(0, down_count + ?)
5+
WHERE resource_id = ? AND github_node_id = ?
6+
RETURNING up_count, down_count

‎src/feedback/database/sqlite.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
DISCUSSION = load("discussion")
1616
PUT_DISCUSSION = load("put_discussion")
1717
UPDATE_REACTIONS = load("update_reactions")
18+
ADJUST_REACTIONS = load("adjust_reactions")
1819

1920

2021
class DatabaseError(RuntimeError):
@@ -124,3 +125,18 @@ def update_reactions(
124125
(up, down, locked, github_updated_at, fetched_at, node_id),
125126
)
126127
return cursor.rowcount == 1
128+
129+
def adjust_reactions(
130+
self,
131+
*,
132+
resource_id: str,
133+
node_id: str,
134+
up_delta: int,
135+
down_delta: int,
136+
) -> tuple[int, int] | None:
137+
with self.connect() as connection:
138+
row = connection.execute(
139+
ADJUST_REACTIONS,
140+
(up_delta, down_delta, resource_id, node_id),
141+
).fetchone()
142+
return (row[0], row[1]) if row is not None else None
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
query FeedbackViewerVote($id: ID!) {
2+
node(id: $id) {
3+
... on Discussion {
4+
reactionGroups {
5+
content
6+
viewerHasReacted
7+
}
8+
}
9+
}
10+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
mutation FeedbackVote(
2+
$id: ID!
3+
$removeUp: Boolean!
4+
$removeDown: Boolean!
5+
$addUp: Boolean!
6+
$addDown: Boolean!
7+
) {
8+
removeUp: removeReaction(input: {subjectId: $id, content: THUMBS_UP})
9+
@include(if: $removeUp) { subject { ...FeedbackReactions } }
10+
removeDown: removeReaction(input: {subjectId: $id, content: THUMBS_DOWN})
11+
@include(if: $removeDown) { subject { ...FeedbackReactions } }
12+
addUp: addReaction(input: {subjectId: $id, content: THUMBS_UP})
13+
@include(if: $addUp) { subject { ...FeedbackReactions } }
14+
addDown: addReaction(input: {subjectId: $id, content: THUMBS_DOWN})
15+
@include(if: $addDown) { subject { ...FeedbackReactions } }
16+
}
17+
18+
fragment FeedbackReactions on Reactable {
19+
reactionGroups {
20+
content
21+
users { totalCount }
22+
viewerHasReacted
23+
}
24+
}

0 commit comments

Comments
 (0)