もふもふ技術部

IT技術系mofmofメディア

RedwoodJSに入門してみた(第2回: CRUD作成 API編)

この記事について

この記事は、全5回の第2回です。

RedwoodJSに入門してみた(第1回: アプリ作成〜モデル作成)

RedwoodJSに入門してみた(第2回: CRUD作成 API編)

RedwoodJSに入門してみた(第3回: CRUD作成 WEB編)

RedwoodJSに入門してみた(第4回: dbAuthによる認証)

RedwoodJSに入門してみた(第5回: 実際に触ってみて感じたこと)

前回に引き続きRedwoodでCRUDを作成していく。

CRUDの作成

前回作成したPostモデルのCRUDを作成するため、下記コマンドを実行する。

yarn rw generate scaffold post

generateエイリアスとしてgを使用できる(以降は全てgを使用する )

yarn rw g scaffold post

http://localhost:8910/posts/newにアクセスすると、いい感じの作成フォームが作られている。 便利だけど、何が起きているのか分からないので、自動生成されたファイルを見ながら深堀りしていく。

GraphQLのSchemaとServices

まずapi配下から見ていくと下記の4つのファイルが作成されていることがわかる。

  1. api/src/graphql/posts.sdl.ts
  2. api/src/services/posts/posts.ts
  3. api/src/services/posts/posts.scenarios.ts
  4. api/src/services/posts/posts.test.ts

これらはscaffoldを使わずに、yarn rw g sdl postとした場合も同じものが作成される。 sdlは必要ないけどservices/posts配下だけを作成したいという場合はyarn rw g service postとすれば2~4のファイルだけ生成できる(ただ、この場合は微妙に内容が違う。そもそもserviceだけ作りたい場面があまり想像できない)

GraphQL Schema

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
  }
