Initial commit

This commit is contained in:
George Suntres
2026-03-29 12:31:04 -04:00
commit ec429adb87
11 changed files with 899 additions and 0 deletions

26
.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Allowlisting gitignore template for GO projects prevents us
# from adding various unwanted local files, such as generated
# files, developer configurations or IDE-specific files etc.
#
# Recommended: Go.AllowList.gitignore
# Ignore everything
*
# But these files...
!.gitignore
!*.go
!go.sum
!go.mod
!README.md
!LICENSE
!Makefile
!*.sh
!*.md
# ...even if they are in subdirectories
!*/

33
encrypt.go Normal file
View File

@@ -0,0 +1,33 @@
package persist
import (
"log"
// "unsafe"
"golang.org/x/crypto/bcrypt"
)
// EncryptPassword will encrypt the password and replace the old one. Will return the encrypted password.
func (user *User) EncryptPassword() string {
if user.Password == "" {
log.Println("Nothing to encrypt.")
return ""
}
hpass, err := bcrypt.GenerateFromPassword([]byte(user.Password), 10)
if err != nil {
log.Fatal(err)
}
user.Password = string(hpass)
return user.Password
}
// CheckPassword returns true if passwords match.
func (user *User) CheckPassword(password string) bool {
err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password))
return err == nil
}

41
encrypt_test.go Normal file
View File

@@ -0,0 +1,41 @@
package persist
import (
"testing"
)
func TestUser_EncryptPassword(t *testing.T) {
u := &User{
Password: "1234",
}
encrypted := u.EncryptPassword()
if u.Password != encrypted {
t.Fatal("Failed to encrypt password")
}
}
func TestUser_CheckPassword(t *testing.T) {
u := &User{
Password: "1234",
}
u.EncryptPassword()
if !u.CheckPassword("1234") {
t.Fatal("Failed to check password")
}
}
func TestUser_CheckPassword_FalsePositive(t *testing.T) {
u := &User{
Password: "1234",
}
u.EncryptPassword()
if u.CheckPassword("!234") {
t.Fatal("Should have not accepted password")
}
}

299
persist.go Normal file
View File

