Skip to content

Commit

Permalink
feat: 블로그 기초 기능 구현 (#5)
Browse files Browse the repository at this point in the history
* feat: 블로그 데이터 함수 추가

* feat: 블로그 글 리스트 추가

* feat: 블로그 글 보기 구현
  • Loading branch information
Tekiter authored Jul 20, 2023
1 parent 960d710 commit e8341de
Show file tree
Hide file tree
Showing 17 changed files with 493 additions and 89 deletions.
2 changes: 2 additions & 0 deletions apps/ssr-gateway/.dev.vars.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
INTERNAL_API_KEY=TEST_API_KEY_ONLY_FOR_DEVELOPMENT
RECRUIT_NOTION_API_KEY=
RECRUIT_NOTION_PAGE_ID=
BLOG_NOTION_API_KEY=
BLOG_NOTION_DB_ID=
6 changes: 3 additions & 3 deletions apps/ssr-gateway/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@trpc/server": "^10.33.1",
"superjson": "^1.12.4",
"@trpc/server": "^10.35.0",
"superjson": "^1.13.1",
"zod": "^3.21.4"
},
"devDependencies": {
Expand All @@ -21,6 +21,6 @@
"eslint": "^8.44.0",
"eslint-config-custom": "*",
"typescript": "^5.1.6",
"wrangler": "^3.1.1"
"wrangler": "^3.3.0"
}
}
4 changes: 2 additions & 2 deletions apps/ssr-gateway/src/notion/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { createRawNotionAPIClient } from './api';
import { NotionBlock, PageObjectResponse } from './types';
import { NotionBlock, NotionPage } from './types';