`

ここではGraphQLのschemaが、DBのschemaをもとに作成されている。

Post

最初のPostはモデル自体の型を定義している。型名のあと!は、fieldがrequiredであることを表している。

Query

Queryはその名の通りqueryの型を定義していて、ここでは、postsというqueryがPostの配列を返すことと、postというqueryは引数にidを受け取りPostを返すこと定義している。

input

inputはMutationのinputの型を定義している。 CreatePostInputUpdatePostInputはいずれもPostからidとcreatedAtを除いた型が定義されている。異なるのは、UpdatePostInputではそれぞれのfieldがrequiredでなくなっている点である。一部のfieldのみを更新したい場合などに、全てのfieldを渡さなくて良いように、こういう定義になっているらしい。

Mutation

MutationはMutationの型を定義している。Queryと同様の指定で、createPostというMutationが引数にinputとしてCreatePostInput!を取り、Postを返すことを定義している。

Directive

QueryやMutationの型定義の後ろには@requireAuthとあるが、これはGraphQLのdirectiveで、Redwoodでは最初から@requireAuth@skipAuthの2つが用意されている。directiveはその他にもカスタムで定義することが可能。 ここで使われている@requireAuthはその名の通り、認証が必須であることを示しており、postsはログイン済みのユーザーのみ、クエリのレスポンスを得られるということになる。(認証を実装するまでは@requireAuthは常にtrueを返すようになっている) 認証をskipしたい場合は@skipAuthを指定すれば良い。

@skipAuthを指定せずに、そもそも@requireAuthを指定しなければいいと思うかもしれないが、Redwoodでは、安全性を担保するためQueryやMutationにdirectiveの指定が必須となっており、何らかの指定をしないとビルドに失敗する。そのため認証が不要な場合は@skipAuth(または任意のカスタムdirective)を指定する必要がある。

ちなみに、directive は field にも設定でき、認証済みかをチェックするような使い方(Validator)としてだけではなく、フォーマットを整形するような使い方(Transformer)もある。

https://redwoodjs.com/docs/directives

Service

Redwoodではservices配下にすべてのビジネスロジックを集約させることを目的としていて、scaffoldでは、QueryとMutationのResolverが自動で定義されている。

import type { QueryResolvers, MutationResolvers } from "types/graphql";

import { db } from "src/lib/db";

export const posts: QueryResolvers["posts"] = () => {
  return db.post.findMany();
};

export const post: QueryResolvers["post"] = ({ id }) => {
  return db.post.findUnique({
    where: { id },
  });
};

export const createPost: MutationResolvers["createPost"] = ({ input }) => {
  return db.post.create({
    data: input,
  });
};

export const updatePost: MutationResolvers["updatePost"] = ({ id, input }) => {
  return db.post.update({
    data: input,
    where: { id },
  });
};

export const deletePost: MutationResolvers["deletePost"] = ({ id }) => {
  return db.post.delete({
    where: { id },
  });
};

サーバーを起動していれば、先程のapi/src/graphql/posts.sdl.tsでの定義をもとに api/types/graphql.d.tsに型が生成される (うまく型が作られていない場合は yarn rw g typesを実行すると生成される)

ここでは主にResolverの定義をしている。クライアントがGraphQLへリクエストを投げると、GraphQLのSchemaをもとにリクエストの型を検証し、GraphQLはResolverをもとにレスポンスを返すが、RedwoodではResolverをServices内で定義する。

バリデーション

自動生成されているものは基本的にPrismaでDBと最低限のやりとりをしているだけだが、バリデーションに関してもここで定義できる。

import { validate } from '@redwoodjs/api' // validate関数をimport

export const createPost: MutationResolvers['createPost'] = ({ input }) => {
  validate(input.title, 'Title', { // 第2引数の'Title'は、任意の文字列を指定でき、エラーメッセージの生成に使われる
    presence: true,
    length: { max: 255 }
  }
  validate(input.body, {
    length: { max: 1000, message: '本文は1000文字以内にしてください' } // 第2引数を省略してエラーメッセージを指定することもできる
  }
  return db.post.create({
    data: input,
  })
}

上記のようにvalidate()を使用することで標準的なバリデーションを設定できる。 また、 validateWith()を使用すればカスタムバリデーションを設定することもできる。 https://redwoodjs.com/docs/services#validatewith

テストと Scenarios

Redwoodではgeneratorコマンドで生成したときにテストファイルも同時に作られる。

import type { Prisma, Post } from "@prisma/client";
import type { ScenarioData } from "@redwoodjs/testing/api";

export const standard = defineScenario<Prisma.PostCreateArgs>({
  post: {
    one: { data: { title: "String", body: "String" } },
    two: { data: { title: "String", body: "String" } },
  },
});

export type StandardScenario = ScenarioData<Post, "post">;

scenarioは、テスト実行時に作られてテスト終了時に削除されるような、テスト用のシードデータ。 テスト関数内で、scenario.post.oneのような感じで個別に呼び出せる。

実際に作られているテストファイルを見てみよう。

import type { Post } from "@prisma/client";

import { posts, post, createPost, updatePost, deletePost } from "./posts";
import type { StandardScenario } from "./posts.scenarios";

// Generated boilerplate tests do not account for all circumstances
// and can fail without adjustments, e.g. Float.
//           Please refer to the RedwoodJS Testing Docs:
//       https://redwoodjs.com/docs/testing#testing-services
// https://redwoodjs.com/docs/testing#jest-expect-type-considerations

describe("posts", () => {
  scenario("returns all posts", async (scenario: StandardScenario) => {
    const result = await posts();

    expect(result.length).toEqual(Object.keys(scenario.post).length);
  });

  scenario("returns a single post", async (scenario: StandardScenario) => {
    const result = await post({ id: scenario.post.one.id });

    expect(result).toEqual(scenario.post.one);
  });

  scenario("creates a post", async () => {
    const result = await createPost({
      input: { title: "String", body: "String" },
    });

    expect(result.title).toEqual("String");
    expect(result.body).toEqual("String");
  });

  scenario("updates a post", async (scenario: StandardScenario) => {
    const original = (await post({ id: scenario.post.one.id })) as Post;
    const result = await updatePost({
      id: original.id,
      input: { title: "String2" },
    });

    expect(result.title).toEqual("String2");
  });

  scenario("deletes a post", async (scenario: StandardScenario) => {
    const original = (await deletePost({ id: scenario.post.one.id })) as Post;
    const result = await post({ id: original.id });

    expect(result).toEqual(null);
  });
});

it()の代わりにscenario()を使うことで先程のscenario(テスト用のシードデータ)を参照することができる。 stndard以外の命名にすることで、複数のパターンのScenarioを作成することも可能

細かいことだけど、scenario()のコールバック関数の引数でscenarioという命名のかぶった変数が定義されているのが気になる…

あとはpostのtestでuserの情報を使用したい場合、下記のように書けるはずだが、defineScenario()に渡すジェネリクスとして何が適当なのか分からなかった。(有識者の方、教えていただけると幸いです…)

export const standard = defineScenario<
  Prisma.PostCreateArgs | Prisma.UserCreateArgs // このようにユニオンで定義すると
>({
  post: {
    one: { data: { title: "String", body: "String" } },
    two: { data: { title: "String", body: "String" } },
  },
  user: {
    john: {
      data: {
        email: "test@exmaple.com",
        title: "String", // ここでUserには存在しないのに、TS上はこれを許容してしまう
      },
    },
  },
});

GraphQL Playground

今回はScaffoldでまとめて作成しているが、実際にはAPI側を作成してからWEB側を作成するというようなフローになることも少なくない。 ここまでの内容をもとにGraphQL APIがイメージ通りに実装できているか、どういう値でリクエストしてどういう値が返ってくるのかをなどを試すGUIが標準で用意されている。

http://localhost:8911/graphqlにアクセスすることで、GraphQL YogaのGraphiQLが使える。

めちゃくちゃ便利。

補足

もしimport文まで細かくチェックしている人がいたら疑問に思ったかもしれない。

api/src/services/posts/posts.ts内に下記のような import 文がある。

import type { QueryResolvers, MutationResolvers } from "types/graphql";

import { db } from "src/lib/db";

path指定がsrctypesから始まっている。 実は、Redwoodではsrcは、そのファイルの存在するworkspace配下のsrcディレクトリを示すエイリアスとなっている(tsconfig.jsonpathsが追加されている) つまり、ここではworkspaceがapiなのでsrc/lib/dbapi/src/lib/dbを指していることになる。

こちらのsrcに関しては、公式ドキュメントに記載がある。

THE src ALIAS

Notice that the import statement uses src/layouts/BlogLayout and not ../src/layouts/BlogLayout or ./src/layouts/BlogLayout. Being able to use just src is a convenience feature provided by Redwood: src is an alias to the src path in the current workspace. So if you're working in web then src points to web/src and in api it points to api/src.

https://redwoodjs.com/docs/tutorial/chapter1/layouts

typesについては明示されてはいないが、srcと同様の挙動で、ここではapi/types/graphql を指している

参考

次回

次回はScaffoldでweb配下に作成されたファイルを見ていく