@@ -0,0 +1,299 @@
// Package persist offers a convinient way to interact with the database. It takes into consideration multitenancy.
package persist
import (
"log"
"context"
"fmt"
"errors"
"reflect"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"github.com/go-viper/mapstructure/v2"
"go.mongodb.org/mongo-driver/v2/bson"
"git.gsuntres.com/general/commons"
"git.gsuntres.com/general/mongo"
"git.gsuntres.com/general/structful"
)
type PersistProps struct {
MongoSysDb string
}
// Persist is the root object for accessing data.
type Persist struct {
// IPersist
SysDb string
}
var fields []reflect.StructField
var sysDb string
func NewPersist(props *PersistProps) *Persist {
p := &Persist{
}
p.SysDb = props.MongoSysDb
return p
}
var persistOriginal *Persist
var deferedFuncs map[string]any = make(map[string]any, 0)
var persist any
var caseString cases.Caser = cases.Title(language.English)
type InitProps struct {
MongoSysDb string
}
// Init runs on boot, reads system_collections from structful and registers them. No changes after boot are going to be applied.
func Init(props *InitProps) {
var persistProps PersistProps
commons.CopyToStruct(props, &persistProps)
persist = NewPersist(&persistProps)
sysDb = props.MongoSysDb
persistOriginal = NewPersist(&persistProps)
// Load structful
s := structful.Current()
filter := map[string]any{}
scol, err := s.FilterByGroup("system_collections", filter)
if err != nil {
log.Fatalf("Failed to get system collections: %v", err)
}
// TODO(me): WIP
// Build a new struct similar to Persist
origType := reflect.TypeOf(persist)
if origType.Kind() == reflect.Ptr {
origType = origType.Elem()
}
// 1. Copy all existing fields
for i := 0; i < origType.NumField(); i++ {
log.Printf("Add existing field: %v", origType.Field(i))
fields = append(fields, origType.Field(i))
}
client := mongo.GetMongoClient()
// Take system_collection from structful and build the calls.
for _, col := range scol {
BuildInsertOne(col)
BuildFindOne(col)
BuildFind(col)
BuildGetOne(col)
BuildDeleteOne(col)
client.AddDefinition(col)
}
// 3. Create the new struct type
newStructType := reflect.StructOf(fields)
// 4. Create a new instance
newStruct := reflect.New(newStructType).Elem()
for _, field := range fields {
// log.Printf("FIELD %s", field.Name)
if actualFunc, ok := deferedFuncs[field.Name]; ok {
f := newStruct.FieldByName(field.Name)
f.Set(reflect.ValueOf(actualFunc))
}
}
persist = newStruct.Addr().Interface()
}
func GetCurrent() any {
return persist
}
func Orig() *Persist {
return persistOriginal
}
func Call(p any, name string, args... any) []reflect.Value {
v := reflect.ValueOf(p)
// 1. Make sure it's the right type
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
// 2. Get field
fn := v.FieldByName(name)
// 3. Validate
if !fn.IsValid() {
log.Fatalf("Failed to find %s field", name)
}
if fn.Kind() != reflect.Func {
log.Fatalf("%s is not a function", name)
}
if fn.IsNil() {
log.Fatalf("%s is nil", name)
}
// 4. Prepare arguments
vArgs := make([]reflect.Value, len(args))
for i, arg := range args {
vArgs[i] = reflect.ValueOf(arg)
}
// 5. Call and return results
return fn.Call(vArgs)
}
const SAVE_USER_DATA = `
{
"type": "object",
"properties": {
"Fullname": {
"type": "string"
},
"Firstname": {
"type": "string"
},
"Username": {
"type": "string",
"minLength": 3
},
"Password": {
"type": "string",
"minLength": 1
},
"Email": {
"type": "string"
}
},
"required": ["Username", "Password"]
}
`
// SaveUser will encrypt the password before saving the user.
func (p *Persist) SaveUser(ctx context.Context, data *User) (*User, error) {
if valid := commons.Validate(SAVE_USER_DATA, data); valid != nil {
return nil, errors.New(fmt.Sprintf("%s", valid.Error()))
}
data.EncryptPassword()
client := mongo.GetMongoClient()
dataNew, err := client.InsertOneFromStruct(ctx, p.SysDb, USER_COLLECTION, data)
if err != nil {
return nil, err
}
d, err := bson.Marshal(dataNew)
if err != nil {
return nil, err
}
// Unmarshal into struct
var u User
err = bson.Unmarshal(d, &u)
if err != nil {
return nil, err
}
return &u, nil
}
func (p *Persist) CheckUser(ctx context.Context, usernameOrEmail string, password string) (*User, error) {
if len(usernameOrEmail) < 3 {
return nil, errors.New("username or email too short")
}
client := mongo.GetMongoClient()
filter := bson.M{
"$or": bson.A{
bson.M{"username": usernameOrEmail},
bson.M{"email": usernameOrEmail},
},
}
found, err := client.FindOne(ctx, p.SysDb, USER_COLLECTION, filter)
if err != nil {
return nil, err
}
var user User
if err := mongo.ToStruct(found, &user); err != nil {
return nil, err
}
if !user.CheckPassword(password) {
return nil, errors.New("invalid credentials")
}
return &user, nil
}
const SAVE_ACCOUNT_DATA = `
{
"type": "object",
"properties": {
"Code": {
"type": "string",
"minLength": 6
},
"Owner": {
"type": "string",
"minLength": 6
}
},
"required": ["Code", "Owner"]
}
`
func (p *Persist) SaveAccount(ctx context.Context, data *Account) (*Account, error) {
if valid := commons.Validate(SAVE_ACCOUNT_DATA, data); valid != nil {
return nil, errors.New(fmt.Sprintf("%s", valid.Error()))
}
client := mongo.GetMongoClient()
dataNew, err := client.InsertOneFromStruct(ctx, p.SysDb, ACCOUNT_COLLECTION, data)
if err != nil {
return nil, err
}
var o Account
mapstructure.Decode(dataNew, &o)
return &o, nil
}
func (p *Persist) GetAccountByCode(ctx context.Context, code string) (*Account, error) {
client := mongo.GetMongoClient()
filter := bson.M{"code": code}
found, err := client.FindOne(ctx, p.SysDb, ACCOUNT_COLLECTION, filter)
if err != nil {
return nil, err
}
var acc Account
if err := mongo.ToStruct(found, &acc); err != nil {
return nil, err
}
return &acc, nil
}

