ReactのフレークワークといえばNext.jsやRemixなどを思い浮かべる人も多いかと思いますがRedwoodJSもReactベースのフレームワークです。

RedwoodJSはJavaScript/TypeScriptフルスタックフレームワークでスタートアップに最適化されておりWebアプリケーションをより簡単に構築できることを目的に作られています。フルスタックフレームワークという名前通り、フロントエンドだけではなくバックエンドについてもコマンドで作成する1つのRedwoodJSプロジェクトの中で管理します。

RedwoodJSはReact, GraphQL, Prismaの3つのメインのコア機能を中心に作成されており、これらの機能を利用することを前提に作られているので通常はGraphQLをRESTに変更するといったような他の機能への置き換えといったことは行いません。そのため技術としてReact, GraphQL, Prismaが好きではないまたは利用したくないという人には勧められないフレームワークです。

3つの機能以外にテストにはJest, UIコンポーネントの開発ツールにはStoryBook, ロギングにはPinoを利用します。

RedwoodJSの特徴には説明した通り利用する技術が決められている以外にコマンドラインを利用することでアプリケーションを構築する際に設定が必ず必要な共通の処理を簡単に行うことができます。例えばページの作成、コンポーネントの作成をコマンドラインから行うとページやコンポーネント、レイアウトファイルを作成するだけでなくページの作成時にはルーティングの設定が自動で行われ、テスト用、Storybookのファイルも同時に作成されます。さらにコマンドラインを使うことでユーザ認証、CRUD(Create, Read, Update, Delete)に関するScaffolding(骨組み)を作成することができます。CRUDのScaffoldingではページの作成だけではなくGraphQLの設定、ルーティングの設定まで行ってくれるのでコマンド実行直後から新規データの作成、更新、削除、表示をブラウザ上からすぐに行うことができます。

認証では認証機能だけではなくサインアップやログインのルーティング、ログイン/サインアップ画面の作成、ログイン/サインアップのデータの送受信に関わるGraphQLの設定も行ってくれます。

本文書ではRedwoodJSの公式ドキュメントのチュートリアルに沿って設定を行っていくことでRedwoodJSの理解を深めることができコマンドラインでScaffoldingを作成するということがどういったものなのかもすぐに理解することができます。公式ドキュメントのチュートリアルは7章で構成されていますが本文書では1, 2, 4章を中心に動作確認を行なっています。RedwoodJSではGraphQLやPrismaを利用していますがチュートリアルの範囲であればそれらの知識がなくても設定を行うことができます。しかしRedwoodJSを理解するためにGraphQL、Prismaの知識が必要になります。

RedwoodJSのインストール

RedwoodJSのプロジェクトを作成するためにはyarnを利用して行います。コマンドにもnpmではくyarnを利用するためyarnが実行できる環境が必要です。

プロジェクトの名前にredwoodblogを指定していますが任意の名前をつけることができます。以下のコマンドを実行するとコマンドを実行したフォルダにredwoodblogフォルダが作成されます。ブログの投稿、表示を行う機能のアプリケーションの構築を行っていくのでフォルダの名前にblogが入っています。


 % yarn create redwood-app ./redwoodblog

TypeScriptを利用する場合は下記のように–tsをつけて実行します。


 % yarn create redwood-app --ts ./redwoodblog

インストールが完了するまでに少し時間がかかります。


 % yarn create redwood-app ./redwoodblog
//略
Thanks for trying out Redwood!

 ⚡️ Get up and running fast with this Quick Start guide: https://redwoodjs.com/docs/quick-start

Join the Community

 ❖ Join our Forums: https://community.redwoodjs.com
 ❖ Join our Chat: https://discord.gg/redwoodjs

Get some help

 ❖ Get started with the Tutorial: https://redwoodjs.com/docs/tutorial
 ❖ Read the Documentation: https://redwoodjs.com/docs

Stay updated

 ❖ Sign up for our Newsletter: https://www.redwoodjs.com/newsletter
 ❖ Follow us on Twitter: https://twitter.com/redwoodjs

Become a Contributor ❤

 ❖ Learn how to get started: https://redwoodjs.com/docs/contributing
 ❖ Find a Good First Issue: https://redwoodjs.com/good-first-issue

Fire it up! 🚀

 > cd ./redwoodblog
 > yarn rw dev

✨  Done in 165.00s.

インストールが完了したらインストールメッセージに表示されている通り作成されるフォルダに移動してyarn rw devコマンドを実行します。


 % yarn rw dev
//略
api | Took 574 ms
api | API listening on http://localhost:8911/
api | GraphQL endpoint at /graphql
api | 14:53:57 🌲 Server listening at http://[::]:8911

ブラウザ上にはRewoodJSのWelcomeページが自動で表示されます。ポート番号は8910で起動していることが確認できます。

RedwoodJSの初期ページ
RedwoodJSのWelcomeページ
ポート番号は8-9-10と覚えやすい番号になっています。

フォルダ構成

本文書ではVSCodeを利用しているのでVSCodeを利用してフォルダ構成を確認します。

インストール直後のフォルダ構成
インストール直後のフォルダ構成

redwoodblogフォルダの直下のフォルダを確認するとnode_modelesと.のついたフォルダを除くとapi, scripts, webが存在することがわかります。

apiはバックエンドに関わるファイルが保存されているフォルダでwebはフロントエンドに関わるファイルが保存されているフォルダです。scriptsはapi, webに直接関係のないNodeのScriptsを保存するフォルダです。デフォルトではseed.jsという名前のフォルダが存在し中身を確認するとデータベースにダミーデータを挿入するためのスクリプトであることが確認できます。

RedwoodJSではコマンドを利用してアプリケーションを構築していくと自動でファイルを配置してくれるのでフォルダ構成で悩むこともありません。

api、webフォルダはyarnではworkspacesとして管理されておりredwoodblog直下のpackage.jsonファイルを見るとworkspacesとしてapi, webが登録されています。


{
  "private": true,
  "workspaces": {
    "packages": [
      "api",
      "web",
      "packages/*"
    ]
  },
  //略
}

workspacesの設定により例えばapiフォルダにmarkedライブラリをインストールしたい場合にはworkspaceを指定してインストールすることができます。


 % yarn workspace web add marked
本文書では他のライブラリのインストールを行わないのでworkspaceを意識することはりません。

apiフォルダ

apiフォルダはバックエンドに関するフォルダでデータベースとの操作に利用されるPrismaの設定ファイルやデータの取得に利用されるGraphQLのサーバ側の設定に関するファイルが保存されています。

srcフォルダのfunctionsフォルダにはserverless functionsのファイル、graphqlフォルダにはGraphQLのSDL(Schema Definition Language)ファイル、libフォルダにはPrism ClientやLoggingの設定ファイル、servicesにはGraphQLのResolversのファイルが保存されています。

webフォルダ

webフォルダはフロントエンドに関わるファイルが保存されているフォルダで直下にはpublicとsrcフォルダが存在します。package.jsonファイルを見るとReactがインストールされていることがわかります。srcフォルダの中身を確認するとApp.jsファイルを含めReactでアプリケーションを構築する際に必要となるフォルダ、ファイルを確認するとことができます。

webフォルダ下のフォルダ構成
webフォルダ下のフォルダ構成

初めてのページ作成

RedwoodjsではWelcomeページはデフォルトで存在していますがその他のページを作成したい場合は”yarn redwood generate page my-page”コマンドを実行して行います。

ここからコマンドを利用していますがどのようなコマンドのオプションが存在するのか知りたい場合はyarn rw –helpで確認することができます。コマンドラインの中で出てくるredwoodはrw,generateはgとして省略することができます。

コマンドyarn redwood generate pageの後ろには追加するページの名前とその後にパスを設定することができます。


 % yarn redwood generate page home /
or 
% yarn rw g page home / (redwoodをrw, generateをgで略)

コマンドを実行すると/web/src/pagesフォルダの下にHomePageフォルダ、その下に3つのファイル(HomePage.stories.js, HomePage.test.js, HomePage.js)が作成されます。またルーティングファイルのの更新も行われていることがコマンドのメッセージから確認することができます。


 % yarn redwood generate page home /
(node:99445) ExperimentalWarning: stream/web is an experimental feature. This feature could change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
(node:99445) [FST_MODULE_DEP_FASTIFY-REPLY-FROM] FastifyWarning.fastify-reply-from: fastify-reply-from has been deprecated. Use @fastify/reply-from@7.0.0 instead.
  ✔ Generating page files...
    ✔ Successfully wrote file `./web/src/pages/HomePage/HomePage.stories.js`
    ✔ Successfully wrote file `./web/src/pages/HomePage/HomePage.test.js`
    ✔ Successfully wrote file `./web/src/pages/HomePage/HomePage.js`
  ✔ Updating routes file...
  ✔ Generating types...
  ✔ One more thing...

    Page created! A note about <MetaTags>:

    At the top of your newly created page is a <MetaTags> component,
    which contains the title and description for your page, essential
    to good SEO. Check out this page for best practices: 

    https://developers.google.com/search/docs/advanced/appearance/good-titles-snippets

ページの内容はHomePage.jsファイルに記述されているので中身を確認します。HomePage.test.jsはテスト用のファイル、HomePage.stories.jsはStorybook用のファイルです。


import { Link, routes } from '@redwoodjs/router'
import { MetaTags } from '@redwoodjs/web'

