From 8a0dbf25b0b2b9bd2d44b869aeeeabdd902a35bf Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Mon, 25 May 2026 17:28:44 +0200 Subject: [PATCH] feat(api): expose nativeFeeds on the REST Feed schema (read-only) GET /v1/feeds/{id} and PATCH /v1/feeds/{id} now include a required nativeFeeds array (possibly empty) derived from the feed metadata via unionNativeFeeds. POST /v1/feeds always returns []. Co-Authored-By: Claude Sonnet 4.6 --- src/routes/api/api.test.ts | 61 ++++++++++++++++++++++++++++++++++++++ src/routes/api/index.ts | 23 +++++++++++--- src/routes/api/schemas.ts | 6 ++++ 3 files changed, 86 insertions(+), 4 deletions(-) diff --git a/src/routes/api/api.test.ts b/src/routes/api/api.test.ts index ee3fa99..05c0fcb 100644 --- a/src/routes/api/api.test.ts +++ b/src/routes/api/api.test.ts @@ -3,6 +3,8 @@ import { Hono } from "hono"; import { apiApp } from "./index"; import { createMockEnv } from "../../test/setup"; import { Env } from "../../types"; +import { FeedRepository } from "../../infrastructure/feed-repository"; +import { FeedId } from "../../domain/value-objects/feed-id"; const PASSWORD = "test-password"; const authHeaders = { Authorization: `Bearer ${PASSWORD}` }; @@ -212,6 +214,65 @@ describe("REST API (/api/v1)", () => { }); }); + describe("nativeFeeds field", () => { + it("returns nativeFeeds as empty array for a brand-new feed", async () => { + const feedId = await createFeed("Native Feed Test"); + const res = await request(`/api/v1/feeds/${feedId}`, { + headers: authHeaders, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { nativeFeeds: unknown }; + expect(body.nativeFeeds).toEqual([]); + }); + + it("returns nativeFeeds populated when the feed metadata has native feeds", async () => { + const feedId = await createFeed("Native Feed With Data"); + const id = FeedId.unchecked(feedId); + const repo = FeedRepository.from(mockEnv); + const feed = await repo.load(id); + expect(feed).not.toBeNull(); + const receivedAt = Date.now(); + feed!.ingest( + { + key: `feed:${feedId}:email:${receivedAt}`, + subject: "Newsletter", + receivedAt, + }, + { + maxBytes: 1e9, + nativeFeeds: { + senderKey: "author@blog.example.com", + feeds: [{ url: "https://blog.example.com/feed.xml", type: "rss" }], + }, + }, + ); + await repo.save(feed!); + + const res = await request(`/api/v1/feeds/${feedId}`, { + headers: authHeaders, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + nativeFeeds: { url: string; type: string }[]; + }; + expect(body.nativeFeeds).toEqual([ + { url: "https://blog.example.com/feed.xml", type: "rss" }, + ]); + }); + + it("PATCH response also includes nativeFeeds", async () => { + const feedId = await createFeed("Patch Native Feed Test"); + const res = await request(`/api/v1/feeds/${feedId}`, { + method: "PATCH", + headers: { ...authHeaders, "Content-Type": "application/json" }, + body: JSON.stringify({ title: "Updated Title" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { nativeFeeds: unknown }; + expect(body.nativeFeeds).toEqual([]); + }); + }); + describe("Emails", () => { it("lists, reads and deletes an email", async () => { const feedId = await createFeed(); diff --git a/src/routes/api/index.ts b/src/routes/api/index.ts index 2131914..6bc8898 100644 --- a/src/routes/api/index.ts +++ b/src/routes/api/index.ts @@ -1,7 +1,8 @@ import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi"; import { cors } from "hono/cors"; import { Scalar } from "@scalar/hono-api-reference"; -import { Env, FeedConfig } from "../../types"; +import { Env, FeedConfig, NativeFeed } from "../../types"; +import { unionNativeFeeds } from "../../domain/native-feed"; import { apiAuthMiddleware } from "../../infrastructure/auth"; import { createFeedRecord, @@ -51,6 +52,7 @@ function toFeed( config: FeedConfig, emailCount: number, env: Env, + nativeFeeds: NativeFeed[], ): z.infer { return { id, @@ -67,6 +69,7 @@ function toFeed( emailAddress: feedEmailAddress(config.mailbox_id, env), rssUrl: feedRssUrl(id, env), atomUrl: feedAtomUrl(id, env), + nativeFeeds, }; } @@ -156,7 +159,7 @@ apiApp.openapi( senderInTitle: body.senderInTitle, lifetimeHours: body.lifetimeHours, }); - return c.json(toFeed(feedId, config, 0, env), 201); + return c.json(toFeed(feedId, config, 0, env, []), 201); }, ); @@ -183,7 +186,13 @@ apiApp.openapi( if (!config) return c.json({ error: "Feed not found" }, 404); const metadata = await repo.getMetadata(id); return c.json( - toFeed(feedId, config, metadata?.emails.length ?? 0, env), + toFeed( + feedId, + config, + metadata?.emails.length ?? 0, + env, + unionNativeFeeds(metadata?.nativeFeeds), + ), 200, ); }, @@ -228,7 +237,13 @@ apiApp.openapi( return c.json({ error: "Feed has expired and cannot be modified" }, 409); const metadata = await FeedRepository.from(env).getMetadata(id); return c.json( - toFeed(feedId, result.config, metadata?.emails.length ?? 0, env), + toFeed( + feedId, + result.config, + metadata?.emails.length ?? 0, + env, + unionNativeFeeds(metadata?.nativeFeeds), + ), 200, ); }, diff --git a/src/routes/api/schemas.ts b/src/routes/api/schemas.ts index b2afb3a..6f65388 100644 --- a/src/routes/api/schemas.ts +++ b/src/routes/api/schemas.ts @@ -99,6 +99,12 @@ export const FeedSchema = z emailAddress: z.string(), rssUrl: z.string(), atomUrl: z.string(), + nativeFeeds: z.array( + z.object({ + url: z.string(), + type: z.enum(["rss", "atom", "json"]), + }), + ), }) .openapi("Feed");