💪

TODOアプリを真面目に作ったので覚え書き(API編)

2024/02/12に公開

前置き

久々に趣味でコードを書きたくなった。SPAタイプのウェブアプリが好きだが、
一から実装したことはなかったので今回は下記を満たす形で作ることにした。

  • コンテナ化
  • RestAPIで簡単なTODOアプリ
  • Reactでクライアントを作る(あとで)

やり方も手探りなので、もっといいやり方、これが間違ってる等あればコメントで教えていただきたいです。

GitHubリポジトリ

https://github.com/ryryryry0321/TodoAPI

コンテナ化

コマンド一つでビルド、アプリを実行できるようにしたいのでDockerを用いる。

Windows用のDockerのインストールと、MavenタイプのSpringBootプロジェクトの生成を前提として話を進める。下記のリソースバージョンのコンテナを構築していく。

  • SpringBoot 3.1.8
  • Java 17
  • PostgreSQL 15
  • Docker version 25.0.2

最近はWSLがあって意外と簡単にDocker使えて便利。数年前もRailsをコンテナで起動しようとしたが、OS差で色々苦労した覚えがある。もうWindowsはゲーム機という揶揄も前よりはし辛いんじゃないだろうか。

PostgreSQL

docker-compose.ymlとinitDBsディレクトリをアプリ直下に用意。
真面目にやるならパスワードとかユーザー名は環境変数等に格納する。

services:
  # postgresコンテナ
  db:
    image: postgres:15
    container_name: postgres
    ports:
      - 5432:5432
    volumes:
      - db-store:/var/lib/postgresql/data
      # これを記入することで、ymlファイルと同じ場所にできたディレクトリ内のSQLが初期化で読み込まれる
      # SQLの変更をあとから加えたら docker-compose down --volume 
      # でボリュームを削除しないとコンテナに反映されない
      - ./initDBs:/docker-entrypoint-initdb.d 
  
    environment:
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_USER=postgres
volumes:
  db-store:

初期化時SQL

initDBs内のやつ

create-database-todoapi.sql
-- DB作成
CREATE DATABASE tododb;
-- DBへ切り替え
\c tododb
-- スキーマ作成
CREATE SCHEMA api;
-- ロールの作成
CREATE ROLE apiUseDev WITH LOGIN PASSWORD 'postgres';
-- 権限追加
GRANT ALL PRIVILEGES ON SCHEMA api TO apiUseDev;

table作成

create-table-todo.sql
-- DB切り替え
\c tododb
-- テーブル作成
CREATE TABLE  api.todo (
  id serial NOT NULL,
  todo_context varchar(500),
  post_user_name varchar(100),
  created_at TIMESTAMP,
  updated_at TIMESTAMP,
  PRIMARY KEY(id)
);
-- 権限追加
GRANT ALL PRIVILEGES ON api.todo TO apiUseDev;
-- サンプルレコード作成
INSERT INTO api.todo VALUES(1, '御飯食べる', 'あすからんぐれー', now());
INSERT INTO api.todo VALUES(2, '眠いので寝ること', 'HappyCat', now());
INSERT INTO api.todo VALUES(3, '仕事を探す', 'モダン技術で仕事したいマン', now());
-- DBコンテナ初期化の際にIdシーケンスが狂うので
-- シーケンスを新たなに作成して修正する
create sequence todo_seq;
select setval('todo_seq', (select max(id) from api.todo));
-- シーケンスをテーブルに適用する
ALTER TABLE api.todo ALTER COLUMN id SET DEFAULT nextval('todo_seq');

一通り設定して起動コマンドをうち、DB接続できたらOK

追記、MyBatisのシーケンス適応について

MapperでuseGeneratedKeyを使えば自動採番を取得することができるそう
この場合、シーケンス作成と適応は不要になる

@Insert("INSERT INTO api.todo(todo_context, post_user_name, created_at, updated_at) VALUES (#{todoContext},#{postUserName}, now(), now())")
@Options(useGeneratedKeys=true, keyColumn="id")
Integer create(TodoData entity);

