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 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-25 17:28:44 +02:00
parent 6236274ce8
commit 8a0dbf25b0
3 changed files with 86 additions and 4 deletions
+61
View File
@@ -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();
+19 -4
View File
@@ -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<typeof FeedSchema> {
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,
);
},
+6
View File
@@ -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");