😎

NotionAPIとNext.jsでブログアプリ開設(下)

2022/11/12に公開約11,800字

ブログアプリ開発実装編
作るものとしては以下のようなページに作ったページをいい感じにマッピングしてwebサイトにて公開する。
サンプルコード:
https://github.com/takacube/NotionCMSBlogService

サイトの完成図はこれ

仕様技術:

  • C#
  • ASP.NET Core
  • Typescript
  • Next.js

設計編:https://zenn.dev/takanao/articles/b0d9105c51d17d
ではコメント機能をつけているが今回はBlogServiceのみの実装:

Blog ServiceのAPI実装

Notion APIをマッピングする用のバックエンドアプリを開発する。
追加で何か機能足すかも

インフラストラクチャ層

NotionAPIの仕様により、一括で子ページの全データを取得することが不可能なので、まず、子ページのIDとタイトルのみを以下のFindAllAsync()関数から取得する。

public IEnumerable<BlogMainRecord> FindAllAsync()
        {
            var client = new HttpClient();
            var request = NotionConn(this.NotionSettings.Value.BlockUrl);
            HttpStatusCode resStatusCode = HttpStatusCode.NotFound;
            Task<HttpResponseMessage> response;
            List<NotionBlockChildResult>? results = new List<NotionBlockChildResult>() { };
            try
            {
                response = client.SendAsync(request);
                var resBody = response.Result.Content.ReadAsStringAsync().Result;
                resStatusCode = response.Result.StatusCode;

                var serializerSettings = new JsonSerializerSettings();
                serializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver();
                
                var deserializedBody = JsonConvert.DeserializeObject<NotionBlockChild>(resBody);
                Console.WriteLine(deserializedBody);
                results = (deserializedBody is not null) ? deserializedBody.results : results;
            }
            catch (Exception err) {
                throw new Exception($"[Error] unexpected error has occured : {err}");
            }
            if (!resStatusCode.Equals(HttpStatusCode.OK))
            {
                throw new Exception($"statuc code is not 200 {resStatusCode}");
            }
            //blogs = new List<BlogRecord>() { new BlogRecord("Content1", "Title1", "Header1", "Id1"), new BlogRecord("Content2", "Title2", "Header2", "Id2") };
            
            foreach (var result in results)
            {
                Console.WriteLine(result);
                if (result.child_page is null) {
                    continue;
                }
                var title = result.child_page.title;
                var id = result.id;

                var res = new BlogMainRecord(title, id);
                yield return res;
            }
            
        }

ここで得られたIDを用いて、以下のFindByIdからすべての情報(id, headerのemoji, タイトル, コンテンツ内容)を取得する.

public BlogRecord FindById(string id)
        {
            var client = new HttpClient();
            var request = NotionConn("https://api.notion.com/v1/blocks/" + id + "/children?page_size=100");
            HttpStatusCode resStatusCode = HttpStatusCode.NotFound;
            Task<HttpResponseMessage> response;
            List<NotionBlockChildResult>? results = new List<NotionBlockChildResult>() { };
            try
            {
                response = client.SendAsync(request);
                var resBody = response.Result.Content.ReadAsStringAsync().Result;
                resStatusCode = response.Result.StatusCode;

                var serializerSettings = new JsonSerializerSettings();
                serializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver();
                var deserializedBody = JsonConvert.DeserializeObject<NotionBlockChild>(resBody);

                results = (deserializedBody is not null) ? deserializedBody.results : results;
            }
            catch (Exception err)
            {
                throw new Exception($"[Error] unexpected error has occured : {err}");
            }
            if (!resStatusCode.Equals(HttpStatusCode.OK))
            {
                throw new Exception($"statuc code is not 200 but: {resStatusCode}");
            }
       
            var blogText = string.Join("\n", results.Select(result => {
                if (result is not null)
                {
                    return (result.type == "paragraph" && result.paragraph.rich_text.Count() != 0) ? result.paragraph.rich_text[0].text.content: "";
                }
                else {
                    return "";
                }
            }).ToArray());
            var pageMainContent = GetPageInfo(id);
            return new BlogRecord(blogText, pageMainContent.title, pageMainContent.emoji, id);
        }