DB接続テスト

docker-compose up -d # -dはバックグラウンド起動オプション
docker exec -it postgres /bin/bash
bash: psql -h localhost -U postgres -d tododb

接続後はこうなる

psql (15.5 (Debian 15.5-1.pgdg120+1))
Type "help" for help.

tododb=#

Springアプリ側

docker-compose.ymlに加筆し、Dockerfileを用意する。
Dockerfileの書き方はまだ理解が浅い。もう少し調べる。

services:
  # springappコンテナ
  java:
    # コンテナ名
    container_name: spring
    build: 
      # 直下にアプリリソースがあるのを指定
      context: .
      dockerfile: Dockerfile
    ports:
     - "8080:8080"
    depends_on:
      - db
  # postgresコンテナ
  db:
    image: postgres:15
    container_n...割愛

Dockerfile

chatgpt先輩に助けてもらいつつ、わからない部分をドキュメントや
他の記事で調べて一応理解。

#  Java 実行環境JRE Java 開発環境 JDK 
# ベースイメージを指定します
FROM eclipse-temurin:17-jdk-jammy AS builder
# ワーキングディレクトリを設定します
WORKDIR /app
# Maven Wrapperを含むすべてのファイルをコピーします
COPY .mvn/ .mvn
COPY mvnw pom.xml ./
# Maven依存関係をダウンロードします
# ローカルに取り込んでる
RUN ./mvnw dependency:go-offline
# アプリケーションのソースコードをコピーします
COPY src ./src
# アプリケーションをビルドします
RUN ./mvnw clean package
# ランタイムステージの定義
FROM eclipse-temurin:17-jre-jammy
# ワーキングディレクトリを設定します
WORKDIR /app
# アプリケーションのビルド結果をコピーします
# app.jarとして扱う
COPY --from=builder /app/target/*.jar app.jar
# コンテナが起動する際に実行されるコマンドを指定します
ENTRYPOINT ["java", "-jar", "app.jar"]

application.properties

少し気をつけて設定しないと行けない。
気をつけること

spring.datasource.driver-class-name=org.postgresql.Driver
# Zenで記事を書いてた方のを引用
# Docker としてアクセスする場合は jdbc:postgresql://{DBコンテナ名}:{ポート}/{DB名} となる
spring.datasource.url=jdbc:postgresql://postgres:5432/tododb
spring.datasource.username=postgres
spring.datasource.password=postgres
# my batis 自動キャメルケース変換(mybatis使う時にコメントアウト)
# mybatis.configuration.map-underscore-to-camel-case=true

サービス名指定で、springの方も起動する

docker-compose up java -d

これでアプリとDB両方のコンテナ化が完了した。

docker-compose down 補足

単純にdownするだけじゃ構築にあたっての情報が再読み込みされていないようなので
オプションをつけてdownした後にupする。--rmi all--volumesあたり
分かってればとりあえず初期化系は大丈夫そう。チームで開発するとなったらこの辺は
他のコマンド含めおさらいしておきたいところ。公式ドキュメントがわかりやすいのが救い。

以下公式よりオプション

使い方: down [オプション]

オプション:
    --rmi type          イメージの削除。type は次のいずれか:
                        'all': あらゆるサービスで使う全イメージを削除
                        'local': image フィールドにカスタム・タグのないイメージだけ削除
    -v, --volumes       Compose ファイルの `volumes` セクションの名前付きボリュームを削除
                        また、コンテナがアタッチした匿名ボリュームも削除
    --remove-orphans    Compose ファイルで定義していないサービス用のコンテナも削除
    -t, --timeout TIMEOUT   シャットダウンのタイムアウト秒を指定(デフォルト: 10)

RestAPIを構築していく

※依存関係等はGitHubリポジトリ参照でお願いします。
MyBatisを使う。簡単なTODOアプリということで編集実装は一度保留にしました。
追記:実装しました。
Entity, Mapper, Serviceは無難な感じで。
コントローラーの処理はカスタムレスポンスを取り扱う関係でこの後説明する。

TodoData.java
/**
 * Entityクラス
 * JSONPropertyを使うとJSON上では指定した任意のフィールド名にできる
 */
