🐙
Hono + Microsoft Azure FunctionsでCRUDしてみたメモ
概要
Honoなるフレームワークがあるということで試してみた。
デプロイ結果 ... 無料のSQL Serverにつないでいるので初回は寝てて動かないかも。失敗したらコーヒー淹れて戻ってくるくらいで見れるようになるはず。
ソースコード
バックエンド
インフラ ( Functions に CORS 設定 )
infra/bin/.env
RESOURCE_GROUP_NAME='resource-group-hogehoge'
PL_APP_REPO=https://user@dev.azure.com/user/reponamefuga/_git/async-ttrpg-hoge
# Microsoft Azure Static Web Apps で公開したドメイン
WSA_ORIGIN=https://hogehoge.azurestaticapps.net
# ;が含まれているので "" で囲む
DATABASE_URL="sqlserver://hoge.database.windows.net:1433;database=fuga;user=piyo;password=piyopiyo;encrypt=true"
infra/bin/createFunction.bash
#!/bin/bash
# 変数設定
LOCATION=japaneast
# ディレクトリパス取得
BIN_DIR=$(cd $(dirname $0) && pwd)
BICEP_DIR=$(cd $BIN_DIR/../biceps && pwd)
# 環境変数読み込み
source $BIN_DIR/.env
cd $BICEP_DIR && az deployment group create \
--name functionsDeployment \
--template-file functions.bicep \
--parameters \
allowedOrigin=$WSA_ORIGIN \
databaseUrl=$DATABASE_URL \
-g $RESOURCE_GROUP_NAME
infra/biceps/functions.bicep
param storageAccountType string = 'Standard_LRS'
param location string = resourceGroup().location
param allowedOrigin string
param databaseUrl string
var storageAccountName = '${uniqueString(resourceGroup().id)}azfunctions'
resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = {
name: storageAccountName
location: location
kind: 'StorageV2'
sku: { name: storageAccountType }
}
var functionAppName = '${uniqueString(resourceGroup().id)}azfunctionsapp'
resource functionApp 'Microsoft.Web/sites@2022-09-01' = {
name: functionAppName
location: location
kind: 'functionapp,linux'
properties: {
reserved: true
siteConfig: {
cors: {
allowedOrigins: [allowedOrigin]
}
linuxFxVersion: 'Node|20'
appSettings: [
{
name: 'AzureWebJobsStorage'
value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccountName};EndpointSuffix=${environment().suffixes.storage};AccountKey=${storageAccount.listKeys().keys[0].value}'
}
{
name: 'WEBSITE_CONTENTAZUREFILECONNECTIONSTRING'
value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccountName};EndpointSuffix=${environment().suffixes.storage};AccountKey=${storageAccount.listKeys().keys[0].value}'
}
{
name: 'WEBSITE_CONTENTSHARE'
value: toLower(functionAppName)
}
{
name: 'FUNCTIONS_EXTENSION_VERSION'
value: '~4'
}
{
name: 'FUNCTIONS_WORKER_RUNTIME'
value: 'node'
}
{
name: 'DATABASE_URL'
value: databaseUrl
}
]
}
}
}
AzureFunctionsを1つだけ作成し、ルーティングはHonoに任せる。
apps/api/functions/httpTrigger.ts
import { app } from '@azure/functions';
import honoApp from '@api/app';
import { azureHonoHandler } from '@marplex/hono-azurefunc-adapter';
app.setup({
enableHttpStream: true,
});
app.http('httpTrigger', {
methods: ['GET', 'POST', 'PUT', 'DELETE'],
authLevel: 'anonymous',
route: 'api/{*proxy}',
// fetchの引数にはcontextを渡すことができないので第2引数のcontextは失われる。context.logによるログ出力はできないが、関数が1つだけのため関数とログの紐づけができなくなっても影響はない。
handler: azureHonoHandler(honoApp.fetch),
});
Prismaでgenerateした型やクライアントを使っているがHonoの本題から逸れるので割愛。
apps/api/routes/character.ts
import { prisma } from '../shared/prisma';
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { CharacterSchema } from '@db/zod';
import { z } from 'zod';
const app = new Hono()
.get('/', async (c) => {
const characters = await prisma.character.findMany();
return c.json(characters);
})
.post('/', zValidator('json', CharacterSchema), async (c) => {
const data = await c.req.valid('json');
const character = await prisma.character.create({ data });
return c.json(character);
})
.get(
'/:id',
zValidator('param', z.object({ id: CharacterSchema.shape.CharacterID })),
async (c) => {
const characters = await prisma.character.findFirstOrThrow({
where: { CharacterID: c.req.valid('param').id },
});
return c.json(characters);
},
)
.put(
'/:id',
zValidator('param', z.object({ id: CharacterSchema.shape.CharacterID })),
zValidator('json', CharacterSchema),
async (c) => {
const data = await c.req.valid('json');
const { CharacterID, ...rest } = data;
const character = await prisma.character.upsert({
where: { CharacterID: CharacterID },
create: data,
update: rest,
});
return c.json(character);
},
)
.delete(
'/:id',
zValidator('param', z.object({ id: CharacterSchema.shape.CharacterID })),
async (c) => {
const character = await prisma.character.delete({
where: { CharacterID: c.req.valid('param').id },
});
return c.json(character);
},
);
export default app;
apps/api/src/app.ts
import characters from './routes/characters';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
const route = new Hono().route('/characters', characters);
const app = new Hono()
.use(
'/api',
cors({
origin: '*',
allowHeaders: ['Content-Type'],
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
}),
)
.route('/api', route);
export default app;
export type AppType = typeof app;
フロントエンド
静的Webアプリを作るのに特別なことはしていないので割愛。
バックエンドでexportした型をフロントエンドで読み込む。
apps/pl-app/src/shared/client.ts
import type { AppType } from '@api/app';
import { hc } from 'hono/client';
// eslint-disable-next-line turbo/no-undeclared-env-vars
export const client = hc<AppType>(`${import.meta.env.VITE_API_URL}`);
apps/pl-app/src/_services/character.service.ts
import { client } from '@pl-app/shared/client';
import { InferRequestType } from 'hono/client';
const characters = client.api.characters;
export type Character = InferRequestType<typeof characters.$post>['json'];
const getAll = async function getAll() {
const res = await characters.$get();
return res.json();
};
const getById = async function getById(id: string) {
const res = await characters[':id'].$get({ param: { id } });
return res.json();
};
const create = async function create(params: Character) {
const res = await characters.$post({ json: params });
return res.json();
};
const update = async function update(id: string, params: Character) {
const res = await characters[':id'].$put({ param: { id }, json: params });
return res.json();
};
// prefixed with underscored because delete is a reserved word in javascript
const _delete = async function _delete(id: string) {
return characters[':id'].$delete({ param: { id } });
};
export const characterService = {
getAll,
getById,
create,
update,
delete: _delete,
};
apps/pl-app/src/characters/AddEdit.tsx
import { useEffect } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
import {
Character,
characterService,
} from '@pl-app/_services/character.service';
import { v4 } from 'uuid';
function AddEdit() {
const navigate = useNavigate();
const { id } = useParams();
const isAddMode = !id;
// form validation rules
const validationSchema = Yup.object().shape({
CharacterID: Yup.string().required('CharacterID is required'),
CharacterName: Yup.string().required('Character is required'),
});
// functions to build form returned by useForm() hook
const {
register,
handleSubmit,
reset,
setValue,
formState: { errors, isSubmitting },
} = useForm<Character>({
resolver: yupResolver(validationSchema),
});
async function onSubmit(data: Character) {
await (isAddMode ? createUser(data) : updateUser(id, data));
navigate('/');
}
async function createUser(data: Character) {
await characterService.create(data);
alert('Character added');
}
async function updateUser(id: string, data: Character) {
await characterService.update(id, data);
alert('Character updated');
}
useEffect(() => {
if (isAddMode) {
setValue('CharacterID', v4());
return;
}
characterService.getById(id).then((c) => {
const fields = ['CharacterID', 'CharacterName'] as const;
fields.forEach((field) => setValue(field, c[field]));
});
}, []);
return (
<form onSubmit={handleSubmit(onSubmit)} onReset={() => reset()}>
<h1>{isAddMode ? 'Add User' : 'Edit User'}</h1>
<div className="form-row">
<div className="form-group col-5">
<label>Character Name</label>
<input
type="text"
{...register('CharacterName')}
className={`form-control ${errors.CharacterName ? 'is-invalid' : ''}`}
/>
<div className="invalid-feedback">
{errors.CharacterName?.message}
</div>
</div>
</div>
<div className="form-group">
<button
type="submit"
disabled={isSubmitting}
className="btn btn-primary"
>
{isSubmitting && (
<span className="spinner-border spinner-border-sm mr-1"></span>
)}
Save
</button>
<Link to={isAddMode ? '.' : '..'} className="btn btn-link">
Cancel
</Link>
</div>
</form>
);
}
export { AddEdit };
apps/src/pl-app/src/characters/List.tsx
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Character, characterService } from '../_services/character.service';
function List() {
const [characters, setCharacters] = useState<
(Character & { isDeleting: boolean })[] | null
>(null);
useEffect(() => {
characterService
.getAll()
.then((x) => setCharacters(x.map((y) => ({ ...y, isDeleting: false }))));
}, []);
async function deleteUser(id: string) {
if (!characters) return;
setCharacters(
characters.map((x) =>
x.CharacterID !== id ? x : { ...x, isDeleting: true },
),
);
await characterService.delete(id);
setCharacters(characters.filter((x) => x.CharacterID !== id));
}
return (
<div>
<h1>キャラクター</h1>
<Link to={`/add`} className="btn btn-sm btn-success mb-2">
キャラクターを追加
</Link>
<table className="table table-striped">
<thead>
<tr>
<th style={{ width: '30%' }}>ID</th>
<th style={{ width: '60%' }}>Name</th>
<th style={{ width: '10%' }}></th>
</tr>
</thead>
<tbody>
{characters &&
characters.map((character) => (
<tr key={character.CharacterID}>
<td>{character.CharacterID}</td>
<td>{character.CharacterName}</td>
<td style={{ whiteSpace: 'nowrap' }}>
<Link
to={`/edit/${character.CharacterID}`}
className="btn btn-sm btn-primary mr-1"
>
Edit
</Link>
<button
onClick={() => deleteUser(character.CharacterID)}
className="btn btn-sm btn-danger btn-delete-user"
disabled={character.isDeleting}
>
{character.isDeleting ? (
<span className="spinner-border spinner-border-sm"></span>
) : (
<span>Delete</span>
)}
</button>
</td>
</tr>
))}
{!characters && (
<tr>
<td colSpan={4} className="text-center">
<div className="spinner-border spinner-border-lg align-center"></div>
</td>
</tr>
)}
{characters && !characters.length && (
<tr>
<td colSpan={4} className="text-center">
<div className="p-2">No Users To Display</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}
export { List };
参考
react-hook-form-crud-example
react-hooks-form migrate-v6-to-v7
hono cors
hono rpc
Honoを使ってAWS Lambda + API Gateway環境でAPI開発に使ってみた
react-hook-formでyupとzodの違いを検証
Hono RPC + Azure Functionsを試したら型エラーでハマったメモ
Discussion