const HomePage = () => {
  return (
    <>
      <MetaTags title="Home" description="Home page" />

      <h1>HomePage</h1>
      <p>
        Find me in <code>./web/src/pages/HomePage/HomePage.js</code>
      </p>
      <p>
        My default route is named <code>home</code>, link to me with `
        <Link to={routes.home()}>Home</Link>`
      </p>
    </>
  )
}

export default HomePage

routesファイルの更新も行われているのでルーティングファイルであるRoutes.jsファイルの中身も確認します。コマンドで指定したページの名前のhomeがRouteコンポーネントのname propsに設定されておりpage propsにはページの内容が記述されたHomePageが指定されていることがわかります。path propsにも指定した値(“/”)が設定されています。


import { Router, Route } from '@redwoodjs/router'

const Routes = () => {
  return (
    <Router>
      <Route path="/" page={HomePage} name="home" />
      <Route notfound page={NotFoundPage} />
    </Router>
  )
}

export default Routes
RedwoodJSのRouterはReact RouterではなくRedwoodJSが独自に作成したRouterが利用されています。

ページ作成後にhttp://localhost:8910/にアクセスすると先ほどまで表示されていたWelcomeページではなくRoutes.jsファイルに設定されている通りHomePage.jsの中身が表示されます。

コマンドで作成したHomePageが表示
コマンドで作成したHomePageが表示

Welcomeページを再度表示させたい場合はRoutes.jsファイルを開いてHomeページで設定したpathの設定を/(ルート)から別のパス(ここでは/home)に設定することで再度http://localhost:8910/にアクセスするとWelcomeページが表示されます。


<Route path="/home" page={HomePage} name="home" />

http://localhost:8910/homeにアクセスするとhomeページの内容が表示されます。URLを変更したい場合にはpathの値を変更するだけでURLを変更することができます。

NotFoundページ

/web/src/pagesフォルダの下にはデフォルトからFatalErrorPageとNotFoundPageフォルダが存在します。ルーティングのRoutes.jsファイルに設定されていないURLにアクセスした場合はRoutes.jsファイルでnotfoundが設定されているので特別な設定を行うことなく”Not Found”ページが表示されます。Not Foundページに表示されている内容は/web/src/pages/NoFoundPage/NotFoundPage.jsに記述されています。

404 Page Not Found
404 Page Not Found

aboutページの作成

次にaboutページの作成を行います。homeページを作成した時と同様にコマンドを利用しますが今回はpathの設定を行わずに実行します。


 % yarn redwood generate page about
//略
 ✔ Generating page files...
    ✔ Successfully wrote file `./web/src/pages/AboutPage/AboutPage.stories.js`
    ✔ Successfully wrote file `./web/src/pages/AboutPage/AboutPage.test.js`
    ✔ Successfully wrote file `./web/src/pages/AboutPage/AboutPage.js`
  ✔ Updating routes file...
  ✔ Generating types...
  ✔ One more thing...
//略」

/web/src/pageフォルダの下にAboutPageフォルダと3つのファイルが作成されます。コマンドでpathを指定しませんでしたがpathは必須ではないためRoutes.jsファイルを見るとRouteコンポーネントのpathには自動で/aboutが設定されていることがわかります。


import { Router, Route } from '@redwoodjs/router'

const Routes = () => {
  return (
    <Router>
      <Route path="/about" page={AboutPage} name="about" />
      <Route path="/" page={HomePage} name="home" />
      <Route notfound page={NotFoundPage} />
    </Router>
  )
}

export default Routes

/aboutにアクセスするとaboutページが表示されます。

aboutページの表示
aboutページの表示

ページの移動

homeページとaboutページを作成しましたが各ページ間を移動したい場合はブラウザのURLを手動で変更する必要があります。手動でURLを変更した場合にはページ全体が再読み込みされます。

aboutページからhomeページに移動できるようにAboutPage.jsファイルを更新します。

AboutPage.jsファイルの中にはLinkコンポーネントがありto propsにroutes.about()が設定されています。homeページに移動できるようにroutes.about()からroutes.home()に書き換えます。homeはRoutes.jsファイルでRouteコンポーネントのname propsに設定されている値です。nameを利用してルーティングを設定するので名前付きルート関数と呼ばれます。


<p>
  My default route is named <code>about</code>, link to me with `
  <Link to={routes.home()}>Home</Link>`
</p>

設定変更後、aboutページに表示されているHomeのリンクをクリックするとhomeページが表示されます。手動でURLを変更した時とは異なりページの再読み込みは行われずスムーズにページ移動できることが確認できます。

Code Splittingの動作確認

RedwoodJSはSPA(Single Page Application)なのでページの描写はブラウザ側でJavaScriptファイルをダウンロードして行います。一度にすべてのページ情報が保存されているJavaScriptファイルをダウンロードするのは時間がかかるためJavaScriptファイルを分割してアクセスしたページ毎にそのページに関わるJavaScriptファイルをダウンロードされることができます。その技術がCode Splittingです。Code Splittingによりアクセス直後にダウンロードするJavaScriptファイルを小さくすることができ初期表示の速度を速くすることができます。

RedwoodJSではデフォルト設定ですべてのページはCode Splittingが設定されているのでページを移動すると移動先のページに関するJavaScriptファイルをブラウザにダウンロードしてダウンロードしたJavaScriptファイルをもとにブラウザ上にページの内容を描写します。

ブラウザのデベロッパーツールのネットワークタブを利用してページを移動することでCode SplittingによりJavaScriptファイルがダウンロードされるのか確認を行います。

aboutページを開くとaboutページに関するJavaScriptファイルとそれ以外のいくつかのJavaScriptファイルがダウンロードされることが確認できます。

aboutページで読み込まれるjsファイル
aboutページで読み込まれるjsファイル

aboutページのHomeリンクをクリックすると新たにsrc_pages_HomePage_HomePage_js.chunk.jsファイルがダウンロードされることが確認できます。

homeページのJavaScriptファイルのダウンロード
homeページのJavaScriptファイルのダウンロード

ファイルの中身を見るとHomePage.jsファイルに記述されているコードを確認することができます。

ファイルの中身
ファイルの中身

もしデフォルト設定のCode-splittingはRoutes.jsファイルでHomePage.jsファイルのimportの行を追加することで停止することができます。


import { Router, Route } from '@redwoodjs/router'
import HomePage from 'src/pages/HomePage' //追加

const Routes = () => {
  return (
    <Router>
      <Route path="/about" page={AboutPage} name="about" />
      <Route path="/home" page={HomePage} name="home" />
      <Route notfound page={NotFoundPage} />
    </Router>
  )
}

export default Routes

Code-splittingを停止した場合はapp.bundle.jsファイルの中にHomePage.jsファイルのコードが保存されます。

ナビゲーションの追加

aboutページにhomeページへのリンクを追加しましたがここではhomeページからもaboutページに移動できるようにナビゲーションを追加します。ページの移動にはLinkコンポーネントを利用します。to属性にはroutes.about()を設定します。aboutはRoutes.jsファイルでaboutページへのルーティングに設定したname属性のaboutです。


import { Link, routes } from '@redwoodjs/router'
import { MetaTags } from '@redwoodjs/web'

const HomePage = () => {
  return (
    <>
      <MetaTags title="Home" description="Home page" />

      <header>
        <h1>Redwood Blog</h1>
        <nav>
          <ul>
            <li>
              <Link to={routes.about()}>About</Link>
            </li>
          </ul>
        </nav>
      </header>
      <main>Home</main>
    </>
  )
}

export default HomePage

ブラウザで確認するとaboutページへのリンクが表示されます。Aboutのリンクをクリックするとスムーズにaboutページへの移動が行われます。

ナビゲーションの追加
ナビゲーションの追加

名前付きルート関数を利用することは必須ではないのでもし利用したくない場合は下記のようにaboutページへのパス/aboutをto propsに設定することができます。


<Link to="/about">About</Link>

名前付きルート関数を利用している場合はRoutes.jsでaboutページへのパスを/aboutから/about-usに変更があった場合でもHomePage.jsで設定したto属性のroute.about()を変更する必要はありません。


<Route path="/about-us" page={AboutPage} name="about" />

AboutPage.jsファイルにもナビゲーションの追加を行い、homeページに戻れるようにリンクの設定を行います。


import { Link, routes } from '@redwoodjs/router'
import { MetaTags } from '@redwoodjs/web'

const AboutPage = () => {
  return (
    <>
      <MetaTags title="About" description="About page" />
      <header>
        <h1>Redwood Blog</h1>
        <nav>
          <ul>
            <li>
              <Link to={routes.about()}>About</Link>
            </li>
          </ul>
        </nav>
      </header>
      <main>
        <p>
          This site was created to demonstrate my mastery of Redwood: Look on my
          works, ye mighty, and despair!
        </p>
        <Link to={routes.home()}>Return home</Link>
      </main>
    </>
  )
}

export default AboutPage

ブラウザで確認するとaboutページにナビゲーションが追加され、homeページに戻るためのリンクが追加されます。

AboutPageにナビテーションを追加
AboutPageにナビテーションを追加

レイアウトの設定

HomePage.js, AboutPage.jsの2つのページファイルのheaderタグにナビゲーションを追加しましたが内容が重複しているためレイアウトファイルの作成を行います。

レイアウトファイルの作成もコマンドを利用して行うことができます。


 % yarn redwood generate layout blog
