emrgilhzp: Proxy the home feed with cursor paging
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import { SESSION_COOKIE } from "@/lib/cookie";
|
||||||
|
import { clampLimit, getHomeFeedPage } from "@/lib/feed";
|
||||||
|
|
||||||
|
// GET /api/feed?cursor=<nextCursor>&limit=<n>
|
||||||
|
// Same-origin proxy for the Minnect home feed. Returns { items, nextCursor }.
|
||||||
|
// 401 { error: "sign_in" } when the session is missing or dead.
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const sessionId = req.cookies.get(SESSION_COOKIE)?.value;
|
||||||
|
const cursor = req.nextUrl.searchParams.get("cursor") || null;
|
||||||
|
const limit = clampLimit(req.nextUrl.searchParams.get("limit"));
|
||||||
|
|
||||||
|
const result = await getHomeFeedPage(sessionId, cursor, limit);
|
||||||
|
if (!result.ok) {
|
||||||
|
return NextResponse.json({ error: result.error }, { status: result.status });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ items: result.items, nextCursor: result.nextCursor });
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { FEED_BASE_URL } from "./env";
|
||||||
|
import { ensureFreshSession, refreshSession } from "./session-refresh";
|
||||||
|
|
||||||
|
// Home-feed proxy logic. Reads one page of the person's home feed from Minnect
|
||||||
|
// with their server-held feed token. Tokens never leave the server and are
|
||||||
|
// never logged. Card-field extraction is a later task; items pass through raw.
|
||||||
|
|
||||||
|
export const DEFAULT_LIMIT = 20;
|
||||||
|
export const MAX_LIMIT = 50;
|
||||||
|
|
||||||
|
export type FeedPageResult =
|
||||||
|
| { ok: true; items: unknown[]; nextCursor: string | null }
|
||||||
|
| { ok: false; status: 401 | 502; error: "sign_in" | "feed_unavailable" };
|
||||||
|
|
||||||
|
interface FeedOptions {
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
now?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamp a requested page size to 1..50, defaulting to 20. */
|
||||||
|
export function clampLimit(raw: string | number | null | undefined): number {
|
||||||
|
const n = typeof raw === "number" ? raw : Number.parseInt(raw ?? "", 10);
|
||||||
|
if (!Number.isFinite(n) || n < 1) return DEFAULT_LIMIT;
|
||||||
|
return Math.min(Math.floor(n), MAX_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUrl(cursor: string | null, limit: number): string {
|
||||||
|
const params = new URLSearchParams({ limit: String(limit) });
|
||||||
|
if (cursor) params.set("cursor", cursor);
|
||||||
|
return `${FEED_BASE_URL}/api/v1/feeds/home/items?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callFeed(
|
||||||
|
token: string,
|
||||||
|
cursor: string | null,
|
||||||
|
limit: number,
|
||||||
|
doFetch: typeof fetch,
|
||||||
|
): Promise<Response> {
|
||||||
|
return doFetch(buildUrl(cursor, limit), {
|
||||||
|
method: "GET",
|
||||||
|
headers: { Accept: "application/json", Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHomeFeedPage(
|
||||||
|
sessionId: string | undefined,
|
||||||
|
cursor: string | null,
|
||||||
|
limit: number,
|
||||||
|
options: FeedOptions = {},
|
||||||
|
): Promise<FeedPageResult> {
|
||||||
|
const signIn = { ok: false, status: 401, error: "sign_in" } as const;
|
||||||
|
const doFetch = options.fetchImpl ?? fetch;
|
||||||
|
|
||||||
|
let session = await ensureFreshSession(sessionId, options);
|
||||||
|
if (!session || !sessionId) return signIn;
|
||||||
|
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await callFeed(session.feedAccessToken, cursor, limit, doFetch);
|
||||||
|
if (res.status === 401) {
|
||||||
|
// One refresh, then one retry. A second 401 means sign in again.
|
||||||
|
session = await refreshSession(sessionId, options);
|
||||||
|
if (!session) return signIn;
|
||||||
|
res = await callFeed(session.feedAccessToken, cursor, limit, doFetch);
|
||||||
|
if (res.status === 401) return signIn;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { ok: false, status: 502, error: "feed_unavailable" };
|
||||||
|
}
|
||||||
|
if (res.status !== 200) {
|
||||||
|
return { ok: false, status: 502, error: "feed_unavailable" };
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await res.json();
|
||||||
|
} catch {
|
||||||
|
return { ok: false, status: 502, error: "feed_unavailable" };
|
||||||
|
}
|
||||||
|
const root = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
|
||||||
|
const meta =
|
||||||
|
root.metadata && typeof root.metadata === "object"
|
||||||
|
? (root.metadata as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
items: Array.isArray(root.data) ? root.data : [],
|
||||||
|
nextCursor: typeof meta.nextCursor === "string" && meta.nextCursor ? meta.nextCursor : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { GET } from "@/app/api/feed/route";
|
||||||
|
import { createSession } from "@/lib/session-store";
|
||||||
|
|
||||||
|
describe("GET /api/feed", () => {
|
||||||
|
it("no cookie -> 401 sign_in", async () => {
|
||||||
|
const res = await GET(new NextRequest("http://localhost/api/feed"));
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(await res.json()).toEqual({ error: "sign_in" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signed in: page then next page with cursor; limit capped at 50", async () => {
|
||||||
|
const sid = createSession({
|
||||||
|
profileAccessToken: "p", profileAccessTokenExpiresAt: null, profileRefreshToken: "r",
|
||||||
|
feedAccessToken: "T", feedAccessTokenExpiresAt: null,
|
||||||
|
person: { userId: "u", name: "A", email: "a@b.co", pictureUrl: null }, createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
const urls: string[] = [];
|
||||||
|
const orig = globalThis.fetch;
|
||||||
|
globalThis.fetch = vi.fn(async (url: string) => {
|
||||||
|
urls.push(String(url));
|
||||||
|
const next = urls.length === 1 ? "C1" : null;
|
||||||
|
return new Response(JSON.stringify({ data: [{ n: urls.length }], metadata: { nextCursor: next } }), { status: 200 });
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
try {
|
||||||
|
const h = { cookie: `mfb_session=${sid}` };
|
||||||
|
const r1 = await GET(new NextRequest("http://localhost/api/feed", { headers: h }));
|
||||||
|
expect(await r1.json()).toEqual({ items: [{ n: 1 }], nextCursor: "C1" });
|
||||||
|
const r2 = await GET(new NextRequest("http://localhost/api/feed?cursor=C1&limit=500", { headers: h }));
|
||||||
|
expect(await r2.json()).toEqual({ items: [{ n: 2 }], nextCursor: null });
|
||||||
|
expect(new URL(urls[0]).searchParams.has("cursor")).toBe(false);
|
||||||
|
const u2 = new URL(urls[1]);
|
||||||
|
expect(u2.searchParams.get("cursor")).toBe("C1");
|
||||||
|
expect(u2.searchParams.get("limit")).toBe("50");
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = orig;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { clampLimit, getHomeFeedPage } from "@/lib/feed";
|
||||||
|
import { createSession } from "@/lib/session-store";
|
||||||
|
|
||||||
|
const json = (body: unknown, status = 200) =>
|
||||||
|
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
||||||
|
|
||||||
|
let sid: string;
|
||||||
|
beforeEach(() => {
|
||||||
|
sid = createSession({
|
||||||
|
profileAccessToken: "p",
|
||||||
|
profileAccessTokenExpiresAt: null,
|
||||||
|
profileRefreshToken: "r",
|
||||||
|
feedAccessToken: "FEEDTOK",
|
||||||
|
feedAccessTokenExpiresAt: null,
|
||||||
|
person: { userId: "u", name: "Ann", email: "a@b.co", pictureUrl: null },
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getHomeFeedPage", () => {
|
||||||
|
it("no session is sign_in and nothing is fetched", async () => {
|
||||||
|
const f = vi.fn();
|
||||||
|
expect(await getHomeFeedPage(undefined, null, 20, { fetchImpl: f as unknown as typeof fetch }))
|
||||||
|
.toEqual({ ok: false, status: 401, error: "sign_in" });
|
||||||
|
expect(f).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("first page: items + nextCursor, no cursor upstream, bearer token, home path", async () => {
|
||||||
|
const f = vi.fn(async () => json({ data: [{ id: 1 }], metadata: { nextCursor: "C1" } }));
|
||||||
|
const r = await getHomeFeedPage(sid, null, 20, { fetchImpl: f as unknown as typeof fetch });
|
||||||
|
expect(r).toEqual({ ok: true, items: [{ id: 1 }], nextCursor: "C1" });
|
||||||
|
const [url, init] = f.mock.calls[0] as unknown as [string, RequestInit];
|
||||||
|
const u = new URL(url);
|
||||||
|
expect(u.pathname).toBe("/api/v1/feeds/home/items");
|
||||||
|
expect(u.searchParams.get("limit")).toBe("20");
|
||||||
|
expect(u.searchParams.has("cursor")).toBe(false);
|
||||||
|
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer FEEDTOK");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards the cursor on a later page; null nextCursor at the end", async () => {
|
||||||
|
const f = vi.fn(async () => json({ data: [], metadata: { nextCursor: null } }));
|
||||||
|
const r = await getHomeFeedPage(sid, "X", 20, { fetchImpl: f as unknown as typeof fetch });
|
||||||
|
expect(r).toEqual({ ok: true, items: [], nextCursor: null });
|
||||||
|
const u = new URL((f.mock.calls[0] as unknown as [string])[0]);
|
||||||
|
expect(u.searchParams.get("cursor")).toBe("X");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("feed 401 twice is sign_in", async () => {
|
||||||
|
const f = vi.fn(async (url: string) =>
|
||||||
|
String(url).includes("/feeds/home/items") ? json({}, 401) : json({}, 500),
|
||||||
|
);
|
||||||
|
const r = await getHomeFeedPage(sid, null, 20, { fetchImpl: f as unknown as typeof fetch });
|
||||||
|
expect(r).toEqual({ ok: false, status: 401, error: "sign_in" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upstream 500 is feed_unavailable", async () => {
|
||||||
|
const f = vi.fn(async () => json({}, 500));
|
||||||
|
const r = await getHomeFeedPage(sid, null, 20, { fetchImpl: f as unknown as typeof fetch });
|
||||||
|
expect(r).toEqual({ ok: false, status: 502, error: "feed_unavailable" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clampLimit", () => {
|
||||||
|
it("defaults to 20, caps at 50", () => {
|
||||||
|
expect(clampLimit(null)).toBe(20);
|
||||||
|
expect(clampLimit("abc")).toBe(20);
|
||||||
|
expect(clampLimit("999")).toBe(50);
|
||||||
|
expect(clampLimit("10")).toBe(10);
|
||||||
|
expect(clampLimit("0")).toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user