export function createNotionClient(notionApiKey: string) {
const notionRawAPI = createRawNotionAPIClient(notionApiKey);

async function getDatabaseContents(id: string) {
const dbData = await notionRawAPI.databaseRetrieve(id);

const objects = dbData.results.filter((result): result is PageObjectResponse => 'properties' in result);
const objects = dbData.results.filter((result): result is NotionPage => 'properties' in result);

return objects;
}
Expand Down
10 changes: 5 additions & 5 deletions apps/ssr-gateway/src/notion/property.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import type { PageObjectResponse } from '@notionhq/client/build/src/api-endpoints';
import { NotionPage } from './types';

export function propertyResolver(properties: PageObjectResponse['properties']) {
function getTypedProperty<T extends PageObjectResponse['properties'][string]['type']>(
export function propertyResolver(properties: NotionPage['properties']) {
function getTypedProperty<T extends NotionPage['properties'][string]['type']>(
name: string,
type: T,
): PageObjectResponse['properties'][string] & { type: T } {
): NotionPage['properties'][string] & { type: T } {
const property = properties[name];

if (property.type !== type) {
throw new Error(`${name} 필드는 ${type} 타입의 속성이 아닙니다.`);
}

return property as PageObjectResponse['properties'][string] & { type: T };
return property as NotionPage['properties'][string] & { type: T };
}

return {
Expand Down
7 changes: 5 additions & 2 deletions apps/ssr-gateway/src/notion/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { BlockObjectResponse as NotionBlock, PageObjectResponse } from '@notionhq/client/build/src/api-endpoints';
import type {
BlockObjectResponse as NotionBlock,
PageObjectResponse as NotionPage,
} from '@notionhq/client/build/src/api-endpoints';

export type { NotionBlock, PageObjectResponse };
export type { NotionBlock, NotionPage };

export type PickNotionBlock<T extends NotionBlock['type']> = Extract<NotionBlock, { [key in T]: unknown }>;

Expand Down
107 changes: 107 additions & 0 deletions apps/ssr-gateway/src/router/blog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { z } from 'zod';

import { propertyResolver } from '../notion/property';
import { NotionPage } from '../notion/types';
import { internalProcedure, router } from '../trpc/stub';

export const blogRouter = router({
list: internalProcedure
.input(
z.object({
category: z.string().optional(),
}),
)
.query(async ({ input, ctx }) => {
const pages = await (async () => {
const cacheKey = () => `cache:blog:list`;

const cached = await ctx.kv.get(cacheKey(), 'json');
if (cached) {
return cached as PagesType;
}

const pages = await ctx.blog.notion.getDatabaseContents(ctx.blog.databaseId);
type PagesType = typeof pages;

await ctx.kv.put(cacheKey(), JSON.stringify(pages));

return pages;
})();

const articles = pages.map((page) => {
const properties = extractArticleProperties(page.properties);

return {
...properties,
id: page.id,
};
});
const categories = articles
.map((article) => article.category)
.filter((category): category is string => !!category);

const filtered = articles.filter((article) => !input.category || article.category === input.category);

const data = {
articles: filtered,
categories,
};

return data;
}),
invalidateList: internalProcedure.mutation(async ({ ctx }) => {
await ctx.kv.delete('cache:blog:list');
}),
article: internalProcedure.input(z.object({ id: z.string() })).query(async ({ input, ctx }) => {
const cached = await ctx.kv.get(`cache:blog:article:${input.id}`, 'json');
if (cached) {
return cached as ArticleData;
}

const [page, blocks] = await Promise.all([ctx.blog.notion.getPage(input.id), ctx.blog.notion.getBlocks(input.id)]);
const properties = extractArticleProperties(page.properties);

const articleData = { ...properties, blocks };
type ArticleData = typeof articleData;

await ctx.kv.put(`cache:blog:article:${input.id}`, JSON.stringify(articleData));

return articleData;
}),
invalidateArticle: internalProcedure.input(z.object({ id: z.string() })).mutation(async ({ input, ctx }) => {
await ctx.kv.delete(`cache:blog:article:${input.id}`);
}),
invalidateAllArticles: internalProcedure.mutation(async ({ ctx }) => {
const { keys } = await ctx.kv.list({ prefix: 'cache:blog:article:' });

await Promise.all(keys.map(async ({ name }) => ctx.kv.delete(name)));
}),
});

function extractArticleProperties(properties: NotionPage['properties']) {
const resolver = propertyResolver(properties);

const title = resolver.title('title');
const editors = resolver.multiSelect('editors').map((raw) => {
const [name, role = undefined] = raw.split('|');

return {
name,
role,
};
});
const publishedAt = resolver.date('publishedAt');
const category = resolver.select('category');
const thumbnailFiles = resolver.files('thumbnail');
const thumbnail = thumbnailFiles.length > 0 ? thumbnailFiles[0] : null;
const publish = resolver.checkbox('publish');

return {
title,
editors,
publishedAt,
category,
thumbnail,
publish,
};
}
2 changes: 2 additions & 0 deletions apps/ssr-gateway/src/router/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { router } from '../trpc/stub';
import { blogRouter } from './blog';
import { internalRouter } from './internal';
import { recruitRouter } from './recruit';

export const appRouter = router({
internal: internalRouter,
recruit: recruitRouter,
blog: blogRouter,
});

// export type definition of API
Expand Down
4 changes: 4 additions & 0 deletions apps/ssr-gateway/src/trpc/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ interface ContextDeps {
waitUntil: (promise: Promise<void>) => void;
checkApiKey: (apiKey: string) => boolean;
recruitNotionClient: NotionClient;
blog: {
notion: NotionClient;
databaseId: string;
};
kv: KVNamespace;
}

Expand Down
34 changes: 29 additions & 5 deletions apps/ssr-gateway/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,22 @@ export interface Env {
INTERNAL_API_KEY?: string;
RECRUIT_NOTION_API_KEY?: string;
RECRUIT_NOTION_PAGE_ID?: string;
BLOG_NOTION_API_KEY?: string;
BLOG_NOTION_DB_ID?: string;
}

export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
if (!env.RECRUIT_NOTION_API_KEY) {
return new Response('Invalid RECRUIT_NOTION_API_KEY', { status: 500 });
}
if (!env.RECRUIT_NOTION_PAGE_ID) {
throw new Error('Env RECRUIT_NOTION_PAGE_ID is not set.');
if (
!check(env, [
'BLOG_NOTION_API_KEY',
'BLOG_NOTION_DB_ID',
'RECRUIT_NOTION_API_KEY',
'RECRUIT_NOTION_PAGE_ID',
'INTERNAL_API_KEY',
])
) {
throw new Error('Some env values are not properly set.');
}

return fetchRequestHandler({
Expand All @@ -57,9 +64,26 @@ export default {
checkApiKey(apiKey) {
return apiKey.trim() === env.INTERNAL_API_KEY;
},
blog: {
notion: createNotionClient(env.BLOG_NOTION_API_KEY),
databaseId: env.BLOG_NOTION_DB_ID,
},
recruitNotionClient: createNotionClient(env.RECRUIT_NOTION_API_KEY),
kv: env.MAKERS_PAGE_KV,
}),
});
},
};

function check<T extends object, K extends keyof T>(
obj: T,
keys: readonly K[],
): obj is T & Required<{ [key in K]: T[K] }> {
for (const key of keys) {
if (!(key in obj)) {
console.log(`Env value ${String(key)} is not properly set.`);
return false;
}
}
return true;
}
14 changes: 8 additions & 6 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,22 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@trpc/client": "^10.33.1",
"@trpc/server": "^10.33.1",
"@trpc/client": "^10.35.0",
"@trpc/server": "^10.35.0",
"axios": "^1.4.0",
"clsx": "^1.2.1",
"clsx": "^2.0.0",
"date-fns": "^2.30.0",
"next": "^13.4.8",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-syntax-highlighter": "^15.5.0",
"ssr-gateway": "*",
"superjson": "^1.12.4"
"superjson": "^1.13.1"
},
"devDependencies": {
"@cloudflare/next-on-pages": "^1.2.0",
"@cloudflare/next-on-pages": "^1.3.1",
"@cloudflare/workers-types": "^4.20230628.0",
"@types/date-fns": "^2.6.0",
"@types/node": "^17.0.12",
"@types/react": "^18.2.14",
"@types/react-dom": "^18.2.6",
Expand All @@ -37,6 +39,6 @@
"tsconfig": "*",
"typescript": "^5.1.6",
"vercel": "^30.0.0",
"wrangler": "^3.1.1"
"wrangler": "^3.3.0"
}
}
19 changes: 19 additions & 0 deletions apps/web/src/app/blog/article/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { FC } from 'react';

import ArticlePage from '@/components/blog/ArticleDetail';

interface BlogArticlePageProps {
params: { id: string };
}

export const runtime = 'edge';

const BlogArticlePage: FC<BlogArticlePageProps> = ({ params }) => {
return (
<>
<ArticlePage id={params.id} />
</>
);
};

export default BlogArticlePage;
19 changes: 19 additions & 0 deletions apps/web/src/app/blog/category/[category]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { FC } from 'react';

import ArticleList from '@/components/blog/ArticleList';

interface BlogCategoryPageProps {
params: { category: string };
}

export const runtime = 'edge';

const BlogCategoryPage: FC<BlogCategoryPageProps> = ({ params }) => {
return (
<>
<ArticleList category={params.category} />
</>
);
};

export default BlogCategoryPage;
17 changes: 17 additions & 0 deletions apps/web/src/app/blog/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { FC } from 'react';

import ArticleList from '@/components/blog/ArticleList';

interface BlogPageProps {}

export const runtime = 'edge';

const BlogPage: FC<BlogPageProps> = async ({}) => {
return (
<div>
<ArticleList />
</div>
);
};

export default BlogPage;
Loading

0 comments on commit e8341de

Please sign in to comment.