@Data
public class TodoData {

    /* データID */
    @JsonProperty("id")
    private Integer id;

    /* 書き込み内容 */
    @JsonProperty("todo_context")
    private String todoContext;

    /* 投稿者 */
    @JsonProperty("post_user_name")
    private String postUserName;

    /* 作成日 */
    @JsonProperty("created_at")
    private LocalDateTime createdAt;

    /* 更新日 */
    @JsonProperty("update_at")
    private LocalDateTime updatedAt;
} 
TodoMapper.java
@Mapper
public interface TodoMapper {

    @Select("SELECT * FROM api.todo")
    List<TodoData> selectAll();

    @Select("SELECT * FROM api.todo WHERE id = #{id}")
    TodoData findById(@Param("id") Integer id);
    
    @Insert("INSERT INTO api.todo(todo_context, post_user_name, created_at, updated_at) VALUES (#{todoContext},#{postUserName}, now(), now())")
    Integer create(TodoData entity);

    @Delete("DELETE FROM api.todo WHERE id = #{id}")
    Integer delete(@Param("id") Integer id);
}

service

TodoService.java
/**
 * TODOAPI サービスクラス
 */
@RequiredArgsConstructor
@Service
public class TodoService {

    /* TODOデータ取得 Mapperクラス(Mybatis3使用) */
    final TodoMapper todoMapper;

    /**
     * TODOデータ一覧取得
     * 
     * @return TODOリストデータ
     */
    public List<TodoData> list() {
        return todoMapper.selectAll();
    }

    /**
     * TODOデータ取得(id)
     * 
     * @param id TODOid
     * @return  TODOデータ
     */
    public TodoData select(Integer id){
        return todoMapper.findById(id);
    }

    /**
     * TODOデータ一作成
     * 
     * @param entity TODOデータ (リクエストボディ)
     */
    public void create(TodoData entity) {
        todoMapper.create(entity);
    }

    /**
     * TODOデータ削除
     * 
     * @param id
     * 
     */
    public void delete(Integer id){
        todoMapper.delete(id);
    }
}

コントローラーの実装

とりあえず、一覧はEntityのリスト返すようにすればRestControllerなので、
JSONが返せる。

TodoApiController.java
    /**
     * 一覧
     */
    @GetMapping("/api/todo/list")
    public List<TodoData> index() {

        var list = todoService.list();
        return list;
    }


TODOの作成、削除、個別選択(データの変更が入る処理)はカスタムレスポンスを使うようにしたいので先に、正常系レスポンスエラーレスポンスを定義する。

TodoResponseBody.java
@Data
public class TodoResponseBody {

    @JsonProperty("status")
    private int code;

    /* レスポンスメッセージ */
    @JsonProperty("message")
    private String message;

    /* 書き込み内容 */
    @JsonProperty("todo_context")
    private String todoContext;

    /* 投稿者 */
    @JsonProperty("post_user_name")
    private String postUserName;

    /* 作成日 */
    @JsonProperty("created_at")
    private LocalDateTime createdAt;

    /* 更新日 */
    @JsonProperty("update_at")
    private LocalDateTime updatedAt;
}
ErrorResponseBody.java
/**
 * エラーレスポンスクラス
 * ここをカスタムすれば
 * 任意の形式のレスポンスを作成できる
 */
@Data
public class ErrorResponseBody {
    /* エラー発生時間 */
    @JsonProperty("timestamp")
    private LocalDateTime exceptionOccurrenceTime;

    /* HTTP ERROR CODE */
    @JsonProperty("status")
    private int status;

    /* Http エラーメッセージ */
    @JsonProperty("error")
    private String error;

    /* 例外概要 */
    @JsonProperty("message")
    private String message;

    /* リクエストURL */
    @JsonProperty("path")
    private String path;

    /* エラー発生時間 */
    @JsonProperty("error_detail")
    private String errorDetail;
}

