From ae89e34e2273e617930beb78596fce604bcc840d Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 25 Aug 2026 07:36:04 +0300 Subject: [PATCH] feat(article): drop the markdown body from list responses ArticleItemSchema served both the detail endpoint and every list endpoint, so GET /articles and GET /articles/me returned the full markdown of every item. The schema caps a body at 100 000 characters, which makes a page of fifty articles megabytes of text no list view renders. Measured against a single 28 KB article: the list page went from 28 930 to 924 bytes. At limit=50 that is roughly 1.4 MB versus 46 KB, and the larger payload was also what Redis cached for 60 seconds per viewer. ArticleSummarySchema is an Omit of the item schema rather than a hand-written twin, and toSummaryResponse now holds every shared field with toResponse spreading the body onto it, so the two shapes cannot drift apart. Narrowing toListResponse propagates through the controller into the route generics, so the type system enforces this rather than fast-json-stringify quietly dropping a field. The repository selects and the Redis cache DTO are deliberately untouched. The repo uses include rather than select, so removing body there would break the ArticleWithRelations cast and Article.with, which requires a body. And stripping body from CachedArticle would feed an empty string into the entity: if a body ever returns to a list response, cached pages would serve "" while uncached pages served real markdown - a bug that appears and disappears on a 60 second cycle. Both belong in a separate change with an explicit summary read model. Doing this now costs nothing: the article migrations are not yet applied to production, so no client is reading these responses. Co-Authored-By: Claude Opus 5 --- .../schemas/article/article-item.schema.ts | 11 ++++ .../schemas/article/get-articles.schema.ts | 4 +- .../mappers/article-prisma.mapper.ts | 54 +++++++++++++++---- tests/e2e/article/read.test.ts | 40 ++++++++++++++ 4 files changed, 97 insertions(+), 12 deletions(-) diff --git a/src/http/types/schemas/article/article-item.schema.ts b/src/http/types/schemas/article/article-item.schema.ts index 0ae133e..052d95a 100644 --- a/src/http/types/schemas/article/article-item.schema.ts +++ b/src/http/types/schemas/article/article-item.schema.ts @@ -39,6 +39,17 @@ export const ArticleItemSchema = FBType.Object({ export type ArticleItem = Static; +/** + * The shape list endpoints return. + * + * A body can be 100 KB, so a page of fifty articles would be megabytes of + * markdown that no list view renders. Derived with Omit rather than written + * out again, so a field added above appears here automatically. + */ +export const ArticleSummarySchema = FBType.Omit(ArticleItemSchema, ["body"]); + +export type ArticleSummary = Static; + /** Envelope shared by create, update, publish and archive. */ export const ArticleResponseSchema = ResponseSchema(ArticleItemSchema); export type ArticleResponse = Static; diff --git a/src/http/types/schemas/article/get-articles.schema.ts b/src/http/types/schemas/article/get-articles.schema.ts index 0d4c52f..76a7d21 100644 --- a/src/http/types/schemas/article/get-articles.schema.ts +++ b/src/http/types/schemas/article/get-articles.schema.ts @@ -1,6 +1,6 @@ import { Type, type Static } from "@fastify/type-provider-typebox"; import { PostCategory } from "@core/domain/enums/post-category-enum"; -import { ArticleItemSchema } from "./article-item.schema"; +import { ArticleSummarySchema } from "./article-item.schema"; export const getArticlesQuerySchema = Type.Object({ page: Type.Optional(Type.Number({ minimum: 1, default: 1 })), @@ -17,7 +17,7 @@ export type GetArticlesQuery = Static; /** Paginated envelope, hand-rolled because it carries counts rather than a timestamp. */ export const GetArticlesResponseSchema = Type.Object({ - data: Type.Array(ArticleItemSchema), + data: Type.Array(ArticleSummarySchema), meta: Type.Object({ total: Type.Number(), currentPage: Type.Number(), diff --git a/src/infrastructure/persistence/mappers/article-prisma.mapper.ts b/src/infrastructure/persistence/mappers/article-prisma.mapper.ts index b62684c..986d055 100644 --- a/src/infrastructure/persistence/mappers/article-prisma.mapper.ts +++ b/src/infrastructure/persistence/mappers/article-prisma.mapper.ts @@ -47,6 +47,14 @@ export interface ArticleResponse { categories: { name: string }[]; } +/** + * The shape list endpoints return: everything except the markdown body. + * + * Declared as an Omit rather than a hand-written twin so a field added to the + * detail response cannot be forgotten here. + */ +export type ArticleSummaryResponse = Omit; + /** * Mapper responsible for transforming Article data across layers. * @@ -127,26 +135,30 @@ export class ArticlePrismaMapper { } /** - * Maps a domain entity to the public API response. + * Maps a domain entity to the list-sized response, without the body. * - * The cover image is stored as a storage key and only becomes a URL here, + * List endpoints return this shape: an article body can be 100 KB, and a + * page of fifty of them is megabytes of markdown nobody rendered. The + * cover image is stored as a storage key and only becomes a URL here, * which is what keeps arbitrary client-supplied URLs out of the database. * + * Every shared field lives here rather than in both builders, so the + * summary and the detail shape cannot drift apart. + * * @param article - The Article domain entity * @param cdnUrl - CDN base URL, without a trailing slash * @param currentUserId - Viewer used to resolve the isMe flag - * @returns A response object safe to serialize + * @returns A response object safe to serialize, minus the markdown body */ - static toResponse( + static toSummaryResponse( article: Article, cdnUrl: string, currentUserId?: string, - ): ArticleResponse { + ): ArticleSummaryResponse { return { id: article.id, slug: article.slug, title: article.title, - body: article.body, excerpt: article.excerpt, coverImageUrl: article.coverImageKey ? `${cdnUrl}/${article.coverImageKey}` @@ -182,20 +194,42 @@ export class ArticlePrismaMapper { } /** - * Maps a list of domain entities to public API responses. + * Maps a domain entity to the full API response, body included. + * + * Used by the detail endpoint and by create, update, publish and archive, + * where the caller is working with one article and wants its markdown. + * + * @param article - The Article domain entity + * @param cdnUrl - CDN base URL, without a trailing slash + * @param currentUserId - Viewer used to resolve the isMe flag + * @returns A response object safe to serialize + */ + static toResponse( + article: Article, + cdnUrl: string, + currentUserId?: string, + ): ArticleResponse { + return { + ...this.toSummaryResponse(article, cdnUrl, currentUserId), + body: article.body, + }; + } + + /** + * Maps a list of domain entities to list-sized API responses. * * @param articles - The Article domain entities * @param cdnUrl - CDN base URL, without a trailing slash * @param currentUserId - Viewer used to resolve the isMe flag - * @returns The response objects + * @returns The response objects, without their markdown bodies */ static toListResponse( articles: Article[], cdnUrl: string, currentUserId?: string, - ): ArticleResponse[] { + ): ArticleSummaryResponse[] { return articles.map((article) => - this.toResponse(article, cdnUrl, currentUserId), + this.toSummaryResponse(article, cdnUrl, currentUserId), ); } } diff --git a/tests/e2e/article/read.test.ts b/tests/e2e/article/read.test.ts index 919fc21..ecdd776 100644 --- a/tests/e2e/article/read.test.ts +++ b/tests/e2e/article/read.test.ts @@ -120,6 +120,22 @@ describe("GET /articles", () => { expect(body.data.map((a) => a.id)).toContain(published.id); }); + it("should not carry the markdown body in list items", async () => { + const response = await request({ + method: "GET", + url: "/articles?limit=50", + }); + const [first] = parseBody(response).data; + + // A body can be 100 KB; a page of fifty would be megabytes no list + // view renders. The summary keeps what a card needs instead. + expect(first).toBeDefined(); + expect(first).not.toHaveProperty("body"); + expect(first).toHaveProperty("excerpt"); + expect(first).toHaveProperty("coverImageUrl"); + expect(first).toHaveProperty("readingTimeMinutes"); + }); + it("should never include drafts or archived articles", async () => { const response = await request({ method: "GET", @@ -198,6 +214,19 @@ describe("GET /articles/:slug", () => { expect(body.data.author.isMe).toBe(false); }); + it("should carry the full markdown body, unlike the list", async () => { + const response = await request({ + method: "GET", + url: `/articles/${published.slug}`, + }); + const body = parseBody<{ data: { body: string } }>(response); + + expect(response.statusCode).toBe(200); + expect(body.data).toHaveProperty("body"); + expect(typeof body.data.body).toBe("string"); + expect(body.data.body.length).toBeGreaterThan(0); + }); + it("should hide a draft from a guest", async () => { const response = await request({ method: "GET", @@ -289,6 +318,17 @@ describe("GET /articles/me", () => { expect(ids).toContain(archived.id); }); + it("should not carry the markdown body either", async () => { + const response = await authRequest(authorToken, { + method: "GET", + url: "/articles/me?limit=50", + }); + const [first] = parseBody(response).data; + + expect(first).toBeDefined(); + expect(first).not.toHaveProperty("body"); + }); + it("should filter by status", async () => { const response = await authRequest(authorToken, { method: "GET",