feat(backend): implement task creation and admin web

This commit is contained in:
QiuSW
2026-07-26 14:03:32 +08:00
parent 2b265c92fc
commit c5d3b215ff
58 changed files with 8773 additions and 83 deletions
@@ -0,0 +1,36 @@
package httpapi
import (
"net"
"net/http"
"github.com/gin-gonic/gin"
)
func loopbackAdminOnly() gin.HandlerFunc {
return func(ctx *gin.Context) {
host, _, err := net.SplitHostPort(ctx.Request.RemoteAddr)
if err != nil {
denyNonLocalAdmin(ctx)
return
}
address := net.ParseIP(host)
if address == nil || !address.IsLoopback() {
denyNonLocalAdmin(ctx)
return
}
ctx.Next()
}
}
func denyNonLocalAdmin(ctx *gin.Context) {
ctx.Header("Cache-Control", "no-store")
ctx.AbortWithStatusJSON(
http.StatusForbidden,
errorResponse(
ctx,
"ADMIN_SESSION_REQUIRED",
"admin session required",
),
)
}
@@ -0,0 +1,64 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestLoopbackAdminOnlyAllowsLoopbackAddresses(t *testing.T) {
for _, remoteAddress := range []string{
"127.0.0.1:12345",
"[::1]:12345",
} {
t.Run(remoteAddress, func(t *testing.T) {
router := gin.New()
router.Use(requestIDMiddleware(), loopbackAdminOnly())
router.GET("/tasks", func(ctx *gin.Context) {
ctx.Status(http.StatusNoContent)
})
request := httptest.NewRequest(http.MethodGet, "/tasks", nil)
request.RemoteAddr = remoteAddress
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusNoContent {
t.Fatalf("status = %d", response.Code)
}
})
}
}
func TestLoopbackAdminOnlyRejectsRemoteOrMalformedAddresses(t *testing.T) {
for _, remoteAddress := range []string{
"192.0.2.1:12345",
"not-an-address",
} {
t.Run(remoteAddress, func(t *testing.T) {
router := gin.New()
router.Use(requestIDMiddleware(), loopbackAdminOnly())
router.GET("/tasks", func(ctx *gin.Context) {
ctx.Status(http.StatusNoContent)
})
request := httptest.NewRequest(http.MethodGet, "/tasks", nil)
request.RemoteAddr = remoteAddress
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("status = %d", response.Code)
}
assertErrorCode(t, response, "ADMIN_SESSION_REQUIRED")
if response.Header().Get("Cache-Control") != "no-store" {
t.Fatalf(
"Cache-Control = %q",
response.Header().Get("Cache-Control"),
)
}
})
}
}
@@ -0,0 +1,512 @@
package httpapi
import (
"encoding/json"
"errors"
"io"
"mime"
"net/http"
"strconv"
"strings"
"time"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/usecase"
"github.com/gin-gonic/gin"
)
const (
localAdminSubject = "local-admin"
maxJSONBodyBytes = 64 << 10
maxMultipartBytes = 21 << 20
)
type AdminServices struct {
Assets *usecase.AssetService
Tasks *usecase.TaskService
}
func (s AdminServices) validate() error {
if s.Assets == nil || s.Tasks == nil {
return errors.New("admin services are required")
}
return nil
}
type adminHandlers struct {
services AdminServices
}
func registerAdminAPI(routes gin.IRoutes, services AdminServices) error {
if err := services.validate(); err != nil {
return err
}
handler := &adminHandlers{services: services}
routes.POST("/api/v1/assets", handler.uploadAsset)
routes.GET("/api/v1/assets/:id/content", handler.assetContent)
routes.POST("/api/v1/tasks", handler.createTask)
routes.GET("/api/v1/tasks", handler.listTasks)
routes.GET("/api/v1/tasks/:id", handler.taskDetail)
routes.POST("/api/v1/tasks/:id/cancel", handler.cancelTask)
return nil
}
func (h *adminHandlers) uploadAsset(ctx *gin.Context) {
if !hasMediaType(ctx, "multipart/form-data") {
writePublicError(
ctx,
http.StatusUnsupportedMediaType,
"UNSUPPORTED_MEDIA_TYPE",
"multipart/form-data is required",
false,
gin.H{},
)
return
}
ctx.Request.Body = http.MaxBytesReader(
ctx.Writer,
ctx.Request.Body,
maxMultipartBytes,
)
if err := ctx.Request.ParseMultipartForm(maxMultipartBytes); err != nil {
writePublicError(
ctx,
http.StatusRequestEntityTooLarge,
"ASSET_TOO_LARGE",
"reference image exceeds the allowed size",
false,
gin.H{},
)
return
}
if ctx.Request.MultipartForm != nil {
defer ctx.Request.MultipartForm.RemoveAll()
}
purpose := strings.TrimSpace(ctx.PostForm("purpose"))
if purpose != domain.AssetPurposeTaskReference {
writePublicError(
ctx,
http.StatusUnprocessableEntity,
"ASSET_PURPOSE_INVALID",
"asset purpose is not supported",
false,
fieldDetails("purpose", "must be TASK_REFERENCE"),
)
return
}
if strings.TrimSpace(ctx.PostForm("task_id")) != "" {
writePublicError(
ctx,
http.StatusUnprocessableEntity,
"ASSET_TASK_ID_INVALID",
"task_id must be empty for a task reference",
false,
fieldDetails("task_id", "must be empty"),
)
return
}
files := ctx.Request.MultipartForm.File["file"]
if len(files) != 1 {
writePublicError(
ctx,
http.StatusBadRequest,
"ASSET_FILE_REQUIRED",
"exactly one reference image is required",
false,
fieldDetails("file", "exactly one file is required"),
)
return
}
content, err := files[0].Open()
if err != nil {
writePublicError(
ctx,
http.StatusUnprocessableEntity,
"ASSET_IMAGE_INVALID",
"reference image is invalid",
false,
fieldDetails("file", "cannot be read"),
)
return
}
defer content.Close()
result, err := h.services.Assets.UploadTaskReference(
ctx.Request.Context(),
usecase.UploadTaskReferenceCommand{
CreatorSubject: localAdminSubject,
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
DeclaredMediaType: files[0].Header.Get("Content-Type"),
Content: content,
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.JSON(http.StatusCreated, assetResponse(result.Asset))
}
func (h *adminHandlers) assetContent(ctx *gin.Context) {
result, err := h.services.Assets.OpenTaskReference(
ctx.Request.Context(),
localAdminSubject,
ctx.Param("id"),
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
defer result.Content.Close()
ctx.Header("Cache-Control", "private, no-store")
ctx.Header("Content-Type", result.Asset.MediaType)
ctx.Header("Content-Length", strconv.FormatInt(result.Asset.SizeBytes, 10))
ctx.Header("ETag", `"`+result.Asset.SHA256+`"`)
ctx.Header("X-Content-Type-Options", "nosniff")
ctx.Header(
"Content-Disposition",
`inline; filename="`+result.Asset.ID+`.jpg"`,
)
ctx.Status(http.StatusOK)
_, _ = io.Copy(ctx.Writer, result.Content)
}
func (h *adminHandlers) createTask(ctx *gin.Context) {
if !hasMediaType(ctx, "application/json") {
writePublicError(
ctx,
http.StatusUnsupportedMediaType,
"UNSUPPORTED_MEDIA_TYPE",
"application/json is required",
false,
gin.H{},
)
return
}
var request struct {
SourceRef *string `json:"source_ref"`
Title string `json:"title"`
Description string `json:"description"`
SKU string `json:"sku"`
ImageAssetID string `json:"image_asset_id"`
Quantity int `json:"quantity"`
MaxBudget *string `json:"max_budget"`
}
if err := decodeJSON(ctx, &request); err != nil {
writePublicError(
ctx,
http.StatusBadRequest,
"INVALID_JSON",
"request body must be valid JSON",
false,
gin.H{},
)
return
}
result, err := h.services.Tasks.Create(
ctx.Request.Context(),
usecase.CreateTaskCommand{
CreatorSubject: localAdminSubject,
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
SourceRef: request.SourceRef,
Title: request.Title,
Description: request.Description,
SKU: request.SKU,
ImageAssetID: request.ImageAssetID,
Quantity: request.Quantity,
MaxBudget: request.MaxBudget,
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.JSON(http.StatusCreated, taskSummaryResponse(result.Task))
}
func (h *adminHandlers) listTasks(ctx *gin.Context) {
query := usecase.ListTasksQuery{
CreatorSubject: localAdminSubject,
Query: ctx.Query("q"),
Cursor: ctx.Query("cursor"),
}
if value := strings.TrimSpace(ctx.Query("status")); value != "" {
query.Status = &value
}
if value := strings.TrimSpace(ctx.Query("created_from")); value != "" {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
writeFilterError(ctx, "created_from", "must be RFC3339")
return
}
query.CreatedFrom = &parsed
}
if value := strings.TrimSpace(ctx.Query("created_to")); value != "" {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
writeFilterError(ctx, "created_to", "must be RFC3339")
return
}
query.CreatedTo = &parsed
}
if value := strings.TrimSpace(ctx.Query("limit")); value != "" {
parsed, err := strconv.Atoi(value)
if err != nil {
writeFilterError(ctx, "limit", "must be an integer")
return
}
query.Limit = parsed
}
page, err := h.services.Tasks.List(ctx.Request.Context(), query)
if err != nil {
writeUsecaseError(ctx, err)
return
}
items := make([]gin.H, 0, len(page.Items))
for _, task := range page.Items {
items = append(items, taskListItemResponse(task))
}
var nextCursor any
if page.NextCursor != "" {
nextCursor = page.NextCursor
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{
"items": items,
"next_cursor": nextCursor,
})
}
func (h *adminHandlers) taskDetail(ctx *gin.Context) {
detail, err := h.services.Tasks.Get(
ctx.Request.Context(),
localAdminSubject,
ctx.Param("id"),
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
events := make([]gin.H, 0, len(detail.Events))
for _, event := range detail.Events {
events = append(events, gin.H{
"id": event.ID,
"type": event.Type,
"message": event.Message,
"occurred_at": formatTime(event.OccurredAt),
})
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{
"id": detail.Task.ID,
"status": detail.Task.Status,
"version": detail.Task.Version,
"created_at": formatTime(detail.Task.CreatedAt),
"updated_at": formatTime(detail.Task.UpdatedAt),
"original_requirement": gin.H{
"source_ref": detail.Task.SourceRef,
"title": detail.Task.Title,
"description": detail.Task.Description,
"sku": detail.Task.SKU,
"image_asset_id": detail.Task.ImageAssetID,
"quantity": detail.Task.Quantity,
"max_budget": domain.FormatOptionalCNY(detail.Task.MaxBudgetCents),
"currency": detail.Task.Currency,
},
"derived_requirement": nil,
"claim": nil,
"execution": nil,
"events": events,
"assets": []gin.H{
assetResponse(detail.Asset),
},
})
}
func (h *adminHandlers) cancelTask(ctx *gin.Context) {
if !hasMediaType(ctx, "application/json") {
writePublicError(
ctx,
http.StatusUnsupportedMediaType,
"UNSUPPORTED_MEDIA_TYPE",
"application/json is required",
false,
gin.H{},
)
return
}
var request struct {
Reason string `json:"reason"`
}
if err := decodeJSON(ctx, &request); err != nil {
writePublicError(
ctx,
http.StatusBadRequest,
"INVALID_JSON",
"request body must be valid JSON",
false,
gin.H{},
)
return
}
task, err := h.services.Tasks.Cancel(
ctx.Request.Context(),
usecase.CancelTaskCommand{
CreatorSubject: localAdminSubject,
TaskID: ctx.Param("id"),
Reason: request.Reason,
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.JSON(http.StatusOK, taskSummaryResponse(task))
}
func decodeJSON(ctx *gin.Context, target any) error {
ctx.Request.Body = http.MaxBytesReader(
ctx.Writer,
ctx.Request.Body,
maxJSONBodyBytes,
)
decoder := json.NewDecoder(ctx.Request.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
var extra any
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
return errors.New("request must contain one JSON value")
}
return nil
}
func hasMediaType(ctx *gin.Context, expected string) bool {
mediaType, _, err := mime.ParseMediaType(ctx.GetHeader("Content-Type"))
return err == nil && strings.EqualFold(mediaType, expected)
}
func writeFilterError(ctx *gin.Context, field, message string) {
writePublicError(
ctx,
http.StatusBadRequest,
"TASK_LIST_FILTER_INVALID",
"task list filter is invalid",
false,
fieldDetails(field, message),
)
}
func writeUsecaseError(ctx *gin.Context, err error) {
var typed *usecase.Error
if !errors.As(err, &typed) {
writePublicError(
ctx,
http.StatusInternalServerError,
"INTERNAL_ERROR",
"internal server error",
false,
gin.H{},
)
return
}
status := http.StatusInternalServerError
switch typed.Kind {
case usecase.ErrorKindInvalid:
status = http.StatusUnprocessableEntity
switch typed.Code {
case "REQUEST_VALIDATION_FAILED",
"ASSET_FILE_REQUIRED",
"IDEMPOTENCY_KEY_REQUIRED":
status = http.StatusBadRequest
case "ASSET_TOO_LARGE":
status = http.StatusRequestEntityTooLarge
case "ASSET_MEDIA_TYPE_UNSUPPORTED":
status = http.StatusUnsupportedMediaType
}
case usecase.ErrorKindNotFound:
status = http.StatusNotFound
case usecase.ErrorKindConflict:
status = http.StatusConflict
case usecase.ErrorKindUnavailable:
status = http.StatusServiceUnavailable
}
details := gin.H{}
if len(typed.Fields) > 0 {
details["fields"] = typed.Fields
}
writePublicError(
ctx,
status,
typed.Code,
typed.Message,
typed.Retryable,
details,
)
}
func writePublicError(
ctx *gin.Context,
status int,
code string,
message string,
retryable bool,
details gin.H,
) {
requestID, _ := ctx.Get(requestIDContextKey)
if details == nil {
details = gin.H{}
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(status, gin.H{
"error": gin.H{
"code": code,
"message": message,
"retryable": retryable,
"details": details,
},
"request_id": requestID,
})
}
func fieldDetails(field, message string) gin.H {
return gin.H{"fields": gin.H{field: message}}
}
func assetResponse(asset domain.Asset) gin.H {
return gin.H{
"id": asset.ID,
"purpose": asset.Purpose,
"media_type": asset.MediaType,
"size_bytes": asset.SizeBytes,
"sha256": asset.SHA256,
"created_at": formatTime(asset.CreatedAt),
}
}
func taskSummaryResponse(task domain.PurchaseTask) gin.H {
return gin.H{
"id": task.ID,
"status": task.Status,
"title": task.Title,
"sku": task.SKU,
"quantity": task.Quantity,
"max_budget": domain.FormatOptionalCNY(task.MaxBudgetCents),
"created_at": formatTime(task.CreatedAt),
"updated_at": formatTime(task.UpdatedAt),
"version": task.Version,
}
}
func taskListItemResponse(task domain.PurchaseTask) gin.H {
response := taskSummaryResponse(task)
response["device_name"] = nil
return response
}
func formatTime(value time.Time) string {
return value.UTC().Format(time.RFC3339Nano)
}
@@ -0,0 +1,421 @@
package httpapi
import (
"bytes"
"context"
"encoding/json"
"image"
"image/color"
"image/jpeg"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/textproto"
"path/filepath"
"strings"
"testing"
"cmroubao/backend-api/internal/platform/assetstore"
"cmroubao/backend-api/internal/platform/database"
"cmroubao/backend-api/internal/platform/migration"
repository "cmroubao/backend-api/internal/repository/sqlite"
"cmroubao/backend-api/internal/usecase"
"github.com/gin-gonic/gin"
)
func TestAdminAPIAssetAndTaskLifecycle(t *testing.T) {
router := newAdminIntegrationRouter(t)
imageBody, imageContentType := referenceUpload(t, "asset-key-1")
assetResponse := performAdminRequest(
t,
router,
http.MethodPost,
"/api/v1/assets",
imageContentType,
imageBody,
"asset-key-1",
)
if assetResponse.Code != http.StatusCreated {
t.Fatalf(
"asset upload status = %d, body = %s",
assetResponse.Code,
assetResponse.Body.String(),
)
}
var asset map[string]any
decodeResponse(t, assetResponse, &asset)
assetID, _ := asset["id"].(string)
if assetID == "" || asset["media_type"] != "image/jpeg" {
t.Fatalf("asset response = %#v", asset)
}
if responseContainsKey(asset, "storage_key") ||
strings.Contains(strings.ToLower(assetResponse.Body.String()), "temp") {
t.Fatalf("asset response exposes storage details: %#v", asset)
}
replayBody, replayContentType := referenceUpload(t, "asset-key-1")
replayResponse := performAdminRequest(
t,
router,
http.MethodPost,
"/api/v1/assets",
replayContentType,
replayBody,
"asset-key-1",
)
var replayedAsset map[string]any
decodeResponse(t, replayResponse, &replayedAsset)
if replayResponse.Code != http.StatusCreated ||
replayedAsset["id"] != assetID {
t.Fatalf(
"asset replay status/body = %d / %#v",
replayResponse.Code,
replayedAsset,
)
}
taskJSON := `{
"source_ref":"external-10001",
"title":"黑色双肩包",
"sku":"BLACK-20L",
"description":"容量约20L",
"image_asset_id":"` + assetID + `",
"quantity":2,
"max_budget":"200.00"
}`
taskResponse := performAdminRequest(
t,
router,
http.MethodPost,
"/api/v1/tasks",
"application/json",
strings.NewReader(taskJSON),
"task-key-1",
)
if taskResponse.Code != http.StatusCreated {
t.Fatalf(
"task create status = %d, body = %s",
taskResponse.Code,
taskResponse.Body.String(),
)
}
var task map[string]any
decodeResponse(t, taskResponse, &task)
taskID, _ := task["id"].(string)
if taskID == "" || task["status"] != "PENDING" ||
task["sku"] != "BLACK-20L" ||
task["max_budget"] != "200.00" {
t.Fatalf("task response = %#v", task)
}
taskReplay := performAdminRequest(
t,
router,
http.MethodPost,
"/api/v1/tasks",
"application/json",
strings.NewReader(taskJSON),
"task-key-1",
)
var replayedTask map[string]any
decodeResponse(t, taskReplay, &replayedTask)
if taskReplay.Code != http.StatusCreated ||
replayedTask["id"] != taskID {
t.Fatalf(
"task replay status/body = %d / %#v",
taskReplay.Code,
replayedTask,
)
}
listResponse := performAdminRequest(
t,
router,
http.MethodGet,
"/api/v1/tasks?q=BLACK-20L&limit=20",
"",
nil,
"",
)
var list map[string]any
decodeResponse(t, listResponse, &list)
items, _ := list["items"].([]any)
if listResponse.Code != http.StatusOK || len(items) != 1 {
t.Fatalf(
"task list status/body = %d / %#v",
listResponse.Code,
list,
)
}
detailResponse := performAdminRequest(
t,
router,
http.MethodGet,
"/api/v1/tasks/"+taskID,
"",
nil,
"",
)
var detail map[string]any
decodeResponse(t, detailResponse, &detail)
requirement, _ := detail["original_requirement"].(map[string]any)
if detailResponse.Code != http.StatusOK ||
requirement["sku"] != "BLACK-20L" ||
requirement["quantity"] != float64(2) {
t.Fatalf(
"task detail status/body = %d / %#v",
detailResponse.Code,
detail,
)
}
contentResponse := performAdminRequest(
t,
router,
http.MethodGet,
"/api/v1/assets/"+assetID+"/content",
"",
nil,
"",
)
if contentResponse.Code != http.StatusOK ||
contentResponse.Header().Get("Content-Type") != "image/jpeg" ||
!bytes.HasPrefix(contentResponse.Body.Bytes(), []byte{0xff, 0xd8}) {
t.Fatalf(
"asset content status/headers = %d / %#v",
contentResponse.Code,
contentResponse.Header(),
)
}
cancelResponse := performAdminRequest(
t,
router,
http.MethodPost,
"/api/v1/tasks/"+taskID+"/cancel",
"application/json",
strings.NewReader(`{"reason":"需求已撤销"}`),
"",
)
var canceled map[string]any
decodeResponse(t, cancelResponse, &canceled)
if cancelResponse.Code != http.StatusOK ||
canceled["status"] != "CANCELED" {
t.Fatalf(
"task cancel status/body = %d / %#v",
cancelResponse.Code,
canceled,
)
}
secondCancel := performAdminRequest(
t,
router,
http.MethodPost,
"/api/v1/tasks/"+taskID+"/cancel",
"application/json",
strings.NewReader(`{"reason":"再次取消"}`),
"",
)
if secondCancel.Code != http.StatusConflict {
t.Fatalf(
"second cancel status = %d, body = %s",
secondCancel.Code,
secondCancel.Body.String(),
)
}
}
func TestAdminRoutesRejectNonLoopbackRequests(t *testing.T) {
router := newAdminIntegrationRouter(t)
request := httptest.NewRequest(http.MethodGet, "/api/v1/tasks", nil)
request.RemoteAddr = "192.0.2.10:3210"
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
var body map[string]any
decodeResponse(t, response, &body)
publicError, _ := body["error"].(map[string]any)
if publicError["code"] != "ADMIN_SESSION_REQUIRED" {
t.Fatalf("error response = %#v", body)
}
}
func TestAdminAssetUploadRequiresIdempotencyKey(t *testing.T) {
router := newAdminIntegrationRouter(t)
imageBody, imageContentType := referenceUpload(t, "missing-key")
response := performAdminRequest(
t,
router,
http.MethodPost,
"/api/v1/assets",
imageContentType,
imageBody,
"",
)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
var body map[string]any
decodeResponse(t, response, &body)
publicError, _ := body["error"].(map[string]any)
if publicError["code"] != "IDEMPOTENCY_KEY_REQUIRED" {
t.Fatalf("error response = %#v", body)
}
}
type emptyAdminWeb struct{}
func (emptyAdminWeb) Register(gin.IRoutes) {}
func newAdminIntegrationRouter(t *testing.T) http.Handler {
t.Helper()
ctx := context.Background()
db, err := database.Open(ctx, filepath.Join(t.TempDir(), "admin.db"))
if err != nil {
t.Fatalf("database.Open() error = %v", err)
}
t.Cleanup(func() { _ = db.Close() })
runner, err := migration.New(db)
if err != nil {
t.Fatalf("migration.New() error = %v", err)
}
if _, err := runner.Up(ctx); err != nil {
t.Fatalf("migration.Up() error = %v", err)
}
repositories, err := repository.New(db)
if err != nil {
t.Fatalf("repository.New() error = %v", err)
}
files, err := assetstore.New(filepath.Join(t.TempDir(), "assets"))
if err != nil {
t.Fatalf("assetstore.New() error = %v", err)
}
clock := usecase.SystemClock{}
ids := usecase.UUIDGenerator{}
assets, err := usecase.NewAssetService(repositories, files, clock, ids)
if err != nil {
t.Fatalf("usecase.NewAssetService() error = %v", err)
}
tasks, err := usecase.NewTaskService(repositories, clock, ids)
if err != nil {
t.Fatalf("usecase.NewTaskService() error = %v", err)
}
registrar, err := NewAdminRouteRegistrar(
AdminServices{Assets: assets, Tasks: tasks},
emptyAdminWeb{},
)
if err != nil {
t.Fatalf("NewAdminRouteRegistrar() error = %v", err)
}
router, err := NewRouter(RouterDependencies{
Database: db,
RegisterAdminRoutes: registrar,
LogEvent: discardEvent,
})
if err != nil {
t.Fatalf("NewRouter() error = %v", err)
}
return router
}
func referenceUpload(t *testing.T, key string) (io.Reader, string) {
t.Helper()
var imageBytes bytes.Buffer
source := image.NewRGBA(image.Rect(0, 0, 8, 6))
for y := 0; y < 6; y++ {
for x := 0; x < 8; x++ {
source.Set(x, y, color.RGBA{R: uint8(x * 20), G: 80, B: 160, A: 255})
}
}
if err := jpeg.Encode(&imageBytes, source, &jpeg.Options{Quality: 85}); err != nil {
t.Fatalf("jpeg.Encode() error = %v", err)
}
var body bytes.Buffer
writer := multipart.NewWriter(&body)
if err := writer.WriteField("purpose", "TASK_REFERENCE"); err != nil {
t.Fatalf("WriteField(purpose) error = %v", err)
}
if err := writer.WriteField("task_id", ""); err != nil {
t.Fatalf("WriteField(task_id) error = %v", err)
}
header := make(textproto.MIMEHeader)
header.Set("Content-Disposition", `form-data; name="file"; filename="`+key+`.jpg"`)
header.Set("Content-Type", "image/jpeg")
part, err := writer.CreatePart(header)
if err != nil {
t.Fatalf("CreatePart() error = %v", err)
}
if _, err := part.Write(imageBytes.Bytes()); err != nil {
t.Fatalf("part.Write() error = %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("multipart.Close() error = %v", err)
}
return bytes.NewReader(body.Bytes()), writer.FormDataContentType()
}
func performAdminRequest(
t *testing.T,
router http.Handler,
method string,
target string,
contentType string,
body io.Reader,
idempotencyKey string,
) *httptest.ResponseRecorder {
t.Helper()
request := httptest.NewRequest(method, target, body)
request.RemoteAddr = "127.0.0.1:3210"
if contentType != "" {
request.Header.Set("Content-Type", contentType)
}
if idempotencyKey != "" {
request.Header.Set("Idempotency-Key", idempotencyKey)
}
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
func decodeResponse(
t *testing.T,
response *httptest.ResponseRecorder,
target any,
) {
t.Helper()
if err := json.Unmarshal(response.Body.Bytes(), target); err != nil {
t.Fatalf(
"json.Unmarshal() error = %v, body = %s",
err,
response.Body.String(),
)
}
}
func responseContainsKey(value any, key string) bool {
switch typed := value.(type) {
case map[string]any:
for candidate, child := range typed {
if candidate == key || responseContainsKey(child, key) {
return true
}
}
case []any:
for _, child := range typed {
if responseContainsKey(child, key) {
return true
}
}
}
return false
}
@@ -19,26 +19,62 @@ type DatabasePinger interface {
type EventLogger func(string)
func NewRouter(
database DatabasePinger,
logEvent EventLogger,
) (http.Handler, error) {
if database == nil {
type RouteRegistrar func(gin.IRoutes) error
type RouterDependencies struct {
Database DatabasePinger
RegisterAdminRoutes RouteRegistrar
LogEvent EventLogger
}
type AdminWeb interface {
Register(gin.IRoutes)
}
func NewAdminRouteRegistrar(
services AdminServices,
web AdminWeb,
) (RouteRegistrar, error) {
if err := services.validate(); err != nil {
return nil, err
}
if web == nil {
return nil, errors.New("admin web handler is required")
}
return func(routes gin.IRoutes) error {
if err := registerAdminAPI(routes, services); err != nil {
return err
}
web.Register(routes)
return nil
}, nil
}
func NewRouter(dependencies RouterDependencies) (http.Handler, error) {
if dependencies.Database == nil {
return nil, errors.New("database pinger is required")
}
if logEvent == nil {
if dependencies.RegisterAdminRoutes == nil {
return nil, errors.New("admin route registrar is required")
}
if dependencies.LogEvent == nil {
return nil, errors.New("event logger is required")
}
gin.SetMode(gin.ReleaseMode)
router := gin.New()
router.Use(requestIDMiddleware())
router.Use(safeRecovery(logEvent))
router.Use(safeRecovery(dependencies.LogEvent))
router.HandleMethodNotAllowed = true
if err := router.SetTrustedProxies(nil); err != nil {
return nil, err
}
router.GET("/healthz", healthHandler(database))
router.GET("/healthz", healthHandler(dependencies.Database))
adminRoutes := router.Group("")
adminRoutes.Use(loopbackAdminOnly())
if err := dependencies.RegisterAdminRoutes(adminRoutes); err != nil {
return nil, err
}
router.NoRoute(func(ctx *gin.Context) {
ctx.JSON(http.StatusNotFound, errorResponse(
ctx,
@@ -9,6 +9,8 @@ import (
"regexp"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
type fakePinger struct {
@@ -20,7 +22,7 @@ func (p fakePinger) PingContext(context.Context) error {
}
func TestHealthzReturnsStableHealthyResponse(t *testing.T) {
router, err := NewRouter(fakePinger{}, discardEvent)
router, err := newTestRouter(fakePinger{}, discardEvent)
if err != nil {
t.Fatalf("NewRouter() error = %v", err)
}
@@ -40,7 +42,7 @@ func TestHealthzReturnsStableHealthyResponse(t *testing.T) {
}
func TestHealthzReturns503WithoutLeakingDatabaseError(t *testing.T) {
router, err := NewRouter(fakePinger{
router, err := newTestRouter(fakePinger{
err: errors.New("private database path and driver details"),
}, discardEvent)
if err != nil {
@@ -59,7 +61,7 @@ func TestHealthzReturns503WithoutLeakingDatabaseError(t *testing.T) {
}
func TestUnknownRouteAndMethodUseStableErrors(t *testing.T) {
router, err := NewRouter(fakePinger{}, discardEvent)
router, err := newTestRouter(fakePinger{}, discardEvent)
if err != nil {
t.Fatalf("NewRouter() error = %v", err)
}
@@ -79,7 +81,7 @@ func TestUnknownRouteAndMethodUseStableErrors(t *testing.T) {
func TestSafeRecoveryReturnsStableErrorWithoutLoggingRequestHeaders(t *testing.T) {
var events []string
router, err := NewRouter(
router, err := newTestRouter(
panicPinger{},
func(event string) {
events = append(events, event)
@@ -111,14 +113,39 @@ func TestSafeRecoveryReturnsStableErrorWithoutLoggingRequestHeaders(t *testing.T
}
func TestRouterRequiresDependencies(t *testing.T) {
if _, err := NewRouter(nil, discardEvent); err == nil {
valid := RouterDependencies{
Database: fakePinger{},
RegisterAdminRoutes: discardRoutes,
LogEvent: discardEvent,
}
missingDatabase := valid
missingDatabase.Database = nil
if _, err := NewRouter(missingDatabase); err == nil {
t.Fatal("NewRouter(nil database) error = nil")
}
if _, err := NewRouter(fakePinger{}, nil); err == nil {
missingRoutes := valid
missingRoutes.RegisterAdminRoutes = nil
if _, err := NewRouter(missingRoutes); err == nil {
t.Fatal("NewRouter(nil routes) error = nil")
}
missingLogger := valid
missingLogger.LogEvent = nil
if _, err := NewRouter(missingLogger); err == nil {
t.Fatal("NewRouter(nil logger) error = nil")
}
}
func newTestRouter(
database DatabasePinger,
logEvent EventLogger,
) (http.Handler, error) {
return NewRouter(RouterDependencies{
Database: database,
RegisterAdminRoutes: discardRoutes,
LogEvent: logEvent,
})
}
func performRequest(
t *testing.T,
handler http.Handler,
@@ -194,6 +221,8 @@ func (panicPinger) PingContext(context.Context) error {
func discardEvent(string) {}
func discardRoutes(gin.IRoutes) error { return nil }
var requestIDPattern = regexp.MustCompile(
`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`,
)