feat: add cmd/api-example as server boilerplate

This commit is contained in:
AJ ONeal 2026-02-16 02:35:43 -07:00
parent 3700770144
commit 614af86976
No known key found for this signature in database
16 changed files with 599 additions and 0 deletions

53
cmd/api-example/args.go Normal file
View File

@ -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
}

42
cmd/api-example/git.go Normal file
View File

@ -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)
}

17
cmd/api-example/go.mod Normal file
View File

@ -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
)

30
cmd/api-example/go.sum Normal file
View File

@ -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=

View File

@ -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, " ")
}

View File

@ -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
}

View File

@ -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)
}

248
cmd/api-example/main.go Normal file
View File

@ -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 <aj@therootcompany.com> (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 <https://github.com/tinygo-org/tinygo/issues/2976>
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)
}

View File

@ -0,0 +1 @@
package db

View File

@ -0,0 +1,3 @@
DELETE FROM _migrations WHERE id = '00000001';
DROP TABLE IF EXISTS _migrations;

View File

@ -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');

View File

@ -0,0 +1,4 @@
-- add-blobs-table (down)
DROP TABLE IF EXISTS "blobs";
DELETE FROM _migrations WHERE id = '45249baf';

View File

@ -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")
)

View File

@ -0,0 +1,4 @@
-- name: BlobsAll :many
SELECT *
FROM blobs
ORDER BY key;

17
cmd/api-example/sqlc.yaml Normal file
View File

@ -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"

36
cmd/api-example/uptime.go Normal file
View File

@ -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
}