この記事は筆者のソロ Advent Calendar 2022 17日目の記事です。
Denoの公式ドキュメントの内容を実際に動かしてみてDenoに入門してみたのでその備忘録の続きです。
今回はせっかくなのでDenoを使用して簡単なTODOのAPIを作成してみました。
Deno入門[インストール、環境構築、ファイル実行、標準ライブラリ]
Deno入門[CLIコマンド、モジュール、環境変数、Webフレームワーク]
Deno入門[npmモジュールの使用]
Deno入門[prisma + oakで作るAPI] <- 今ここ
Deno入門[テスト編]
今回の成果物はこちら
prisma
prismaについて
今回はDB接続をprismaというNode.js製のORMを使用してみたいと思います。前回の記事で書いたようにDenoはnpmモジュールの対応をしており、prismaの対応も進んでいるので使えるはず。
公式
Zennでprismaのデビュー記事を書いているのでprismaについてもう少し知りたい方はこちらもぜひ
init
とりあえずinitします。
deno run --allow-read --allow-env --allow-write npm:prisma@^4.5 init
公式のコピペですが執筆時点で--allow-sys --allow-run --allow-net
こちらの権限も必要だったので実行時に追加してください。
完了するとnode_modulesやpackage.jsonが作成されてるはず。重要なのは一緒に作成されている.env
と prisma/schema.prisma
です。
schema.prisma
以下のように修正します。
generator client {
provider = "prisma-client-js"
+ previewFeatures = ["deno"]
+ output = "../generated/infrastructure/client"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
+model User {
+ id BigInt @id @default(autoincrement())
+ name String @db.VarChar(50)
+ age Int
+ updatedAt DateTime @default(now())
+ createdAt DateTime @default(now())
+ todos Todo[]
+}
+model Todo {
+ id BigInt @id @default(autoincrement())
+ userId BigInt
+ owner User @relation(fields: [userId], references: [id])
+ title String @db.VarChar(50)
+ description String @db.VarChar(255)
+ updatedAt DateTime @default(now())
+ createdAt DateTime @default(now())
+}
最初はMySQLをローカルで起動して試そうと思ったのですが、Denoでprismaを使用する方法が今の所prismaのプロキシサービスを利用しないとできなそうで、そうなるとローカルで構築したDBが利用できないので、無料でpostgresを利用できるsupabaseというサービスがあるそうなのでpostgresを採用しました。
supabaseでDBを構築する
上述した理由でsupabaseでプロジェクトを新規で作成していきます。アカウント作成してプロジェクトを新規で作成すると設定タブからpostgresql://postgres:[YOUR-PASSWORD]@db.lxftkjgkqhcypatnxpzv.supabase.co:5432/postgres
のようなconnectionURLが取得できるのでコピーしておく。ここまでできたらDBの準備は完了。
DBにマイグレーションとコード生成実行
.env
ファイルの値を先ほどコピーしたsupabaseのconnectionURLで上書きする。
# Environment variables declared in this file are automatically made available to Prisma.
# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
# DATABASE_URL="postgresql://postgres:jUpMBp9%23UvCPics@db.lxftkjgkqhcypatnxpzv.supabase.co:5432/postgres"
DATABASE_URL=postgresql://postgres:[YOUR-PASSWORD]@db.lxftkjgkqhcypatnxpzv.supabase.co:5432/postgres
マイグレーションを実行。完了するとsupabaseにテーブルが作成されているはず。
deno run -A npm:prisma@^4.5 db push
TypeScriptのコードを生成する。
deno run -A --unstable npm:prisma@^4.5 generate --data-proxy
Prisma Data Platform
Prisma Data Platformとは前述したようにprismaのプロキシサーバーサービスです。Denoでprismaを使用するのにはまだこのプロキシ経由でないと使用できなそうなのでアカウントを作成し、prismaを使用するgitのプロジェクトを連携します。アカウント作成とプロジェクトの作成は無料で利用することができます。
プロジェクトの作成が完了したらprisma://
で始まるconnectionURLが表示されるのでコピーして.envファイルの値を上書きします。
PrismaClientを作成する
以下のようなPrismaClientを作成する。
import { PrismaClient } from "../../../generated/infrastructure/client/deno/edge.ts";
import { config } from "https://deno.land/std@0.166.0/dotenv/mod.ts";
const envVars = await config();
export const prisma = new PrismaClient({
datasources: {
db: {
url: envVars.DATABASE_URL,
},
},
});
TODO APIを作る
Prismaの準備ができたのでAPIを作成していきますが、今回はDeno製のexpressライクなoakを使ってみたいと思います。一応雰囲気レイヤーアーキテクチャを採用して作ってみます。
infrasturcture(repository)
以下のような感じでRepositoryの型定義を作成します。
ユーザー作成と取得のメソッドだけ定義します。
import {
Prisma,
User,
} from "../../../../generated/infrastructure/client/deno/index.d.ts";
export type UserRepository = {
save: (
data: Prisma.UserCreateInput,
) => Promise<User>;
findById: (id: Prisma.UserWhereUniqueInput) => Promise<User | null>;
};
作成しておいたPrismaClientをインポートしてデータの作成と取得の処理を実装します。
import { Prisma } from "../../../../generated/infrastructure/client/deno/edge.ts";
import type { UserRepository } from "../../../domain/repository/user/userRepository.ts";
import { prisma } from "../prismaClient.ts";
const save = async (data: Prisma.UserCreateInput) => {
return await prisma.user.create({ data });
};
const findById = async (id: Prisma.UserWhereUniqueInput) => {
return await prisma.user.findUnique({ where: id });
};
export const userRepository: UserRepository = {
save,
findById,
};
最初は以下のようなクラス定義して書いたんですがTypeScriptで書くなら関数ベースの方がぽいのかなと思ったので上記のような感じで書き直しました。NestJSとかだとクラス定義でDIフレームワークみたいな感じだった気がするけどどんな感じがTypeScriptっぽいのかがよくわかんないので詳しい方がいましたらコメントとかいただけるとありがたいです。
class UserRepositoryImpl implements UserRepository {
private static _instance: UserRepository
static getInstance(): UserRepository {
if(!this._instance) this._instance = new UserRepositoryImpl()
return this._instance
}
save() {}
findById() {}
}
export const userRepository = UserRepositoryImpl.getInstance()
TODOのRepositoryも以下のように作成。
import {
Prisma,
Todo,
} from "../../../../generated/infrastructure/client/deno/edge.ts";
import { TodoRepository } from "../../../domain/repository/todo/todoRepository.ts";
import { prisma } from "../prismaClient.ts";
const save = async (data: Prisma.TodoCreateInput): Promise<Todo> => {
return await prisma.todo.create({ data });
};
const getAll = async (userId: number): Promise<Todo[]> => {
return await prisma.todo.findMany({ where: { userId } });
};
export const todoRepository: TodoRepository = {
save,
getAll,
};
application(service)
以下のような感じで。
import { User } from "../../../domain/model/user/user.ts";
import { userRepository } from "../../../infrastructure/data/user/userRepository.ts";
const create = async (name: string, age: number): Promise<User> => {
const user = await userRepository.save({ name, age });
return { id: user.id.toString(), name: user.name, age: user.age };
};
const get = async (id: number): Promise<User | null> => {
const user = await userRepository.findById({ id });
if (!user) return null;
return { id: user.id.toString(), name: user.name, age: user.age };
};
export type UserService = {
create: (name: string, age: number) => Promise<User>;
get: (id: number) => Promise<User | null>;
};
export const userService: UserService = {
create,
get,
};
import { Todo } from "../../../domain/model/todo/model.ts";
import { Todo as TodoEntity } from "../../../../generated/infrastructure/client/deno/index.d.ts";
import { todoRepository } from "../../../infrastructure/data/todo/todoRepository.ts";
const create = async (
data: { userId: number; title: string; description: string },
): Promise<Todo> => {
const { userId, title, description } = data;
const todo = await todoRepository.save({
owner: { connect: { id: userId } },
title,
description,
});
return toModel(todo);
};
const getAll = async (userId: number): Promise<Todo[]> => {
const todos = await todoRepository.getAll(userId);
return todos.map((entity) => toModel(entity));
};
const toModel = (entity: TodoEntity): Todo => {
return {
id: entity.id.toString(),
userId: entity.userId.toString(),
title: entity.title,
description: entity.description,
};
};
export type CreateTodo = { userId: number; title: string; description: string };
export type TodoService = {
create: (data: CreateTodo) => Promise<Todo>;
getAll: (userId: number) => Promise<Todo[]>;
};
export const todoService: TodoService = {
create,
getAll,
};
presentation(controller)
ここでようやくoakのRouting機能を使用してcontroller的なものを書いてみます。
import { userService } from "../../../application/service/user/userService.ts";
import { Router } from "../../../deps.ts";
import { getBodyValue } from "../routeUtils.ts";
import { response } from "../response.ts";
import { assertError } from "../routeUtils.ts";
import { getQuery } from "https://deno.land/x/oak@v11.1.0/helpers.ts";
type CreateUserRequest = {
name: string;
age: number;
};
export const userRouter = new Router();
userRouter
.post("/user/create", async (ctx) => {
try {
const body = ctx.request.body({ type: "json" });
const { name, age } = await getBodyValue<CreateUserRequest>(body);
const result = await userService.create(name, age);
ctx.response.body = response.success(result);
} catch (e: unknown) {
assertError(e);
ctx.response.body = response.error(e.message);
}
})
.get("/user/:userId", async (ctx) => {
try {
const { userId } = getQuery(ctx, { mergeParams: true });
const result = await userService.get(Number(userId));
ctx.response.body = response.success(result);
} catch (e: unknown) {
assertError(e);
ctx.response.body = response.error(e.message);
}
});
import { BodyJson } from "https://deno.land/x/oak@v11.1.0/body.ts";
export const getBodyValue = async <T>(body: BodyJson) => {
const value: T = await body.value;
return value;
};
export function assertError(e: unknown): asserts e is Error {
if (e instanceof Error) {
return;
}
throw Error(`e is not Error instance. e: ${e}`);
}
oakからRouterをインポートしてpostとgetのエンドポイントを設定しています。postではctx.request.body({ type: "json" })
を使用して、bodyパラメーターを取得し、getではhelperからgetQuery()
を使用してパスパラメーターを取得しています。
serviceの実行結果をresponseでラップしてresponseにセットして終わりです。
TODO側のルーティングは似たような感じなので省略します。
ここまでできたらmain.tsを以下のように作成します。
import { Application } from "https://deno.land/x/oak@v11.1.0/application.ts";
import { todoRouter } from "./presentation/route/todo/todoRoute.ts";
import { userRouter } from "./presentation/route/user/userRoute.ts";
const app = new Application();
// Logger
app.use(async (ctx, next) => {
console.log(
`method: ${ctx.request.method} url: ${ctx.request.url} body: ${ctx.request.body().value}`,
);
await next();
});
// Timing
app.use(async (ctx, next) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
ctx.response.headers.set("X-Response-Time", `${ms}ms`);
});
await app.use(userRouter.routes())
.use(todoRouter.routes())
.listen({ port: 8000 });
oakからインポートしたApplicationをインスタンス化し、use()で処理を書いていきます。上記のようにuse()の引数の関数の引数にはctxとnextが渡され、next()
を呼ぶことで次に処理を移していけるようです。上記の例だとログ -> 開始時間記録 -> 処理 -> 実行時間をヘッダーにセット -> レスポンス返却 みたいなフローになる。
動作確認
実際に動かしてみる
起動
deno run -A src/main.ts
ユーザー作成
curl -XPOST localhost:8000/user/create -d '{"name": "user", "age": 32}' | jq
{
"success": true,
"payload": {
"id": "9",
"name": "user",
"age": 32
}
}
ユーザー取得
curl localhost:8000/user/9 | jq
{
"success": true,
"payload": {
"id": "9",
"name": "user",
"age": 32
}
}
TODO作成
curl -XPOST localhost:8000/todo/create -d '{"userId": 9, "title": "title", "description": "description"}' | jq
{
"success": true,
"payload": {
"id": "3",
"userId": "9",
"title": "title",
"description": "description"
}
}
TODO取得
curl 'http://localhost:8000/todo/all?userId=9' | jq
{
"success": true,
"payload": [
{
"id": "3",
"userId": "9",
"title": "title",
"description": "description"
}
]
}
まとめ
- Denoでprismaを使用する方法を紹介しました。
- oakを使用しAPIを作成する方法を紹介しました。
実プロジェクトの採用にはもう少し先の話かなと思いましたが、現段階でも手元で何か動くものを作る分には十分です!npmも使えてReactやPrismaも完全に使えるよってなったらDenoの使用者が一気に増えたりするのかなと思いました。今回は以上です!