refactor(api): enforce protocol-neutral service boundaries
- refactor: move HTTP status and DTO concerns out of model and service layers into API and handler code. - feat: add admin service boundary and route auth through services instead of direct repository access. - test: add architecture and error tests covering package boundaries, redaction, and service behavior. - docs: record the protocol-neutral boundary decision and update architecture and roadmap notes.
This commit is contained in:
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -26,10 +27,29 @@ type createPasskeyRequest struct {
|
||||
Label string `json:"label" binding:"required"`
|
||||
}
|
||||
|
||||
type accountResponse struct {
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
type passkeyResponse struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Type string `json:"type"`
|
||||
Label string `json:"label"`
|
||||
LastUsedAt *time.Time `json:"last_used_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type createdPasskeyResponse struct {
|
||||
ID string `json:"id"`
|
||||
Raw string `json:"raw"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// GetAccount handles GET /api/v1/account.
|
||||
func (h *AccountHandler) GetAccount(c *gin.Context) {
|
||||
userID := middleware.MustGetUserID(c)
|
||||
c.JSON(http.StatusOK, gin.H{"user_id": userID})
|
||||
c.JSON(http.StatusOK, accountResponse{UserID: userID})
|
||||
}
|
||||
|
||||
// ListPasskeys handles GET /api/v1/account/passkeys.
|
||||
@@ -46,7 +66,7 @@ func (h *AccountHandler) ListPasskeys(c *gin.Context) {
|
||||
creds = []model.Credential{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, creds)
|
||||
c.JSON(http.StatusOK, toPasskeyResponses(creds))
|
||||
}
|
||||
|
||||
// CreatePasskey handles POST /api/v1/account/passkeys.
|
||||
@@ -66,7 +86,11 @@ func (h *AccountHandler) CreatePasskey(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, pk)
|
||||
c.JSON(http.StatusCreated, createdPasskeyResponse{
|
||||
ID: pk.ID,
|
||||
Raw: pk.Raw,
|
||||
Label: pk.Label,
|
||||
})
|
||||
}
|
||||
|
||||
// RevokePasskey handles DELETE /api/v1/account/passkeys/:id.
|
||||
@@ -86,3 +110,18 @@ func (h *AccountHandler) RevokePasskey(c *gin.Context) {
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func toPasskeyResponses(creds []model.Credential) []passkeyResponse {
|
||||
items := make([]passkeyResponse, 0, len(creds))
|
||||
for i := range creds {
|
||||
items = append(items, passkeyResponse{
|
||||
ID: creds[i].ID,
|
||||
UserID: creds[i].UserID,
|
||||
Type: creds[i].Type,
|
||||
Label: creds[i].Label,
|
||||
LastUsedAt: creds[i].LastUsedAt,
|
||||
CreatedAt: creds[i].CreatedAt,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/middleware"
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
"github.com/dhao2001/mygo/internal/service"
|
||||
)
|
||||
|
||||
func setupAccountRouter(t *testing.T) (*gin.Engine, []byte) {
|
||||
@@ -31,7 +29,7 @@ func setupAccountRouter(t *testing.T) (*gin.Engine, []byte) {
|
||||
}
|
||||
|
||||
protected := r.Group("/api/v1")
|
||||
protected.Use(middleware.AuthRequired(secret))
|
||||
protected.Use(middleware.AuthRequired(svc))
|
||||
{
|
||||
account := protected.Group("/account")
|
||||
{
|
||||
@@ -65,7 +63,7 @@ func TestAccountEndpoint(t *testing.T) {
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
var pair service.TokenPair
|
||||
var pair testTokenPairResponse
|
||||
json.Unmarshal(rec.Body.Bytes(), &pair)
|
||||
|
||||
// Get /account
|
||||
@@ -107,7 +105,7 @@ func TestPasskeyCRUD(t *testing.T) {
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
var pair service.TokenPair
|
||||
var pair testTokenPairResponse
|
||||
json.Unmarshal(rec.Body.Bytes(), &pair)
|
||||
authHeader := "Bearer " + pair.AccessToken
|
||||
|
||||
@@ -134,7 +132,7 @@ func TestPasskeyCRUD(t *testing.T) {
|
||||
}
|
||||
|
||||
// Revoke passkey
|
||||
var creds []model.Credential
|
||||
var creds []passkeyResponse
|
||||
json.Unmarshal(rec.Body.Bytes(), &creds)
|
||||
if len(creds) != 1 {
|
||||
t.Fatalf("expected 1 passkey, got %d", len(creds))
|
||||
|
||||
@@ -9,17 +9,17 @@ import (
|
||||
|
||||
"github.com/dhao2001/mygo/internal/api"
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
"github.com/dhao2001/mygo/internal/repository"
|
||||
"github.com/dhao2001/mygo/internal/service"
|
||||
)
|
||||
|
||||
// AdminHandler handles admin-only endpoints for user management.
|
||||
type AdminHandler struct {
|
||||
userRepo repository.UserRepository
|
||||
adminService *service.AdminService
|
||||
}
|
||||
|
||||
// NewAdminHandler creates an AdminHandler.
|
||||
func NewAdminHandler(userRepo repository.UserRepository) *AdminHandler {
|
||||
return &AdminHandler{userRepo: userRepo}
|
||||
func NewAdminHandler(adminService *service.AdminService) *AdminHandler {
|
||||
return &AdminHandler{adminService: adminService}
|
||||
}
|
||||
|
||||
// adminUserResponse exposes all user fields, including status, for admin views.
|
||||
@@ -55,7 +55,7 @@ func toAdminResponse(u *model.User) adminUserResponse {
|
||||
func (h *AdminHandler) DeleteUser(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
if err := h.userRepo.Delete(c.Request.Context(), id); err != nil {
|
||||
if err := h.adminService.DeleteUser(c.Request.Context(), id); err != nil {
|
||||
api.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
@@ -67,7 +67,7 @@ func (h *AdminHandler) DeleteUser(c *gin.Context) {
|
||||
func (h *AdminHandler) GetUser(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
user, err := h.userRepo.FindByIDIncludeDeleted(c.Request.Context(), id)
|
||||
user, err := h.adminService.GetUser(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
api.RespondError(c, err)
|
||||
return
|
||||
@@ -93,7 +93,7 @@ func (h *AdminHandler) ListUsers(c *gin.Context) {
|
||||
limit = 200
|
||||
}
|
||||
|
||||
users, total, err := h.userRepo.ListIncludeDeleted(c.Request.Context(), offset, limit)
|
||||
users, total, err := h.adminService.ListUsers(c.Request.Context(), offset, limit)
|
||||
if err != nil {
|
||||
api.RespondError(c, err)
|
||||
return
|
||||
|
||||
@@ -42,7 +42,8 @@ func setupAdminRouter(t *testing.T) (*gin.Engine, repository.UserRepository) {
|
||||
)
|
||||
|
||||
authHandler := NewAuthHandler(authService)
|
||||
adminHandler := NewAdminHandler(userRepo)
|
||||
adminService := service.NewAdminService(userRepo)
|
||||
adminHandler := NewAdminHandler(adminService)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
@@ -54,8 +55,8 @@ func setupAdminRouter(t *testing.T) (*gin.Engine, repository.UserRepository) {
|
||||
}
|
||||
|
||||
admin := r.Group("/api/v1/admin")
|
||||
admin.Use(middleware.AuthRequired(secret))
|
||||
admin.Use(middleware.AdminRequired(userRepo))
|
||||
admin.Use(middleware.AuthRequired(authService))
|
||||
admin.Use(middleware.AdminRequired())
|
||||
{
|
||||
admin.GET("/users", adminHandler.ListUsers)
|
||||
admin.GET("/users/:id", adminHandler.GetUser)
|
||||
@@ -104,7 +105,7 @@ func loginHelper(t *testing.T, r *gin.Engine, email, password string) string {
|
||||
t.Fatalf("login %s: status = %d, body = %s", email, rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var pair service.TokenPair
|
||||
var pair testTokenPairResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &pair); err != nil {
|
||||
t.Fatalf("unmarshal login response: %v", err)
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ package handler
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/api"
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
"github.com/dhao2001/mygo/internal/service"
|
||||
)
|
||||
|
||||
@@ -35,6 +37,20 @@ type tokenRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
|
||||
type userResponse struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type tokenPairResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
// Register handles POST /api/v1/auth/register.
|
||||
func (h *AuthHandler) Register(c *gin.Context) {
|
||||
var req registerRequest
|
||||
@@ -50,7 +66,7 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, user)
|
||||
c.JSON(http.StatusCreated, toUserResponse(user))
|
||||
}
|
||||
|
||||
// Login handles POST /api/v1/auth/login.
|
||||
@@ -68,7 +84,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, pair)
|
||||
c.JSON(http.StatusOK, toTokenPairResponse(pair))
|
||||
}
|
||||
|
||||
// Refresh handles POST /api/v1/auth/refresh.
|
||||
@@ -86,7 +102,7 @@ func (h *AuthHandler) Refresh(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, pair)
|
||||
c.JSON(http.StatusOK, toTokenPairResponse(pair))
|
||||
}
|
||||
|
||||
// Logout handles POST /api/v1/auth/logout.
|
||||
@@ -105,3 +121,21 @@ func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func toUserResponse(user *model.User) userResponse {
|
||||
return userResponse{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
IsAdmin: user.IsAdmin,
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func toTokenPairResponse(pair *service.TokenPair) tokenPairResponse {
|
||||
return tokenPairResponse{
|
||||
AccessToken: pair.AccessToken,
|
||||
RefreshToken: pair.RefreshToken,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,11 @@ import (
|
||||
"github.com/dhao2001/mygo/internal/service"
|
||||
)
|
||||
|
||||
type testTokenPairResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
func setupTestAuthService(t *testing.T) (*service.AuthService, []byte) {
|
||||
t.Helper()
|
||||
|
||||
@@ -140,7 +145,7 @@ func TestLoginHandler(t *testing.T) {
|
||||
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var pair service.TokenPair
|
||||
var pair testTokenPairResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &pair); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
@@ -196,7 +201,7 @@ func TestRefreshHandler(t *testing.T) {
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
var pair service.TokenPair
|
||||
var pair testTokenPairResponse
|
||||
json.Unmarshal(rec.Body.Bytes(), &pair)
|
||||
|
||||
// Refresh
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -52,6 +53,24 @@ type updateFileRequest struct {
|
||||
ParentID *string `json:"parent_id"`
|
||||
}
|
||||
|
||||
type fileInfoResponse struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
ParentID *string `json:"parent_id"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
MimeType string `json:"mime_type"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
Hash string `json:"hash,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type fileListResponse struct {
|
||||
Files []fileInfoResponse `json:"files"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
// Upload handles POST /api/v1/files.
|
||||
// If the content type is multipart/form-data, it uploads a file.
|
||||
// If the content type is application/json, it creates a directory.
|
||||
@@ -81,7 +100,7 @@ func (h *FileHandler) Upload(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, dir)
|
||||
c.JSON(http.StatusCreated, toFileInfoResponse(dir))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -130,7 +149,7 @@ func (h *FileHandler) Upload(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, info)
|
||||
c.JSON(http.StatusCreated, toFileInfoResponse(info))
|
||||
}
|
||||
|
||||
func nextUploadFilePart(reader *multipart.Reader) (*multipart.Part, string, error) {
|
||||
@@ -217,7 +236,7 @@ func (h *FileHandler) List(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, list)
|
||||
c.JSON(http.StatusOK, toFileListResponse(list))
|
||||
}
|
||||
|
||||
// Get handles GET /api/v1/files/:id.
|
||||
@@ -231,7 +250,7 @@ func (h *FileHandler) Get(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, info)
|
||||
c.JSON(http.StatusOK, toFileInfoResponse(info))
|
||||
}
|
||||
|
||||
// Download handles GET /api/v1/files/:id/content.
|
||||
@@ -299,7 +318,7 @@ func (h *FileHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, info)
|
||||
c.JSON(http.StatusOK, toFileInfoResponse(info))
|
||||
}
|
||||
|
||||
// Delete handles DELETE /api/v1/files/:id.
|
||||
@@ -314,3 +333,29 @@ func (h *FileHandler) Delete(c *gin.Context) {
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func toFileInfoResponse(info *service.FileInfo) fileInfoResponse {
|
||||
return fileInfoResponse{
|
||||
ID: info.ID,
|
||||
UserID: info.UserID,
|
||||
ParentID: info.ParentID,
|
||||
Name: info.Name,
|
||||
Size: info.Size,
|
||||
MimeType: info.MimeType,
|
||||
IsDir: info.IsDir,
|
||||
Hash: info.Hash,
|
||||
CreatedAt: info.CreatedAt,
|
||||
UpdatedAt: info.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func toFileListResponse(list *service.FileList) fileListResponse {
|
||||
items := make([]fileInfoResponse, 0, len(list.Files))
|
||||
for i := range list.Files {
|
||||
items = append(items, toFileInfoResponse(&list.Files[i]))
|
||||
}
|
||||
return fileListResponse{
|
||||
Files: items,
|
||||
Total: list.Total,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ func TestFileHandler_Upload(t *testing.T) {
|
||||
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusCreated, rec.Body.String())
|
||||
}
|
||||
|
||||
var info service.FileInfo
|
||||
var info fileInfoResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
@@ -318,7 +318,7 @@ func TestFileHandler_CreateDir(t *testing.T) {
|
||||
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusCreated, rec.Body.String())
|
||||
}
|
||||
|
||||
var info service.FileInfo
|
||||
var info fileInfoResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
@@ -353,7 +353,7 @@ func TestFileHandler_List(t *testing.T) {
|
||||
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var list service.FileList
|
||||
var list fileListResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
@@ -378,7 +378,7 @@ func TestFileHandler_Get(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
var uploaded service.FileInfo
|
||||
var uploaded fileInfoResponse
|
||||
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
||||
|
||||
// Get metadata.
|
||||
@@ -391,7 +391,7 @@ func TestFileHandler_Get(t *testing.T) {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var info service.FileInfo
|
||||
var info fileInfoResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
@@ -430,7 +430,7 @@ func TestFileHandler_Download(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
var uploaded service.FileInfo
|
||||
var uploaded fileInfoResponse
|
||||
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
||||
|
||||
// Download.
|
||||
@@ -466,7 +466,7 @@ func TestFileHandler_DownloadReadErrorAfterResponseStarted(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
var uploaded service.FileInfo
|
||||
var uploaded fileInfoResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &uploaded); err != nil {
|
||||
t.Fatalf("unmarshal upload: %v", err)
|
||||
}
|
||||
@@ -502,7 +502,7 @@ func TestFileHandler_Update(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
var uploaded service.FileInfo
|
||||
var uploaded fileInfoResponse
|
||||
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
||||
|
||||
// Rename.
|
||||
@@ -517,7 +517,7 @@ func TestFileHandler_Update(t *testing.T) {
|
||||
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var updated service.FileInfo
|
||||
var updated fileInfoResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &updated); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
@@ -542,7 +542,7 @@ func TestFileHandler_UpdateNoFields(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
var uploaded service.FileInfo
|
||||
var uploaded fileInfoResponse
|
||||
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
||||
|
||||
updateBody, _ := json.Marshal(gin.H{})
|
||||
@@ -573,7 +573,7 @@ func TestFileHandler_Delete(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
var uploaded service.FileInfo
|
||||
var uploaded fileInfoResponse
|
||||
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
||||
|
||||
// Delete.
|
||||
@@ -613,7 +613,7 @@ func TestFileHandler_ForbiddenAccess(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
var uploaded service.FileInfo
|
||||
var uploaded fileInfoResponse
|
||||
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
||||
|
||||
// Try to access as user2.
|
||||
|
||||
Reference in New Issue
Block a user