例外発生時に必要なので、独自例外を定義する

ResponseStatusException.java
@Getter
public class ResponseStatusException extends RuntimeException{
    
    private ErrorResponseBody responseBody;

    public ResponseStatusException(String message, ErrorResponseBody responseBody){
        super(message);
        this.responseBody = responseBody;
    }
}

例外ハンドラクラスを作成する

@RestControllerAdviceを使うことで共通のエラーハンドルクラスを作成ができる
例外が発生した時にこちらが実行されるようになる。

ResponseStatusException.java
@RestControllerAdvice
public class ExceptionAdvice extends ResponseEntityExceptionHandler {

    /**
     * 例外発生時に呼ばれる
     * 
     * @param exception
     * @param request
     * @return
     */
    @ExceptionHandler(ResponseStatusException.class)
    public ResponseEntity<Object> handleResponseStatusException(ResponseStatusException exception, WebRequest request) {
        HttpHeaders headers = new HttpHeaders();

        return super.handleExceptionInternal(exception,
                createErrorResponseBody(exception, request),
                headers,
                HttpStatus.BAD_REQUEST,
                request);
    }

    // レスポンスのボディ部を作成
    private ErrorResponseBody createErrorResponseBody(ResponseStatusException exception, WebRequest request) {

        var errorResponseBody = new ErrorResponseBody();
        int statusCode = exception.getResponseBody().getStatus();
        String responseErrorMessage = handleErrMessage(statusCode);
        String uri = ((ServletWebRequest) request).getRequest().getRequestURI();

        errorResponseBody.setStatus(statusCode);
        errorResponseBody.setExceptionOccurrenceTime(LocalDateTime.now());
        errorResponseBody.setError(responseErrorMessage);
        errorResponseBody.setMessage(exception.getMessage());
        errorResponseBody.setPath(uri);
        errorResponseBody.setErrorDetail(exception.getResponseBody().getMessage());

        return errorResponseBody;
    }

    /**
     * ステータスコード別にデフォルトフレーズも変化させる
     * 
     * @param statusCode
     * @return
     */
    private String handleErrMessage(int statusCode) {
        // default 400 にする
        String message = HttpStatus.BAD_REQUEST.getReasonPhrase();

        if (statusCode == HttpStatus.NOT_FOUND.value()) {
            message = HttpStatus.NOT_FOUND.getReasonPhrase();

        } else if (statusCode == HttpStatus.INTERNAL_SERVER_ERROR.value()) {
            message = HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase();

        } else if (statusCode == HttpStatus.CONFLICT.value()) {
            message = HttpStatus.CONFLICT.getReasonPhrase();

        } else if (statusCode == HttpStatus.UNAUTHORIZED.value()) {
            message = HttpStatus.UNAUTHORIZED.getReasonPhrase();

        }
        return message;
    }
}

リクエスト自体をハンドリングするサービスを作る

例外は先に作ったハンドルに任せる形にした

TodoBodyHandleService.java
@Service
public class TodoBodyHandleService {
    /**
     * リクエスト自体をハンドルする
     * 
     * @param statusCode
     * @param entity
     * @return
     */
    public TodoResponseBody createTodoResponseBody(int statusCode, TodoData entity, String requestType) {
        var res = new TodoResponseBody();

        res.setMessage("TODO-INFO(SUCCESS):" + requestType);
        res.setCode(statusCode);
        res.setTodoContext(entity.getTodoContext());
        res.setPostUserName(entity.getPostUserName());
        // TODO: Editの時はCreatedAtいらない?
        res.setCreatedAt(LocalDateTime.now());
        res.setUpdatedAt(LocalDateTime.now());

        return res;
    }

    /* 例外発生時処理はErrResponseBodyに任せる */
    /**
     * 独自例外的リクエストが来た際に独自例外を投げる。
     * 
     * @return
     * @throws ResponseStatusException
     */
    public void handleExceptionRequest(String errBodyMessage, int status) throws ResponseStatusException {
        var errBody = new ErrorResponseBody();
        errBody.setStatus(status);
        errBody.setMessage(errBodyMessage);

        throw new ResponseStatusException("不正なリクエストが発生しました。", errBody);
    }
}

