iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🥑

Introduction to RedwoodJS (Part 2: Creating CRUD - API Edition)

に公開
2022/12/19

About this article

This article is the second in a five-part series.
Introduction to RedwoodJS (Part 1: App Creation to Model Creation)
Introduction to RedwoodJS (Part 2: Creating CRUD API)
Introduction to RedwoodJS (Part 3: Creating CRUD Web)
Introduction to RedwoodJS (Part 4: Authentication with dbAuth)
Introduction to RedwoodJS (Part 5: Impressions after using it)

Continuing from the previous part, we will create CRUD with Redwood.

Creating CRUD

To create CRUD for the Post model created last time, run the following command.

yarn rw generate scaffold post

You can use g as an alias for generate (from now on, I will use g).

yarn rw g scaffold post

If you access http://localhost:8910/posts/new, you will find a nicely crafted creation form.
It's convenient, but since I don't know exactly what's happening under the hood, I'll dive deeper while looking at the auto-generated files.

GraphQL Schema and Services

First, looking under the api directory, you can see that the following four files have been created.

  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

These are the same files that would be created if you ran yarn rw g sdl post without using scaffold.
If you don't need the SDL but only want to create the contents of services/posts, you can generate just files 2–4 by running yarn rw g service post (although the contents differ slightly in that case. In the first place, it's hard to imagine many scenarios where you would only want to create the service).

GraphQL Schema

api/src/graphql/posts.sdl.ts
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
  }
`

Here, the GraphQL schema is created based on the database schema.

Post

The initial Post defines the type for the model itself. The ! after the type name indicates that the field is required.

Query

As the name suggests, Query defines the types for queries. Here, it defines that the posts query returns an array of Post objects and the post query takes an id as an argument and returns a Post object.

input

input defines the types for Mutation inputs.
Both CreatePostInput and UpdatePostInput define types excluding id and createdAt from Post. The difference is that in UpdatePostInput, each field is no longer required. Apparently, it's defined this way so you don't have to pass all fields when you only want to update specific ones.

Mutation

Mutation defines the types for mutations. With specifications similar to Query, it defines that the createPost mutation takes CreatePostInput! as an input argument and returns a Post.

Directive

Following the type definitions for Query and Mutation, you'll see @requireAuth. This is a GraphQL directive, and Redwood provides two out of the box: @requireAuth and @skipAuth. Directives can also be custom-defined.
As the name implies, @requireAuth indicates that authentication is required, meaning only logged-in users can receive a response for queries like posts. (Until authentication is implemented, @requireAuth is designed to always return true.)
If you want to skip authentication, you can specify @skipAuth.

By the way, directives can also be set on fields and used not only for checking authentication (Validator) but also for formatting data (Transformer).

https://redwoodjs.com/docs/directives

Service

Redwood aims to centralize all business logic under services, and scaffold automatically defines Query and Mutation Resolvers.

api/src/services/posts/posts.ts
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 },
  });
};

If the server is running, types will be generated in api/types/graphql.d.ts based on the definitions in api/src/graphql/posts.sdl.ts.

Here, it mainly defines Resolvers. When a client sends a GraphQL request, the request type is validated based on the GraphQL Schema, and GraphQL returns a response based on the Resolver. In Redwood, Resolvers are defined within Services.

Validation

The auto-generated code basically just performs minimal interactions with the DB using Prisma, but validations can also be defined here.

api/src/services/posts/posts.ts
import { validate } from '@redwoodjs/api' // import the validate function

export const createPost: MutationResolvers['createPost'] = ({ input }) => {
  validate(input.title, 'Title', { // The second argument 'Title' can be any string and is used to generate error messages
    presence: true,
    length: { max: 255 }
  }
  validate(input.body, {
    length: { max: 1000, message: 'Please keep the body within 1000 characters' } // You can also omit the second argument to specify the error message
  }
  return db.post.create({
    data: input,
  })
}

Using validate() as shown above allows you to set standard validations. Additionally, you can set custom validations using validateWith().

https://redwoodjs.com/docs/services#validatewith

Testing and Scenarios

In Redwood, test files are also created simultaneously when generating with the generator command.

api/src/services/posts/posts.scenarios.ts
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">;

A scenario is seed data for testing that is created when the test is executed and deleted when the test ends.
Inside the test function, you can call them individually, like scenario.post.one.

Let's look at the actual test file that was created.

api/src/services/posts/posts.test.ts
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);
  });
});

By using scenario() instead of it(), you can refer to the previously defined scenario (seed data for testing).
By using names other than standard, it is also possible to create multiple scenario patterns.

This is a minor point, but I'm slightly bothered by the fact that the variable named scenario is defined as an argument in the scenario() callback function, resulting in name shadowing...

Also, if you want to use user information in the post test, it should be possible to write it as follows, but I wasn't sure what generics would be appropriate to pass to defineScenario(). (If anyone knows, I would appreciate it if you could let me know...)

api/src/services/posts/posts.scenarios.tsx
export const standard = defineScenario<
  Prisma.PostCreateArgs | Prisma.UserCreateArgs // If defined as a union like this
>({
  post: {
    one: { data: { title: "String", body: "String" } },
    two: { data: { title: "String", body: "String" } },
  },
  user: {
    john: {
      data: {
        email: "test@exmaple.com",
        title: "String", // TS allows this even though it doesn't exist in User
      },
    },
  },
});

GraphQL Playground

In this case, we are creating everything at once with Scaffold, but in practice, the flow of creating the API side first and then the WEB side is not uncommon.
A GUI is provided by default to test whether the GraphQL API is implemented as imagined based on the content so far, what values to use for requests, and what values are returned.

By accessing http://localhost:8911/graphql, you can use GraphQL Yoga's GraphiQL.

It's incredibly convenient.

Supplementary Information

If you were checking the import statements in detail, you might have wondered about this.

There is an import statement like the following in api/src/services/posts/posts.ts.

api/src/services/posts/posts.ts
import type { QueryResolvers, MutationResolvers } from "types/graphql";

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

The path specifications start with src or types.
Actually, in Redwood, src is an alias for the src directory within the workspace where that file is located (paths are added in tsconfig.json).
In other words, since the workspace is api here, src/lib/db refers to api/src/lib/db.

Regarding this src, there is a description in the official documentation.

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

While types is not explicitly mentioned, it follows the same behavior as src, and in this case, it points to api/types/graphql.

References

Next Time

Next time, we will look at the files created under the web directory using Scaffold.

GitHubで編集を提案

Discussion