ドメイン層

今回は大したアプリじゃないのでロジックがドメイン層にはほぼないので、インフラレイヤーを呼び出しているだけのようになる。

namespace Blog.Domain
{
    public class BlogDomain: IBlogDomain
    {
        private readonly INotionBlogs notionBlogs;
        public BlogDomain(INotionBlogs notionBlogs) { 
            this.notionBlogs = notionBlogs;
        }

        public IEnumerable<BlogMainRecord> list() {
            var blogs = this.notionBlogs.FindAllAsync();
            foreach (var record in blogs) { 
                yield return record;
            }
        }
        public BlogRecord get(string id) {
            var blog = this.notionBlogs.FindById(id);
            return blog;
        }

        public IEnumerable<BlogRecord> fullList() {
            var blogs = this.notionBlogs.FindAllAsync();
            foreach (var record in blogs) {
                var blogFullInofo = this.get(record.Id);
                yield return blogFullInofo;
            }
        }
    }
}

サービス層

ここもただただドメイン層をインターフェイスに沿って呼び出すのみの実装。

namespace Blog.Services
{
    public class BlogService : IBlogService
    {
        private readonly ILogger<BlogService> logger;
        private readonly IBlogDomain blogDomain;
        public BlogService(ILogger<BlogService> logger, IBlogDomain blogDomain)
        {
            this.logger = logger;
            this.blogDomain = blogDomain;
        }

        public IEnumerable<BlogMainRecord> ListBlogs()
        {
            var blogList = this.blogDomain.list();
            foreach (var blog in blogList)
            {
                yield return blog;
            }            
        }

        public IEnumerable<BlogRecord> ListBlogsFullData()
        {
            var blogList = this.blogDomain.fullList();
            foreach (var blog in blogList)
            {
                yield return blog;
            }
        }
        public BlogRecord GetBlog(string id)
        {
            return this.blogDomain.get(id);
        }
    }
}

Github ActionによるApp serviceへのデプロイ

ここではNotionAPIで使用するNOTION_AUTHとWeb appserviceのデプロイで必要なDEV_AZURE_PUBLISH_PROFILE, BLOCK_URLをsecretsに埋め込む。
ASP.NET Coreではappsettings.json内に環境変数やらを埋め込むことにしていて、
下のようにjsonファイルの中身を書き換える。

notion_auth="${{ secrets.NOTION_AUTH }}"
notion_version="${{ env.NOTION_VERSION }}"
block_url="${{ secrets.BLOCK_URL}}"
cat appsettings.json | jq \
        ".NotionSettings.Authorization=\"$notion_auth\" | .NotionSettings.NotionVersion=\"$notion_version\" | .NotionSettings.BlockUrl=\"$block_url\"" \
        > appsettings.new.json
        mv appsettings.new.json appsettings.json

フロントアプリ実装

API通信用のディレクトリであるapi/配下にblogList.tsとblogRecord.tsファイルを設置する。

blogList.ts
ここではさっき作成したAPIにブログのリストを取得する実装を記述している。

export const fetchBlogsList = async (): Promise<blogRecordType[]> => {
    const result = await fetch(`${process.env.NEXT_PUBLIC_BACKEND_URL}/Blogs/full_list`, {
    method: 'GET'
    });
    
    const blogList: Promise<blogRecordType[]> = result.json();
    return blogList;
}

blogRecord.ts
ここではブログのIDよりブログのデータを取得する実装を記述する。

export const fetchBlog = async (id: string): Promise<blogRecordType> => {
    const result = await fetch(`${process.env.NEXT_PUBLIC_BACKEND_URL}/Blogs?id=${id}`, {
    method: 'GET'
    });
    const blogContent: Promise<blogRecordType> = result.json()
    return blogContent;
}

