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つのファイルが作成されていることがわかる。
api/src/graphql/posts.sdl.ts
api/src/services/posts/posts.ts
api/src/services/posts/posts.scenarios.ts
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の型を定義している。
CreatePostInput
とUpdatePostInput
はいずれも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
を指定すれば良い。
ちなみに、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
に型が生成される
ここでは主に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指定がsrc
やtypes
から始まっている。
実は、Redwoodではsrc
は、そのファイルの存在するworkspace配下のsrcディレクトリを示すエイリアスとなっている(tsconfig.json
で paths
が追加されている)
つまり、ここではworkspaceがapi
なのでsrc/lib/db
はapi/src/lib/db
を指していることになる。
こちらのsrc
に関しては、公式ドキュメントに記載がある。
THE
src
ALIASNotice that the import statement uses
src/layouts/BlogLayout
and not../src/layouts/BlogLayout
or./src/layouts/BlogLayout
. Being able to use justsrc
is a convenience feature provided by Redwood:src
is an alias to thesrc
path in the current workspace. So if you're working inweb
thensrc
points toweb/src
and inapi
it points toapi/src
.
https://redwoodjs.com/docs/tutorial/chapter1/layouts
types
については明示されてはいないが、src
と同様の挙動で、ここではapi/types/graphql
を指している
参考
- https://redwoodjs.com/docs/graphql#server-side
- https://redwoodjs.com/docs/directives
- https://redwoodjs.com/docs/services#overview
- https://redwoodjs.com/docs/services#service-validations
- https://redwoodjs.com/docs/testing#scenarios
- https://redwoodjs.com/docs/graphql#preview-in-graphiql
次回
次回はScaffoldでweb
配下に作成されたファイルを見ていく
Discussion