本文へスキップ

エラー処理 ​

ユーザーの取得に失敗した原因に応じてHTTP 400または404を返し、予期しないエラーの内部情報をレスポンスに公開しないようにします。

このページの機能別 src/user の例は、プロジェクト構造で --layout legacy を指定して作成した my-app を使用します。CLI v0.4.0 の標準レイアウトは役割別の layered-v1 です。どちらも Spine v0.6.0 と Echo プラットフォーム v0.1.0 を使用します。

最初のアプリとコントローラーのCLIプロジェクトを使用します。spine-cli generate resource user で生成した src/user が必要です。以下では、GETハンドラーを読み取り専用のメモリ内検索に置き換えるため、データベースのチュートリアルとは別に適用してください。ドメインエラーとHTTPステータスを対応付けるコードは手動で作成します。

HTTPエラーとレスポンス ​

成功時の結果は httpx.Response[T] に格納し、エラーは別の error 戻り値で渡します。ハンドラーの戻り値の型は (httpx.Response[User], error) とし、(User, error) は使用しません。httperr は次の4つの関数を提供します。

関数HTTPステータス用途
httperr.BadRequest(message)400不正な入力
httperr.Unauthorized(message)401認証が必要
httperr.NotFound(message)404リソースが存在しない
httperr.InternalServerError(message)500クライアントに公開するサーバーエラーメッセージ

HTTPError.Message はクライアントに公開されるため、内部のDBエラーや秘密情報をそのまま入れないでください。通常の error を返すと、HTTP 500と {"message":"Internal server error"} に変換されます。

生成されたリソースに取得エラーを追加 ​

生成された src/user/user_repository.go と src/user/user_controller.go を以下の内容に置き換え、src/user/user_service.go に Find メソッドを追加します。すべてのファイルを package user に置きます。既存のコンストラクタ名、user.Register(app)、GET /user/:id の登録は維持します。

リポジトリには、IDが1のAliceだけを保持します。取得エラーを再現するための例であり、DBにはデータを保存しません。

go
// src/user/user_repository.go
package user

import "errors"

var ErrUserNotFound = errors.New("user not found")
var ErrEmailAlreadyExists = errors.New("email already exists")

type User struct {
    ID int64 `json:"id"`
    Name string `json:"name"`
}

type UserRepository struct{ users map[int64]User }

func NewUserRepository() *UserRepository {
    return &UserRepository{users: map[int64]User{1: {ID: 1, Name: "Alice"}}}
}

func (r *UserRepository) FindByID(id int64) (User, error) {
    user, ok := r.users[id]
    if !ok {
        return User{}, ErrUserNotFound
    }
    return user, nil
}
go
// src/user/user_service.go
func (s *UserService) Find(id int64) (User, error) {
    return s.repository.FindByID(id)
}
go
// src/user/user_controller.go
package user

import (
    "errors"
    "net/http"

    "github.com/NARUBROWN/spine/pkg/httperr"
    "github.com/NARUBROWN/spine/pkg/httpx"
    "github.com/NARUBROWN/spine/pkg/path"
)

type UserController struct{ service *UserService }

func NewUserController(service *UserService) *UserController {
    return &UserController{service: service}
}

func (c *UserController) Get(id path.Int) (httpx.Response[User], error) {
    if id.Value <= 0 {
        return httpx.Response[User]{}, httperr.BadRequest("invalid user ID")
    }
    user, err := c.service.Find(id.Value)
    if err != nil {
        return httpx.Response[User]{}, toHTTPError(err)
    }
    return httpx.Response[User]{Body: user}, nil
}

func toHTTPError(err error) error {
    switch {
    case errors.Is(err, ErrUserNotFound):
        return httperr.NotFound("user not found")
    case errors.Is(err, ErrEmailAlreadyExists):
        return &httperr.HTTPError{
            Status: http.StatusConflict,
            Message: "email already exists",
            Cause: err,
        }
    default:
        return err
    }
}

各レイヤーの責任 ​

リポジトリはドメインエラーを返し、サービスはそれをコントローラーに渡します。コントローラーは想定したエラーをHTTPステータスに変換します。errors.Is を使用すると、別のエラーにラップされたドメインエラーも判定できます。

未知のエラーはそのまま返し、通常のHTTP 500の処理を使用します。これを BadRequest(err.Error()) に変更すると、内部のエラーメッセージがレスポンスに公開されます。上のメモリ内リポジトリは ErrUserNotFound だけを返します。ErrEmailAlreadyExists の分岐は、後で保存機能を追加する際に使用するマッピングの例です。

HTTPErrorとその他のステータス ​