このアプリケーションではcomponent単位でいくつか分けていて、ホームのページでの記事のカード表示をblogCard.tsxに記述した。デザインはMaterial UIを使用した。

ホームページではブログ内容を全部表示させないように、80文字以上の記事であれば、それ以降をスライスして初めの80文字だけ表示させるようにしている。

import * as React from 'react';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import CardMedia from '@mui/material/CardMedia';
import Typography from '@mui/material/Typography';
import { CardActionArea } from '@mui/material';
import Link from "next/link"
import { IBlog } from './IBlog';

export default function BlogCard(props: IBlog) {
    const shortenString = (content: string | null) => {
        if (content != null && content.length >= 80) {
            return content.slice(0, 80);
        }
        return content
    }
    const blogId = props.id;
    const blogUrl = "/blog?id=" + blogId;
    const blogContent =shortenString(props.content);

    
    return (
    <div className="blogCard">
        <Link href={blogUrl}>
        <Card>
        <div className="cardHeader"> {props.emoji}</div>
        <div className="card">
            <CardActionArea>
                <CardContent>
                <Typography gutterBottom variant="h5" component="div">
                </Typography>
                <Typography gutterBottom variant="h5" component="div">
                    {props.title}
                </Typography>
                <Typography variant="body2" color="text.secondary">
                    
                    {blogContent}
                    
                </Typography>
                </CardContent>
            </CardActionArea>
        </div>
        </Card>
        </Link>
    </div>
  );
}

上で作ったブログカードをリスト表示させるためのblogCardList.tsxを作成する。
fetchBlogsListから取得したリスト形式のブログデータを一つずつblogTarbleに渡してリスト化している。
また、NEXT_PUBLIC_BACKEND_URLを.env.localファイルに設定したいので、workflow内で書き込んでからbuildするようにしている。

import { useState } from "react";
import BlogCard from "./blogCard";
import { fetchBlogsList, blogRecordType } from "../api/blogList";

const BlogCardList = () => {
    const blogContentList: blogRecordType[] = [];
    const [blogs, setBlogs] = useState(blogContentList)
    
    fetchBlogsList().then((blogList) => {
        setBlogs(blogList);
    });

    return(
        <div className="cardList">
            {blogs.map((blog: blogRecordType) => {
                return (
                        <BlogCard key={blog.id} id={blog.id} title={blog.title} content={blog.content} emoji={blog.header}></BlogCard>
                )
            })}
        </div>
    )
}

export default BlogCardList;

フロントアプリケーションのs3へのデプロイ

これに関しては何のひねりもなく、build後のstaticファイルをs3にアップロードするだけ。
ただし、Reactのパッケージのdependenciesでconflictが起きてモジュールのインストールでコケるので、npm config set legacy-peer-deps trueと打ち込んでからnpm install を実行するようにしている。

name: Deploy main on push

on:
  push:
    branches:
      - master

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Setup node
        uses: actions/setup-node@v3
        with:
          node-version: '16'

      - name: Install Dependencies
        run: |
          cd src
          rm package-lock.json
          npm config set legacy-peer-deps true
          npm install

      - name: Build
        run: |
          cd src 
          backend_url="${{ secrets.BACKEND_URL }}"
          echo "NEXT_PUBLIC_BACKEND_URL=\"$backend_url\"" > .env.local
          npm run build

      - name: Deploy
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.SECRET_ACCESS_KEY }}
        run: |
          echo "AWS s3 sync"
          ls
          aws s3 sync --region ap-northeast-1 src/out s3://${{ secrets.AWS_S3_BUCKET}} --delete

とりあえずはこんな感じでNotionで書いたブログをweb上で表示させるところまではできた。
あとはコメント機能を気が向いたら実装したい。

Discussion

ログインするとコメントできます