frm/internal/default.go

334 lines
9 KiB
Go
Raw Permalink Normal View History

2024-11-05 16:18:04 +00:00
package internal
import (
"context"
2025-01-18 16:09:16 +00:00
"encoding/json"
2024-11-05 16:18:04 +00:00
"errors"
"fmt"
2025-02-14 18:39:22 +00:00
"log/slog"
2025-01-18 16:09:16 +00:00
"math/rand"
2024-11-05 16:18:04 +00:00
"strings"
2025-02-20 21:51:59 +00:00
"time"
2024-11-05 16:18:04 +00:00
"github.com/acaloiaro/frm/db/migrations"
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
2024-11-05 16:18:04 +00:00
"github.com/jackc/pgx/v5/pgxpool"
)
var (
ErrNoopDatabase = errors.New("frm had a problem getting a database connection")
)
type MockRow struct{}
type NoopDBTX struct{}
func (m *MockRow) Scan(dest ...any) (err error) {
return ErrNoopDatabase
}
func (n *NoopDBTX) Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) {
return pgconn.CommandTag{}, ErrNoopDatabase
}
func (n *NoopDBTX) Query(context.Context, string, ...interface{}) (pgx.Rows, error) {
return nil, ErrNoopDatabase
}
func (n *NoopDBTX) QueryRow(context.Context, string, ...interface{}) pgx.Row {
return &MockRow{}
}
type contextKey string
const (
2025-01-09 19:11:53 +00:00
// BuilderMountPointContextKey is the context key representing frm's builder mount point on the request context
BuilderMountPointContextKey contextKey = "builder_mount_point_context_key"
2025-01-18 16:09:16 +00:00
// DefaultShortcodeLen is the default length for generating short codes
DefaultShortcodeLen = 6
2025-01-09 19:11:53 +00:00
// CollectorMountPointContextKey is the context key representing frm's collector mount point on the request context
CollectorMountPointContextKey contextKey = "collector_mount_point_context_key"
// FrmContextKey is the context key representing the frm instance on the request context
FrmContextKey contextKey = "frm_instance"
)
2025-01-18 16:09:16 +00:00
var (
2025-01-22 18:20:05 +00:00
FormIDContextKey contextKey = "frm_form_id"
FieldIDContextKey contextKey = "frm_field_id"
ShortCodeContextKey contextKey = "frm_short_code"
pool *pgxpool.Pool
shortcodeCharset = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
2025-01-18 16:09:16 +00:00
)
2024-11-05 16:18:04 +00:00
type Forms []Form
var enumTypes = []string{
"form_status",
"_form_status", // array of form statuses
2025-01-11 00:11:33 +00:00
"submission_status",
"_submission_status",
}
2024-11-05 16:18:04 +00:00
// getPool returns a database pool for the specified connection string
2025-01-09 19:11:53 +00:00
func getPool(ctx context.Context, args DBArgs) (p *pgxpool.Pool, err error) {
2024-11-05 16:18:04 +00:00
if pool == nil {
var poolConfig *pgxpool.Config
2025-01-09 19:11:53 +00:00
var postgresURL string
postgresURL, err = pgConnectionString(args)
if err != nil {
return
}
poolConfig, err = pgxpool.ParseConfig(postgresURL)
2024-11-05 16:18:04 +00:00
if err != nil {
err = fmt.Errorf("invalid connection string: %v", err)
return
}
// this after conect hook allows pgx to correclty encode enum types as query params
// reference: https://github.com/jackc/pgx/issues/1549#issuecomment-1467107173
poolConfig.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error {
for _, typ := range enumTypes {
t, err := conn.LoadType(ctx, typ)
if err != nil {
return err
}
conn.TypeMap().RegisterType(t)
}
return nil
}
2025-01-09 19:11:53 +00:00
err = InitializeDB(ctx, args)
2024-11-05 16:18:04 +00:00
if err != nil {
err = fmt.Errorf("database failed to initialize: %v", err)
return
}
p, err = pgxpool.NewWithConfig(context.Background(), poolConfig)
if err != nil {
err = fmt.Errorf("invalid connection string: %v", err)
return
}
} else {
p = pool
}
return
}
2025-02-20 21:51:59 +00:00
// Tx returns a new transaction from the pool
func Tx(ctx context.Context, args DBArgs) (tx pgx.Tx, err error) {
var p *pgxpool.Pool
p, err = getPool(ctx, args)
if err != nil {
slog.Error("[frm] unable to get database connection", "error", err)
return
}
tx, err = p.Begin(ctx)
return
}
2024-11-05 16:18:04 +00:00
// Q returns a DBTX instance for querying the frm database
2025-01-09 19:11:53 +00:00
func Q(ctx context.Context, args DBArgs) *Queries {
p, err := getPool(ctx, args)
2024-11-05 16:18:04 +00:00
if err != nil {
2025-02-14 19:41:37 +00:00
slog.Error("[frm] unable to get database connection", "error", err)
return New(&NoopDBTX{})
2024-11-05 16:18:04 +00:00
}
return New(p)
}
2025-01-09 19:11:53 +00:00
type DBArgs struct {
URL string
DisableSSL bool
Schema string
}
2024-11-05 16:18:04 +00:00
// InitializeDB creates the application database if it doesn't exist and runs all migrations against it
//
// databaseUrl is the database URL string to connect to the database
2025-01-09 19:11:53 +00:00
func InitializeDB(ctx context.Context, args DBArgs) (err error) {
err = createIfNotExist(ctx, args)
2024-11-05 16:18:04 +00:00
if err != nil {
2025-01-09 19:11:53 +00:00
return
2024-11-05 16:18:04 +00:00
}
2025-01-09 19:11:53 +00:00
err = runMigrations(args)
return
}
2025-02-20 21:51:59 +00:00
// DraftMonitor runs a periodic goroutine that cleans up drafts older than [f.DraftMaxAge]
func DraftMonitor(ctx context.Context, args DBArgs, draftMaxAge time.Duration) (err error) {
if draftMaxAge == 0 {
slog.Info("[draft_monitor] not monitoring old drafts for cleanup")
return
}
t := time.NewTicker(draftMaxAge)
defer t.Stop()
// perform the initial cleanup on start, with all successive cleanups happening according to the ticker
err = Q(ctx, args).CleanupDrafts(ctx, draftMaxAge)
if err != nil {
slog.Error("[draft_monitor] unable to clean up old drafts", slog.Any("error", err))
}
slog.Info("[draft_monitor] monitoring old drafts for cleanup with:", slog.Any("interval", draftMaxAge))
for {
select {
case <-t.C:
err = Q(ctx, args).CleanupDrafts(ctx, draftMaxAge)
if err != nil {
slog.Error("[draft_monitor] unable to clean up old drafts", slog.Any("error", err))
}
case <-ctx.Done():
err = nil
return
}
}
}
2025-01-09 19:11:53 +00:00
// TODO not all db credentials have permission create databases/schemas. Fail gracefully when user lacks permission
func createIfNotExist(ctx context.Context, args DBArgs) (err error) {
2024-11-05 16:18:04 +00:00
var cfg *pgx.ConnConfig
2025-01-09 19:11:53 +00:00
cfg, err = pgx.ParseConfig(args.URL)
2024-11-05 16:18:04 +00:00
if err != nil {
return
}
2025-01-09 19:11:53 +00:00
dbName := cfg.Database
schemaName := "frm"
if args.Schema != "" {
schemaName = args.Schema
2024-11-05 16:18:04 +00:00
}
cfg.Database = "postgres"
2024-11-05 16:18:04 +00:00
cfg.RuntimeParams = nil
conn, err := pgx.ConnectConfig(context.Background(), cfg)
if err != nil {
return
}
2025-01-09 19:11:53 +00:00
var dbExists int
err = conn.QueryRow(ctx, "SELECT 1 FROM pg_database WHERE datname=$1", dbName).Scan(&dbExists)
2024-11-05 16:18:04 +00:00
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("unable to create database: %w", err)
}
2025-01-09 19:11:53 +00:00
if dbExists != 1 {
2024-11-05 16:18:04 +00:00
// note: prepared statements are not supported by pgx for CREATE DATABASE queries
_, err = conn.Exec(ctx, fmt.Sprintf("CREATE DATABASE %s", dbName))
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("unable to create database: %w", err)
}
}
2025-01-09 19:11:53 +00:00
cfg.Database = dbName
conn, err = pgx.ConnectConfig(context.Background(), cfg)
if err != nil {
return
}
var schemaExists int
err = conn.QueryRow(ctx, "SELECT 1 FROM information_schema.schemata where schema_name=$1", schemaName).Scan(&schemaExists)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("unable to create schema: %w", err)
}
if schemaExists != 1 {
// note: prepared statements are not supported by pgx for CREATE DATABASE queries
_, err = conn.Exec(ctx, fmt.Sprintf("CREATE schema %s", schemaName))
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("unable to create schema: %w", err)
}
}
2024-11-05 16:18:04 +00:00
return
}
// runMigrations runs all available migrations if steps are unspecified ( 0 ), and runs either up steps or down steps
2025-01-09 19:11:53 +00:00
func runMigrations(args DBArgs) (err error) {
postgresURL, err := pgConnectionString(args)
if err != nil {
return fmt.Errorf("invalid connection string: %v", err)
}
2024-11-05 16:18:04 +00:00
ms, err := iofs.New(migrations.FS, ".")
if err != nil {
panic(fmt.Sprintf("unable to run migrations: %v", err))
}
m, err := migrate.NewWithSourceInstance("iofs", ms, postgresURL)
if err != nil {
panic(fmt.Sprintf("unable to run migrations: %v", err))
}
// We don't need the migration tooling to hold it's connections to the DB once it has been completed.
defer m.Close()
err = m.Up()
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
2025-01-09 19:11:53 +00:00
err = fmt.Errorf("unable to run frm migrations: %v", err)
2024-11-05 16:18:04 +00:00
} else {
err = nil
}
return
}
2025-01-09 19:11:53 +00:00
func pgConnectionString(args DBArgs) (connString string, err error) {
2024-11-05 16:18:04 +00:00
var cfg *pgx.ConnConfig
2025-01-09 19:11:53 +00:00
cfg, err = pgx.ParseConfig(args.URL)
2024-11-05 16:18:04 +00:00
if err != nil {
return
}
2025-01-09 19:11:53 +00:00
options := []string{}
if args.DisableSSL {
2024-11-05 16:18:04 +00:00
options = append(options, "sslmode=disable")
}
2025-01-09 19:11:53 +00:00
if args.Schema != "" {
options = append(options, fmt.Sprintf("search_path=%s", args.Schema))
} else {
options = append(options, "search_path=frm")
}
2024-11-05 16:18:04 +00:00
connString = fmt.Sprintf("postgres://%s:%s@%s:%d/%s?%s", cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.Database, strings.Join(options, "&"))
return
}
2025-01-18 16:09:16 +00:00
// JSON returns the form's JSON-seralized string representation
func (f Form) JSON() string {
b, err := json.Marshal(f)
if err != nil {
return ""
}
return string(b)
}
2025-01-23 14:31:12 +00:00
// GenShortCode generates new shortcodes
func GenShortCode() string {
2025-01-18 16:09:16 +00:00
b := make([]rune, DefaultShortcodeLen)
chsLen := len(shortcodeCharset)
for i := range b {
b[i] = shortcodeCharset[rand.Intn(chsLen)]
}
return string(b)
}
2025-01-23 14:31:12 +00:00
// FormSubmissionMap converts a FormSubmission to its map[string]any representation (for queueing webhooks)
func FormSubmissionMap(s FormSubmission) (m map[string]any) {
m = map[string]any{}
m["id"] = s.ID
m["form_id"] = s.FormID
m["workspace_id"] = s.WorkspaceID
if s.SubjectID != nil {
m["subject_id"] = *s.SubjectID
}
m["fields"] = s.Fields
m["created_at"] = s.CreatedAt
m["updated_at"] = s.UpdatedAt
return
}