From 86070b236a3f1277bfcd2366f517e91765ad775b Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Wed, 6 May 2026 15:04:53 +0200 Subject: [PATCH] refactor(blog): unify use-case I/O schemas + presenter + feature error map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Plan 9 (spec R1-R28): - Use cases: input + output schemas (getArticles, createArticle, getArticleBySlug). Output validated via outputSchema.parse before return. status field uses articleStatusSchema (was loose `string`). - Controllers: receive `unknown`; safeParse with use-case schema; identity presenter (R11) on every controller. - New integrations/api/procedures.ts with blogProcedure ([InputParseError → BAD_REQUEST], [ArticleNotFoundError → NOT_FOUND]). - Router uses blogProcedure + .input(xInputSchema) for all 3 procedures. - src/index.ts: remove articleBySlugQuery/listArticlesQuery re-exports; export schemas + types + IUseCase/IController aliases. - src/ui/index.ts (NEW): query builders moved here; package.json adds ./ui subpath. - New tests: R25 output-validation per use case; R26 router error- mapping (NOT_FOUND on missing slug, BAD_REQUEST on schema fail). Refactor log: §1, §2, §3.1, §3.2, §3.3, §5.1, §5.2, §6.1, §6.2 Spec: R1–R6, R8–R15, R18–R20, R22–R26 Co-Authored-By: Claude Sonnet 4.6 --- .../2026-05-06-input-output-unification.md | 19 +++++++++ packages/blog/package.json | 1 + .../use-cases/create-article.use-case.test.ts | 31 +++++++++++++- .../use-cases/create-article.use-case.ts | 34 +++++++++------ .../get-article-by-slug.use-case.test.ts | 29 ++++++++++++- .../use-cases/get-article-by-slug.use-case.ts | 16 +++++-- .../use-cases/get-articles.use-case.test.ts | 21 +++++++++- .../use-cases/get-articles.use-case.ts | 27 ++++++++---- packages/blog/src/index.ts | 29 ++++++++++++- .../blog/src/integrations/api/procedures.ts | 12 ++++++ .../blog/src/integrations/api/router.test.ts | 42 +++++++++++++++++-- packages/blog/src/integrations/api/router.ts | 41 +++++++----------- .../controllers/create-article.controller.ts | 30 ++++++------- .../get-article-by-slug.controller.test.ts | 2 +- .../get-article-by-slug.controller.ts | 22 +++++----- .../get-articles.controller.test.ts | 2 +- .../controllers/get-articles.controller.ts | 26 ++++++------ packages/blog/src/ui/index.ts | 1 + 18 files changed, 285 insertions(+), 100 deletions(-) create mode 100644 packages/blog/src/integrations/api/procedures.ts create mode 100644 packages/blog/src/ui/index.ts diff --git a/docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md b/docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md index 29d302b..bdd64a7 100644 --- a/docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md +++ b/docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md @@ -19,6 +19,8 @@ doc-update items so docs are written once for the post-Plan-9 state. - packages/core-shared/src/trpc/define-error-middleware.test.ts — 4 tests covering mapped translation, multiple codes, unmapped passthrough (verifies INTERNAL_SERVER_ERROR + cause preservation), cause preservation - packages/auth/src/integrations/api/procedures.ts — authProcedure with feature error map (InputParse → BAD_REQUEST, Auth/Unauthenticated → UNAUTHORIZED, Unauthorized → FORBIDDEN) - packages/auth/src/ui/index.ts — placeholder UI surface (no queries today; mutations only) +- packages/blog/src/integrations/api/procedures.ts — blogProcedure with feature error map (InputParseError → BAD_REQUEST, ArticleNotFoundError → NOT_FOUND) +- packages/blog/src/ui/index.ts — re-exports articleBySlugQuery and listArticlesQuery from ./query (moved from feature root index) ## 2. Files modified @@ -36,17 +38,30 @@ doc-update items so docs are written once for the post-Plan-9 state. - packages/auth/package.json — added ./ui subpath - packages/auth/tests/sign-in-flow.feature.test.ts — updated to match new presenter shapes (cookie return, void sign-out, object input) - All affected auth use-case + controller tests updated for new contracts +- packages/blog/src/application/use-cases/get-articles.use-case.ts — getArticlesInputSchema + getArticlesOutputSchema; status narrowed to articleStatusSchema; output.parse; types exported +- packages/blog/src/application/use-cases/create-article.use-case.ts — createArticleInputSchema + createArticleOutputSchema; output.parse; types exported +- packages/blog/src/application/use-cases/get-article-by-slug.use-case.ts — getArticleBySlugInputSchema + getArticleBySlugOutputSchema; output.parse; types exported +- packages/blog/src/interface-adapters/controllers/get-articles.controller.ts — identity presenter; unknown input; imports getArticlesInputSchema from use-case +- packages/blog/src/interface-adapters/controllers/create-article.controller.ts — identity presenter; unknown input; imports createArticleInputSchema from use-case +- packages/blog/src/interface-adapters/controllers/get-article-by-slug.controller.ts — identity presenter; unknown input; imports getArticleBySlugInputSchema from use-case +- packages/blog/src/integrations/api/router.ts — uses blogProcedure + .input(xInputSchema) for all 3 procedures +- packages/blog/src/index.ts — removed articleBySlugQuery/listArticlesQuery re-exports; added schemas + types + IUseCase/IController aliases +- packages/blog/package.json — added ./ui subpath export +- All affected blog use-case + controller tests updated for new contracts ## 3. Pattern changes (code-level) ### 3.1 Use-case files — input + output schemas + runtime parse auth migrated: all 3 use cases. signIn and signUp export xInputSchema + xOutputSchema + types; signOut exports xInputSchema only (void output). All non-void use cases end with `xOutputSchema.parse(result)` before returning. +blog migrated: all 3 use cases. getArticles, createArticle, getArticleBySlug each export xInputSchema + xOutputSchema + XInput + XOutput types. getArticlesInputSchema narrows status to articleStatusSchema (not loose string). All 3 end with `xOutputSchema.parse(result)` before returning. ### 3.2 Controller files — presenter + unknown input + view return type auth migrated: all 3 controllers. signIn/signUp have `function presenter(value: XOutput)` returning `value.cookie`; return type is `ReturnType`. signOut has no presenter (void). All controllers accept `unknown` input and safeparse with the use-case schema. +blog migrated: all 3 controllers. All 3 (getArticles, createArticle, getArticleBySlug) have `function presenter(value: XOutput)` that is identity (`return value`); return type is `ReturnType`. All accept `unknown` input and safeparse with the imported use-case schema. ### 3.3 tRPC integration — feature-scoped procedures, schema reuse from use cases auth migrated: authProcedure in procedures.ts wraps defineErrorMiddleware with 4-tuple error map. Router uses `authProcedure.input(xInputSchema)` for all 3 procedures — no more local schema redefinition. +blog migrated: blogProcedure in procedures.ts wraps defineErrorMiddleware with 2-tuple map (InputParseError → BAD_REQUEST, ArticleNotFoundError → NOT_FOUND). Router uses `blogProcedure.input(xInputSchema)` for all 3 procedures. ## 4. Error-middleware adoption @@ -59,17 +74,21 @@ auth migrated: authProcedure in procedures.ts wraps defineErrorMiddleware with 4 ### 5.1 ./ui subpath added per feature auth: `./ui` subpath added to package.json exports; `src/ui/index.ts` placeholder created (auth has no query builders — all procedures are mutations). +blog: `./ui` subpath added to package.json exports; `src/ui/index.ts` created re-exporting articleBySlugQuery and listArticlesQuery from ./query. ### 5.2 Feature root index.ts cleanup auth: root `src/index.ts` now exports all use-case schemas (signInInputSchema, signInOutputSchema, signUpInputSchema, signUpOutputSchema, signOutInputSchema) and types (SignInInput/Output, SignUpInput/Output, SignOutInput, ISignInUseCase, ISignUpUseCase, ISignOutUseCase) plus controller type aliases. +blog: root `src/index.ts` removed articleBySlugQuery/listArticlesQuery re-exports (moved to ./ui); now exports getArticlesInputSchema/Output, createArticleInputSchema/Output, getArticleBySlugInputSchema/Output, all XInput/XOutput types, IUseCase aliases, and IController aliases. ## 6. Test additions ### 6.1 R25 — output-validation tests (use case) auth: signIn and signUp each have 2 new R25 tests — one verifying that a malformed service response throws (Zod parse error), one verifying the output schema parses a valid shape. signOut is void — no R25 test. +blog: getArticles, createArticle, getArticleBySlug each have 2 new R25 tests — one verifying that a repository returning a malformed object throws ZodError (instanceof), one verifying the output schema parses a valid shape. ### 6.2 R26 — router error-mapping tests auth: 2 new R26 tests in router.test.ts — UNAUTHORIZED on missing user (AuthenticationError translation), BAD_REQUEST on Zod schema failure (schema validation at procedure boundary). +blog: 2 new R26 tests in router.test.ts — NOT_FOUND on articleBySlug with missing slug (ArticleNotFoundError translation via blogProcedure), BAD_REQUEST on articleBySlug with empty input ({} as { slug: string }) (schema validation at tRPC procedure boundary). ### 6.3 R27/R28 — presenter shape tests diff --git a/packages/blog/package.json b/packages/blog/package.json index 4ec0014..dc2fcf1 100644 --- a/packages/blog/package.json +++ b/packages/blog/package.json @@ -5,6 +5,7 @@ "type": "module", "exports": { ".": "./src/index.ts", + "./ui": "./src/ui/index.ts", "./cms": "./src/integrations/cms/index.ts", "./api": "./src/integrations/api/router.ts", "./di/bind-production": "./src/di/bind-production.ts" diff --git a/packages/blog/src/application/use-cases/create-article.use-case.test.ts b/packages/blog/src/application/use-cases/create-article.use-case.test.ts index 8535c2b..70132bd 100644 --- a/packages/blog/src/application/use-cases/create-article.use-case.test.ts +++ b/packages/blog/src/application/use-cases/create-article.use-case.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; -import { createArticleUseCase } from "@/application/use-cases/create-article.use-case"; +import { ZodError } from "zod"; +import { createArticleUseCase, createArticleOutputSchema } from "@/application/use-cases/create-article.use-case"; import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock"; +import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface"; describe("createArticleUseCase", () => { it("creates an article in draft status with auto-generated slug", async () => { @@ -34,3 +36,30 @@ describe("createArticleUseCase", () => { expect(result.slug).toBe("custom-slug"); }); }); + +describe("createArticleUseCase output validation (R25)", () => { + it("throws when repository returns a malformed article", async () => { + const repo = { + createArticle: async () => ({ id: 1 }) as unknown as never, + } as unknown as IArticlesRepository; + const useCase = createArticleUseCase(repo); + await expect( + useCase({ title: "X", authorId: "u1" }), + ).rejects.toBeInstanceOf(ZodError); + }); + + it("exports an output schema that accepts a valid article shape", () => { + expect(createArticleOutputSchema).toBeDefined(); + const result = createArticleOutputSchema.safeParse({ + id: "a1", + title: "Test", + slug: "test", + content: null, + status: "draft", + authorId: "u1", + createdAt: new Date(), + updatedAt: new Date(), + }); + expect(result.success).toBe(true); + }); +}); diff --git a/packages/blog/src/application/use-cases/create-article.use-case.ts b/packages/blog/src/application/use-cases/create-article.use-case.ts index b430556..38526d6 100644 --- a/packages/blog/src/application/use-cases/create-article.use-case.ts +++ b/packages/blog/src/application/use-cases/create-article.use-case.ts @@ -1,6 +1,21 @@ -import type { Article } from "../../entities/models/article"; +import { z } from "zod"; + +import { articleSchema } from "../../entities/models/article"; import type { IArticlesRepository } from "../repositories/articles.repository.interface"; +export const createArticleInputSchema = z + .object({ + title: z.string().min(1).max(255), + content: z.unknown().optional(), + authorId: z.string(), + slug: z.string().optional(), + }) + .strict(); +export type CreateArticleInput = z.infer; + +export const createArticleOutputSchema = articleSchema; +export type CreateArticleOutput = z.infer; + function generateSlug(title: string): string { return title .toLowerCase() @@ -12,23 +27,18 @@ export type ICreateArticleUseCase = ReturnType; export const createArticleUseCase = (articlesRepository: IArticlesRepository) => - async (input: { - title: string; - content?: unknown; - authorId: string; - slug?: string; - }): Promise
=> { + async (input: CreateArticleInput): Promise => { const now = new Date(); - const article: Article = { + const article = { id: crypto.randomUUID(), title: input.title, slug: input.slug ?? generateSlug(input.title), - content: input.content, - status: "draft", + content: input.content ?? null, + status: "draft" as const, authorId: input.authorId, createdAt: now, updatedAt: now, }; - - return articlesRepository.createArticle(article); + const result = await articlesRepository.createArticle(article); + return createArticleOutputSchema.parse(result); }; diff --git a/packages/blog/src/application/use-cases/get-article-by-slug.use-case.test.ts b/packages/blog/src/application/use-cases/get-article-by-slug.use-case.test.ts index daf31e9..a0cbda0 100644 --- a/packages/blog/src/application/use-cases/get-article-by-slug.use-case.test.ts +++ b/packages/blog/src/application/use-cases/get-article-by-slug.use-case.test.ts @@ -1,8 +1,10 @@ import { describe, it, expect } from "vitest"; -import { getArticleBySlugUseCase } from "@/application/use-cases/get-article-by-slug.use-case"; +import { ZodError } from "zod"; +import { getArticleBySlugUseCase, getArticleBySlugOutputSchema } from "@/application/use-cases/get-article-by-slug.use-case"; import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock"; import { ArticleNotFoundError } from "@/entities/errors/article"; import { articleFactory } from "@/__factories__/article.factory"; +import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface"; describe("getArticleBySlugUseCase", () => { it("returns the article when slug exists", async () => { @@ -22,3 +24,28 @@ describe("getArticleBySlugUseCase", () => { await expect(useCase({ slug: "does-not-exist" })).rejects.toThrow(ArticleNotFoundError); }); }); + +describe("getArticleBySlugUseCase output validation (R25)", () => { + it("throws when repository returns a malformed article", async () => { + const repo = { + getArticleBySlug: async () => ({ id: 123 }) as unknown as never, + } as unknown as IArticlesRepository; + const useCase = getArticleBySlugUseCase(repo); + await expect(useCase({ slug: "test" })).rejects.toBeInstanceOf(ZodError); + }); + + it("exports an output schema that accepts a valid article shape", () => { + expect(getArticleBySlugOutputSchema).toBeDefined(); + const result = getArticleBySlugOutputSchema.safeParse({ + id: "a1", + title: "Test", + slug: "test", + content: null, + status: "draft", + authorId: "u1", + createdAt: new Date(), + updatedAt: new Date(), + }); + expect(result.success).toBe(true); + }); +}); diff --git a/packages/blog/src/application/use-cases/get-article-by-slug.use-case.ts b/packages/blog/src/application/use-cases/get-article-by-slug.use-case.ts index e1b1459..d0651fe 100644 --- a/packages/blog/src/application/use-cases/get-article-by-slug.use-case.ts +++ b/packages/blog/src/application/use-cases/get-article-by-slug.use-case.ts @@ -1,15 +1,25 @@ +import { z } from "zod"; + import { ArticleNotFoundError } from "../../entities/errors/article"; -import type { Article } from "../../entities/models/article"; +import { articleSchema } from "../../entities/models/article"; import type { IArticlesRepository } from "../repositories/articles.repository.interface"; +export const getArticleBySlugInputSchema = z + .object({ slug: z.string().min(1) }) + .strict(); +export type GetArticleBySlugInput = z.infer; + +export const getArticleBySlugOutputSchema = articleSchema; +export type GetArticleBySlugOutput = z.infer; + export type IGetArticleBySlugUseCase = ReturnType; export const getArticleBySlugUseCase = (articlesRepository: IArticlesRepository) => - async (input: { slug: string }): Promise
=> { + async (input: GetArticleBySlugInput): Promise => { const article = await articlesRepository.getArticleBySlug(input.slug); if (!article) { throw new ArticleNotFoundError(`Article with slug "${input.slug}" not found`); } - return article; + return getArticleBySlugOutputSchema.parse(article); }; diff --git a/packages/blog/src/application/use-cases/get-articles.use-case.test.ts b/packages/blog/src/application/use-cases/get-articles.use-case.test.ts index 96a6f18..5d10d8e 100644 --- a/packages/blog/src/application/use-cases/get-articles.use-case.test.ts +++ b/packages/blog/src/application/use-cases/get-articles.use-case.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { getArticlesUseCase } from "@/application/use-cases/get-articles.use-case"; +import { ZodError } from "zod"; +import { getArticlesUseCase, getArticlesOutputSchema } from "@/application/use-cases/get-articles.use-case"; import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock"; import { articleFactory } from "@/__factories__/article.factory"; @@ -10,7 +11,7 @@ describe("getArticlesUseCase", () => { await repo.createArticle(articleFactory.build({ id: "1", title: "A", slug: "a" })); const useCase = getArticlesUseCase(repo); - const result = await useCase(); + const result = await useCase({}); expect(result).toHaveLength(1); expect(result[0]?.id).toBe("1"); }); @@ -27,3 +28,19 @@ describe("getArticlesUseCase", () => { expect(result[0]?.id).toBe("2"); }); }); + +describe("getArticlesUseCase output validation (R25)", () => { + it("throws when the repository returns a malformed article", async () => { + const repo = new MockArticlesRepository(); + // bypass the mock's createArticle (which is typed) by reaching into _articles directly + (repo as unknown as { _articles: unknown[] })._articles.push({ id: 123 }); + + const useCase = getArticlesUseCase(repo); + await expect(useCase({})).rejects.toBeInstanceOf(ZodError); + }); + + it("exports an output schema that mirrors Article[]", () => { + expect(getArticlesOutputSchema).toBeDefined(); + expect(getArticlesOutputSchema.safeParse([]).success).toBe(true); + }); +}); diff --git a/packages/blog/src/application/use-cases/get-articles.use-case.ts b/packages/blog/src/application/use-cases/get-articles.use-case.ts index 14a9680..60bf66b 100644 --- a/packages/blog/src/application/use-cases/get-articles.use-case.ts +++ b/packages/blog/src/application/use-cases/get-articles.use-case.ts @@ -1,15 +1,26 @@ -import type { Article } from "../../entities/models/article"; +import { z } from "zod"; + +import { articleSchema, articleStatusSchema } from "../../entities/models/article"; import type { IArticlesRepository } from "../repositories/articles.repository.interface"; +export const getArticlesInputSchema = z + .object({ + status: articleStatusSchema.optional(), + authorId: z.string().optional(), + limit: z.number().int().positive().optional(), + offset: z.number().int().nonnegative().optional(), + }) + .strict(); +export type GetArticlesInput = z.infer; + +export const getArticlesOutputSchema = z.array(articleSchema); +export type GetArticlesOutput = z.infer; + export type IGetArticlesUseCase = ReturnType; export const getArticlesUseCase = (articlesRepository: IArticlesRepository) => - async (options?: { - status?: string; - authorId?: string; - limit?: number; - offset?: number; - }): Promise => { - return articlesRepository.getArticles(options); + async (input: GetArticlesInput): Promise => { + const result = await articlesRepository.getArticles(input); + return getArticlesOutputSchema.parse(result); }; diff --git a/packages/blog/src/index.ts b/packages/blog/src/index.ts index 8e89a81..0b72a72 100644 --- a/packages/blog/src/index.ts +++ b/packages/blog/src/index.ts @@ -2,4 +2,31 @@ export type { Article, ArticleStatus } from "./entities/models/article"; export type { BlogRouter } from "./integrations/api/router"; export { ArticleNotFoundError } from "./entities/errors/article"; export { InputParseError } from "./entities/errors/common"; -export { articleBySlugQuery, listArticlesQuery } from "./ui/query"; + +// Use case schemas + types (Plan 9 R18) +export { + getArticlesInputSchema, + getArticlesOutputSchema, + type GetArticlesInput, + type GetArticlesOutput, + type IGetArticlesUseCase, +} from "./application/use-cases/get-articles.use-case"; +export { + createArticleInputSchema, + createArticleOutputSchema, + type CreateArticleInput, + type CreateArticleOutput, + type ICreateArticleUseCase, +} from "./application/use-cases/create-article.use-case"; +export { + getArticleBySlugInputSchema, + getArticleBySlugOutputSchema, + type GetArticleBySlugInput, + type GetArticleBySlugOutput, + type IGetArticleBySlugUseCase, +} from "./application/use-cases/get-article-by-slug.use-case"; + +// Controller type aliases +export type { IGetArticlesController } from "./interface-adapters/controllers/get-articles.controller"; +export type { ICreateArticleController } from "./interface-adapters/controllers/create-article.controller"; +export type { IGetArticleBySlugController } from "./interface-adapters/controllers/get-article-by-slug.controller"; diff --git a/packages/blog/src/integrations/api/procedures.ts b/packages/blog/src/integrations/api/procedures.ts new file mode 100644 index 0000000..50974e1 --- /dev/null +++ b/packages/blog/src/integrations/api/procedures.ts @@ -0,0 +1,12 @@ +import { t } from "@repo/core-shared/trpc/init"; +import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware"; + +import { ArticleNotFoundError } from "../../entities/errors/article"; +import { InputParseError } from "../../entities/errors/common"; + +export const blogProcedure = t.procedure.use( + defineErrorMiddleware([ + [InputParseError, "BAD_REQUEST"], + [ArticleNotFoundError, "NOT_FOUND"], + ]), +); diff --git a/packages/blog/src/integrations/api/router.test.ts b/packages/blog/src/integrations/api/router.test.ts index fd5a761..6caad98 100644 --- a/packages/blog/src/integrations/api/router.test.ts +++ b/packages/blog/src/integrations/api/router.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { blogContainer } from "../../di/container"; -import { BlogModule } from "../../di/module"; -import { blogRouter } from "./router"; +import { TRPCError } from "@trpc/server"; +import { blogContainer } from "@/di/container"; +import { BlogModule } from "@/di/module"; +import { blogRouter } from "@/integrations/api/router"; // The router resolves controllers from blogContainer (a singleton). // We reload the module between tests to get a fresh MockArticlesRepository. @@ -24,7 +25,7 @@ describe("blogRouter", () => { it("listArticles returns empty array by default", async () => { const caller = blogRouter.createCaller({}); - const result = await caller.listArticles(); + const result = await caller.listArticles({}); expect(result).toEqual([]); }); @@ -43,3 +44,36 @@ describe("blogRouter", () => { expect(fetched.id).toBe(created.id); }); }); + +describe("blogRouter (R26 error mapping)", () => { + beforeEach(() => { + blogContainer.unbindAll(); + blogContainer.load(BlogModule); + }); + + afterEach(() => { + blogContainer.unbindAll(); + }); + + it("translates ArticleNotFoundError → NOT_FOUND", async () => { + const caller = blogRouter.createCaller({}); + try { + await caller.articleBySlug({ slug: "missing" }); + throw new Error("expected throw"); + } catch (e) { + expect(e).toBeInstanceOf(TRPCError); + expect((e as TRPCError).code).toBe("NOT_FOUND"); + } + }); + + it("translates zod parse failure → BAD_REQUEST", async () => { + const caller = blogRouter.createCaller({}); + try { + await caller.articleBySlug({} as unknown as { slug: string }); + throw new Error("expected throw"); + } catch (e) { + expect(e).toBeInstanceOf(TRPCError); + expect((e as TRPCError).code).toBe("BAD_REQUEST"); + } + }); +}); diff --git a/packages/blog/src/integrations/api/router.ts b/packages/blog/src/integrations/api/router.ts index 664f787..5c24c2f 100644 --- a/packages/blog/src/integrations/api/router.ts +++ b/packages/blog/src/integrations/api/router.ts @@ -1,14 +1,21 @@ -import { z } from "zod"; -import { router, publicProcedure } from "@repo/core-shared/trpc/init"; +import { router } from "@repo/core-shared/trpc/init"; + import { blogContainer } from "../../di/container"; import { BLOG_SYMBOLS } from "../../di/symbols"; + +import { getArticlesInputSchema } from "../../application/use-cases/get-articles.use-case"; +import { createArticleInputSchema } from "../../application/use-cases/create-article.use-case"; +import { getArticleBySlugInputSchema } from "../../application/use-cases/get-article-by-slug.use-case"; + import type { IGetArticlesController } from "../../interface-adapters/controllers/get-articles.controller"; import type { ICreateArticleController } from "../../interface-adapters/controllers/create-article.controller"; import type { IGetArticleBySlugController } from "../../interface-adapters/controllers/get-article-by-slug.controller"; +import { blogProcedure } from "./procedures"; + export const blogRouter = router({ - articleBySlug: publicProcedure - .input(z.object({ slug: z.string().min(1) })) + articleBySlug: blogProcedure + .input(getArticleBySlugInputSchema) .query(({ input }) => { const ctrl = blogContainer.get( BLOG_SYMBOLS.IGetArticleBySlugController, @@ -16,33 +23,17 @@ export const blogRouter = router({ return ctrl(input); }), - listArticles: publicProcedure - .input( - z - .object({ - status: z.string().optional(), - authorId: z.string().optional(), - limit: z.number().optional(), - offset: z.number().optional(), - }) - .optional(), - ) + listArticles: blogProcedure + .input(getArticlesInputSchema) .query(({ input }) => { const ctrl = blogContainer.get( BLOG_SYMBOLS.IGetArticlesController, ); - return ctrl(input ?? {}); + return ctrl(input); }), - createArticle: publicProcedure - .input( - z.object({ - title: z.string().min(1).max(255), - content: z.unknown().optional(), - authorId: z.string(), - slug: z.string().optional(), - }), - ) + createArticle: blogProcedure + .input(createArticleInputSchema) .mutation(({ input }) => { const ctrl = blogContainer.get( BLOG_SYMBOLS.ICreateArticleController, diff --git a/packages/blog/src/interface-adapters/controllers/create-article.controller.ts b/packages/blog/src/interface-adapters/controllers/create-article.controller.ts index b19d4ed..10a030c 100644 --- a/packages/blog/src/interface-adapters/controllers/create-article.controller.ts +++ b/packages/blog/src/interface-adapters/controllers/create-article.controller.ts @@ -1,29 +1,23 @@ -import { z } from "zod"; - import { InputParseError } from "../../entities/errors/common"; -import type { Article } from "../../entities/models/article"; -import type { ICreateArticleUseCase } from "../../application/use-cases/create-article.use-case"; +import { + createArticleInputSchema, + type CreateArticleOutput, + type ICreateArticleUseCase, +} from "../../application/use-cases/create-article.use-case"; -const inputSchema = z.object({ - title: z.string().min(1).max(255), - content: z.unknown().optional(), - authorId: z.string(), - slug: z.string().optional(), -}); +function presenter(value: CreateArticleOutput) { + return value; +} export type ICreateArticleController = ReturnType; export const createArticleController = (createArticleUseCase: ICreateArticleUseCase) => - async (input: Partial>): Promise
=> { - const parsed = inputSchema.safeParse(input); + async (input: unknown): Promise> => { + const parsed = createArticleInputSchema.safeParse(input); if (!parsed.success) { throw new InputParseError("Invalid create-article input", { cause: parsed.error }); } - return createArticleUseCase({ - title: parsed.data.title, - content: parsed.data.content ?? null, - authorId: parsed.data.authorId, - slug: parsed.data.slug, - }); + const result = await createArticleUseCase(parsed.data); + return presenter(result); }; diff --git a/packages/blog/src/interface-adapters/controllers/get-article-by-slug.controller.test.ts b/packages/blog/src/interface-adapters/controllers/get-article-by-slug.controller.test.ts index 6cb9c11..ba33d35 100644 --- a/packages/blog/src/interface-adapters/controllers/get-article-by-slug.controller.test.ts +++ b/packages/blog/src/interface-adapters/controllers/get-article-by-slug.controller.test.ts @@ -33,7 +33,7 @@ describe("getArticleBySlugController", () => { const controller = getArticleBySlugController(useCase); await expect( - controller({} as { slug: string }), + controller({}), ).rejects.toBeInstanceOf(InputParseError); }); }); diff --git a/packages/blog/src/interface-adapters/controllers/get-article-by-slug.controller.ts b/packages/blog/src/interface-adapters/controllers/get-article-by-slug.controller.ts index 94f6b29..7dc8db5 100644 --- a/packages/blog/src/interface-adapters/controllers/get-article-by-slug.controller.ts +++ b/packages/blog/src/interface-adapters/controllers/get-article-by-slug.controller.ts @@ -1,21 +1,23 @@ -import { z } from "zod"; - import { InputParseError } from "../../entities/errors/common"; -import type { Article } from "../../entities/models/article"; -import type { IGetArticleBySlugUseCase } from "../../application/use-cases/get-article-by-slug.use-case"; +import { + getArticleBySlugInputSchema, + type GetArticleBySlugOutput, + type IGetArticleBySlugUseCase, +} from "../../application/use-cases/get-article-by-slug.use-case"; -const inputSchema = z.object({ - slug: z.string().min(1), -}); +function presenter(value: GetArticleBySlugOutput) { + return value; +} export type IGetArticleBySlugController = ReturnType; export const getArticleBySlugController = (getArticleBySlugUseCase: IGetArticleBySlugUseCase) => - async (input: Partial>): Promise
=> { - const parsed = inputSchema.safeParse(input); + async (input: unknown): Promise> => { + const parsed = getArticleBySlugInputSchema.safeParse(input); if (!parsed.success) { throw new InputParseError("Invalid get-article-by-slug input", { cause: parsed.error }); } - return getArticleBySlugUseCase(parsed.data); + const result = await getArticleBySlugUseCase(parsed.data); + return presenter(result); }; diff --git a/packages/blog/src/interface-adapters/controllers/get-articles.controller.test.ts b/packages/blog/src/interface-adapters/controllers/get-articles.controller.test.ts index 96a9b37..0c3c3e9 100644 --- a/packages/blog/src/interface-adapters/controllers/get-articles.controller.test.ts +++ b/packages/blog/src/interface-adapters/controllers/get-articles.controller.test.ts @@ -35,7 +35,7 @@ describe("getArticlesController", () => { const controller = getArticlesController(useCase); await expect( - controller({ limit: "not a number" } as unknown as Record), + controller({ limit: "not a number" }), ).rejects.toBeInstanceOf(InputParseError); }); }); diff --git a/packages/blog/src/interface-adapters/controllers/get-articles.controller.ts b/packages/blog/src/interface-adapters/controllers/get-articles.controller.ts index ae3aa7d..a739ed4 100644 --- a/packages/blog/src/interface-adapters/controllers/get-articles.controller.ts +++ b/packages/blog/src/interface-adapters/controllers/get-articles.controller.ts @@ -1,24 +1,24 @@ -import { z } from "zod"; - import { InputParseError } from "../../entities/errors/common"; -import type { Article } from "../../entities/models/article"; -import type { IGetArticlesUseCase } from "../../application/use-cases/get-articles.use-case"; +import { + getArticlesInputSchema, + type GetArticlesOutput, + type IGetArticlesUseCase, +} from "../../application/use-cases/get-articles.use-case"; -const inputSchema = z.object({ - status: z.string().optional(), - authorId: z.string().optional(), - limit: z.number().optional(), - offset: z.number().optional(), -}); +function presenter(value: GetArticlesOutput) { + // identity for now (R11 — every non-void controller has a presenter) + return value; +} export type IGetArticlesController = ReturnType; export const getArticlesController = (getArticlesUseCase: IGetArticlesUseCase) => - async (input: Partial>): Promise => { - const parsed = inputSchema.safeParse(input); + async (input: unknown): Promise> => { + const parsed = getArticlesInputSchema.safeParse(input); if (!parsed.success) { throw new InputParseError("Invalid get-articles input", { cause: parsed.error }); } - return getArticlesUseCase(parsed.data); + const result = await getArticlesUseCase(parsed.data); + return presenter(result); }; diff --git a/packages/blog/src/ui/index.ts b/packages/blog/src/ui/index.ts new file mode 100644 index 0000000..8129565 --- /dev/null +++ b/packages/blog/src/ui/index.ts @@ -0,0 +1 @@ +export { articleBySlugQuery, listArticlesQuery } from "./query";