feat: complete API-driven projects with infrastructure and business acceptance
This commit is contained in:
13
examples/userService/internal/handler/openapiGen.go
Normal file
13
examples/userService/internal/handler/openapiGen.go
Normal file
File diff suppressed because one or more lines are too long
27
examples/userService/internal/handler/routesGen.go
Normal file
27
examples/userService/internal/handler/routesGen.go
Normal file
@@ -0,0 +1,27 @@
|
||||
// Code generated by go-omni api. DO NOT EDIT.
|
||||
package handler
|
||||
|
||||
import (
|
||||
h0 "example.com/userService/internal/handler/user"
|
||||
s0 "example.com/userService/internal/service/user"
|
||||
"example.com/userService/internal/servicecontext"
|
||||
"fmt"
|
||||
"git.yuntuo.hk/k2safe/go-omni/openapi"
|
||||
)
|
||||
|
||||
func RegisterRoutes(router openapi.Registrar, deps *servicecontext.Context) error {
|
||||
if router == nil || deps == nil {
|
||||
return fmt.Errorf("router and dependencies required")
|
||||
}
|
||||
if deps.Middlewares["Auth"] == nil {
|
||||
return fmt.Errorf("missing middleware Auth")
|
||||
}
|
||||
h0 := h0.New(s0.New(deps))
|
||||
router.Handle("POST", "/api/v1/users", h0.CreateUser, deps.Middlewares["Auth"])
|
||||
router.Handle("GET", "/api/v1/users/:id", h0.GetUser, deps.Middlewares["Auth"])
|
||||
router.Handle("GET", "/api/v1/users", h0.ListUsers, deps.Middlewares["Auth"])
|
||||
router.Handle("PUT", "/api/v1/users/:id", h0.UpdateUser, deps.Middlewares["Auth"])
|
||||
router.Handle("DELETE", "/api/v1/users/:id", h0.DeleteUser, deps.Middlewares["Auth"])
|
||||
router.Handle("POST", "/api/v1/users/:id/export", h0.ExportUser, deps.Middlewares["Auth"])
|
||||
return nil
|
||||
}
|
||||
120
examples/userService/internal/handler/user/handlersGen.go
Normal file
120
examples/userService/internal/handler/user/handlersGen.go
Normal file
@@ -0,0 +1,120 @@
|
||||
// Code generated by go-omni api. DO NOT EDIT.
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"example.com/userService/internal/types"
|
||||
"fmt"
|
||||
"git.yuntuo.hk/k2safe/go-omni/contract"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
CreateUser(ctx context.Context, req *types.CreateUserReq) (*types.UserResp, error)
|
||||
GetUser(ctx context.Context, req *types.UserIDReq) (*types.UserResp, error)
|
||||
ListUsers(ctx context.Context, req *types.ListUsersReq) (*types.UserPageResp, error)
|
||||
UpdateUser(ctx context.Context, req *types.UpdateUserReq) (*types.UserResp, error)
|
||||
DeleteUser(ctx context.Context, req *types.UserIDReq) error
|
||||
ExportUser(ctx context.Context, req *types.UserIDReq) (*types.ExportResp, error)
|
||||
}
|
||||
type Handler struct{ service Service }
|
||||
|
||||
func New(s Service) *Handler { return &Handler{service: s} }
|
||||
|
||||
var createUserRequest = contract.MustNew[types.CreateUserReq]()
|
||||
|
||||
func (h *Handler) CreateUser(c *httpx.Context) error {
|
||||
var req types.CreateUserReq
|
||||
if err := createUserRequest.Bind(c.Request(), httpx.RouteParams(c.Context()), &req); err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := h.service.CreateUser(c.Context(), &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp == nil {
|
||||
return fmt.Errorf("CreateUser returned a nil response")
|
||||
}
|
||||
return c.Created(resp)
|
||||
}
|
||||
|
||||
var getUserRequest = contract.MustNew[types.UserIDReq]()
|
||||
|
||||
func (h *Handler) GetUser(c *httpx.Context) error {
|
||||
var req types.UserIDReq
|
||||
if err := getUserRequest.Bind(c.Request(), httpx.RouteParams(c.Context()), &req); err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := h.service.GetUser(c.Context(), &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp == nil {
|
||||
return fmt.Errorf("GetUser returned a nil response")
|
||||
}
|
||||
return c.OK(resp)
|
||||
}
|
||||
|
||||
var listUsersRequest = contract.MustNew[types.ListUsersReq]()
|
||||
|
||||
func (h *Handler) ListUsers(c *httpx.Context) error {
|
||||
var req types.ListUsersReq
|
||||
if err := listUsersRequest.Bind(c.Request(), httpx.RouteParams(c.Context()), &req); err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := h.service.ListUsers(c.Context(), &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp == nil {
|
||||
return fmt.Errorf("ListUsers returned a nil response")
|
||||
}
|
||||
return c.OK(resp)
|
||||
}
|
||||
|
||||
var updateUserRequest = contract.MustNew[types.UpdateUserReq]()
|
||||
|
||||
func (h *Handler) UpdateUser(c *httpx.Context) error {
|
||||
var req types.UpdateUserReq
|
||||
if err := updateUserRequest.Bind(c.Request(), httpx.RouteParams(c.Context()), &req); err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := h.service.UpdateUser(c.Context(), &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp == nil {
|
||||
return fmt.Errorf("UpdateUser returned a nil response")
|
||||
}
|
||||
return c.OK(resp)
|
||||
}
|
||||
|
||||
var deleteUserRequest = contract.MustNew[types.UserIDReq]()
|
||||
|
||||
func (h *Handler) DeleteUser(c *httpx.Context) error {
|
||||
var req types.UserIDReq
|
||||
if err := deleteUserRequest.Bind(c.Request(), httpx.RouteParams(c.Context()), &req); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.service.DeleteUser(c.Context(), &req); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.NoContent()
|
||||
}
|
||||
|
||||
var exportUserRequest = contract.MustNew[types.UserIDReq]()
|
||||
|
||||
func (h *Handler) ExportUser(c *httpx.Context) error {
|
||||
var req types.UserIDReq
|
||||
if err := exportUserRequest.Bind(c.Request(), httpx.RouteParams(c.Context()), &req); err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := h.service.ExportUser(c.Context(), &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp == nil {
|
||||
return fmt.Errorf("ExportUser returned a nil response")
|
||||
}
|
||||
return c.OK(resp)
|
||||
}
|
||||
40
examples/userService/internal/middleware/auth.go
Normal file
40
examples/userService/internal/middleware/auth.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Auth is the fail-closed registry default. Startup replaces it with configured
|
||||
// authentication. This administrative example uses one environment-only key;
|
||||
// production applications can inject JWT/OIDC through the same middleware slot.
|
||||
func Auth(next httpx.Handler) httpx.Handler {
|
||||
return func(c *httpx.Context) error {
|
||||
return apierrors.Unauthorized("UNAUTHENTICATED", "authentication required")
|
||||
}
|
||||
}
|
||||
func NewAuthFromEnv() (httpx.Middleware, error) {
|
||||
token := os.Getenv("USER_SERVICE_API_TOKEN")
|
||||
if len(token) < 32 || strings.ContainsAny(token, " \t\r\n") {
|
||||
return nil, errors.New("USER_SERVICE_API_TOKEN requires at least 32 bytes without whitespace")
|
||||
}
|
||||
expected := sha256.Sum256([]byte(token))
|
||||
return func(next httpx.Handler) httpx.Handler {
|
||||
return func(c *httpx.Context) error {
|
||||
parts := strings.Fields(c.Header("Authorization"))
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||
return apierrors.Unauthorized("UNAUTHENTICATED", "authentication required")
|
||||
}
|
||||
actual := sha256.Sum256([]byte(parts[1]))
|
||||
if subtle.ConstantTimeCompare(expected[:], actual[:]) != 1 {
|
||||
return apierrors.Unauthorized("UNAUTHENTICATED", "authentication required")
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
35
examples/userService/internal/middleware/auth_test.go
Normal file
35
examples/userService/internal/middleware/auth_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuthFailsClosedAndDoesNotLeakKey(t *testing.T) {
|
||||
t.Setenv("USER_SERVICE_API_TOKEN", "")
|
||||
if _, err := NewAuthFromEnv(); err == nil {
|
||||
t.Fatal("missing credential accepted")
|
||||
}
|
||||
token := "test-key-012345678901234567890123456789"
|
||||
t.Setenv("USER_SERVICE_API_TOKEN", token)
|
||||
middleware, err := NewAuthFromEnv()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, header := range []string{"", "Basic " + token, "Bearer wrong", "Bearer " + token} {
|
||||
called := false
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
r.Header.Set("Authorization", header)
|
||||
c := httpx.NewContext(r, httptest.NewRecorder(), nil)
|
||||
err = middleware(func(*httpx.Context) error { called = true; return nil })(c)
|
||||
if header == "Bearer "+token {
|
||||
if err != nil || !called {
|
||||
t.Fatal("valid token rejected")
|
||||
}
|
||||
} else if err == nil || called || apierrors.From(err).Status != 401 {
|
||||
t.Fatal("unauthenticated request reached service")
|
||||
}
|
||||
}
|
||||
}
|
||||
10
examples/userService/internal/middleware/registryGen.go
Normal file
10
examples/userService/internal/middleware/registryGen.go
Normal file
@@ -0,0 +1,10 @@
|
||||
// Code generated by go-omni api. DO NOT EDIT.
|
||||
package middleware
|
||||
|
||||
import "git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
|
||||
func Defaults() map[string]httpx.Middleware {
|
||||
return map[string]httpx.Middleware{
|
||||
"Auth": Auth,
|
||||
}
|
||||
}
|
||||
11
examples/userService/internal/model/errors.go
Normal file
11
examples/userService/internal/model/errors.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package model
|
||||
|
||||
import "errors"
|
||||
|
||||
// Domain errors are translated into HTTP responses only by the handler layer.
|
||||
var (
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrEmailTaken = errors.New("email already in use")
|
||||
ErrInvalidUser = errors.New("invalid user")
|
||||
ErrInvalidPage = errors.New("invalid pagination")
|
||||
)
|
||||
25
examples/userService/internal/model/user.go
Normal file
25
examples/userService/internal/model/user.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// User is the business entity; it has no dependency on HTTP or a database driver.
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// UserInput is shared by creation and full replacement (PUT).
|
||||
type UserInput struct {
|
||||
Name string
|
||||
Email string
|
||||
}
|
||||
|
||||
type UserPage struct {
|
||||
Items []User `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
143
examples/userService/internal/repository/user/mysql.go
Normal file
143
examples/userService/internal/repository/user/mysql.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"example.com/userService/internal/model"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
// SQLExecutor is implemented by both *sql.DB and *sql.Tx. A repository created
|
||||
// with a transaction keeps all of its queries on that same transaction.
|
||||
type SQLExecutor interface {
|
||||
ExecContext(context.Context, string, ...any) (sql.Result, error)
|
||||
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
|
||||
QueryRowContext(context.Context, string, ...any) *sql.Row
|
||||
}
|
||||
|
||||
// MySQLUsers is the persistent implementation of UserRepository.
|
||||
// The schema is documented in schema.sql; migrations stay outside the framework.
|
||||
type MySQLUsers struct {
|
||||
db SQLExecutor
|
||||
}
|
||||
|
||||
func NewMySQLUsers(db SQLExecutor) *MySQLUsers {
|
||||
return &MySQLUsers{db: db}
|
||||
}
|
||||
|
||||
// CheckSchema validates that the application's migration has been applied.
|
||||
// Runtime startup never creates or alters tables.
|
||||
func (r *MySQLUsers) CheckSchema(ctx context.Context) error {
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT id, name, email, created_at, updated_at FROM users LIMIT 0`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rows.Close()
|
||||
}
|
||||
|
||||
func (r *MySQLUsers) Create(ctx context.Context, input model.UserInput) (model.User, error) {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO users (name, email, created_at, updated_at)
|
||||
VALUES (?, ?, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6))`, input.Name, input.Email)
|
||||
if err != nil {
|
||||
return model.User{}, mapMySQLError(err)
|
||||
}
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
return r.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (r *MySQLUsers) Get(ctx context.Context, id int64) (model.User, error) {
|
||||
var user model.User
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, name, email, created_at, updated_at
|
||||
FROM users WHERE id = ?`, id).Scan(
|
||||
&user.ID, &user.Name, &user.Email, &user.CreatedAt, &user.UpdatedAt,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return model.User{}, model.ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *MySQLUsers) List(ctx context.Context, page, pageSize int) (model.UserPage, error) {
|
||||
if page < 1 || pageSize < 1 || pageSize > 100 {
|
||||
return model.UserPage{}, model.ErrInvalidPage
|
||||
}
|
||||
var total int
|
||||
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&total); err != nil {
|
||||
return model.UserPage{}, err
|
||||
}
|
||||
result := model.UserPage{Items: []model.User{}, Total: total, Page: page, PageSize: pageSize}
|
||||
if total == 0 || page-1 > (total-1)/pageSize {
|
||||
return result, nil
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, name, email, created_at, updated_at
|
||||
FROM users ORDER BY id LIMIT ? OFFSET ?`, pageSize, offset)
|
||||
if err != nil {
|
||||
return model.UserPage{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var user model.User
|
||||
if err := rows.Scan(&user.ID, &user.Name, &user.Email, &user.CreatedAt, &user.UpdatedAt); err != nil {
|
||||
return model.UserPage{}, err
|
||||
}
|
||||
result.Items = append(result.Items, user)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return model.UserPage{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *MySQLUsers) Update(ctx context.Context, id int64, input model.UserInput) (model.User, error) {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE users SET name = ?, email = ?, updated_at = UTC_TIMESTAMP(6)
|
||||
WHERE id = ?`, input.Name, input.Email, id)
|
||||
if err != nil {
|
||||
return model.User{}, mapMySQLError(err)
|
||||
}
|
||||
changed, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
if changed == 0 {
|
||||
// MySQL may report zero affected rows when the replacement is identical;
|
||||
// distinguish that from an ID that does not exist.
|
||||
return r.Get(ctx, id)
|
||||
}
|
||||
return r.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (r *MySQLUsers) Delete(ctx context.Context, id int64) error {
|
||||
result, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deleted, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if deleted == 0 {
|
||||
return model.ErrUserNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mapMySQLError(err error) error {
|
||||
var mysqlErr *mysql.MySQLError
|
||||
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
|
||||
return model.ErrEmailTaken
|
||||
}
|
||||
return err
|
||||
}
|
||||
16
examples/userService/internal/repository/user/repository.go
Normal file
16
examples/userService/internal/repository/user/repository.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"example.com/userService/internal/model"
|
||||
)
|
||||
|
||||
// UserRepository describes the storage operations the business needs.
|
||||
// Implementations must atomically enforce unique, normalized email addresses.
|
||||
type UserRepository interface {
|
||||
Create(context.Context, model.UserInput) (model.User, error)
|
||||
Get(context.Context, int64) (model.User, error)
|
||||
List(context.Context, int, int) (model.UserPage, error)
|
||||
Update(context.Context, int64, model.UserInput) (model.User, error)
|
||||
Delete(context.Context, int64) error
|
||||
}
|
||||
41
examples/userService/internal/repository/user/store.go
Normal file
41
examples/userService/internal/repository/user/store.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||
"time"
|
||||
)
|
||||
|
||||
type UnitOfWork interface {
|
||||
UserRepository
|
||||
WithTx(context.Context, func(UserRepository, func(string, int64, string) error) error) error
|
||||
}
|
||||
type Store struct {
|
||||
*MySQLUsers
|
||||
db *database.DB
|
||||
}
|
||||
|
||||
func NewStore(db *database.DB) *Store { return &Store{MySQLUsers: NewMySQLUsers(db.DB), db: db} }
|
||||
func (s *Store) WithTx(ctx context.Context, work func(UserRepository, func(string, int64, string) error) error) error {
|
||||
return s.db.InTx(ctx, nil, func(tx *sql.Tx) error {
|
||||
audit := func(operation string, id int64, requestID string) error {
|
||||
_, err := tx.ExecContext(ctx, "INSERT INTO user_audit (user_id,operation,request_id,created_at) VALUES (?,?,?,?)", id, operation, requestID, time.Now().UTC())
|
||||
return err
|
||||
}
|
||||
return work(NewMySQLUsers(tx), audit)
|
||||
})
|
||||
}
|
||||
|
||||
// CheckSchema validates columns; it never mutates database schema.
|
||||
func (s *Store) CheckSchema(ctx context.Context) error {
|
||||
if err := s.MySQLUsers.CheckSchema(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, "SELECT id,user_id,operation,request_id,created_at FROM user_audit LIMIT 0")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.Join(rows.Err(), rows.Close())
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"example.com/userService/internal/model"
|
||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||
)
|
||||
|
||||
// This suite runs only against the disposable database created by verifyAPIWorkflow.
|
||||
func TestTransactions(t *testing.T) {
|
||||
if os.Getenv("USER_SERVICE_TEST_DATABASE") != "1" {
|
||||
t.Skip("isolated MySQL required")
|
||||
}
|
||||
cfg, err := database.ConfigFromEnv("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := database.Open(context.Background(), cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
store := NewStore(db)
|
||||
for _, kind := range []string{"error", "panic", "cancel", "commit"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
email := kind + "@transaction.example"
|
||||
var recovered any
|
||||
func() {
|
||||
defer func() { recovered = recover() }()
|
||||
err = store.WithTx(ctx, func(r UserRepository, audit func(string, int64, string) error) error {
|
||||
u, e := r.Create(ctx, model.UserInput{Name: "Transaction", Email: email})
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if e = audit("created", u.ID, "transactionCheck"); e != nil {
|
||||
return e
|
||||
}
|
||||
switch kind {
|
||||
case "error":
|
||||
return errors.New("rollback sentinel")
|
||||
case "panic":
|
||||
panic("rollback sentinel")
|
||||
case "cancel":
|
||||
cancel()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
if kind == "panic" && recovered == nil {
|
||||
t.Fatal("panic lost")
|
||||
}
|
||||
if kind == "commit" && err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if kind != "commit" && kind != "panic" && err == nil {
|
||||
t.Fatal("failed transaction committed")
|
||||
}
|
||||
var count int
|
||||
if e := db.QueryRow("SELECT COUNT(*) FROM users WHERE email=?", email).Scan(&count); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
want := 0
|
||||
if kind == "commit" {
|
||||
want = 1
|
||||
}
|
||||
if count != want {
|
||||
t.Fatalf("rows=%d want=%d", count, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
var audits int
|
||||
if err = db.QueryRow("SELECT COUNT(*) FROM user_audit WHERE request_id='transactionCheck'").Scan(&audits); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if audits != 1 {
|
||||
t.Fatalf("audit transaction mismatch: %d", audits)
|
||||
}
|
||||
}
|
||||
16
examples/userService/internal/service/user/createUser.go
Normal file
16
examples/userService/internal/service/user/createUser.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"example.com/userService/internal/model"
|
||||
repo "example.com/userService/internal/repository/user"
|
||||
"example.com/userService/internal/types"
|
||||
)
|
||||
|
||||
func (s *Service) CreateUser(ctx context.Context, req *types.CreateUserReq) (*types.UserResp, error) {
|
||||
input, err := normalize(req.Name, req.Email)
|
||||
if err != nil {
|
||||
return nil, s.failure(ctx, err)
|
||||
}
|
||||
return s.mutate(ctx, "created", func(r repo.UserRepository) (model.User, error) { return r.Create(ctx, input) })
|
||||
}
|
||||
16
examples/userService/internal/service/user/deleteUser.go
Normal file
16
examples/userService/internal/service/user/deleteUser.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"example.com/userService/internal/model"
|
||||
repo "example.com/userService/internal/repository/user"
|
||||
"example.com/userService/internal/types"
|
||||
)
|
||||
|
||||
func (s *Service) DeleteUser(ctx context.Context, req *types.UserIDReq) error {
|
||||
_, err := s.mutate(ctx, "deleted", func(r repo.UserRepository) (model.User, error) {
|
||||
err := r.Delete(ctx, req.ID)
|
||||
return model.User{ID: req.ID}, err
|
||||
})
|
||||
return err
|
||||
}
|
||||
40
examples/userService/internal/service/user/exportUser.go
Normal file
40
examples/userService/internal/service/user/exportUser.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"example.com/userService/internal/types"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Service) ExportUser(ctx context.Context, req *types.UserIDReq) (*types.ExportResp, error) {
|
||||
u, err := s.GetUser(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload, err := json.Marshal(u)
|
||||
if err != nil {
|
||||
return nil, s.failure(ctx, err)
|
||||
}
|
||||
var nonce [16]byte
|
||||
if _, err = rand.Read(nonce[:]); err != nil {
|
||||
return nil, s.failure(ctx, err)
|
||||
}
|
||||
key := fmt.Sprintf("exports/users/%d/%s.txt", u.ID, hex.EncodeToString(nonce[:]))
|
||||
if _, err = s.deps.Uploads.Put(ctx, key, strings.NewReader(string(payload)), int64(len(payload)), "text/plain"); err != nil {
|
||||
return nil, s.failure(ctx, err)
|
||||
}
|
||||
url, err := s.deps.Downloads.PresignGet(ctx, key, 5*time.Minute)
|
||||
if err != nil {
|
||||
cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
|
||||
defer cancel()
|
||||
_ = s.deps.Files.Delete(cleanup, key)
|
||||
return nil, s.failure(ctx, err)
|
||||
}
|
||||
s.deps.Logger.InfoContext(ctx, "user.exported", "userId", u.ID)
|
||||
return &types.ExportResp{DownloadURL: url.String()}, nil
|
||||
}
|
||||
14
examples/userService/internal/service/user/getUser.go
Normal file
14
examples/userService/internal/service/user/getUser.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"example.com/userService/internal/types"
|
||||
)
|
||||
|
||||
func (s *Service) GetUser(ctx context.Context, req *types.UserIDReq) (*types.UserResp, error) {
|
||||
u, err := s.deps.Users.Get(ctx, req.ID)
|
||||
if err != nil {
|
||||
return nil, s.failure(ctx, err)
|
||||
}
|
||||
return response(u), nil
|
||||
}
|
||||
18
examples/userService/internal/service/user/listUsers.go
Normal file
18
examples/userService/internal/service/user/listUsers.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"example.com/userService/internal/types"
|
||||
)
|
||||
|
||||
func (s *Service) ListUsers(ctx context.Context, req *types.ListUsersReq) (*types.UserPageResp, error) {
|
||||
page, err := s.deps.Users.List(ctx, req.Page, req.PageSize)
|
||||
if err != nil {
|
||||
return nil, s.failure(ctx, err)
|
||||
}
|
||||
result := &types.UserPageResp{Items: []types.UserResp{}, Total: page.Total, Page: page.Page, PageSize: page.PageSize}
|
||||
for _, item := range page.Items {
|
||||
result.Items = append(result.Items, *response(item))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
65
examples/userService/internal/service/user/service.go
Normal file
65
examples/userService/internal/service/user/service.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"example.com/userService/internal/model"
|
||||
repo "example.com/userService/internal/repository/user"
|
||||
"example.com/userService/internal/servicecontext"
|
||||
"example.com/userService/internal/types"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type Service struct{ deps *servicecontext.Context }
|
||||
|
||||
func New(deps *servicecontext.Context) *Service { return &Service{deps: deps} }
|
||||
func normalize(name, email string) (model.UserInput, error) {
|
||||
input := model.UserInput{Name: strings.TrimSpace(name), Email: strings.ToLower(strings.TrimSpace(email))}
|
||||
address, err := mail.ParseAddress(input.Email)
|
||||
if input.Name == "" || utf8.RuneCountInString(input.Name) > 100 || len(input.Email) > 254 || err != nil || address.Address != input.Email {
|
||||
return model.UserInput{}, model.ErrInvalidUser
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
func response(u model.User) *types.UserResp {
|
||||
return &types.UserResp{ID: u.ID, Name: u.Name, Email: u.Email, CreatedAt: u.CreatedAt.UTC().Format(time.RFC3339Nano), UpdatedAt: u.UpdatedAt.UTC().Format(time.RFC3339Nano)}
|
||||
}
|
||||
func (s *Service) failure(ctx context.Context, err error) error {
|
||||
switch {
|
||||
case err == nil:
|
||||
return nil
|
||||
case errors.Is(err, model.ErrUserNotFound):
|
||||
return apierrors.NotFound("USER_NOT_FOUND", "user not found")
|
||||
case errors.Is(err, model.ErrEmailTaken):
|
||||
return apierrors.New(409, "EMAIL_TAKEN", "email already in use")
|
||||
case errors.Is(err, model.ErrInvalidUser):
|
||||
return apierrors.BadRequest("INVALID_USER", "invalid user")
|
||||
case errors.Is(err, model.ErrInvalidPage):
|
||||
return apierrors.BadRequest("INVALID_PAGE", "invalid pagination")
|
||||
default:
|
||||
// Do not log SQL/DSN or user input; requestId correlates the failed operation.
|
||||
s.deps.Logger.ErrorContext(ctx, "user.operationFailed", "category", "persistence")
|
||||
return apierrors.Internal(err)
|
||||
}
|
||||
}
|
||||
func (s *Service) mutate(ctx context.Context, operation string, fn func(repo.UserRepository) (model.User, error)) (*types.UserResp, error) {
|
||||
var u model.User
|
||||
err := s.deps.Users.WithTx(ctx, func(r repo.UserRepository, audit func(string, int64, string) error) error {
|
||||
var err error
|
||||
u, err = fn(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return audit(operation, u.ID, logx.RequestID(ctx))
|
||||
})
|
||||
if err != nil {
|
||||
return nil, s.failure(ctx, err)
|
||||
}
|
||||
s.deps.Logger.InfoContext(ctx, "user."+operation, "userId", u.ID)
|
||||
return response(u), nil
|
||||
}
|
||||
16
examples/userService/internal/service/user/updateUser.go
Normal file
16
examples/userService/internal/service/user/updateUser.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"example.com/userService/internal/model"
|
||||
repo "example.com/userService/internal/repository/user"
|
||||
"example.com/userService/internal/types"
|
||||
)
|
||||
|
||||
func (s *Service) UpdateUser(ctx context.Context, req *types.UpdateUserReq) (*types.UserResp, error) {
|
||||
input, err := normalize(req.Name, req.Email)
|
||||
if err != nil {
|
||||
return nil, s.failure(ctx, err)
|
||||
}
|
||||
return s.mutate(ctx, "updated", func(r repo.UserRepository) (model.User, error) { return r.Update(ctx, req.ID, input) })
|
||||
}
|
||||
122
examples/userService/internal/servicecontext/context.go
Normal file
122
examples/userService/internal/servicecontext/context.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package servicecontext
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"example.com/userService/internal/middleware"
|
||||
userrepo "example.com/userService/internal/repository/user"
|
||||
"example.com/userService/internal/settings"
|
||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||
"git.yuntuo.hk/k2safe/go-omni/health"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Context owns infrastructure, while Logger is borrowed from main.
|
||||
// Add business repositories here; HTTP contracts do not define database tables.
|
||||
type Context struct {
|
||||
Users userrepo.UnitOfWork
|
||||
Logger *logx.Logger
|
||||
Health *health.Checker
|
||||
Middlewares map[string]httpx.Middleware
|
||||
DB *database.DB
|
||||
|
||||
Files storage.Store
|
||||
Uploads *storage.Uploader
|
||||
Downloads storage.Presigner
|
||||
UploadLinks storage.UploadPresigner
|
||||
FileHTTP http.Handler
|
||||
storageClose func() error
|
||||
|
||||
closeMu sync.Mutex
|
||||
closed bool
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
}
|
||||
|
||||
// Pass the settings loaded by main. The optional form supports lightweight
|
||||
// tests without infrastructure; selected adapters still require valid settings.
|
||||
func New(ctx context.Context, logger *logx.Logger, configs ...settings.Config) (result *Context, err error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err = ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(configs) > 1 {
|
||||
return nil, errors.New("expected at most one configuration")
|
||||
}
|
||||
cfg := settings.Default()
|
||||
if len(configs) == 1 {
|
||||
cfg = configs[0]
|
||||
}
|
||||
_ = cfg
|
||||
checker, err := health.New()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := &Context{Logger: logger, Health: checker, Middlewares: middleware.Defaults()}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = errors.Join(err, c.Close())
|
||||
}
|
||||
}()
|
||||
|
||||
if err = cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dsn, parseErr := mysql.ParseDSN(cfg.Database.DSN)
|
||||
if parseErr != nil || dsn.DBName == "" {
|
||||
return nil, errors.New("GO_OMNI_DB_DSN must contain a valid MySQL database name")
|
||||
}
|
||||
dsn.ParseTime = true
|
||||
dsn.Loc = time.UTC
|
||||
cfg.Database.DSN = dsn.FormatDSN()
|
||||
c.DB, err = database.Open(ctx, cfg.Database)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = checker.Add(health.Probe{Name: "database", Timeout: cfg.Database.PingTimeout, Check: c.DB.Health}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = c.InitStorage(ctx, cfg.Storage); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Users = userrepo.NewStore(c.DB)
|
||||
checkCtx, cancel := context.WithTimeout(ctx, cfg.Database.PingTimeout)
|
||||
defer cancel()
|
||||
if err = userrepo.NewStore(c.DB).CheckSchema(checkCtx); err != nil {
|
||||
return nil, errors.New("user schema is missing or incompatible; apply migrations first")
|
||||
}
|
||||
authHook, authErr := middleware.NewAuthFromEnv()
|
||||
if authErr != nil {
|
||||
return nil, authErr
|
||||
}
|
||||
c.Middlewares["Auth"] = authHook
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Close is idempotent, including startup failure and graceful shutdown.
|
||||
func (c *Context) Close() error {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
c.closeMu.Lock()
|
||||
defer c.closeMu.Unlock()
|
||||
c.closed = true
|
||||
c.closeOnce.Do(func() {
|
||||
if c.storageClose != nil {
|
||||
c.closeErr = errors.Join(c.closeErr, c.storageClose())
|
||||
}
|
||||
if c.DB != nil {
|
||||
c.closeErr = errors.Join(c.closeErr, c.DB.Close())
|
||||
}
|
||||
})
|
||||
return c.closeErr
|
||||
}
|
||||
81
examples/userService/internal/servicecontext/context_test.go
Normal file
81
examples/userService/internal/servicecontext/context_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package servicecontext
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"example.com/userService/internal/settings"
|
||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
func TestStartupFailureAndIdempotentCleanup(t *testing.T) {
|
||||
if os.Getenv("USER_SERVICE_TEST_DATABASE") != "1" {
|
||||
t.Skip("isolated MySQL required")
|
||||
}
|
||||
t.Setenv("USER_SERVICE_API_TOKEN", "test-012345678901234567890123456789")
|
||||
t.Setenv("GO_OMNI_STORAGE_SIGNING_KEY", "test-012345678901234567890123456789")
|
||||
cfg, err := settings.Load("../../etc/config.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
observer, err := database.Open(context.Background(), cfg.Database)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer observer.Close()
|
||||
count := func() int {
|
||||
var n int
|
||||
if err := observer.QueryRow("SELECT COUNT(*) FROM information_schema.processlist WHERE USER=SUBSTRING_INDEX(CURRENT_USER(),'@',1) AND DB=DATABASE()").Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
baseline := count()
|
||||
cfg.Server.Log.File = ""
|
||||
cfg.Server.Log.Writer = io.Discard
|
||||
cfg.Server.Log.Rotation = logx.RotationConfig{}
|
||||
logger, err := logx.New(cfg.Server.Log)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer logger.Close()
|
||||
root := t.TempDir()
|
||||
file := filepath.Join(root, "notADirectory")
|
||||
if err = os.WriteFile(file, []byte("x"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.Storage.Root = file
|
||||
if c, err := New(context.Background(), logger, cfg); err == nil {
|
||||
c.Close()
|
||||
t.Fatal("invalid storage root accepted")
|
||||
}
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for count() != baseline {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("failed startup leaked MySQL connection")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
cfg.Storage.Root = filepath.Join(root, "files")
|
||||
c, err := New(context.Background(), logger, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = c.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = c.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = c.DB.Ping(); err == nil {
|
||||
t.Fatal("closed application retained database")
|
||||
}
|
||||
if err = c.InitStorage(context.Background(), cfg.Storage); err == nil {
|
||||
t.Fatal("closed dependencies reopened storage")
|
||||
}
|
||||
}
|
||||
49
examples/userService/internal/servicecontext/storage.go
Normal file
49
examples/userService/internal/servicecontext/storage.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package servicecontext
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage/local"
|
||||
|
||||
"example.com/userService/internal/settings"
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage"
|
||||
)
|
||||
|
||||
// InitStorage is called once, before serving requests. The composition root
|
||||
// owns provider clients; business services receive only capability interfaces.
|
||||
func (c *Context) InitStorage(ctx context.Context, cfg settings.StorageConfig) error {
|
||||
if c == nil || ctx == nil {
|
||||
return errors.New("storage initialization requires context")
|
||||
}
|
||||
c.closeMu.Lock()
|
||||
defer c.closeMu.Unlock()
|
||||
if c.closed {
|
||||
return errors.New("service dependencies are closed")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Files != nil {
|
||||
return errors.New("storage already initialized")
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
files, err := local.New(cfg.Root, cfg.MaxBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
signer, err := storage.NewUploadSigner(files, cfg.PublicURL, []byte(cfg.SigningKey), cfg.Policy())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uploader, err := storage.NewUploader(files, cfg.Policy())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Files, c.Uploads, c.Downloads, c.UploadLinks, c.FileHTTP = files, uploader, signer, signer, signer
|
||||
|
||||
return nil
|
||||
}
|
||||
193
examples/userService/internal/settings/config.go
Normal file
193
examples/userService/internal/settings/config.go
Normal file
@@ -0,0 +1,193 @@
|
||||
// Package settings owns this project's configuration; it adds no business identity model.
|
||||
package settings
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server config.Config `json:"server"`
|
||||
Database database.Config `json:"database"`
|
||||
Storage StorageConfig `json:"storage"`
|
||||
}
|
||||
|
||||
func Default() Config {
|
||||
server := config.Default()
|
||||
server.Address = "127.0.0.1:8080"
|
||||
server.EnableLogger = true
|
||||
server.SlowRequestThreshold = 500 * time.Millisecond
|
||||
server.Log.Service = "example.com/userService"
|
||||
server.Log.Format = "json"
|
||||
server.Log.File = "./var/log/server.log"
|
||||
server.Log.Rotation = logx.RotationConfig{Enabled: true, MaxSizeMB: 100, MaxBackups: 10, MaxAgeDays: 7, Compress: true}
|
||||
|
||||
return Config{Server: server, Database: database.DefaultConfig(), Storage: StorageConfig{
|
||||
Provider: "local", Root: "./var/files", PublicURL: "http://127.0.0.1:8080/storage/object",
|
||||
Secure: true, MaxBytes: 32 << 20, ContentTypes: []string{"text/plain", "image/png", "image/jpeg", "application/pdf"},
|
||||
LinkTTL: 5 * time.Minute, RequestTimeout: 30 * time.Second,
|
||||
}}
|
||||
}
|
||||
|
||||
// Load uses defaults -> strict YAML/JSON -> nonempty environment variables.
|
||||
// Pass an empty path to use only defaults and environment.
|
||||
func Load(path string) (Config, error) {
|
||||
cfg := Default()
|
||||
var err error
|
||||
if path != "" {
|
||||
cfg, err = config.DecodeFile(path, cfg)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
}
|
||||
cfg.Server, err = config.ApplyEnv(cfg.Server, "")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
// A stream explicitly selected by environment overrides a file profile.
|
||||
if output := strings.TrimSpace(os.Getenv("GO_OMNI_LOG_OUTPUT")); output != "" && strings.TrimSpace(os.Getenv("GO_OMNI_LOG_FILE")) == "" {
|
||||
cfg.Server.Log.File = ""
|
||||
cfg.Server.Log.Rotation = logx.RotationConfig{}
|
||||
}
|
||||
|
||||
if err = cfg.Storage.applyEnv(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.Database, err = database.ApplyEnv(cfg.Database, "")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err = cfg.Validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
func (c Config) Validate() error {
|
||||
|
||||
if err := c.Database.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Database.Driver != "mysql" || c.Database.MaxOpenConns < 1 {
|
||||
return errors.New("database requires driver mysql and a positive maxOpenConns")
|
||||
}
|
||||
|
||||
if err := c.Server.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Server.Log.Format != "json" && c.Server.Log.Format != "text" {
|
||||
return errors.New("log.format must be json or text")
|
||||
}
|
||||
switch strings.ToLower(c.Server.Log.Level) {
|
||||
case "debug", "info", "warn", "warning", "error":
|
||||
default:
|
||||
return errors.New("invalid log.level")
|
||||
}
|
||||
return c.Storage.Validate()
|
||||
}
|
||||
|
||||
type StorageConfig struct {
|
||||
Provider string `json:"provider"`
|
||||
Root string `json:"root"`
|
||||
PublicURL string `json:"publicUrl"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Region string `json:"region"`
|
||||
Bucket string `json:"bucket"`
|
||||
AccountID string `json:"accountId"`
|
||||
Secure bool `json:"secure"`
|
||||
MaxBytes int64 `json:"maxBytes"`
|
||||
ContentTypes []string `json:"contentTypes"`
|
||||
LinkTTL time.Duration `json:"linkTtl"`
|
||||
RequestTimeout time.Duration `json:"requestTimeout"`
|
||||
// Secrets are environment-only and cannot be populated by config files.
|
||||
FilesToken string `json:"-"`
|
||||
SigningKey string `json:"-"`
|
||||
AccessKey string `json:"-"`
|
||||
SecretKey string `json:"-"`
|
||||
SessionToken string `json:"-"`
|
||||
}
|
||||
|
||||
func (s StorageConfig) Policy() storage.UploadPolicy {
|
||||
return storage.UploadPolicy{MaxBytes: s.MaxBytes, ContentTypes: s.ContentTypes}
|
||||
}
|
||||
func (s *StorageConfig) applyEnv() error {
|
||||
values := map[string]*string{
|
||||
"GO_OMNI_STORAGE_ROOT": &s.Root, "GO_OMNI_STORAGE_PUBLIC_URL": &s.PublicURL,
|
||||
"GO_OMNI_STORAGE_ENDPOINT": &s.Endpoint, "GO_OMNI_STORAGE_REGION": &s.Region,
|
||||
"GO_OMNI_STORAGE_BUCKET": &s.Bucket, "GO_OMNI_STORAGE_ACCOUNT_ID": &s.AccountID,
|
||||
"GO_OMNI_STORAGE_SIGNING_KEY": &s.SigningKey, "GO_OMNI_FILES_TOKEN": &s.FilesToken,
|
||||
"GO_OMNI_STORAGE_ACCESS_KEY": &s.AccessKey, "GO_OMNI_STORAGE_SECRET_KEY": &s.SecretKey,
|
||||
"GO_OMNI_STORAGE_SESSION_TOKEN": &s.SessionToken,
|
||||
}
|
||||
for name, target := range values {
|
||||
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
||||
*target = value
|
||||
}
|
||||
}
|
||||
for name, target := range map[string]*time.Duration{"GO_OMNI_STORAGE_LINK_TTL": &s.LinkTTL, "GO_OMNI_STORAGE_REQUEST_TIMEOUT": &s.RequestTimeout} {
|
||||
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
||||
v, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s must be a duration", name)
|
||||
}
|
||||
*target = v
|
||||
}
|
||||
}
|
||||
if value := os.Getenv("GO_OMNI_STORAGE_MAX_BYTES"); value != "" {
|
||||
v, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return errors.New("GO_OMNI_STORAGE_MAX_BYTES must be an integer")
|
||||
}
|
||||
s.MaxBytes = v
|
||||
}
|
||||
if value := os.Getenv("GO_OMNI_STORAGE_SECURE"); value != "" {
|
||||
v, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return errors.New("GO_OMNI_STORAGE_SECURE must be a boolean")
|
||||
}
|
||||
s.Secure = v
|
||||
}
|
||||
if value := strings.TrimSpace(os.Getenv("GO_OMNI_STORAGE_CONTENT_TYPES")); value != "" {
|
||||
s.ContentTypes = strings.Split(value, ",")
|
||||
for i := range s.ContentTypes {
|
||||
s.ContentTypes[i] = strings.TrimSpace(s.ContentTypes[i])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s StorageConfig) Validate() error {
|
||||
if s.Provider != "local" {
|
||||
return errors.New("storage.provider must match the adapter selected at generation")
|
||||
}
|
||||
if err := s.Policy().Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.LinkTTL < time.Second || s.LinkTTL > time.Hour {
|
||||
return errors.New("storage.linkTtl must be 1s..1h")
|
||||
}
|
||||
if s.RequestTimeout <= 0 || s.RequestTimeout > 5*time.Minute {
|
||||
return errors.New("storage.requestTimeout must be >0 and <=5m")
|
||||
}
|
||||
|
||||
if s.Root == "" {
|
||||
return errors.New("storage.root is required")
|
||||
}
|
||||
u, err := url.Parse(s.PublicURL)
|
||||
if err != nil || u.Host == "" || u.Hostname() == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != "/storage/object" {
|
||||
return errors.New("storage.publicUrl must be an absolute HTTP(S) URL ending in /storage/object")
|
||||
}
|
||||
if len(s.SigningKey) < 32 {
|
||||
return errors.New("set GO_OMNI_STORAGE_SIGNING_KEY to a stable secret of at least 32 bytes")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
35
examples/userService/internal/types/typesGen.go
Normal file
35
examples/userService/internal/types/typesGen.go
Normal file
@@ -0,0 +1,35 @@
|
||||
// Code generated by go-omni api. DO NOT EDIT.
|
||||
package types
|
||||
|
||||
type UserResp struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
type CreateUserReq struct {
|
||||
Name string `json:"name" binding:"required,max=100"`
|
||||
Email string `json:"email" binding:"required,email,max=254"`
|
||||
}
|
||||
type UserIDReq struct {
|
||||
ID int64 `path:"id,range=[1:9007199254740991]"`
|
||||
}
|
||||
type UpdateUserReq struct {
|
||||
ID int64 `path:"id,range=[1:9007199254740991]"`
|
||||
Name string `json:"name" binding:"required,max=100"`
|
||||
Email string `json:"email" binding:"required,email,max=254"`
|
||||
}
|
||||
type ListUsersReq struct {
|
||||
Page int `form:"page,default=1,range=[1:1000000]"`
|
||||
PageSize int `form:"pageSize,default=20,range=[1:100]"`
|
||||
}
|
||||
type UserPageResp struct {
|
||||
Items []UserResp `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
type ExportResp struct {
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
}
|
||||
Reference in New Issue
Block a user