54
persist_deleteone.go Normal file
View File

@@ -0,0 +1,54 @@
package persist
import (
"fmt"
"context"
"reflect"
"git.gsuntres.com/general/mongo"
)
func BuildDeleteOne(col map[string]any) {
name := col["_name"].(string)
singular := col["singular"].(string)
// prepare input arguments and return results
in := []reflect.Type{
reflect.TypeOf((*context.Context)(nil)).Elem(),
reflect.TypeOf((*map[string]any)(nil)).Elem(),
}
out := []reflect.Type{
reflect.TypeOf((*error)(nil)).Elem(),
}
// create function signature
variadic := false
funcType := reflect.FuncOf(in, out, variadic)
deleteOneName := fmt.Sprintf("%s%s", "Delete", caseString.String(singular))
fields = append(fields, reflect.StructField{
Name: deleteOneName,
Type: funcType,
})
isSystem := false
if v, ok := col["system"]; ok {
isSystem = v.(bool)
}
mc := mongo.GetMongoClient()
// we defer function's implementation until we create the actual struct
deferedFuncs[deleteOneName] = func(ctx context.Context, filter map[string]any) error {
db := "__undefined__"
if isSystem {
db = sysDb
} else {
account := ctx.Value("account").(string)
if account != "" {
db = mc.GetName(account)
}
}
return mc.DeleteOne(ctx, db, name, filter)
}
}

58
persist_find.go Normal file
View File

@@ -0,0 +1,58 @@
package persist
import (
"fmt"
"context"
"reflect"
"go.mongodb.org/mongo-driver/v2/bson"
"git.gsuntres.com/general/mongo"
)
func BuildFind(col map[string]any) {
name := col["_name"].(string)
plural := col["plural"].(string)
// prepare input arguments and return results
in := []reflect.Type{
reflect.TypeOf((*context.Context)(nil)).Elem(),
reflect.TypeOf((*map[string]any)(nil)).Elem(),
reflect.TypeOf((*int64)(nil)).Elem(),
}
out := []reflect.Type{
reflect.TypeOf(bson.M{}),
reflect.TypeOf((*error)(nil)).Elem(),
}
// create function signature
variadic := false
funcType := reflect.FuncOf(in, out, variadic)
funcName := fmt.Sprintf("%s%s", "Find", caseString.String(plural))
fields = append(fields, reflect.StructField{
Name: funcName,
Type: funcType,
})
isSystem := false
if v, ok := col["system"]; ok {
isSystem = v.(bool)
}
mc := mongo.GetMongoClient()
// we defer function's implementation until we create the actual struct
deferedFuncs[funcName] = func(ctx context.Context, filter map[string]any, limit int64) (bson.M, error) {
db := "__undefined__"
if isSystem {
db = sysDb
} else {
account := ctx.Value("account").(string)
if account != "" {
db = mc.GetName(account)
}
}
return mc.Find(ctx, db, name, filter, limit)
}
}

57
persist_findone.go Normal file
View File

@@ -0,0 +1,57 @@
package persist
import (
"fmt"
"context"
"reflect"
"go.mongodb.org/mongo-driver/v2/bson"
"git.gsuntres.com/general/mongo"
)
func BuildFindOne(col map[string]any) {
name := col["_name"].(string)
singular := col["singular"].(string)
// prepare input arguments and return results
in := []reflect.Type{
reflect.TypeOf((*context.Context)(nil)).Elem(),
reflect.TypeOf((*map[string]any)(nil)).Elem(),
}
out := []reflect.Type{
reflect.TypeOf(bson.M{}),
reflect.TypeOf((*error)(nil)).Elem(),
}
// create function signature
variadic := false
funcType := reflect.FuncOf(in, out, variadic)
funcName := fmt.Sprintf("%s%s", "Find", caseString.String(singular))
fields = append(fields, reflect.StructField{
Name: funcName,
Type: funcType,
})
isSystem := false
if v, ok := col["system"]; ok {
isSystem = v.(bool)
}
mc := mongo.GetMongoClient()
// we defer function's implementation until we create the actual struct
deferedFuncs[funcName] = func(ctx context.Context, filter map[string]any) (bson.M, error) {
db := "__undefined__"
if isSystem {
db = sysDb
} else {
account := ctx.Value("account").(string)
if account != "" {
db = mc.GetName(account)
}
}
return mc.FindOne(ctx, db, name, filter)
}
}