(node:2458) ExperimentalWarning: stream/web is an experimental feature. This feature could change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
(node:2458) [FST_MODULE_DEP_FASTIFY-REPLY-FROM] FastifyWarning.fastify-reply-from: fastify-reply-from has been deprecated. Use @fastify/reply-from@7.0.0 instead.
  ✔ Generating layout files...
    ✔ Successfully wrote file `./web/src/layouts/BlogLayout/BlogLayout.test.js`
    ✔ Successfully wrote file `./web/src/layouts/BlogLayout/BlogLayout.stories.js`
    ✔ Successfully wrote file `./web/src/layouts/BlogLayout/BlogLayout.js`

コマンドを実行するとweb/src/layouts/BlogLayoutフォルダの下に3つのファイルが作成されることがわかります。ブラウザに表示されるコードを記述するファイルはBlogLayout.jsファイルなのでこのファイルを開いてHomePage.jsとAboutPage.jsファイルに追加したheaderタグをコピー&ペーストします。


import { Link, routes } from '@redwoodjs/router'

const BlogLayout = ({ children }) => {
  return (
    <>
      <header>
        <h1>Redwood Blog</h1>
        <nav>
          <ul>
            <li>
              <Link to={routes.about()}>About</Link>
            </li>
          </ul>
        </nav>
      </header>
      <main>{children}</main>
    </>
  )
}

export default BlogLayout

HomePage.js、AboutPage.jsからはheaderタグがなくなるので下記のようになります。


import { Link, routes } from '@redwoodjs/router'
import { MetaTags } from '@redwoodjs/web'

const HomePage = () => {
  return (
    <>
      <MetaTags title="Home" description="Home page" />

      <main>Home</main>
    </>
  )
}

export default HomePage

import { Link, routes } from '@redwoodjs/router'
import { MetaTags } from '@redwoodjs/web'

const AboutPage = () => {
  return (
    <>
      <MetaTags title="About" description="About page" />

      <main>
        <p>
          This site was created to demonstrate my mastery of Redwood: Look on my
          works, ye mighty, and despair!
        </p>
        <Link to={routes.home()}>Return home</Link>
      </main>
    </>
  )
}

export default AboutPage

コマンドでレイアウトファイルを作成したので自動でレイアウトファイルがhomeページとaboutページに反映されるわけではありません。

作成したレイアウトファイルはRoutes.jsファイルで設定を行う必要があります。redwoodjs/routerからSetコンポーネントをimportしてwrap propsに作成したBlogLayoutを設定します。BlogLayoutファイルはimportが必要です。


import { Router, Route, Set } from '@redwoodjs/router'
import BlogLayout from 'src/layouts/BlogLayout'

const Routes = () => {
  return (
    <Router>
      <Set wrap={BlogLayout}>
        <Route path="/about" page={AboutPage} name="about" />
        <Route path="/" page={HomePage} name="home" />
      </Set>
      <Route notfound page={NotFoundPage} />
    </Router>
  )
}

export default Routes

Setコンポーネントのwrap propsにBlogLayoutを設定することでBlogLayout.jsファイルのchildrenの中にHomePage.js, AboutPage,jsに記述したコードが挿入されて表示されます。

AboutPageにナビテーションを追加
AboutPageにLayoutファイルを適用

Layoutファイルを利用する場合は新たにナビテーションにリンクを追加した場合は複数のファイルを更新することなくLayoutファイルを更新するだけでLayoutファイルを利用するすべてのページの更新内容が反映されます。

ナビゲーションにはaboutページのみへのリンクだったのでhomeページへのリンクも追加します。


import { Link, routes } from '@redwoodjs/router'

const BlogLayout = ({ children }) => {
  return (
    <>
      <header>
        <h1>Redwood Blog</h1>
        <nav>
          <ul>
            <li>
              <Link to={routes.home()}>Home</Link>
            </li>
            <li>
              <Link to={routes.about()}>About</Link>
            </li>
          </ul>
        </nav>
      </header>
      <main>{children}</main>
    </>
  )
}

export default BlogLayout

aboutページを確認するとBlogLayout.jsファイルに追加したリンクが表示されます。

BlogLayoutに追加したリンクがaboutページに反映
BlogLayoutに追加したリンクがaboutページに反映

Routes.jsファイルでSetコンポーネントを利用しましたがそのままBlogLayoutタグでwrapすることでもレイアウトを反映させることができます。


import { Router, Route } from '@redwoodjs/router'
import BlogLayout from 'src/layouts/BlogLayout'

const Routes = () => {
  return (
    <Router>
        <BlogLayout>
          <Route path="/about" page={AboutPage} name="about" />
          <Route path="/" page={HomePage} name="home" />
        </BlogLayout>
      <Route notfound page={NotFoundPage} />
    </Router>
  )
}

export default Routes

データベースの設定

homeページとaboutページの作成が完了したので次はブログ記事をページ上に表示できるように設定を行っていきます。ブログ情報を永続的に保存するためにはデータベースを設定が必要となります

RedwoodJSではテーブルの設定、テーブルへのSQLの実行にPrismaを利用しています。PrismaはORM(Object Relationnal Mapping)なのでデータベースに直接アクセスするのではなくPrismaを経由してデータベースのテーブルにアクセスを行うことになります。Prismaを利用した場合はSQLを記述する必要はなくJavaScriptのメソッドを利用してオブジェクトを操作するようにデータベースの操作を行うことができます。例えばテーブルからすべてのデータを取得したい場合には下記のようなメソッドを実行することで取得できます。


db.post.findMany()
//SQL
select * from post;

テーブルの作成

データベースに保存するブログ記事の情報はid, title, body, created_atの4つのフィールドを持つ構成として定義します。データベースに作成するテーブルにはid, title, body, created_at列を持つことになります。

idはブログ記事毎に割り振られる一意の識別子です。titleはブログのタイトル、bodyはブログの内容、created_atには記事の作成日時を保存します。

Prismaでは設定ファイルのPrisma schema file(schema.prisma) の中でデータベースへの接続情報やモデルの設定を行います。schema.prismaファイルはデフォルトで作成されておりapi¥dbフォルダの中に保存されています。

schema.prismaファイルの内容を確認します。datasourceのproviderにSQLiteデータベースが設定されています。urlでenv関数の引数に設定されている環境変数は.env.defaultsに設定されているDATABASE_URLのfile:./dev.dbが設定されます。DATABASE_URLにはデータベースへの接続情報の設定を行い、SQLiteではデータベースとしてファイルを利用するのでデータベースファイルがdev.dbとなっています。


datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

generator client {
  provider      = "prisma-client-js"
  binaryTargets = "native"
}

model UserExample {
  id    Int     @id @default(autoincrement())
  email String  @unique
  name  String?
}

modelでUserExampleが記述されていますがUserExampleからPostモデルに変更します。


datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

generator client {
  provider      = "prisma-client-js"
  binaryTargets = "native"
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  body      String
  createdAt DateTime @default(now())
}

Postモデルではブログの記事を構成するid, title, body, createdAtにデータタイプを設定しています。idにはInt型を設定しデータを追加する度に自動で加算された数字が設定されるようにautoincrementを設定しています。titleとbodyには文字列が入るのでString型、createdAtには時刻が入るのでDateTime型を設定しています。default値はデータ作成時の時刻が入ることになります。

schema.prismaファイルの設定を行ったのでコマンドを実行(yarn rw prisma migrate dev)することで設定の内容に従ってSQLiteのデータベースとPostテーブルを作成することができます。実行するとマイグレーション(migration)のnameを入力する必要があるので任意の名前”create post”を入力します。今回は初めてのmigrateコマンドの実行でPostテーブルを作成することになるので”create post”と入力しています。作成されるマイグレーションファイルにはテーブルの作成や列の追加、削除などにデータベースの構成を変更するSQLが記述されています。マイグレーションに名前をつけることで後ほどどのような構成変更を行なったのかわかるようになります。マイグレーションにつけた名前はファイル名だけではなくデータベースの中のテーブル(_prisma_migrations)にも保存されます。


 % yarn rw prisma migrate dev
//略
Running Prisma CLI...
$ yarn prisma migrate dev --schema /Users/mac/Desktop/redwoodblog/api/db/schema.prisma

Environment variables loaded from .env
Prisma schema loaded from api/db/schema.prisma
Datasource "db": SQLite database "dev.db" at "file:./dev.db"

SQLite database dev.db created at file:./dev.db

✔ Enter a name for the new migration: … create post
Applying migration `20220514064115_create_post`

The following migration(s) have been created and applied from new schema changes:

migrations/
  └─ 20220514064115_create_post/
    └─ migration.sql

Your database is now in sync with your schema.

✔ Generated Prisma Client (3.13.0 | library) to ./node_modules/@prisma/client in 480ms


Running seed command `yarn rw exec seed` ...
(node:3326) ExperimentalWarning: stream/web is an experimental feature. This feature could change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
(node:3326) [FST_MODULE_DEP_FASTIFY-REPLY-FROM] FastifyWarning.fastify-reply-from: fastify-reply-from has been deprecated. Use @fastify/reply-from@7.0.0 instead.
[15:41:24] Generating Prisma client [started]
[15:41:24] Generating Prisma client [completed]
[15:41:24] Running script [started]

Using the default './scripts/seed.{js,ts}' template
Edit the file to add seed data

[15:41:25] Running script [completed]

🌱  The seed command has been executed.

