Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/http/types/schemas/article/article-item.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ export const ArticleItemSchema = FBType.Object({

export type ArticleItem = Static<typeof ArticleItemSchema>;

/**
* 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<typeof ArticleSummarySchema>;

/** Envelope shared by create, update, publish and archive. */
export const ArticleResponseSchema = ResponseSchema(ArticleItemSchema);
export type ArticleResponse = Static<typeof ArticleResponseSchema>;
4 changes: 2 additions & 2 deletions src/http/types/schemas/article/get-articles.schema.ts
Original file line number Diff line number Diff line change
@@ -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 })),
Expand All @@ -17,7 +17,7 @@ export type GetArticlesQuery = Static<typeof getArticlesQuerySchema>;

/** 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(),
Expand Down
54 changes: 44 additions & 10 deletions src/infrastructure/persistence/mappers/article-prisma.mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ArticleResponse, "body">;

/**
* Mapper responsible for transforming Article data across layers.
*
Expand Down Expand Up @@ -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}`
Expand Down Expand Up @@ -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),
);
}
}
40 changes: 40 additions & 0 deletions tests/e2e/article/read.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ListEnvelope>(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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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<ListEnvelope>(response).data;

expect(first).toBeDefined();
expect(first).not.toHaveProperty("body");
});

it("should filter by status", async () => {
const response = await authRequest(authorToken, {
method: "GET",
Expand Down