57
persist_getone.go Normal file
View File

@@ -0,0 +1,57 @@
package persist
import (
"fmt"
"context"
"reflect"
"go.mongodb.org/mongo-driver/v2/bson"
"git.gsuntres.com/general/mongo"
)
func BuildGetOne(col map[string]any) {
name := col["_name"].(string)
singular := col["singular"].(string)
// prepare input arguments and return results
in := []reflect.Type{
reflect.TypeOf((*context.Context)(nil)).Elem(),
reflect.TypeOf((*map[string]any)(nil)).Elem(),
}
out := []reflect.Type{
reflect.TypeOf(bson.M{}),
reflect.TypeOf((*error)(nil)).Elem(),
}
// create function signature
variadic := false
funcType := reflect.FuncOf(in, out, variadic)
funcName := fmt.Sprintf("%s%s", "Get", caseString.String(singular))
fields = append(fields, reflect.StructField{
Name: funcName,
Type: funcType,
})
isSystem := false
if v, ok := col["system"]; ok {
isSystem = v.(bool)
}
mc := mongo.GetMongoClient()
// we defer function's implementation until we create the actual struct
deferedFuncs[funcName] = func(ctx context.Context, data map[string]any) (bson.M, error) {
db := "__undefined__"
if isSystem {
db = sysDb
} else {
account := ctx.Value("account").(string)
if account != "" {
db = mc.GetName(account)
}
}
return mc.GetOne(ctx, db, name, data)
}
}

57
persist_insertone.go Normal file
View File

@@ -0,0 +1,57 @@
package persist
import (
"fmt"
"context"
"reflect"
"go.mongodb.org/mongo-driver/v2/bson"
"git.gsuntres.com/general/mongo"
)
func BuildInsertOne(col map[string]any) {
name := col["_name"].(string)
singular := col["singular"].(string)
// prepare input arguments and return results
in := []reflect.Type{
reflect.TypeOf((*context.Context)(nil)).Elem(),
reflect.TypeOf((*map[string]any)(nil)).Elem(),
}
out := []reflect.Type{
reflect.TypeOf(bson.M{}),
reflect.TypeOf((*error)(nil)).Elem(),
}
// create function signature
variadic := false
funcType := reflect.FuncOf(in, out, variadic)
insertOneName := fmt.Sprintf("%s%s", "Insert", caseString.String(singular))
fields = append(fields, reflect.StructField{
Name: insertOneName,
Type: funcType,
})
isSystem := false
if v, ok := col["system"]; ok {
isSystem = v.(bool)
}
mc := mongo.GetMongoClient()
// we defer function's implementation until we create the actual struct
deferedFuncs[insertOneName] = func(ctx context.Context, data map[string]any) (bson.M, error) {
db := "__undefined__"
if isSystem {
db = sysDb
} else {
account := ctx.Value("account").(string)
if account != "" {
db = mc.GetName(account)
}
}
return mc.InsertOne(ctx, db, name, data)
}
}

194
persist_test.go Normal file
View File

