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:
2026-07-05 23:30:17 +08:00
parent 28e17a5b08
commit 63ede5c237
34 changed files with 1028 additions and 438 deletions
+42 -3
View File
@@ -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
}
+4 -6
View File
@@ -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))
+7 -7
View File
@@ -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
+5 -4
View File
@@ -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)
}
+37 -3
View File
@@ -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,
}
}
+7 -2
View File
@@ -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
+50 -5
View File
@@ -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,
}
}
+12 -12
View File
@@ -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.