これでコントローラーは正常レスポンスとエラーを取り扱って実装できる。
TodoBodyHandleServicerequestHandlerという名前で使っている。

TodoApiController.java(create, select, delete)
    /**
     * 選択
     * 
     * @return TodoData(json形式)
     */
    @GetMapping("/api/todo/{id}")
    public TodoData selectTodo(@PathVariable("id") Integer id) {

        var resultById = todoService.select(id);

        if(resultById == null){
           // TodoAPIMessageにはpublic static Stringでメッセージが格納されてます。
           requestHandler.handleExceptionRequest(TodoAPIMessages.DATA_NOT_FOUND, HttpStatus.NOT_FOUND.value());
        }
        return resultById;
    }

    /**
     * id別選択例外
     * 
     * @return
     */
    @GetMapping("/api/todo/")
    public void handleSelectTodo() {
        requestHandler.handleExceptionRequest(TodoAPIMessages.NO_PARAMETER, HttpStatus.BAD_REQUEST.value());
    }
    
    /**
     * 新規作成
     *
     * @param entity
     * @return 処理完了レスポンスエンティティ(JSON)
     */
    @PostMapping("api/todo/create")
    @ResponseBody
    public TodoResponseBody create(@RequestBody @Validated TodoData entity, BindingResult result) {
         
        // 不適切なRequestJSONが送信されたらキャッチ
        if(result.hasErrors()){
            requestHandler.handleExceptionRequest(TodoAPIMessages.WRONG_REQUEST, HttpStatus.BAD_REQUEST.value());
        }
    
        todoService.create(entity);

        var postResponse = requestHandler.createTodoResponseBody(HttpStatus.CREATED.value(), entity, "POST TODO");;

        return postResponse;
    }

    /**
     * 削除
     * 
     * @param id
     * @return 処理完了レスポンスエンティティ(JSON)
     */
    @DeleteMapping("/api/todo/delete/{id}")
    public TodoResponseBody delete(@PathVariable("id") Integer id) {
        
        var resultById = todoService.select(id);

        if(resultById == null){
            requestHandler.handleExceptionRequest(TodoAPIMessages.DATA_NOT_FOUND, HttpStatus.NOT_FOUND.value());
        }

        todoService.delete(id);

        var deleteResponse = requestHandler.createTodoResponseBody(HttpStatus.OK.value(), resultById, "DELETE TODO");
        
        return deleteResponse;
    }

これでとりあえず簡易的なTODOを作るAPIはできた

動作確認

curlで適当にやればJSONを返せる
POSTでJSONを送る際は若干今回特殊だと思ったので一応ここに上げておく。

 # リクエストURL例:(Windowsのcurlはシングルクオートが使えない。そのうえエスケープ処理も必要)
curl -X POST -H "Content-Type:application/json" -d "{\"todo_context\":\"バグを直す\",\"post_user_name\": \"Hello-san\"}" http://localhost:8080/api/todo/create

ちなみに普遍的なエラーはSpringBoot側で見てくれているようだ

アクセストークンによる認証をしたかった

結論から言うと、真面目に調べた結果、認証・認可は中途半端にやるだけあまり意味合いのあるものではないことが分かった。やるならユーザーを作成して、実用サービスと同じレベルで実装しないといけないようだ。

そして今回クライアント側も創りたいのもあって切り上げた。という状態。
また時間できたら認証関連の実装を他でやってみたい。

最初、以下の簡単なフローでトークン認証を実装した。
一応コードには残しておくのでGitHubリポジトリでTokenXXX系の名前が
ついてるファイルを良かったら見てください。

- トークンを生成、DBに保存するメソッドをコントローラーで実装
   /generate/{username} で生成できる。ユーザー名はtokenにハッシュとしてつく
   ↓
-  登録・削除にトークン検証をするInterceptorを登録
   DBにあるトークン == Authorizationヘッダーに定義したトークン →認証
   ↓
