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",