@@ -0,0 +1,194 @@
package persist
import (
"os"
"testing"
"context"
"fmt"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"go.mongodb.org/mongo-driver/v2/bson"
"git.gsuntres.com/general/mongo"
)
func TestMain(m *testing.M) {
if os.Getenv("RUN_INTEGRATION") == "" {
fmt.Println("Skipping package tests: RUN_INTEGRATION is missing")
os.Exit(0) // Exit with success, but no tests ran
}
ctx := context.Background()
user := "admin"
pass := "1"
// 1. Setup: Start the MongoDB container
mongoContainer, err := mongodb.Run(ctx, "mongo:8",
testcontainers.WithEnv(map[string]string{
"MONGO_INITDB_ROOT_USERNAME": user,
"MONGO_INITDB_ROOT_PASSWORD": pass,
}),)
if err != nil {
panic("failed to start container")
}
// 2. Get the connection string dynamically
endpoint, _ := mongoContainer.ConnectionString(ctx)
mongo.Start(&mongo.MongoStartProps{
MongoUri: endpoint,
MongoUser: user,
MongoPass: pass,
})
// 3. Run tests
code := m.Run()
// 4. Teardown: Clean up resources
mongo.Stop()
_ = testcontainers.TerminateContainer(mongoContainer)
os.Exit(code)
}
func TestSaveUser_Valid(t *testing.T) {
p := NewPersist(&PersistProps{
MongoSysDb: "test_boxtep_sys",
})
data := &User{
Fullname: "King Long Too",
Firstname: "Too",
Username: "toolong",
Password: "mypass",
Email: "too@long.test",
}
user, err := p.SaveUser(context.TODO(), data)
if err != nil {
t.Fatalf("Should have saved user %#v", err)
}
data.Id = user.Id
dataMap, _ := mongo.ToMap(data)
userMap, _ := mongo.ToMap(user)
mongo.AssertSubset(t, dataMap, userMap, "Should have been equal")
}
func TestSaveUser_RequireUsername(t *testing.T) {
p := NewPersist(&PersistProps{
MongoSysDb: "test_boxtep_sys",
})
data := &User{
Fullname: "King Long Too",
Firstname: "Too",
Password: "mypass",
Email: "too@long.test",
}
_, err := p.SaveUser(context.TODO(), data)
if err == nil || err.Error() != `properties/Username: value "" too short for "minLength" argument 3` {
t.Fatal("Should require username")
}
}
func TestSaveUser_RequirePassword(t *testing.T) {
p := NewPersist(&PersistProps{
MongoSysDb: "test_boxtep_sys",
})
data := &User{
Fullname: "King Long Too",
Firstname: "Too",
Username: "toolong",
Email: "too@long.test",
}
_, err := p.SaveUser(context.TODO(), data)
if err == nil || err.Error() != `properties/Password: value "" too short for "minLength" argument 1` {
t.Fatal("Should require password")
}
}
func TestSaveUser_UsesSysDb(t *testing.T) {
db := "test_boxtep_sys"
p := NewPersist(&PersistProps{
MongoSysDb: db,
})
data := &User{
Fullname: "King Long Too",
Firstname: "Too",
Username: "toolong",
Password: "1",
Email: "too@long.test",
}
_, err := p.SaveUser(context.TODO(), data)
if err != nil {
t.Fatalf("Should have saved user %#v", err)
}
// check if user is saved
client := mongo.GetMongoClient()
collection := client.GetCollection(db, USER_COLLECTION)
var found bson.M
filter := bson.M{"username": "toolong"}
if err := collection.FindOne(context.Background(), filter).Decode(&found); err != nil {
t.Fatalf("Should have found user %#v", err)
}
delete(found, "_id")
delete(found, "password")
delete(found, "created_at")
delete(found, "updated_at")
d, _ := mongo.ToMap(data)
mongo.AssertSubset(t, found, d, "Should have been equal")
}
func TestAuthUser_UsingUsername(t *testing.T) {
db := "test_boxtep_sys"
p := NewPersist(&PersistProps{
MongoSysDb: db,
})
data := &User{
Fullname: "King Long Too",
Firstname: "Too",
Username: "toolong_test1",
Password: "1",
Email: "too@long.test",
}
_, err := p.SaveUser(context.TODO(), data)
if err != nil {
t.Fatalf("Should have saved user %#v", err)
}
user, err := p.CheckUser(context.TODO(), "toolong_test1", "1")
if err != nil {
t.Fatalf("Should have checked %#v", err)
}
d, _ := mongo.ToMap(data)
u, _ := mongo.ToMap(user)
delete(u, "_id")
mongo.AssertSubset(t, u, d, "Wrong user")
}

23
type.go Normal file
View File

@@ -0,0 +1,23 @@
package persist
const USER_COLLECTION = "user"
// User can use [User.EncryptPassword] to encrypt the password
type User struct {
Id string `bson:"_id" json:"id"`
Fullname string `bson:"fullname" json:"fullname"`
Firstname string `bson:"firstname" json:"firstname"`
Username string `bson:"username" json:"username"`
Password string `bson:"password" json:"password"`
Email string `bson:"email" json:"email"`
Roles []string `bson:"roles" json:"roles"`
Credentials map[string]any `bson:"credentials" json:"credentials"`
}
const ACCOUNT_COLLECTION = "account"
// Deprecate: use core.BusinessAccount
type Account struct {
Id string `bson:"_id" json:"id"`
Code string `bson:"code" json:"code"`
Owner string `bson:"owner" json:"owner"`
}