コマンドを実行したメッセージからSQLite用のdev.dbファイルの作成、マイグレーションファイルの作成と実行、Prisma clientの作成が行われていることが確認できます。もし作成したテーブルにダミーデータを挿入したい場合にはscriptsフォルダに存在するseed.jsファイルが利用できることもわかります。

api¥dbフォルダをdev.db, dev.db-journalファイルとmigrationsフォルダが作成されmigrationsフォルダの中にはコマンドを実行した日付とコマンド実行時に入力したmigrationの名前がついたフォルダが作成され、そのフォルダの下にmigrations.sqlファイルが作成されます。

マイグレーションファイル
マイグレーションファイル

migration.sqlファイルにはSQLiteデータベースに対して実行さるcreate tableが記述されています。schema.prismファイルで定義したPostモデルと接続するデータベースの情報を元に作成されます。


-- CreateTable
CREATE TABLE "Post" (
    "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    "title" TEXT NOT NULL,
    "body" TEXT NOT NULL,
    "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

SQLiteデータベースであればデータベース管理用のGUIのソフトウェアのTablePlusやコマンドラインを利用して接続することができますがPrismaではPrism Studioを利用してテーブルの中身を確認することができます。

Prisma Studioの利用

Prisma Studioを利用するために以下のコマンドを実行します。


 % yarn rw prisma studio
 
//略

Running Prisma CLI...
$ yarn prisma studio --schema /Users/mac/Desktop/redwoodblog/api/db/schema.prisma

Environment variables loaded from .env
Prisma schema loaded from api/db/schema.prisma
Prisma Studio is up on http://localhost:5555

実行するとブラウザが自動で起動してhttp://localhost:5555へのアクセスを行い以下の画面が表示されます。作成したModelがAll Modelsの下に表示されます。現在はPost ModelのみなのでPostのみ表示されています。

Prisma Studioの初期画面
Prisma Studioの初期画面

PostをクリックするとPostテーブルの内容が表示されますがデータがまだ何も追加されていないため列情報のみ確認することができます。prisma.schemaファイルで定義した列が存在することがわかります。

Postテーブルの中身を確認
Postテーブルの中身を確認

データを挿入した場合などはPrism Studioを利用して実際に期待通りのデータが保存されているか確認します。

CRUDの設定

RedwoodJSの重要な機能の一つでコマンドを実行することでPostテーブルに対するCRUD(Create, Read, Update, Delete)のページのScaffold(骨組み)を作成することができます。RedwoodJSのScaffoldではコマンドを実行しただけでPostテーブルへのデータの作成、更新、削除、表示に関わるすべての情報を作成してくれます。すべての情報の中にはページの作成、ルーティングの登録、GraphQL、データベースへの操作などが含まれます。そのため実行後すぐに画面上にデータの作成を行うことができます。

scaffoldingを作成するコマンドの引数にはprisma.schemaファイルで設定したモデルを指定します。実行後のメッセージを見るとコマンド一つでPostモデルに関連するさまざまなファイルが作成されていることが確認できます。


 % yarn redwood generate scaffold post
 
 //略

  ✔ Generating scaffold files...
    ✔ Successfully wrote file `./web/src/components/Post/EditPostCell/EditPostCell.js`
    ✔ Successfully wrote file `./web/src/components/Post/Post/Post.js`
    ✔ Successfully wrote file `./web/src/components/Post/PostCell/PostCell.js`
    ✔ Successfully wrote file `./web/src/components/Post/PostForm/PostForm.js`
    ✔ Successfully wrote file `./web/src/components/Post/Posts/Posts.js`
    ✔ Successfully wrote file `./web/src/components/Post/PostsCell/PostsCell.js`

//略

  ✔ Generating scaffold files...
    ✔ Successfully wrote file `./web/src/components/Post/EditPostCell/EditPostCell.js`
    ✔ Successfully wrote file `./web/src/components/Post/Post/Post.js`
    ✔ Successfully wrote file `./web/src/components/Post/PostCell/PostCell.js`
    ✔ Successfully wrote file `./web/src/components/Post/PostForm/PostForm.js`
    ✔ Successfully wrote file `./web/src/components/Post/Posts/Posts.js`
    ✔ Successfully wrote file `./web/src/components/Post/PostsCell/PostsCell.js`
    ✔ Successfully wrote file `./web/src/components/Post/NewPost/NewPost.js`
    ✔ Successfully wrote file `./api/src/graphql/posts.sdl.js`
    ✔ Successfully wrote file `./api/src/services/posts/posts.js`
    ✔ Successfully wrote file `./api/src/services/posts/posts.scenarios.js`
    ✔ Successfully wrote file `./api/src/services/posts/posts.test.js`
    ✔ Successfully wrote file `./web/src/scaffold.css`
    ✔ Successfully wrote file `./web/src/layouts/PostsLayout/PostsLayout.js`
    ✔ Successfully wrote file `./web/src/pages/Post/EditPostPage/EditPostPage.js`
    ✔ Successfully wrote file `./web/src/pages/Post/PostPage/PostPage.js`
    ✔ Successfully wrote file `./web/src/pages/Post/PostsPage/PostsPage.js`
    ✔ Successfully wrote file `./web/src/pages/Post/NewPostPage/NewPostPage.js`
  ✔ Adding layout import...
  ✔ Adding set import...
  ✔ Adding scaffold routes...
  ✔ Adding scaffold asset imports...
  ✔ Generating types ...

新規のファイルが作成されているだけではなく/web/srcフォルダにあるルーティングファイルRoutes.jsファイルを見るとCRUDに関連するルーティングだけではなくLayoutまで設定されていることがわかります。


import { Router, Route, Set } from '@redwoodjs/router'
import PostsLayout from 'src/layouts/PostsLayout'
import BlogLayout from 'src/layouts/BlogLayout'

const Routes = () => {
  return (
    <Router>
      <Set wrap={PostsLayout}>
        <Route path="/posts/new" page={PostNewPostPage} name="newPost" />
        <Route path="/posts/{id:Int}/edit" page={PostEditPostPage} name="editPost" />
        <Route path="/posts/{id:Int}" page={PostPostPage} name="post" />
        <Route path="/posts" page={PostPostsPage} name="posts" />
      </Set>
      <Set wrap={BlogLayout}>
        <Route path="/about" page={AboutPage} name="about" />
        <Route path="/" page={HomePage} name="home" />
      </Set>
      <Route notfound page={NotFoundPage} />
    </Router>
  )
}

export default Routes

追加されたルーティングの一つhttp://localhost:8910/postsにアクセスすると下記の画面が表示されます。

/postsへのアクセス
/postsへのアクセス

画面に表示されている”Create one?”か”+NEW POST”ボタンをクリックすると新規のPostデータの入力画面が表示されます。

Postの新規作成画面
Postの新規作成画面

動作確認のためにTitle, Bodyを入力して”SAVE”ボタンをクリックします。

Post新規作成画面での入力
Post新規作成画面での入力

“SAVE”ボタンをクリック後にリダイレクトが行われPosts画面に入力した情報が表示されます。

Posts一覧画面
Posts一覧画面

表示されている内容がSQLiteデータベースに保存されているのか確認するためにPrisma Studioで確認します。Prisma Studioが起動していない場合は”yarn rw prisma studio”を実行してください。

先ほどPostの新規作成画面で入力した内容がPostテーブルに保存されていることがわかります。

Prisma Studio上でのテーブルの内容確認
Prisma Studio上でのテーブルの内容確認

もう一度Postsの一覧画面に戻って”Show”, “EDIT”, “DELETE”を確認します。

Posts一覧画面
Posts一覧画面

“SHOW”をクリックすると詳細画面が表示されます。

POSTの詳細画面
POSTの詳細画面

“EDIT”ボタンをクリックすると編集画面が表示されます。

POSTの編集画面
POSTの編集画面

POSTの一覧画面から”DELETE”をクリックするとPOSTを削除していいか確認のメッセージが表示されます。”OK”ボタンをクリックするとPOSTデータは削除されます。

DELETEの実行
DELETEの実行

”yarn redwood generate scaffold post”を実行するだけでPostモデルに関連する処理を行えるようになりました。

Post一覧が表示されるまでの流れ

ScaffoldによってPostモデルに関連するすべての処理が実行されるようになりましたがどのような実装がなされているのか理解していないとカスタマイズを行なっていくことができないのでPostの一覧を表示するまでの流れを確認しておきます。

/postsにアクセスした場合に利用されるRoutes.jsファイルを再度確認します。


import { Router, Route, Set } from '@redwoodjs/router'
import PostsLayout from 'src/layouts/PostsLayout'
import BlogLayout from 'src/layouts/BlogLayout'

const Routes = () => {
  return (
    <Router>
      <Set wrap={PostsLayout}>
        <Route path="/posts/new" page={PostNewPostPage} name="newPost" />
        <Route path="/posts/{id:Int}/edit" page={PostEditPostPage} name="editPost" />
        <Route path="/posts/{id:Int}" page={PostPostPage} name="post" />
        <Route path="/posts" page={PostPostsPage} name="posts" />
      </Set>
      <Set wrap={BlogLayout}>
        <Route path="/about" page={AboutPage} name="about" />
        <Route path="/" page={HomePage} name="home" />
      </Set>
      <Route notfound page={NotFoundPage} />
    </Router>
  )
}

export default Routes

/postsへのアクセスが行われるのとpage propsに設定されているPostPostsPageが実行されます。これはpagesフォルダのPost¥PostsPageフォルダのPostsPage.jsファイルに対応します。

PostsPage.jsファイルではPostsCellコンポーネントがimportされてreturnされているだけです。


import PostsCell from 'src/components/Post/PostsCell'

const PostsPage = () => {
  return <PostsCell />
}

export default PostsPage

src¥components¥PostフォルダにあるPostsCell.jsファイル確認します。ファイル内にはQuery, Loading, Empty, Failure, Successの5つがexportされていることが確認できます。QueryやLoadingの単語からどのようなものかイメージすることができますがこのファイルを見ただけではどのような仕組みでそれらの処理が実行されるのかわかりません。RedwoodJSではGraphpQLによるデータ取得の処理を容易にするためにRedwoodJSではCellsというアプローチを取り入れておりそのCellsがPostsCell.jsファイルで利用されています。


import { Link, routes } from '@redwoodjs/router'

import Posts from 'src/components/Post/Posts'

export const QUERY = gql`
  query FindPosts {
    posts {
      id
      title
      body
      createdAt
    }
  }
`

export const Loading = () => <div>Loading...</div>

export const Empty = () => {
  return (
    <div className="rw-text-center">
      {'No posts yet. '}
      <Link to={routes.newPost()} className="rw-link">
        {'Create one?'}
      </Link>
    </div>
  )
}

export const Failure = ({ error }) => (
  <div className="rw-cell-error">{error.message}</div>
)

export const Success = ({ posts }) => {
  return <Posts posts={posts} />
}

Cellsとは

Cellsはデータの取得の一般的な処理を簡単に行うことができます。一般的な処理とはデータ取得中にはLoadingを表示させデータの取得に成功したら取得したデータを表示させ、エラーが発生した場合にはエラー内容を画面に表示させるといったものです。

Cellsを利用することでそれらの状態をデータ取得のライフサイクルとして1つのファイルの中で処理を行うことができます。Cellsを構成するQuery, Loading, Empty, Failure, Successを再度見るとデータの取得に関連する名前が並んでいることわかりどのような役割を持っているか名前から想像できるかと思います。

PostsCell.jsファイルの場合は最初にQueryが実行され、データ取得中にはLoadingコンポーネントが表示されます。Queryが成功した場合にはSuccessコンポーネント、エラーが発生した場合にはFailureコンポーネント、取得したデータが空の場合にはEmptyコンポーネントが表示されます。

最初に/postsにアクセスした場合はPostにデータは保存されていなかったのでEmptyコンポーネントの中に記述された内容(No posts yet. Create one?)が画面上に表示されていました。データ追加後はSuccessコンポーネントが表示されるのでimportしたPostsコンポーネントのpropsで取得したデータが渡されます。

Cellの状態は上記の5つ以外にbeforeQuery, isEmpty, afterQueryがあります。QUERYとSuccessは必須でEmptyがない場合はSuccessに空の結果が送られFailureがない場合はコンソールにエラーメッセージが表示されます。

PostsCell.jsのQueryで取得したpostsデータを渡されたPostsコンポーネントではmap関数を利用してPostsの一覧を表示させています。

CellsについてはPostsの一覧の時のみに利用されているわけではなくPostの詳細画面を表示させる場合にはPostsCellフォルダのPostCell.js, Postを更新する場合にはEditPostCellフォルダのEditPostCell.jsファイルで利用されています。どちらのファイルにもQuery, Loading, Empty, Failure, Successが記述されています。

Cellsの作成

postsのページではなく作成済みのhomeページにブログ記事の一覧を表示するためにCellsを作成します。Cellsの作成はコマンドを利用して行うことができます。PostsのScaffoldを作成する際にPostsのCellsは作成済みなのでその代わりに記事を意味するArticlesのCellsを作成します。


 % yarn redwood generate cell Articles
 //略
 Error: Could not generate GraphQL type definitions (web)
AggregateError: GraphQL Document Validation failed with 1 errors;
  Error 0: GraphQLDocumentError: Cannot query field "articles" on type "Query"
//略
  ✔ Generating cell files...
    ✔ Successfully wrote file `./web/src/components/ArticlesCell/ArticlesCell.mock.js`
    ✔ Successfully wrote file `./web/src/components/ArticlesCell/ArticlesCell.test.js`
    ✔ Successfully wrote file `./web/src/components/ArticlesCell/ArticlesCell.stories.js`
    ✔ Successfully wrote file `./web/src/components/ArticlesCell/ArticlesCell.js`
  ✔ Generating types ...
 

コマンドを実行した時にエラーが発生します無視することでき/web/src/componentsフォルダ下にArticlesCellフォルダが作成されます。

ArticlesCellフォルダに作成されたArticlesCell.jsファイルを開き、QUERYの中のarticlesをpostsに変更します。Successコンポーネントのpropsに設定されたarticlesとulタグのarticlesをpostsに変更します。


export const QUERY = gql`
  query ArticlesQuery {
    posts {
      id
    }
  }
`

export const Loading = () => <div>Loading...</div>

export const Empty = () => <div>Empty</div>

export const Failure = ({ error }) => (
  <div style={{ color: 'red' }}>Error: {error.message}</div>
)

export const Success = ({ posts }) => {
  return (
    <ul>
      {posts.map((item) => {
        return <li key={item.id}>{JSON.stringify(item)}</li>
      })}
    </ul>
  )
}

HomePage.jsファイルでArticlesCellのimportを行いArticlesCellコンポーネントを追加します。


import { MetaTags } from '@redwoodjs/web'
import ArticlesCell from 'src/components/ArticlesCell/ArticlesCell'

const HomePage = () => {
  return (
    <>
      <MetaTags title="Home" description="Home page" />

      <main>Home</main>
      <ArticlesCell />
    </>
  )
}

export default HomePage

ブラウザで確認するとQueryで取得したpostsデータが表示されます。

Cellsを利用したデータ取得
Cellsを利用したデータ取得

id以外のtitle, body, created_atも取得できるようにArticlesCell.jsファイルのQueryを変更します。


export const QUERY = gql`
  query ArticlesQuery {
    posts {
      id
      title
      body
      createdAt
    }
  }
export default HomePage

再度ブラウザを確認するとtitle, body, createdAtの情報も表示されます。

id, title,body, createAtの表示
id, title,body, createAtの表示

Successコンポーネントのmap関数の中ではstringify関数を利用してオブジェクトを文字列に変換していますがstringifyを利用せずそのままオブジェクトを展開します。


export const Success = ({ posts }) => {
  return (
    <>
      {posts.map((article) => (
        <article key={article.id}>
          <header>
            <h2>{article.title}</h2>
          </header>
          <p>{article.body}</p>
          <div>Posted at: {article.createdAt}</div>
        </article>
      ))}
    </>
  )
}

ブラウザで確認すると登録したブログの記事をページ上に表示できるようになりました。

homeページにブログの記事表示
homeページにブログの記事表示

Cellという新しい概念が最初は難しく感じられたかもしれませんが実際にCellsを作成しデータの表示を行ってみると非常に簡単であることがわかります。

現在ブログ記事が1つかありませんが追加を行いたい場合は/postsにアクセスを行い記事の新規登録を行います。新たに記事の追加を行うとhomeページの記事一覧に追加した記事が反映されます。

複数の記事の表示
複数の記事の表示

ダイナミックルーティング

/(ルート)にアクセスを行うとブログの記事一覧を表示することができるようになりましたが記事一覧ではなく記事毎に個別ページとして表示したい場合の方法について確認していきます。

個別記事を表示するページを作成します。ページの作成なのでコマンドを利用して行います。コマンドはhomeページ、aboutページを作成した時と同じで実行すると/web/src/pagesにArticlePageフォルダが作成されその下に3つのファイルが作成されます。


 % yarn redwood generate page Article
//略
  ✔ Generating page files...
    ✔ Successfully wrote file `./web/src/pages/ArticlePage/ArticlePage.stories.js`
    ✔ Successfully wrote file `./web/src/pages/ArticlePage/ArticlePage.test.js`
    ✔ Successfully wrote file `./web/src/pages/ArticlePage/ArticlePage.js`
  ✔ Updating routes file...
  ✔ Generating types...
  ✔ One more thing...

    Page created! A note about <MetaTags>:
//略
 

Routes.jsファイルにarticleページのルーティングが自動追加されます。


import { Router, Route, Set } from '@redwoodjs/router'
import PostsLayout from 'src/layouts/PostsLayout'
import BlogLayout from 'src/layouts/BlogLayout'

const Routes = () => {
  return (
    <Router>
      <Route path="/article" page={ArticlePage} name="article" /> //自動で追加
      <Set wrap={PostsLayout}>
        <Route path="/posts/new" page={PostNewPostPage} name="newPost" />
        <Route path="/posts/{id:Int}/edit" page={PostEditPostPage} name="editPost" />
        <Route path="/posts/{id:Int}" page={PostPostPage} name="post" />
        <Route path="/posts" page={PostPostsPage} name="posts" />
      </Set>
      <Set wrap={BlogLayout}>
        <Route path="/about" page={AboutPage} name="about" />
        <Route path="/" page={HomePage} name="home" />
      </Set>
      <Route notfound page={NotFoundPage} />
    </Router>
  )
}

export default Routes

ブログ記事の個別ページなのでhomeページで表示している記事のタイトルから移動できるようにリンク設定を行うためArticlesCell.jsファイルのSuccessコンポーネントの中身を更新します。リンクを設定する場合にはLink, routesをredwoodjs/routerからimportします。Linkコンポーネントのto propsにはroutes.article()を設定します。artcileはRoutes.jsのルーティングに設定されているname propsの値(article)を設定しています。


import { Link,routes } from '@redwoodjs/router'

//略

export const Success = ({ posts }) => {
  return (
    <>
      {posts.map((article) => (
        <article key={article.id}>
          <header>
            <h2><Link to={ routes.article()}>{article.title}</Link></h2>
          </header>
          <p>{article.body}</p>
          <div>Posted at: {article.createdAt}</div>
        </article>
      ))}
    </>
  )
}

ブラウザで確認すると記事のタイトルにリンクが貼られます。

タイトルにリンクを設定
タイトルにリンクを設定

リンクをクリックすると/articleに移動してweb¥src¥pages¥ArticlePageのArticlePage.jsに記述されている内容が表示されます。

articleページのデフォルトの内容
articleページのデフォルトの内容

記事一覧に表示されているどちらのリンクをクリックしても同じ内容のarticleページが表示されます。個別の記事を表示するために/article/1, /article/2というように/articleのURLの後ろにidを設定することで個別記事を識別し表示できるように設定を行っていきます。

Routes.jsファイルの/articleへのルーティングを変更します。pathには/article/の後ろに{id}をつけ、Blogのレイアウトを共有できるようにwrapにBlogLayoutが設定されているSetタグの中に移動します。


import { Router, Route, Set } from '@redwoodjs/router'
import PostsLayout from 'src/layouts/PostsLayout'
import BlogLayout from 'src/layouts/BlogLayout'

const Routes = () => {
  return (
    <Router>

//略
      <Set wrap={BlogLayout}>
        <Route path="/article/{id}" page={ArticlePage} name="article" />
        <Route path="/about" page={AboutPage} name="about" />
        <Route path="/" page={HomePage} name="home" />
      </Set>
      <Route notfound page={NotFoundPage} />
    </Router>
  )
}

export default Routes

{id}を追加後に保存するとブラウザにはエラーメッセージが表示されます。

{id}追加によるエラーメッセージの表示
{id}追加によるエラーメッセージの表示

エラーを解消するためにはArticles.cell.jsファイルのLinkのto propsを変更する必要があります。routes.articleの引数にidプロパティを持つオブジェクトを指定します。


<h2><Link to={ routes.article({id:article.id})}>{article.title}</Link></h2>

エラーメッセージは解消されタイトルのリンク先が(http://localhost:8910/article/1, http://localhost:8910/article/2)に変わります。どちらかのリンクをクリックすると先ほどのエラーメッセージと同じ内容が画面に表示されます。ArticlePage.jsファイルの中のLinkコンポーネントのto propsにroutes.article()が設定されていることが原因なのでこの1行を削除します。


import { Link, routes } from '@redwoodjs/router'
import { MetaTags } from '@redwoodjs/web'

const ArticlePage = () => {
  return (
    <>
      <MetaTags title="Article" description="Article page" />

      <h1>ArticlePage</h1>
      <p>
        Find me in <code>./web/src/pages/ArticlePage/ArticlePage.js</code>
      </p>
      <p>
        My default route is named <code>article</code>, link to me with `
        <Link to={routes.article()}>Article</Link>` //削除
      </p>
    </>
  )
}

export default ArticlePage

削除後はエラーは解消します。articleページにアクセスする際に個別記事のデータを取得する必要があります。データの取得処理を行うのでArticleのCellsの作成を行います。

個別記事用のCellsを作成

ArticleのCellsを作成するために下記のコマンドを実行します。途中エラーが発生していますが無視することができ実行すると/web/src/components/ArticleCellフォルダが作成され4つのファイルが作成されます。


 % yarn redwood generate cell Article
 //略
Error: Could not generate GraphQL type definitions (web)
AggregateError: GraphQL Document Validation failed with 1 errors;
  Error 0: GraphQLDocumentError: Cannot query field "article" on type "Query".
 //略
  ✔ Generating cell files...
    ✔ Successfully wrote file `./web/src/components/ArticleCell/ArticleCell.mock.js`
    ✔ Successfully wrote file `./web/src/components/ArticleCell/ArticleCell.test.js`
    ✔ Successfully wrote file `./web/src/components/ArticleCell/ArticleCell.stories.js`
    ✔ Successfully wrote file `./web/src/components/ArticleCell/ArticleCell.js`
  ✔ Generating types ...
 

QUERYの中のarticleとSuccessコンポーネントのpropsのarticleをpostに変更してGraphQLで取得する情報をidだけではなくtitle, body, createdAtを追加します。


export const QUERY = gql`
  query FindArticleQuery($id: Int!) {
    post: post(id: $id) {
      id
      title
      body
      createdAt
    }
  }
`

export const Loading = () => <div>Loading...</div>

export const Empty = () => <div>Empty</div>

export const Failure = ({ error }) => (
  <div style={{ color: 'red' }}>Error: {error.message}</div>
)

export const Success = ({ post }) => {
  return <div>{JSON.stringify(post)}</div>
}

更新したArticleCell.jsをArticlePage.jsファイルでimportします。idはpropsで受けることができArticleCellにはid propsを利用してidを渡します。


import { MetaTags } from '@redwoodjs/web'
import ArticleCell from 'src/components/ArticleCell'

const ArticlePage = ({id}) => {
  return (
    <>
      <MetaTags title="Article" description="Article page" />
      <ArticleCell id={id} />
    </>
  )
}

export default ArticlePage

設定後、homeページに表示されている記事一覧のタイトルをクリックするとエラーメッセージが表示されます。エラーメッセージはデータの取得中にエラーが発生したためArticleCell.jsのFailure関数が実行され赤文字でメッセージが表示されています。

idの型に関するエラー
idの型に関するエラー

エラーの原因はArticlesCell.jsのGraphQLのQUERYではidはInt型が設定されていますがURLのidは文字列のためです。URLのidをIntにするためRoutes.jsファイルのarticleのpathのidにInt型を設定します。


<Route path="/article/{id:Int}" page={ArticlePage} name="article" />

{id}から{id:Int}に変更するとエラーは解消され取得した個別記事の内容が表示されます。

Article CellのQueryで取得したデータを表示
Article CellのQueryで取得したデータを表示

ArticleCell.jsのSuccessコンポーネントの中でJSON.stringifyを利用して取得したpostのデータを文字列として表示しています。postはオブジェクトなので展開することができますが表示させたい内容が記事一覧の時に利用したArticlesCell.jsでの処理と同じなのでコンポーネントを利用して行います。

コンポーネントの作成

RedwoodJSではコンポーネントの作成をコマンドを利用して行うことができます。コマンドを実行すると/web/src/componentsフォルダにArticleフォルダが作成され3つのファイルが作成されます。


 % yarn redwood generate component Article
//略
  ✔ Generating component files...
    ✔ Successfully wrote file `./web/src/components/Article/Article.test.js`
    ✔ Successfully wrote file `./web/src/components/Article/Article.stories.js`
    ✔ Successfully wrote file `./web/src/components/Article/Article.js`
 

Article.jsファイルには下記のコードが記述されています。


const Article = () => {
  return (
    <div>
      <h2>{'Article'}</h2>
      <p>{'Find me in ./web/src/components/Article/Article.js'}</p>
    </div>
  )
}

export default Article

Articleコンポーネントでは記事の内容を表示したいのでpropsでpostを受け取り、受け取ったオブジェクトのpostを展開します。


import { Link, routes } from '@redwoodjs/router'

const Article = ({ post }) => {
  return (
    <article>
      <header>
        <h2>
          <Link to={routes.article({ id: post.id })}>{post.title}</Link>
        </h2>
      </header>
      <div>{post.body}</div>
      <div>Posted at: {post.createdAt}</div>
    </article>
  )
}

export default Article

作成したArticleコンポーネントをArticleCell.jsでimportして利用します。post propsを利用してpostを渡します。


import Article from 'src/components/Article'

//略

export const Success = ({ post }) => {
  return <Article post={post}/>
}

設定後個別ページにアクセスすると記事の内容が表示されます。

個別記事の内容を表示
個別記事の内容を表示

ArticleコンポーネントについてはArticlesCell.jsファイルでも利用することができます。


import Article from 'src/components/Article'

export const QUERY = gql`
  query ArticlesQuery {
    posts {
      id
      title
      body
      createdAt
    }
  }
`

export const Loading = () => <div>Loading...</div>

export const Empty = () => <div>Empty</div>

export const Failure = ({ error }) => (
  <div style={{ color: 'red' }}>Error: {error.message}</div>
)

export const Success = ({ posts }) => {
  return (
    <>
      {posts.map((post) => (
        <Article key={post.id} post={post} />
      ))}
    </>
  )
}

ここまでの設定でブログの投稿(作成、更新、削除)機能の実装、ブログ記事一覧の表示と個別のブログ記事の表示を行うことができるようになりました。

認証設定

RedwoodJSではサードパーティの認証機能を利用する方法と自分で準備したデータベースに認証情報を保存する2つの方法があります。本文書では後者の方法で認証機能を実装します。認証機能を一から実装するわけではなくRedwoodJSではdbAuthという機能を持ちその機能を利用します。CRUDのScaffoldと同様にコマンドを利用することで認証機能の実装も簡単に行うことができます。

ルーティングのPathの変更

認証したユーザのみページにアクセスできるように認証機能の設定を行っていきます。ブログの記事の投稿、更新、削除は認証ユーザのみ行うことができるようにRoutes.jsに設定されているルーティングの中で/postsから始めるURLの前にadminをつけます。(yarn redwood generate scaffold postで作成したページのルーティングです。)


import { Router, Route, Set } from '@redwoodjs/router'
import PostsLayout from 'src/layouts/PostsLayout'
import BlogLayout from 'src/layouts/BlogLayout'

const Routes = () => {
  return (
    <Router>

      <Set wrap={PostsLayout}>
        <Route path="/admin/posts/new" page={PostNewPostPage} name="newPost" />
        <Route path="/admin/posts/{id:Int}/edit" page={PostEditPostPage} name="editPost" />
        <Route path="/admin/posts/{id:Int}" page={PostPostPage} name="post" />
        <Route path="/admin/posts" page={PostPostsPage} name="posts" />
      </Set>
      <Set wrap={BlogLayout}>
        <Route path="/article/{id:Int}" page={ArticlePage} name="article" />
        <Route path="/about" page={AboutPage} name="about" />
        <Route path="/" page={HomePage} name="home" />
      </Set>
      <Route notfound page={NotFoundPage} />
    </Router>
  )
}

export default Routes

記事一覧のURLはhttp://localhost:8910/postsからhttp://localhost:8910/admin/postsに変わりますがページ内で設定で行っているLinkコンポーネントのto propsではnameを利用しているのでリンク設定の変更は必要ありません。

dbAuthの設定

dbAuthの設定を行うために以下のコマンドを実行します。コマンドを実行すると/api/src/lib/auth.[jt]s?を上書きしてもいいか聞かれるので”yes”を選択します。”yes”を選択すると処理が開始され今後追加設定を行う必要がある情報が表示されます。RedWoodJSではコマンド実行後のメッセージに実装する上で必要となる貴重な情報が表示されることがわかります。


% yarn rw setup auth dbAuth
✔ Overwrite existing /api/src/lib/auth.[jt]s? … yes
  ✔ Generating auth lib...
    ✔ Successfully wrote file `./api/src/lib/auth.js`
    ✔ Successfully wrote file `./api/src/functions/auth.js`
  ✔ Adding auth config to web...
  ✔ Adding auth config to GraphQL API...
  ✔ Adding required web packages...
  ✔ Installing packages...
  ✔ Adding SESSION_SECRET...
  ✔ One more thing...
    Done! But you have a little more work to do:
    You will need to add a couple of fields to your User table in order
    to store a hashed password and salt:
   
      model User {
        id                  Int @id @default(autoincrement())
        email               String  @unique
        hashedPassword      String    // <─┐
        salt                String    // <─┼─ add these lines
        resetToken          String?   // <─┤
        resetTokenExpiresAt DateTime? // <─┘
      }
   
    If you already have existing user records you will need to provide
    a default value for `hashedPassword` and `salt` or Prisma complains, so
    change those to: 
   
      hashedPassword String @default("")
      salt           String @default("")
   
    If you expose any of your user data via GraphQL be sure to exclude
    `hashedPassword` and `salt` (or whatever you named them) from the
    SDL file that defines the fields for your user.
   
    You'll need to let Redwood know what fields you're using for your
    users' `id` and `username` fields. In this case we're using `id` and
    `email`, so update those in the `authFields` config in
    `/api/src/functions/auth.js` (this is also the place to tell Redwood if
    you used a different name for the `hashedPassword`, `salt`,
    `resetToken` or `resetTokenExpiresAt`, fields:`
   
      authFields: {
        id: 'id',
        username: 'email',
        hashedPassword: 'hashedPassword',
        salt: 'salt',
        resetToken: 'resetToken',
        resetTokenExpiresAt: 'resetTokenExpiresAt',
      },
   
    To get the actual user that's logged in, take a look at `getCurrentUser()`
    in `/api/src/lib/auth.js`. We default it to something simple, but you may
    use different names for your model or unique ID fields, in which case you
    need to update those calls (instructions are in the comment above the code).
   
    Finally, we created a SESSION_SECRET environment variable for you in
    /Users/mac/Desktop/redwoodblog/.env. This value should NOT be checked
    into version control and should be unique for each environment you
    deploy to. If you ever need to log everyone out of your app at once
    change this secret to a new value and deploy. To create a new secret, run:
   
      yarn rw generate secret
   
    Need simple Login, Signup and Forgot Password pages? We've got a generator
    for those as well:
   
      yarn rw generate dbAuth
 

認証を行うためにはデータベースにUserテーブルを作成する必要があります。RedwoodJSではPrismaを利用しているのでPostテーブルと同様にPrismaの設定ファイルであるprisma.schemaにUser Modelを追加する必要があります。

User Modelを構成する列名や型の情報については”yarn rw setup auth dbAuth"コマンド実行時のメッセージに表示されているのでそのメッセージを元にUser Modelの設定を行います。


datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

generator client {
  provider      = "prisma-client-js"
  binaryTargets = "native"
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  body      String
  createdAt DateTime @default(now())
}

model User {
  id                  Int       @id @default(autoincrement())
  name                String?
  email               String    @unique
  hashedPassword      String
  salt                String
  resetToken          String?
  resetTokenExpiresAt DateTime?
}

prisma.schemaの設定が完了したらデータベースにUserテーブルを作成するためにmigrateコマンドを実行します。実行するとmigrationの名前を聞かれるので"create user"を入力します。Postテーブルを作成時にもmigrateを行なったようにテーブルの作成はすべてのmigrateコマンドを利用して行うことがわかります。


 % yarn rw prisma migrate dev
// 略
 Running Prisma CLI...
$ yarn prisma migrate dev --schema /Users/mac/Desktop/redwoodblog/api/db/schema.prisma

Environment variables loaded from .env
Prisma schema loaded from api/db/schema.prisma
Datasource "db": SQLite database "dev.db" at "file:./dev.db"

✔ Enter a name for the new migration: … create user
Applying migration `20220515140049_create_user`

The following migration(s) have been created and applied from new schema changes:

migrations/
  └─ 20220515140049_create_user/
    └─ migration.sql

Your database is now in sync with your schema.

✔ Generated Prisma Client (3.13.0 | library) to ./node_modules/@prisma/client in 83ms
 

migrateコマンドを実行後にhttp://localhost:8910/admin/postsにアクセスすると許可を持っていないため記事の一覧が表示されないようになります。

permissionがないためエラーが表示
permissionがないためエラーが表示

"You don't have permission to do that."というメッセージが表示されていますが"+NEW POST"ボタンも表示されている状態となっています。その理由はページ全体のアクセスが許可されていないのではなくGraphQLの認証設定によりデータ取得の際にエラーが発生しているためです。GraphQLの設定を確認するために/api/src/graphql/posts.sdl.jsファイルを確認します。一部の行に@requireAuthディレクティブが設定されており認証が完了していないとGraphQLでのリクエストを行うことができません。


export const schema = gql`
  type Post {
    id: Int!
    title: String!
    body: String!
    createdAt: DateTime!
  }

  type Query {
    posts: [Post!]! @requireAuth
    post(id: Int!): Post @requireAuth
  }

  input CreatePostInput {
    title: String!
    body: String!
  }

  input UpdatePostInput {
    title: String
    body: String
  }

  type Mutation {
    createPost(input: CreatePostInput!): Post! @requireAuth
    updatePost(id: Int!, input: UpdatePostInput!): Post! @requireAuth
    deletePost(id: Int!): Post! @requireAuth
  }
`
post.sdl.jsのsdlはSchema Definition Languageの略ですposts.sdl.jsファイルではGraphQLのサーバ側でのスキーマの設定を行います。スキーマの設定の理解にはGraphQLサーバを学習する必要があります。

GraphQLのリクエストレベルではなくページ全体のアクセス制限を行たい場合にはルーティングファイルのRoutes.jsでPrivateコンポーネントをimportしてアクセス制限を行たいルーティングを包みます。認証が完了していない場合のリダイレクト先をunautheticated propsに設定します。ここではhomeに設定しています。


import { Router, Route, Set, Private } from '@redwoodjs/router'
import PostsLayout from 'src/layouts/PostsLayout'
import BlogLayout from 'src/layouts/BlogLayout'

const Routes = () => {
  return (
    <Router>
      <Private unauthenticated="home">
        <Set wrap={PostsLayout}>
          <Route path="/admin/posts/new" page={PostNewPostPage} name="newPost" />
          <Route path="/admin/posts/{id:Int}/edit" page={PostEditPostPage} name="editPost" />
          <Route path="/admin/posts/{id:Int}" page={PostPostPage} name="post" />
          <Route path="/admin/posts" page={PostPostsPage} name="posts" />
        </Set>
      </Private>
      <Set wrap={BlogLayout}>
        <Route path="/article/{id:Int}" page={ArticlePage} name="article" />
        <Route path="/about" page={AboutPage} name="about" />
        <Route path="/" page={HomePage} name="home" />
      </Set>
      <Route notfound page={NotFoundPage} />
    </Router>
  )
}

export default Routes

Privateコンポーネントの設定後はhttp://localhost:8910/admin/postsにアクセスするとhomeページにリダイレクトされます。homeページではGraphQLを利用してブログの記事一覧を取得しているためGraphQLの@requiredAuthの設定によりリクエストが行えないためにエラーメッセージが表示されます。

homeページにリダイレクト
homeページにリダイレクト

homeページについては認証によるアクセスの制限を行っていないのでGraphQLからデータが取得できるように設定を行う必要があります。/api/src/graphql/posts.sdl.jsファイルで設定されているpostsの@requireAuthを@skipAuthに変更します。


type Query {
  posts: [Post!]! @skipAuth
  post(id: Int!): Post @requireAuth
}

設定後、/admin/postsにアクセスすると/にリダイレクトされますが今回は記事一覧が表示されます。

@skipAuth設定による記事一覧の表示
@skipAuth設定による記事一覧の表示

しかし個別ページへのリンクをクリックすると再度エラーメッセージが表示されます。

個別ページにアクセスするとエラーメッセージが表示
個別ページにアクセスするとエラーメッセージが表示

エラーメッセージが表示されてないようにするためには先程と同様にposts.sdl.jsファイルを更新します。


type Query {
  posts: [Post!]! @skipAuth
  post(id: Int!): Post @skipAuth
}

@skipAuthを設定すると個別ページの内容も表示されるようになります。今後ページを追加していくと"You don't have permission to to that"を目にする機会もあると思います。このメッセージが表示される度にセキュリティを考慮にいれてそのページが一般ユーザに公開されても問題ないページなのかどうか確認して設定の変更を行ってください。

ログイン、サインアップページの作成

homeページ、個別記事のページについては@requiredAuthを@skipAuthに変更することでデータベースに保存されているデータを表示することができるようになりました。次は/admin/postsにアクセスした場合に認証が完了したユーザのみ表示できるように設定を行っていきます。

ユーザ認証にはユーザの登録を行うサインアップ、ログイン、ログアウトの機能が必要となります。サインアップページ、ログインページ、reset passwordページ、forgot passwordページをコマンドを利用して作成することができます。

コマンドを実行するとメッセージから4つのファイルが作成されていることを確認することができます。


% yarn redwood generate dbAuth
//略
  ✔ Creating pages...
    ✔ Successfully wrote file `./web/src/pages/SignupPage/SignupPage.js`
    ✔ Successfully wrote file `./web/src/pages/ResetPasswordPage/ResetPasswordPage.js`
    ✔ Successfully wrote file `./web/src/pages/LoginPage/LoginPage.js`
    ✔ Successfully wrote file `./web/src/pages/ForgotPasswordPage/ForgotPasswordPage.js`
  ✔ Adding routes...
  ✔ Adding scaffold import...
  ✔ One more thing...

    Pages created! But you're not done yet:

    You'll need to tell your pages where to redirect after a user has logged in,
    signed up, or reset their password. Look in LoginPage, SignupPage,
    ForgotPasswordPage and ResetPasswordPage for these lines: 

      if (isAuthenticated) {
        navigate(routes.home())
      }

    and change the route to where you want them to go if the user is already
    logged in. Also take a look in the onSubmit() functions in ForgotPasswordPage
    and ResetPasswordPage to change where the user redirects to after submitting
    those forms.

    Oh, and if you haven't already, add the necessary dbAuth functions and
    app setup by running:

      yarn rw setup auth dbAuth

    Happy authenticating!
 

Routes.jsファイルには作成された4つのファイルに関するルーティングが追加されます。


import { Router, Route, Set, Private } from '@redwoodjs/router'
import PostsLayout from 'src/layouts/PostsLayout'
import BlogLayout from 'src/layouts/BlogLayout'

const Routes = () => {
  return (
    <Router>
      <Route path="/login" page={LoginPage} name="login" />
      <Route path="/signup" page={SignupPage} name="signup" />
      <Route path="/forgot-password" page={ForgotPasswordPage} name="forgotPassword" />
      <Route path="/reset-password" page={ResetPasswordPage} name="resetPassword" />
      <Private unauthenticated="home">
        <Set wrap={PostsLayout}>
          <Route path="/admin/posts/new" page={PostNewPostPage} name="newPost" />
          <Route path="/admin/posts/{id:Int}/edit" page={PostEditPostPage} name="editPost" />
          <Route path="/admin/posts/{id:Int}" page={PostPostPage} name="post" />
          <Route path="/admin/posts" page={PostPostsPage} name="posts" />
        </Set>
      </Private>
      <Set wrap={BlogLayout}>
        <Route path="/article/{id:Int}" page={ArticlePage} name="article" />
        <Route path="/about" page={AboutPage} name="about" />
        <Route path="/" page={HomePage} name="home" />
      </Set>
      <Route notfound page={NotFoundPage} />
    </Router>
  )
}

export default Routes

http://localhost:8910/loginにアクセスするとログイン画面が表示されます。まだユーザの登録が完了していないためログインすることはできません。ページの下部にある"Sign up!"のリンクをクリックします。

ログイン画面の表示
ログイン画面の表示

クリックするとサインアップのページが表示されます。ユーザ登録を行うUserNameとPasswordを入力してください。入力後、"SIGN UP"ボタンをクリックします。Usernameにはemailアドレスを入力してください。Userテーブルのemailアドレス列にUsernameに入力した値が保存されます。

サインアップ画面からのユーザ登録
サインアップ画面からのユーザ登録

クリックするとhomeページが表示されます。このページを見ただけでは認証が完了しているかどうかはわかりません。

タイトルにリンクを設定
homeページ

URLに/admin/postsを入力してください。先程までhomeページにリダイレクトされていましたがサインアップを行うと認証が完了しているので画面が表示されます。

/admin/postsページの表示
/admin/postsページの表示

認証完了後は/login, /signupにアクセスするとhomeページにリダイレクトされます。

Prisma Studioを起動(yarn rw prisma studio)するとUserモデルの中に作成したユーザの情報が保存されていることが確認できます。

Postモデルのみ表示されてUserモデルが表示されない場合はPrisma Studioを再起動すると表示されます。

ログアウトの設定

現在の設定ではユーザがログインしているかどうか確認できない上、ログアウトを行うこともできません。

RedwoodJSではユーザがログインしているかどうか(isAuthenticated)やログインしているユーザ情報(currentUser)の取得とログアウト処理を行うための関数(logout)をuseAuth Hookとして提供しています。

レイアウトファイルのBlogLayout.jsファイルでuseAuth HookをimportしてisAuthenticatedによる条件分岐を使ってログアウトボタンの表示を行います。


import { useAuth } from '@redwoodjs/auth'
import { Link, routes } from '@redwoodjs/router'

const BlogLayout = ({ children }) => {
  const { isAuthenticated, currentUser, logOut } = useAuth()
  return (
    <>
      <header>
      <div className="flex-between">
          <h1>
            <Link to={routes.home()}>Redwood Blog</Link>
          </h1>
          {isAuthenticated ? (
            <div>
              <span>Logged in as {currentUser.email}</span>{' '}
              <button type="button" onClick={logOut}>
                Logout
              </button>
            </div>
          ) : (
            <Link to={routes.login()}>Login</Link>
          )}
        </div>
        <nav>
          <ul>
            <li>
              <Link to={routes.home()}>Home</Link>
            </li>
            <li>
              <Link to={routes.about()}>About</Link>
            </li>
          </ul>
        </nav>
      </header>
      <main>{children}</main>
    </>
  )
}

export default BlogLayout

classNameでflex-betweenを設定しているので/web/src/index.cssファイルにflex-betweenクラスを追加します。index.cssはデフォルトから存在しているCSSファイルでスタイルを設定することができます。


.flex-between{
  display: flex;
  justify-content: space-between;
}

homeページの右側に"Logout"ボタンが表示されます。

ログアウトボタンの表示
ログアウトボタンの表示

BlogLayout.jsファイルの中ではcurrentUserオブジェクトからemailを表示するように設定を行っていますが画面上には表示されていません。


{isAuthenticated ? (
  <div>
    <span>Logged in as {currentUser.email}</span>{' '}
    <button type="button" onClick={logOut}>
      Logout
    </button>
  </div>
) : (
  <Link to={routes.login()}>Login</Link>
)}

ユーザ情報の取得はGraphQLを利用して行っているので認証に関するGraphQLの設定が記述されている/web/src/lib/auth.jsファイルを確認します。その中のgetCurrentUser関数を確認します。


export const getCurrentUser = async (session) => {
  return await db.user.findUnique({
    where: { id: session.id },
    select: { id: true },
  }

selectではidのみ取得しているのでemailも追加します。


export const getCurrentUser = async (session) => {
  return await db.user.findUnique({
    where: { id: session.id },
    select: { id: true, email: true},
  })
}

ブラウザで確認するとemailが表示されていることが確認できます。

emailアドレスの表示
emailアドレスの表示

ログアウトボタンをクリックするとLoginのリンクに変わります。

ログインページへのリンク表示
ログインページへのリンク表示

ログアウトが完了しているので/admin/postsにアクセスするとhomeページにリダイレクトされます。このようにコマンドを利用することでログインに関するページが作成されるため簡単に認証機能を実装することができます。

まとめ

ここまでの動作確認でRedwoodJSを理解するための基本的な機能を理解することができました。デプロイの方法やサードパーティを利用した認証方法の設定、Storybookやテストの方法などの説明は全く行なっていません。今後別の記事で情報を公開していく予定です。