ginでAPIサーバを作る

ディレクトリをほって初期化する。 モジュール名のところはリポジトリ名にするんだよ、とする情報が多くて、正しいのだけれど、公開しないものであれば別にそのフォルダ名とかでも特に支障はない(たぶん)。

mkdir XXXX
cd XXXX
go mod init モジュール名

DBを立ち上げるdocker-compose.ymlをかく

services:
  db:
    image: postgres:latest
    container_name: postgresql
    ports:
      - 5432:5432
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    volumes:
      - ./pgdata:/var/lib/postgresql/data
      - ./pginit:/docker-entrypoint-initdb.d

初期化して初期データをつっこむSQLを書く

DROP TABLE IF EXISTS Users;

CREATE TABLE Users (
    user_id SERIAL,
    username VARCHAR(128),
    account VARCHAR(64),
    password VARCHAR(64)
);

INSERT INTO Users(username, account, password) VALUES('', 'user01', 'user01');
INSERT INTO Users(username, account, password) VALUES('', 'user02', 'user02');
INSERT INTO Users(username, account, password) VALUES('', 'user03', 'user03');
INSERT INTO Users(username, account, password) VALUES('', 'user04', 'user04');
INSERT INTO Users(username, account, password) VALUES('', 'user05', 'user05');

勢いで書く。

package main

import (
    "database/sql"
    "fmt"
    "log"
    "strconv"
    "strings"
    "time"

    _ "github.com/lib/pq"

    "github.com/gin-contrib/cors"
    "github.com/gin-gonic/gin"
    "github.com/golang-jwt/jwt"
)

const SECRETKEY = "secretkey"


func main() {
    // ログにファイル名と行番号を出す設定
    log.SetFlags(log.Lshortfile)
    app := gin.Default()
    // グループの作成
    apiRouter := app.Group("/api/v0")
    // CORS設定、どこからでもアクセスOKにしてみた
    apiRouter.Use(cors.New(cors.Config{
        AllowOrigins: []string{"*"},
        AllowMethods: []string{"POST", "GET", "OPTIONS"},
        AllowHeaders: []string{"Authorization", "Content-Type"}}))
    // このグループ共通のミドル、ログインしていたらユーザ情報を取得する
    apiRouter.Use(middleware)
    // ログイン
    apiRouter.POST("/login", Login)
    // ログインしているユーザの情報の取得
    apiRouter.GET("/me", Me)
    app.Run()
}

//
// Authorizationヘッダに正しいトークンが乗っていたらコンテキストにそのユーザの情報を乗せる
//
func middleware(c *gin.Context) {
    // Authorizationヘッダからトークンを取り出す
    authorizationHeader := c.Request.Header.Get("Authorization")
    if authorizationHeader != "" {
        ary := strings.Split(authorizationHeader, " ")
        if len(ary) == 2 {
            if ary[0] == "Bearer" {
                // ここまででトークンと思われるものを取り出せたので、解析する
                // ここのロジックは公式サイト参照
                // https://pkg.go.dev/github.com/golang-jwt/jwt#example-Parse-Hmac
                token, err := jwt.Parse(ary[1], func(token *jwt.Token) (interface{}, error) {
                    if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
                        return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
                    }
                    return []byte(SECRETKEY), nil
                })
                if err == nil {
                    // 解析に成功したら、ユーザIDを取り出す
                    if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
                        userIDStr := claims["sub"].(string)
                        userID, err := strconv.Atoi(userIDStr)
                        if err == nil {
                            // ユーザIDを使って新しいトークンを取り出す(有効期限切れ対策)
                            newToken, err := generateToken(userID)
                            if err == nil {
                                // ユーザの情報を取り出す
                                conn, err := connectDB()
                                if err == nil {
                                    defer conn.Close()
                                    var username string
                                    err := conn.QueryRow(`
                                      SELECT username FROM Users WHERE user_id = $1`,
                                        userID).Scan(&username)
                                    if err == nil {
                                        // 新しいトークンとユーザの情報をコンテキストに保存
                                        c.Set("token", newToken)
                                        c.Set("username", username)
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
    c.Next()
}

//
// 投入されたログイン情報を格納するための構造体
//
type LoginInfo struct {
    Account  string `json:"account" binding:"required"`
    Password string `json:"password" binding:"required"`
}

//
// ログイン処理
//   正しいアカウントとパスワードが渡されたらトークンを生成して返す
//
func Login(c *gin.Context) {
    // 投げつけられた情報を取り出す
    var loginInfo LoginInfo
    err := c.ShouldBindJSON(&loginInfo)
    if err != nil {
        log.Println(err)
        c.JSON(400, gin.H{"message": "bad request"})
        return
    }
    // DBに接続
    conn, err := connectDB()
    if err != nil {
        log.Println(err)
        c.JSON(500, gin.H{"message": "server internal error"})
        return
    }
    defer conn.Close()
    // 渡された情報に該当するユーザを探す。
    // 本当だったらDBに生パスワードは保存しないだろうからこの前にハッシュ化などが必要
    var userID int
    rows, err := conn.Query(`
      SELECT user_id FROM Users WHERE account = $1 AND password = $2`,
        loginInfo.Account, loginInfo.Password)
    for rows.Next() {
        err := rows.Scan(&userID)
        if err != nil {
            log.Println(err)
            c.JSON(500, gin.H{"message": "server internal error"})
            return
        }
    }
    // トークン生成
    token, err := generateToken(userID)
    if err != nil {
        c.JSON(500, gin.H{"message": "server error"})
        return
    }
    c.JSON(200, gin.H{"token": token})
}

//
// ログインしているユーザの自分自身の情報取得
//
func Me(c *gin.Context) {
    // 自作ミドルウェアがコンテキストに保存した情報を取り出す
    username, exist := c.Get("username")
    if !exist {
        c.JSON(403, gin.H{"message": "need to login"})
        return
    }
    token, exist := c.Get("token")
    if !exist {
        c.JSON(403, gin.H{"message": "need to login"})
        return
    }
    c.JSON(200, gin.H{"username": username, "token": token})
}

//
// DBへの接続
//
func connectDB() (*sql.DB, error) {
    db, err := sql.Open("postgres", `
      host=localhost
      port=5432
      dbname=postgres
      user=postgres
      password=postgres
      sslmode=disable`)
    if err != nil {
        log.Println(err)
        return nil, err
    }
    return db, nil

}

//
// トークン生成
//
func generateToken(userID int) (string, error) {
    now := time.Now()
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
        "sub": fmt.Sprintf("%d", userID),
        "iat": now.Unix(),
        "exp": now.Add(60 * time.Minute).Unix(), // 一時間で無効になる
    })
    return token.SignedString([]byte(SECRETKEY))
}