-  トークンデータは定周期チェックして生成から10分で削除

トークン生成ロジックイメージ

public static String generateAccessTokenByUserName(String username) {
   // ユーザーネームをSHA-256でハッシュ化
   String hashedUsername = hashSHA256(username);
   // ランダムなバイト列を生成 // SecureRandomでやってるので普通のRandomより安全?
   byte[] randomBytes = generateRandomBytes(16); // 16バイト = 128ビット
   // ハッシュしたユーザーネームとランダムなバイト列を結合
   String combinedString = hashedUsername + bytesToHex(randomBytes);

   return combinedString;
}

その後にOAuthやJWTセキュリティ的な話を調べて軽く絶望した。なので絶望した分学んだことをここに簡易的にまとめます。

認証やめた分学んだことをまとめ

まだ理解が浅い点があると思うので引き続き勉強していく

認証・認可

それぞれ確認しているものが違う

  • 認証: 通信してる相手が誰かを確認する
  • 認可: 通信してる相手がどれ程の権限を持っているか確認する

例: 鍵を持っていて家に入れると認可はされたことになる。
しかし、鍵の持ち主は誰かは分からない(家の住人以外の可能性)ので認証はされていない。

JWT

JSON Web Tokenの略。ユーザー情報が入ったJSONがBase64エンコードされたもので、それが認証に使われる。

最終的に生成されるJWTの形式。ピリオドで結合される。

[ヘッダ(署名生成に使用したアルゴリズム)].
[ペイロード(ユーザー情報など)].
[署名(ヘッダーとペイロードをBase64urlエンコーディングしてピリオドで結合したもの)]

認証フロー

  • ユーザーが自分の情報を使ってログインする
  • JWTトークンがログイン情報に伴って生成される
  • それ以降は生成されたトークンの検証を行ってリクエストが行われる

認証情報が設定されたJWTをもとにユーザ認証を行うという理解ではいるが、もう少し
勉強が必要かもしれない。

JWTを保存する場所はCookieかローカルストレージの二択で、どちらもそれぞれメリットとデメリットがあるようだ。しかしながら一般的にはHttpOnlyCookie内に保存することが多く、XSSが防げる為、安全性があるらしい。

JWT認証を採用する例が多い理由

理由は他にもあるが以下の三つは大きなものである。

  • トークンに署名が含まれているので改ざん検知もしやすく安全な認証を提供できる
  • cookieヘッダーを使わない場合はCORS通信も可能。別リソースで呼ぶ場合のあるAPIだと役に立つ
  • DBへの問い合わせがない、署名検証のみで認証フローが成り立つ

以上より、シンプルで安全な認証が提供できるのがわかる。しかし、シンプル過ぎるが故に
トークンが漏れたら情報が簡単に盗まれたり、有効期限の管理には別の仕組みが必要だったりと、デメリットもあるようだ。

それでも、適切な取り扱いをすれば比較的安全にはできる。
(HTTPS通信でトークンを送信, 有効期限設定, パスワードハッシュetc)

OAuth

X連携で別サービスの機能を利用したりする例をよく見る。

先に使っているサービスで認証認可が済んでいるから他サービスもAPIを呼んで利用できるようにする仕組みである。ただ、ユーザ側はどこまで別サービスの認可を受け入れるか注意が必要。

流れの説明は以下の記事が一番スムーズに理解できた。
https://www.tdk.com/ja/tech-mag/knowledge/147#:~:text=OAuthとは、複数の,連動ができるのです。

ReactAppを作る。(記事作成中)

こちらのリンクを参考にコンテナを作って開発環境を構築。
(わかりやすかったです。ありがとうございました。)
NodeJSのバージョンだけ最新に変えてビルドした。

実際に作る記事は次回で。
https://zenn.dev/rihito/articles/96dfad8d4990f9

追記:記事出来ました。

こちらです。良かったら見てください。
https://zenn.dev/rf0321/articles/f0405923c8c215

Discussion