🐙

Hono + Microsoft Azure FunctionsでCRUDしてみたメモ

2024/07/28に公開

概要

Honoなるフレームワークがあるということで試してみた。

デプロイ結果 ... 無料のSQL Serverにつないでいるので初回は寝てて動かないかも。失敗したらコーヒー淹れて戻ってくるくらいで見れるようになるはず。
ソースコード

バックエンド

API一覧

インフラ ( 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を試したら型エラーでハマったメモ

ZennのMarkdown記法一覧

Discussion