HTTPError.Unwrap() は Cause を返します。errors.Is と errors.As で原因の連鎖を調べるか、Cause を直接参照できます。原因エラーは JSON レスポンスに含まれません。

go
type HTTPError struct {
    Status int
    Message string
    Cause error
}

func (e *HTTPError) Error() string { return e.Message }
func (e *HTTPError) Unwrap() error { return e.Cause }
go
return &httperr.HTTPError{
    Status: http.StatusForbidden,
    Message: "permission denied",
}

リクエストDTOの検証 ​

入力検証を追加するには、次の src/user/user_create.go を作成します。Validate() は自動で呼び出されないため、ハンドラーから直接呼び出します。リクエストDTOはポインタで受け取ります。

このPOSTハンドラーは、名前またはメールアドレスが空の場合や、名前が100文字を超える場合にHTTP 400を返します。検証を通過すると入力をHTTP 201で返しますが、ユーザーは保存しません。使用する場合は、以下のPOSTルートも src/user/user_route.go の Register に追加してください。

go
// src/user/user_create.go
package user

import (
    "errors"
    "net/http"
    "unicode/utf8"

    "github.com/NARUBROWN/spine/pkg/httperr"
    "github.com/NARUBROWN/spine/pkg/httpx"
)

type CreateUserRequest struct {
    Name string `json:"name"`
    Email string `json:"email"`
}

func (r *CreateUserRequest) Validate() error {
    if r == nil || r.Name == "" {
        return errors.New("name is required")
    }
    if utf8.RuneCountInString(r.Name) > 100 {
        return errors.New("name must be at most 100 characters")
    }
    if r.Email == "" {
        return errors.New("email is required")
    }
    return nil
}

func (c *UserController) Create(req *CreateUserRequest) (httpx.Response[CreateUserRequest], error) {
    if err := req.Validate(); err != nil {
        return httpx.Response[CreateUserRequest]{}, httperr.BadRequest(err.Error())
    }
    return httpx.Response[CreateUserRequest]{
        Body: *req,
        Options: httpx.ResponseOptions{Status: http.StatusCreated},
    }, nil
}
go
// src/user/user_route.go: Register
app.Route("POST", "/user", (*UserController).Create)

インターセプターのエラー処理 ​

PreHandle でエラーを返すと、コントローラーは実行されません。次のコードは、独自の認証サービスを持つインターセプターのメソッドの一部です。このメソッドだけをコピーしても使用できません。src/interceptor に型と認証サービスを定義し、PreHandle、PostHandle、BeforeResponse、AfterCompletion の4つのメソッドをすべて実装する必要があります。HTTPリクエストに適用するには、BuildApplication() で boot.InterceptorHTTP の範囲に登録します。

go
func (i *AuthInterceptor) PreHandle(ctx core.ExecutionContext, meta core.HandlerMeta) error {
    token := ctx.Header("Authorization")
    if token == "" {
        return httperr.Unauthorized("authentication required")
    }
    user, err := i.auth.Validate(token)
    if err != nil {
        return httperr.Unauthorized("invalid token")
    }
    ctx.Set("auth.user", user)
    return nil
}

エラーのログ記録 ​

AfterCompletion で最終的な実行エラーをログに記録できます。次のコードもロギング用インターセプターから抜粋したメソッドであるため、インターセプター全体の型定義と登録が必要です。認証トークンやリクエストの秘密情報をログに記録しないでください。

go
func (i *LoggingInterceptor) AfterCompletion(ctx core.ExecutionContext, meta core.HandlerMeta, err error) {
    if err != nil {
        log.Printf("[ERR] %s %s: %v", ctx.Method(), ctx.Path(), err)
    }
}

検証とHTTPレスポンスの確認 ​

bash
gofmt -w src/user
go mod tidy
go mod download all
spine-cli doctor
spine-cli contract check
spine-cli verify
spine-cli dev

契約検査とローカル検査を通過した後、別のターミナルでレスポンスを確認します。以下の結果は、読み取り専用のメモリ内検索の例を適用した場合の動作です。

bash
curl -i http://localhost:8080/user/1
curl -i http://localhost:8080/user/0
curl -i http://localhost:8080/user/2
リクエストHTTPステータスJSON
/user/1200{"id":1,"name":"Alice"}
/user/0400{"message":"invalid user ID"}
/user/2404{"message":"user not found"}

上のリクエストでは、ハンドラーに渡された整数IDに対する処理を確認します。数値として解釈できないパスの値のエラー処理は、この例の id.Value <= 0 による検証とは別です。

実際のDBや認証サービスを接続した場合は、そのサービスが失敗する経路も実際にリクエストして確認してください。静的な契約検査だけでは、外部サービスのエラーマッピングは検証できません。