From 614af86976f036c79a6333568c1d419f2ce06810 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Mon, 16 Feb 2026 02:35:43 -0700 Subject: [PATCH] feat: add cmd/api-example as server boilerplate --- cmd/api-example/args.go | 53 ++++ cmd/api-example/git.go | 42 +++ cmd/api-example/go.mod | 17 ++ cmd/api-example/go.sum | 30 +++ cmd/api-example/internal/duration.go | 41 +++ cmd/api-example/internal/proxy.go | 19 ++ cmd/api-example/internal/routes.go | 60 +++++ cmd/api-example/main.go | 248 ++++++++++++++++++ cmd/api-example/sql/db.go | 1 + .../0001-01-01-01000_init-migrations.down.sql | 3 + .../0001-01-01-01000_init-migrations.up.sql | 14 + ...2026-01-01-001000_add-blobs-table.down.sql | 4 + .../2026-01-01-001000_add-blobs-table.up.sql | 10 + cmd/api-example/sql/queries/blobs.sql | 4 + cmd/api-example/sqlc.yaml | 17 ++ cmd/api-example/uptime.go | 36 +++ 16 files changed, 599 insertions(+) create mode 100644 cmd/api-example/args.go create mode 100644 cmd/api-example/git.go create mode 100644 cmd/api-example/go.mod create mode 100644 cmd/api-example/go.sum create mode 100644 cmd/api-example/internal/duration.go create mode 100644 cmd/api-example/internal/proxy.go create mode 100644 cmd/api-example/internal/routes.go create mode 100644 cmd/api-example/main.go create mode 100644 cmd/api-example/sql/db.go create mode 100644 cmd/api-example/sql/migrations/0001-01-01-01000_init-migrations.down.sql create mode 100644 cmd/api-example/sql/migrations/0001-01-01-01000_init-migrations.up.sql create mode 100644 cmd/api-example/sql/migrations/2026-01-01-001000_add-blobs-table.down.sql create mode 100644 cmd/api-example/sql/migrations/2026-01-01-001000_add-blobs-table.up.sql create mode 100644 cmd/api-example/sql/queries/blobs.sql create mode 100644 cmd/api-example/sqlc.yaml create mode 100644 cmd/api-example/uptime.go diff --git a/cmd/api-example/args.go b/cmd/api-example/args.go new file mode 100644 index 0000000..18361a0 --- /dev/null +++ b/cmd/api-example/args.go @@ -0,0 +1,53 @@ +package main + +import ( + "fmt" + "net/netip" + "net/url" + "os" + "slices" + "strconv" +) + +func peekOption(args []string, flags []string, defaultOpt string) string { + n := len(args) + for i := range n { + if slices.Contains(flags, args[i]) { + if i+1 < n { + return args[i+1] + } + break + } + } + + return defaultOpt +} + +func parseEnvs(opts *MainConfig) error { + if envPort := os.Getenv("PORT"); envPort != "" { + if p, err := strconv.Atoi(envPort); err == nil && p > 0 { + opts.defaultPort = p + } else { + return fmt.Errorf("invalid PORT environment variable value: %q", envPort) + } + } + if envAddress := os.Getenv("ADDRESS"); envAddress != "" { + if _, err := netip.ParseAddr(envAddress); err != nil { + return fmt.Errorf("invalid ADDRESS environment variable value: %q", envAddress) + } + opts.defaultAddress = envAddress + } + + if opts.pgURL = os.Getenv("PG_URL"); opts.pgURL != "" { + if _, err := url.Parse(opts.pgURL); err != nil { + return fmt.Errorf("invalid PG_URL environment variable value: %q", opts.pgURL) + } + } + if opts.jwtInspectURL = os.Getenv("JWT_INSPECT_URL"); opts.jwtInspectURL != "" { + if _, err := url.Parse(opts.jwtInspectURL); err != nil { + return fmt.Errorf("invalid JWT_INSPECT_URL environment variable value: %q", opts.jwtInspectURL) + } + } + + return nil +} diff --git a/cmd/api-example/git.go b/cmd/api-example/git.go new file mode 100644 index 0000000..dcf9e21 --- /dev/null +++ b/cmd/api-example/git.go @@ -0,0 +1,42 @@ +package main + +import ( + "os/exec" + "strings" + "time" +) + +func maybeGetVersion() string { + // Try git describe for tag + commits since tag + args := []string{"describe", "--tags", "--abbrev=7", "--dirty=+local", "--always"} + if out, err := exec.Command("git", args...).Output(); err == nil { + return strings.TrimSpace(strings.TrimPrefix(string(out), "v")) + } + + return "0.0.0-dev" +} + +func maybeGetCommit() string { + // Try git rev-parse for short commit hash + if out, err := exec.Command("git", "rev-parse", "--short", "HEAD").Output(); err == nil { + if out, err := exec.Command("git", "status", "--porcelain").Output(); err == nil && len(out) == 0 { + return strings.TrimSpace(string(out)) + } + return strings.TrimSpace(string(out)) + "+dev" + } + return "0000000" +} + +func maybeGetDate() string { + // Get timestamp of most recent commit, if clean + if out, err := exec.Command("git", "status", "--porcelain").Output(); err == nil && len(out) == 0 { + if out, err := exec.Command("git", "log", "-1", "--format=%ci").Output(); err == nil { + if t, err := time.Parse("2006-01-02 15:04:05 -0700", strings.TrimSpace(string(out))); err == nil { + return t.Format(time.RFC3339) + } + } + } + + // Return current day with 0s for hour, minute, second + return time.Now().UTC().Truncate(24 * time.Hour).Format(time.RFC3339) +} diff --git a/cmd/api-example/go.mod b/cmd/api-example/go.mod new file mode 100644 index 0000000..65835b7 --- /dev/null +++ b/cmd/api-example/go.mod @@ -0,0 +1,17 @@ +module github.com/therootcompany/golib/cmd/api-example + +go 1.26.0 + +require ( + github.com/jackc/pgx/v5 v5.8.0 + github.com/joho/godotenv v1.5.1 + github.com/therootcompany/golib v0.0.0-20260215001229-3700770144be +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/text v0.29.0 // indirect +) diff --git a/cmd/api-example/go.sum b/cmd/api-example/go.sum new file mode 100644 index 0000000..c929956 --- /dev/null +++ b/cmd/api-example/go.sum @@ -0,0 +1,30 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/therootcompany/golib v0.0.0-20260215001229-3700770144be h1:RpIFiKHN7yM2yj1kSjJh1SMTIBGEE7yxR7CRVTQgZYY= +github.com/therootcompany/golib v0.0.0-20260215001229-3700770144be/go.mod h1:BWCvTz1AYl4S+vZD/I/gt7i7yJYy3yU9sLX/3HqqcNA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cmd/api-example/internal/duration.go b/cmd/api-example/internal/duration.go new file mode 100644 index 0000000..ffd5f98 --- /dev/null +++ b/cmd/api-example/internal/duration.go @@ -0,0 +1,41 @@ +package internal + +import ( + "fmt" + "strings" + "time" +) + +func FormatDuration(d time.Duration) string { + if d < 0 { + d = -d + } + days := int(d / (24 * time.Hour)) + d -= time.Duration(days) * 24 * time.Hour + hours := int(d / time.Hour) + d -= time.Duration(hours) * time.Hour + minutes := int(d / time.Minute) + d -= time.Duration(minutes) * time.Minute + seconds := int(d / time.Second) + + var parts []string + if days > 0 { + parts = append(parts, fmt.Sprintf("%dd", days)) + } + if hours > 0 { + parts = append(parts, fmt.Sprintf("%dh", hours)) + } + if minutes > 0 { + parts = append(parts, fmt.Sprintf("%dm", minutes)) + } + if seconds > 0 { + parts = append(parts, fmt.Sprintf("%ds", seconds)) + } + if seconds == 0 || len(parts) == 0 { + d -= time.Duration(seconds) * time.Second + millis := int(d / time.Millisecond) + parts = append(parts, fmt.Sprintf("%dms", millis)) + } + + return strings.Join(parts, " ") +} diff --git a/cmd/api-example/internal/proxy.go b/cmd/api-example/internal/proxy.go new file mode 100644 index 0000000..13fbdad --- /dev/null +++ b/cmd/api-example/internal/proxy.go @@ -0,0 +1,19 @@ +package internal + +import ( + "net/http" + "net/http/httputil" + "net/url" +) + +func ProxyToOtherAPI(proxyTarget string) http.Handler { + target, _ := url.Parse(proxyTarget) + proxy := &httputil.ReverseProxy{ + Rewrite: func(r *httputil.ProxyRequest) { + // r.SetXForwarded() // these are trusted from the tls-terminating proxy + r.SetURL(target) + r.Out.Host = r.In.Host + }, + } + return proxy +} diff --git a/cmd/api-example/internal/routes.go b/cmd/api-example/internal/routes.go new file mode 100644 index 0000000..c5f85a2 --- /dev/null +++ b/cmd/api-example/internal/routes.go @@ -0,0 +1,60 @@ +package internal + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/therootcompany/golib/cmd/api-example/db" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type API struct { + BootTime time.Time + StartTime time.Time + PG *pgxpool.Pool + Queries *db.Queries +} + +type APIStatus struct { + SystemSeconds float64 `json:"system_seconds"` + SystemUptime string `json:"system_uptime"` + APISeconds float64 `json:"api_seconds"` + APIUptime string `json:"api_uptime"` +} + +type Greeting struct { + Message string `json:"message,omitempty"` +} + +func (a *API) HandleStatus(w http.ResponseWriter, r *http.Request) { + systemUptime := time.Since(a.BootTime) + apiUptime := time.Since(a.StartTime) + apiStatus := APIStatus{ + SystemSeconds: systemUptime.Seconds(), + SystemUptime: FormatDuration(systemUptime), + APISeconds: apiUptime.Seconds(), + APIUptime: FormatDuration(apiUptime), + } + + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + _ = enc.Encode(apiStatus) +} + +func (a *API) HandleGreet(w http.ResponseWriter, r *http.Request) { + subject := r.PathValue("subject") + if subject == "" { + subject = "World" + } + + msg := Greeting{ + Message: fmt.Sprintf("Hello, %s!", subject), + } + + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + _ = enc.Encode(msg) +} diff --git a/cmd/api-example/main.go b/cmd/api-example/main.go new file mode 100644 index 0000000..5edc96a --- /dev/null +++ b/cmd/api-example/main.go @@ -0,0 +1,248 @@ +package main + +import ( + "context" + "encoding/hex" + "errors" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "time" + + "github.com/therootcompany/golib/cmd/api-example/db" + "github.com/therootcompany/golib/cmd/api-example/internal" + "github.com/therootcompany/golib/crypto/passphrase" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/joho/godotenv" +) + +const ( + name = "CHANGE_ME" + licenseYear = "2025" + licenseOwner = "AJ ONeal (https://therootcompany.com)" + licenseType = "LICENSE in LICENSE" +) + +// for goreleaser +var ( + version = "" + commit = "" + date = "" +) + +var ( + newHTTPServer func(context.Context, string) (*http.Server, error) +) + +func init() { + // workaround for `tinygo` ldflag replacement handling not allowing default values + // See + if len(version) == 0 { + version = maybeGetVersion() // defaults to "0.0.0-dev" + } + if len(date) == 0 { + date = maybeGetDate() // defaults to date-only "20xx-01-00 00:00:00" + } + if len(commit) == 0 { + commit = maybeGetCommit() // defaults to "0000000" + } +} + +// printVersion displays the version, commit, and build date. +func printVersion(w io.Writer) { + _, _ = fmt.Fprintf(w, "%s v%s %s (%s)\n", name, version, commit[:7], date) + _, _ = fmt.Fprintf(w, "Copyright (C) %s %s\n", licenseYear, licenseOwner) + _, _ = fmt.Fprintf(w, "Licensed under the %s license\n", licenseType) +} + +type MainConfig struct { + defaultAddress string + defaultPort int + defaultProxyTarget string + address string + port int + proxyTarget string + showVersion bool + pgURL string + encryptionPassphrase string + encryptionSalt string +} + +func main() { + cfg := MainConfig{ + defaultAddress: "0.0.0.0", + defaultPort: 3080, + defaultProxyTarget: "127.0.0.1:3081", + } + + var envErr error + { + envPath := peekOption(os.Args[1:], []string{"-envfile", "--envfile"}, ".env") + + if err := godotenv.Load(envPath); err != nil { + if !errors.Is(err, os.ErrNotExist) { + envErr = err + } + } else if err := parseEnvs(&cfg); err != nil { + printVersion(os.Stderr) + fmt.Fprintf(os.Stderr, "\nerror: %v\n", err) + } + } + + // note: --help is implicit, but handled specially below + mainFlags := flag.NewFlagSet("", flag.ContinueOnError) + mainFlags.BoolVar(&cfg.showVersion, "version", false, "Show version and exit") + mainFlags.IntVar(&cfg.port, "port", cfg.defaultPort, "Port to listen on") + _ = mainFlags.String("envfile", ".env", "Load ENVs from this file") + mainFlags.StringVar(&cfg.pgURL, "pg-url", cfg.pgURL, "Postgres URL such as postgres://postgres@localhost:5432/postgres") + mainFlags.StringVar(&cfg.proxyTarget, "proxy-target", cfg.defaultProxyTarget, "Proxy unhandled requests to this target") + mainFlags.StringVar(&cfg.address, "address", cfg.defaultAddress, "Address to bind to") + + flagOut := os.Stderr + mainFlags.Usage = func() { + _, _ = fmt.Fprintf(flagOut, "USAGE\n") + _, _ = fmt.Fprintf(flagOut, " CHANGE_ME [options]\n") + _, _ = fmt.Fprintf(flagOut, "\n") + _, _ = fmt.Fprintf(flagOut, "EXAMPLES\n") + _, _ = fmt.Fprintf(flagOut, " CHANGE_ME --address 0.0.0.0 --port 443\n") + _, _ = fmt.Fprintf(flagOut, "\n") + _, _ = fmt.Fprintf(flagOut, "OPTIONS\n") + mainFlags.PrintDefaults() + } + + if len(os.Args) > 1 { + switch os.Args[1] { + case "-V", "version", "-version", "--version": + printVersion(os.Stdout) + os.Exit(0) + return + case "help", "-help", "--help": + printVersion(os.Stdout) + _, _ = fmt.Fprintf(os.Stdout, "\n") + + flagOut = os.Stdout + mainFlags.SetOutput(flagOut) + mainFlags.Usage() + os.Exit(0) + return + } + } + printVersion(os.Stderr) + fmt.Fprintf(os.Stderr, "\n") + + if err := mainFlags.Parse(os.Args[1:]); err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + + mainFlags.Usage() + os.Exit(1) + return + } + + if envErr != nil { + fmt.Fprintf(os.Stderr, "could not read .env: %s", envErr) + } + + cfg.encryptionPassphrase = os.Getenv("APP_BIP39_PASSPHRASE") + cfg.encryptionSalt = os.Getenv("APP_BIP39_SALT") + run(&cfg) +} + +func run(cfg *MainConfig) { + var err error + boottime, err := maybeGetUptime() + if err != nil { + log.Printf("could not get server uptime: %s", err) + } + + // TODO: add signal handling for graceful shutdowns and restarts + newHTTPServer = func(ctx context.Context, addr string) (*http.Server, error) { + mux := http.NewServeMux() + + seed, _ := passphrase.SeedFrom(cfg.encryptionPassphrase, cfg.encryptionSalt) + sensitiveKeyHex := hex.EncodeToString(seed) + pgPool, err := configDB(cfg, ctx, sensitiveKeyHex) + if err != nil { + log.Fatalf("Error while creating new pgxpool: %s", err) + } + defer func() { + pgPool.Close() + }() + + api := &internal.API{ + BootTime: boottime, + StartTime: time.Now(), + PG: pgPool, + Queries: db.New(pgPool), + } + mux.HandleFunc("GET /api/status", api.HandleStatus) + mux.HandleFunc("GET /api/hello", api.HandleGreet) + mux.HandleFunc("GET /api/hello/{subject}", api.HandleGreet) + + proxy := internal.ProxyToOtherAPI(cfg.proxyTarget) + mux.HandleFunc("OPTIONS /api/", proxy.ServeHTTP) + mux.HandleFunc("GET /api/", proxy.ServeHTTP) + mux.HandleFunc("POST /api/", proxy.ServeHTTP) + mux.HandleFunc("PUT /api/", proxy.ServeHTTP) + mux.HandleFunc("DELETE /api/", proxy.ServeHTTP) + + // allow http/1.1 and h2 from tls-terminating proxy + protocols := &http.Protocols{} + protocols.SetHTTP1(true) + protocols.SetUnencryptedHTTP2(true) + server := &http.Server{ + Addr: addr, + Handler: mux, + ReadHeaderTimeout: 10 * time.Second, // still needs per-request ReadTimeout + WriteTimeout: 10 * time.Second, + IdleTimeout: (30 + 1) * time.Second, + MaxHeaderBytes: 1 << 14, // 2^14 = 16k + Protocols: protocols, + } + + return server, nil + } + + addr := fmt.Sprintf("%s:%d", cfg.address, cfg.port) + server, err := newHTTPServer(context.TODO(), addr) + if err != nil { + log.Fatal(err) + } + + fmt.Fprintf(os.Stderr, "Listening for http on %s\n", addr) + err = server.ListenAndServe() + log.Fatal(err) +} + +func configDB(cfg *MainConfig, ctx context.Context, sensitiveKeyHex string) (*pgxpool.Pool, error) { + pgConfig, err := pgxpool.ParseConfig(cfg.pgURL) + if err != nil { + log.Fatalf("Error while parsing PG_URL: %s", err) + } + + // This runs for every **new** connection created by the pool + pgConfig.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error { + // Escape the hex string properly for PostgreSQL bytea literal + // \\x + hex digits is the standard way + query := fmt.Sprintf("SET my.sensitive_data_key = '\\x%s';", sensitiveKeyHex) + + // Use Exec (no parameters needed here) + _, err := conn.Exec(ctx, query) + if err != nil { + return fmt.Errorf("failed to set my.sensitive_data_key: %w", err) + } + + // Optional: verify it (for debugging) + // var val string + // err = conn.QueryRow(ctx, "SHOW my.sensitive_data_key").Scan(&val) + // log.Printf("After set: %s", val) + + return nil + } + + return pgxpool.NewWithConfig(ctx, pgConfig) +} diff --git a/cmd/api-example/sql/db.go b/cmd/api-example/sql/db.go new file mode 100644 index 0000000..3a49c63 --- /dev/null +++ b/cmd/api-example/sql/db.go @@ -0,0 +1 @@ +package db diff --git a/cmd/api-example/sql/migrations/0001-01-01-01000_init-migrations.down.sql b/cmd/api-example/sql/migrations/0001-01-01-01000_init-migrations.down.sql new file mode 100644 index 0000000..45a4a4d --- /dev/null +++ b/cmd/api-example/sql/migrations/0001-01-01-01000_init-migrations.down.sql @@ -0,0 +1,3 @@ +DELETE FROM _migrations WHERE id = '00000001'; + +DROP TABLE IF EXISTS _migrations; diff --git a/cmd/api-example/sql/migrations/0001-01-01-01000_init-migrations.up.sql b/cmd/api-example/sql/migrations/0001-01-01-01000_init-migrations.up.sql new file mode 100644 index 0000000..c2079e1 --- /dev/null +++ b/cmd/api-example/sql/migrations/0001-01-01-01000_init-migrations.up.sql @@ -0,0 +1,14 @@ +-- Config variables for sql-migrate (do not delete) +-- sql_command: psql "$PG_URL" -v ON_ERROR_STOP=on -A -t --file %s +-- migrations_log: ./db/paperdb-migrations.log +-- + +CREATE TABLE IF NOT EXISTS _migrations ( + id CHAR(8) PRIMARY KEY, + name VARCHAR(80) UNIQUE NOT NULL, + applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- note: to enable text-based tools to grep and sort we put 'name' before 'id' +-- grep -r 'INSERT INTO _migrations' ./sql/migrations/ | cut -d':' -f2 | sort +INSERT INTO _migrations (name, id) VALUES ('0001-01-01-01000_init-migrations', '00000001'); diff --git a/cmd/api-example/sql/migrations/2026-01-01-001000_add-blobs-table.down.sql b/cmd/api-example/sql/migrations/2026-01-01-001000_add-blobs-table.down.sql new file mode 100644 index 0000000..7804dba --- /dev/null +++ b/cmd/api-example/sql/migrations/2026-01-01-001000_add-blobs-table.down.sql @@ -0,0 +1,4 @@ +-- add-blobs-table (down) +DROP TABLE IF EXISTS "blobs"; + +DELETE FROM _migrations WHERE id = '45249baf'; diff --git a/cmd/api-example/sql/migrations/2026-01-01-001000_add-blobs-table.up.sql b/cmd/api-example/sql/migrations/2026-01-01-001000_add-blobs-table.up.sql new file mode 100644 index 0000000..aad2449 --- /dev/null +++ b/cmd/api-example/sql/migrations/2026-01-01-001000_add-blobs-table.up.sql @@ -0,0 +1,10 @@ +INSERT INTO _migrations (name, id) VALUES ('2026-01-01-001000_add-ai-blobs-table', '45249baf'); + +-- add-blobs-table (up) +CREATE TABLE "blobs" ( + "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "key" VARCHAR(100) NOT NULL, + "value" TEXT NOT NULL, + PRIMARY KEY ("key") +) diff --git a/cmd/api-example/sql/queries/blobs.sql b/cmd/api-example/sql/queries/blobs.sql new file mode 100644 index 0000000..ab96023 --- /dev/null +++ b/cmd/api-example/sql/queries/blobs.sql @@ -0,0 +1,4 @@ +-- name: BlobsAll :many +SELECT * +FROM blobs +ORDER BY key; diff --git a/cmd/api-example/sqlc.yaml b/cmd/api-example/sqlc.yaml new file mode 100644 index 0000000..22d991d --- /dev/null +++ b/cmd/api-example/sqlc.yaml @@ -0,0 +1,17 @@ +version: "2" +sql: + - engine: "postgresql" + schema: "./sql/migrations/" + queries: + - "./sql/queries/" + gen: + go: + package: "db" + out: "./db" + sql_package: "pgx/v5" + emit_json_tags: true + overrides: + - db_type: jsonb + go_type: "encoding/json.RawMessage" + - column: "blobs.value" + go_type: "encoding/json.RawMessage" diff --git a/cmd/api-example/uptime.go b/cmd/api-example/uptime.go new file mode 100644 index 0000000..3f9bc30 --- /dev/null +++ b/cmd/api-example/uptime.go @@ -0,0 +1,36 @@ +package main + +import ( + "fmt" + "os/exec" + "regexp" + "strconv" + "time" +) + +func maybeGetUptime() (time.Time, error) { + out, err := exec.Command("uptime").Output() + if err != nil { + return time.Now(), fmt.Errorf("uptime command failed: %s\n%w", out, err) + } + // Parse uptime output (e.g., "up 1 day, 2:34" or "up 2:34") + re := regexp.MustCompile(`up\s+(?:(\d+)\s+days?,?\s+)?(?:(\d+):)?(\d+)`) + matches := re.FindStringSubmatch(string(out)) + if len(matches) < 2 { + return time.Now(), fmt.Errorf("invalid uptime format") + } + + var seconds int64 + if days, err := strconv.Atoi(matches[1]); err == nil && matches[1] != "" { + seconds += int64(days) * 24 * 3600 + } + if hours, err := strconv.Atoi(matches[2]); err == nil && matches[2] != "" { + seconds += int64(hours) * 3600 + } + if minutes, err := strconv.Atoi(matches[3]); err == nil { + seconds += int64(minutes) * 60 + } + + duration := time.Duration(seconds) * time.Second + return time.Now().Add(-duration), nil +}