diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4e60d8d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,45 @@ +# Build Stage +FROM golang:1.23-alpine AS builder + +WORKDIR /app + +# Install git and build dependencies +RUN apk add --no-cache git + +# Copy go mod and sum files +COPY go.mod go.sum ./ + +# Download dependencies +RUN go mod download + +# Copy source code +COPY . . + +# Build the application +# CGO_ENABLED=0 ensures a static binary +ARG TARGETOS TARGETARCH +RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o api-server ./cmd/api/main.go + +# Runtime Stage +FROM alpine:latest + +WORKDIR /app + +# Install ca-certificates for database connections (TLS) +RUN apk add --no-cache ca-certificates + +# Copy the binary from the builder stage +COPY --from=builder /app/api-server . + +# Copy migrations +COPY --from=builder /app/db/migrations ./db/migrations + +# Expose the application port +EXPOSE 8080 + +# Environment variables with defaults (can be overridden) +ENV PORT=8080 +ENV DATABASE_URL="" + +# Run the binary +CMD ["./api-server"] diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..65ebe1d --- /dev/null +++ b/Justfile @@ -0,0 +1,65 @@ +# Justfile for Go Project Management + +# List all available recipes +default: + @just --list + +# --- Backend --- + +# Build the API server +build-api: + go build -o bin/api ./cmd/api + +# Run the API server locally +run-api: + go run ./cmd/api/main.go + +# Start the API and Database using Docker Compose +docker-up: + docker-compose up --build -d + +# Stop Docker Compose services +docker-down: + docker-compose down + +# Run all tests +test: + go test ./... + +# Run tests with verbose output +test-v: + go test -v ./... + +# Clean build artifacts +clean: + rm -rf bin/ + +# --- TUI --- + +# Build the Terminal UI +build-tui: + go build -o bin/tui ./cmd/tui + +# Run the Terminal UI +run-tui: + go run ./cmd/tui/main.go + +# --- GUI --- + +# Build the GUI (Raylib) +build-gui: + go build -o bin/gui ./cmd/gui + +# Run the GUI +run-gui: + go run ./cmd/gui/main.go + +# --- Database --- + +# Generate Go code from SQL (requires sqlc) +sqlc: + sqlc generate + +# Connect to the running database via psql +db-shell: + docker-compose exec db psql -U postgres -d gamedb diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..9265dc1 --- /dev/null +++ b/cmd/api/main.go @@ -0,0 +1,138 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "net/url" + "os" + "strings" + + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/adapters/humaecho" + "github.com/golang-migrate/migrate/v4" + _ "github.com/golang-migrate/migrate/v4/database/postgres" + _ "github.com/golang-migrate/migrate/v4/source/file" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + _ "github.com/lib/pq" + + "completed/internal/api" + "completed/internal/db" +) + +func ensureDatabase(dbURL string) error { + u, err := url.Parse(dbURL) + if err != nil { + return err + } + + dbName := strings.TrimPrefix(u.Path, "/") + if dbName == "" { + return fmt.Errorf("database name is empty in URL: %s", dbURL) + } + + // Connect to 'postgres' database to create the new DB + u.Path = "/postgres" + db, err := sql.Open("postgres", u.String()) + if err != nil { + return err + } + defer db.Close() + + // Check if DB exists + var exists bool + err = db.QueryRow("SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)", dbName).Scan(&exists) + if err != nil { + return err + } + + if !exists { + fmt.Printf("Database '%s' does not exist. Creating...\n", dbName) + _, err = db.Exec(fmt.Sprintf("CREATE DATABASE \"%s\"", dbName)) + if err != nil { + return err + } + fmt.Printf("Database '%s' created successfully.\n", dbName) + } else { + fmt.Printf("Database '%s' already exists.\n", dbName) + } + return nil +} + +func runMigrations(dbURL string) { + m, err := migrate.New( + "file:///app/db/migrations", // Path inside Docker container + dbURL, + ) + if err != nil { + // Fallback for local development if not in /app + if os.Getenv("MIGRATION_PATH") != "" { + m, err = migrate.New( + "file://"+os.Getenv("MIGRATION_PATH"), + dbURL, + ) + } else { + // Try relative path default + m, err = migrate.New( + "file://db/migrations", + dbURL, + ) + } + } + + if err != nil { + fmt.Fprintf(os.Stderr, "Migration initialization failed: %v\n", err) + return + } + + if err := m.Up(); err != nil && err != migrate.ErrNoChange { + fmt.Fprintf(os.Stderr, "Migration failed: %v\n", err) + } else { + fmt.Println("Migrations ran successfully") + } +} + +func main() { + // 1. Database connection + dbURL := os.Getenv("DATABASE_URL") + if dbURL == "" { + dbURL = "postgres://user:password@localhost:5432/dbname?sslmode=disable" + } + + // Ensure Database Exists + if err := ensureDatabase(dbURL); err != nil { + fmt.Fprintf(os.Stderr, "Failed to ensure database: %v\n", err) + } + + // Run Migrations + runMigrations(dbURL) + + pool, err := pgxpool.New(context.Background(), dbURL) + if err != nil { + fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err) + os.Exit(1) + } + defer pool.Close() + + queries := db.New(pool) + + // 2. Setup Echo + e := echo.New() + e.Use(middleware.Logger()) + e.Use(middleware.Recover()) + + // 3. Setup Huma + humaApi := humaecho.New(e, huma.DefaultConfig("My API", "1.0.0")) + + // 4. Register Routes + api.RegisterRoutes(humaApi, queries) + + // 5. Start Server + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + e.Logger.Fatal(e.Start(":" + port)) +} diff --git a/cmd/gui/main.go b/cmd/gui/main.go new file mode 100644 index 0000000..79d4853 --- /dev/null +++ b/cmd/gui/main.go @@ -0,0 +1,145 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + rl "github.com/gen2brain/raylib-go/raylib" +) + +type Game struct { + ID int64 `json:"id"` + Name string `json:"name"` + PlatformID int64 `json:"platform_id"` + Score int32 `json:"score"` + ReleaseYear string `json:"release_year"` + Finished string `json:"finished,omitempty"` +} + +type GamesResponse struct { + Body []Game `json:"body"` +} + +func main() { + rl.InitWindow(800, 600, "My Game Collection - Raylib") + defer rl.CloseWindow() + + rl.SetTargetFPS(60) + + loading := false + var err error + var games []Game + + // Channel to receive data from background goroutine + dataChan := make(chan []Game) + errChan := make(chan error) + + func() { + loading = true + go fetchGames(dataChan, errChan) + }() + + for !rl.WindowShouldClose() { + rl.BeginDrawing() + rl.ClearBackground(rl.RayWhite) + + // Header + rl.DrawRectangle(0, 0, 800, 60, rl.NewColor(100, 100, 255, 255)) + rl.DrawText("My Game Collection", 20, 15, 30, rl.White) + + startY := 80 + + if loading { + rl.DrawText("Loading games...", 20, int32(startY), 20, rl.Gray) + // Simple spinner animation + if int(rl.GetTime()*4)%2 == 0 { + rl.DrawText(".", 180, int32(startY), 20, rl.Gray) + } + } else if err != nil { + rl.DrawText(fmt.Sprintf("Error: %v", err), 20, int32(startY), 20, rl.Red) + rl.DrawText("Press SPACE to retry", 20, int32(startY)+30, 20, rl.DarkGray) + } else { + if len(games) == 0 { + rl.DrawText("No games found in database.", 20, int32(startY), 20, rl.DarkGray) + } else { + for i, g := range games { + yPos := int32(startY + (i * 40)) + + // Row background (alternating) + if i%2 == 0 { + rl.DrawRectangle(20, yPos-5, 760, 35, rl.NewColor(240, 240, 240, 255)) + } + + // Game Name + rl.DrawText(g.Name, 30, yPos, 20, rl.Black) + + // Year + rl.DrawText(g.ReleaseYear, 350, yPos, 20, rl.DarkGray) + + // Score + scoreColor := rl.Orange + if g.Score >= 90 { + scoreColor = rl.Green + } else if g.Score < 70 { + scoreColor = rl.Red + } + rl.DrawText(fmt.Sprintf("%d", g.Score), 500, yPos, 20, scoreColor) + + // Finished Status + if g.Finished != "" { + rl.DrawText("Finished", 600, yPos, 20, rl.NewColor(0, 200, 0, 255)) + } + } + } + } + + // Footer / Help + rl.DrawText("Press SPACE to refresh", 20, 570, 10, rl.Gray) + + rl.EndDrawing() + + // Inputs + if rl.IsKeyPressed(rl.KeySpace) && !loading { + loading = true + err = nil + games = nil + go fetchGames(dataChan, errChan) + } + + // Check channels (non-blocking) + select { + case data := <-dataChan: + loading = false + games = data + case e := <-errChan: + loading = false + err = e + default: + } + } +} + +func fetchGames(dataChan chan<- []Game, errChan chan<- error) { + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get("http://localhost:8080/games") + if err != nil { + errChan <- err + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + errChan <- fmt.Errorf("status code: %d", resp.StatusCode) + return + } + + var data GamesResponse + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + errChan <- err + return + } + + dataChan <- data.Body +} diff --git a/cmd/tui/main.go b/cmd/tui/main.go new file mode 100644 index 0000000..d40ac05 --- /dev/null +++ b/cmd/tui/main.go @@ -0,0 +1,150 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "time" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// Styles +var ( + titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FAFAFA")). + Background(lipgloss.Color("#7D56F4")). + Padding(0, 1) + + itemStyle = lipgloss.NewStyle(). + PaddingLeft(2) + + scoreStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFD700")) // Gold color + + errorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF5555")). + Bold(true) +) + +type Game struct { + ID int64 `json:"id"` + Name string `json:"name"` + PlatformID int64 `json:"platform_id"` + Score int32 `json:"score"` + ReleaseYear string `json:"release_year"` + Finished string `json:"finished,omitempty"` +} + +type GamesResponse struct { + Body []Game `json:"body"` +} + +type Model struct { + spinner spinner.Model + loading bool + err error + games []Game + quitting bool +} + +func InitialModel() Model { + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) + return Model{spinner: s, loading: true} +} + +type loadedMsg []Game +type errMsg error + +func fetchGames() tea.Msg { + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get("http://localhost:8080/games") + if err != nil { + return errMsg(err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return errMsg(fmt.Errorf("status code: %d", resp.StatusCode)) + } + + var data GamesResponse + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return errMsg(err) + } + + return loadedMsg(data.Body) +} + +func (m Model) Init() tea.Cmd { + return tea.Batch(m.spinner.Tick, fetchGames) +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "q" || msg.String() == "ctrl+c" { + m.quitting = true + return m, tea.Quit + } + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + case loadedMsg: + m.loading = false + m.games = msg + return m, nil + case errMsg: + m.loading = false + m.err = msg + return m, nil + } + return m, nil +} + +func (m Model) View() string { + if m.quitting { + return "Bye!\n" + } + + s := titleStyle.Render("My Game Collection") + "\n\n" + + if m.loading { + s += fmt.Sprintf("%s Loading games...", m.spinner.View()) + } else if m.err != nil { + s += errorStyle.Render(fmt.Sprintf("Error: %v", m.err)) + s += "\nMake sure the API is running on localhost:8080" + } else { + if len(m.games) == 0 { + s += "No games found." + } else { + for _, g := range m.games { + score := scoreStyle.Render(fmt.Sprintf("★ %d", g.Score)) + line := fmt.Sprintf("• %s (%s) - %s", g.Name, g.ReleaseYear, score) + if g.Finished != "" { + line += " [✓ Finished]" + } + s += itemStyle.Render(line) + "\n" + } + } + } + + s += strings.Repeat("\n", 2) + lipgloss.NewStyle().Foreground(lipgloss.Color("#555")).Render("Press 'q' to quit") + return s +} + +func main() { + p := tea.NewProgram(InitialModel()) + if _, err := p.Run(); err != nil { + fmt.Printf("Alas, there's been an error: %v", err) + os.Exit(1) + } +} diff --git a/db/migrations/20251222211500_add_auth_to_users.down.sql b/db/migrations/20251222211500_add_auth_to_users.down.sql new file mode 100644 index 0000000..3616c65 --- /dev/null +++ b/db/migrations/20251222211500_add_auth_to_users.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE users +DROP COLUMN email, +DROP COLUMN password_hash; diff --git a/db/migrations/20251222211500_add_auth_to_users.up.sql b/db/migrations/20251222211500_add_auth_to_users.up.sql new file mode 100644 index 0000000..0f3a471 --- /dev/null +++ b/db/migrations/20251222211500_add_auth_to_users.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE users +ADD COLUMN email TEXT NOT NULL UNIQUE, +ADD COLUMN password_hash TEXT NOT NULL; diff --git a/db/query.sql b/db/query.sql new file mode 100644 index 0000000..39b5fd8 --- /dev/null +++ b/db/query.sql @@ -0,0 +1,67 @@ +-- name: GetUser :one +SELECT * FROM users +WHERE id = $1 LIMIT 1; + +-- name: ListUsers :many +SELECT * FROM users +ORDER BY name; + +-- name: CreateUser :one +INSERT INTO users ( + name, bio +) VALUES ( + $1, $2 +) +RETURNING *; + +-- name: DeleteUser :exec +DELETE FROM users +WHERE id = $1; + +-- name: CreatePlatform :one +INSERT INTO platform ( + name +) VALUES ( + $1 +) +RETURNING *; + +-- name: ListPlatforms :many +SELECT * FROM platform +ORDER BY name; + +-- name: CreateGame :one +INSERT INTO game ( + name, platform_id, score, release_year, finished +) VALUES ( + $1, $2, $3, $4, $5 +) +RETURNING *; + +-- name: GetGame :one +SELECT * FROM game +WHERE id = $1 LIMIT 1; + +-- name: ListGames :many +SELECT * FROM game +ORDER BY name; + +-- name: UpdateGame :one +UPDATE game +SET name = $2, platform_id = $3, score = $4, release_year = $5, finished = $6 +WHERE id = $1 +RETURNING *; + +-- name: DeleteGame :exec +DELETE FROM game +WHERE id = $1; + +-- name: UpdatePlatform :one +UPDATE platform +SET name = $2 +WHERE id = $1 +RETURNING *; + +-- name: DeletePlatform :exec +DELETE FROM platform +WHERE id = $1; diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..9ba015c --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,21 @@ +CREATE TABLE users ( + id BIGSERIAL PRIMARY KEY, + name text NOT NULL, + email text NOT NULL UNIQUE, + password_hash text NOT NULL, + bio text +); + +CREATE TABLE platform ( + id BIGSERIAL PRIMARY KEY, + name text NOT NULL +); + +CREATE TABLE game ( + id BIGSERIAL PRIMARY KEY, + name text NOT NULL, + platform_id BIGINT NOT NULL REFERENCES platform(id), + score INT NOT NULL, + release_year DATE NOT NULL, + finished DATE +); diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..0936c24 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,28 @@ +version: '3.8' + +services: + app: + build: . + ports: + - "8080:8080" + environment: + - PORT=8080 + - DATABASE_URL=postgres://postgres:password@db:5432/gamedb?sslmode=disable + depends_on: + - db + restart: on-failure + + db: + image: postgres:16-alpine + restart: always + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=password + - POSTGRES_DB=gamedb + ports: + - "5432:5432" + volumes: + - db-data:/var/lib/postgresql/data + +volumes: + db-data: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5993383 --- /dev/null +++ b/go.mod @@ -0,0 +1,52 @@ +module completed + +go 1.25.4 + +require ( + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/danielgtaylor/huma/v2 v2.34.1 + github.com/jackc/pgx/v5 v5.7.6 + github.com/labstack/echo/v4 v4.14.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/ebitengine/purego v0.7.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/gen2brain/raylib-go/raylib v0.55.1 // indirect + github.com/golang-migrate/migrate/v4 v4.19.1 // indirect + 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 + github.com/labstack/gommon v0.4.2 // indirect + github.com/lib/pq v1.10.9 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + golang.org/x/time v0.14.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..94e8194 --- /dev/null +++ b/go.sum @@ -0,0 +1,103 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/danielgtaylor/huma/v2 v2.34.1 h1:EmOJAbzEGfy0wAq/QMQ1YKfEMBEfE94xdBRLPBP0gwQ= +github.com/danielgtaylor/huma/v2 v2.34.1/go.mod h1:ynwJgLk8iGVgoaipi5tgwIQ5yoFNmiu+QdhU7CEEmhk= +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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ebitengine/purego v0.7.1 h1:6/55d26lG3o9VCZX8lping+bZcmShseiqlh2bnUDiPA= +github.com/ebitengine/purego v0.7.1/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/gen2brain/raylib-go/raylib v0.55.1 h1:1rdc10WvvYjtj7qijHnV9T38/WuvlT6IIL+PaZ6cNA8= +github.com/gen2brain/raylib-go/raylib v0.55.1/go.mod h1:BaY76bZk7nw1/kVOSQObPY1v1iwVE1KHAGMfvI6oK1Q= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +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/labstack/echo/v4 v4.14.0 h1:+tiMrDLxwv6u0oKtD03mv+V1vXXB3wCqPHJqPuIe+7M= +github.com/labstack/echo/v4 v4.14.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +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/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= +golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +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/internal/api/mock_db_test.go b/internal/api/mock_db_test.go new file mode 100644 index 0000000..2c31b9f --- /dev/null +++ b/internal/api/mock_db_test.go @@ -0,0 +1,63 @@ +package api + +import ( + "context" + + "completed/internal/db" +) + +type MockQuerier struct { + CreateGameFunc func(ctx context.Context, arg db.CreateGameParams) (db.Game, error) + CreatePlatformFunc func(ctx context.Context, name string) (db.Platform, error) + CreateUserFunc func(ctx context.Context, arg db.CreateUserParams) (db.User, error) + DeleteGameFunc func(ctx context.Context, id int64) error + DeletePlatformFunc func(ctx context.Context, id int64) error + DeleteUserFunc func(ctx context.Context, id int64) error + GetGameFunc func(ctx context.Context, id int64) (db.Game, error) + GetUserFunc func(ctx context.Context, id int64) (db.User, error) + ListGamesFunc func(ctx context.Context) ([]db.Game, error) + ListPlatformsFunc func(ctx context.Context) ([]db.Platform, error) + ListUsersFunc func(ctx context.Context) ([]db.User, error) + UpdateGameFunc func(ctx context.Context, arg db.UpdateGameParams) (db.Game, error) + UpdatePlatformFunc func(ctx context.Context, arg db.UpdatePlatformParams) (db.Platform, error) +} + +func (m *MockQuerier) CreateGame(ctx context.Context, arg db.CreateGameParams) (db.Game, error) { + return m.CreateGameFunc(ctx, arg) +} +func (m *MockQuerier) CreatePlatform(ctx context.Context, name string) (db.Platform, error) { + return m.CreatePlatformFunc(ctx, name) +} +func (m *MockQuerier) CreateUser(ctx context.Context, arg db.CreateUserParams) (db.User, error) { + return m.CreateUserFunc(ctx, arg) +} +func (m *MockQuerier) DeleteGame(ctx context.Context, id int64) error { + return m.DeleteGameFunc(ctx, id) +} +func (m *MockQuerier) DeletePlatform(ctx context.Context, id int64) error { + return m.DeletePlatformFunc(ctx, id) +} +func (m *MockQuerier) DeleteUser(ctx context.Context, id int64) error { + return m.DeleteUserFunc(ctx, id) +} +func (m *MockQuerier) GetGame(ctx context.Context, id int64) (db.Game, error) { + return m.GetGameFunc(ctx, id) +} +func (m *MockQuerier) GetUser(ctx context.Context, id int64) (db.User, error) { + return m.GetUserFunc(ctx, id) +} +func (m *MockQuerier) ListGames(ctx context.Context) ([]db.Game, error) { + return m.ListGamesFunc(ctx) +} +func (m *MockQuerier) ListPlatforms(ctx context.Context) ([]db.Platform, error) { + return m.ListPlatformsFunc(ctx) +} +func (m *MockQuerier) ListUsers(ctx context.Context) ([]db.User, error) { + return m.ListUsersFunc(ctx) +} +func (m *MockQuerier) UpdateGame(ctx context.Context, arg db.UpdateGameParams) (db.Game, error) { + return m.UpdateGameFunc(ctx, arg) +} +func (m *MockQuerier) UpdatePlatform(ctx context.Context, arg db.UpdatePlatformParams) (db.Platform, error) { + return m.UpdatePlatformFunc(ctx, arg) +} diff --git a/internal/api/routes.go b/internal/api/routes.go new file mode 100644 index 0000000..f8ceef2 --- /dev/null +++ b/internal/api/routes.go @@ -0,0 +1,257 @@ +package api + +import ( + "context" + "net/http" + "time" + + "github.com/danielgtaylor/huma/v2" + "github.com/jackc/pgx/v5/pgtype" + + "completed/internal/db" +) + +func RegisterRoutes(api huma.API, queries db.Querier) { + // Root/Hello Route + huma.Register(api, huma.Operation{ + OperationID: "get-hello", + Method: http.MethodGet, + Path: "/hello", + Summary: "Say Hello", + Description: "Returns a friendly hello message.", + }, func(ctx context.Context, input *struct{}) (*struct { + Body struct { + Message string `json:"message"` + } + }, error) { + resp := &struct { + Body struct { + Message string `json:"message"` + } + }{} + resp.Body.Message = "Hello from Huma + Echo!" + return resp, nil + }) + + registerPlatformRoutes(api, queries) + registerGameRoutes(api, queries) +} + +// --- Platform Routes --- + +// PlatformDTO ensures consistent JSON output +type PlatformDTO struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +type PlatformInput struct { + Name string `json:"name" doc:"Name of the platform"` +} + +func registerPlatformRoutes(api huma.API, queries db.Querier) { + huma.Register(api, huma.Operation{ + OperationID: "list-platforms", + Method: http.MethodGet, + Path: "/platforms", + Summary: "List Platforms", + }, func(ctx context.Context, input *struct{}) (*struct{ Body []PlatformDTO }, error) { + platforms, err := queries.ListPlatforms(ctx) + if err != nil { + return nil, huma.Error500InternalServerError("Database error: " + err.Error()) + } + dtos := make([]PlatformDTO, len(platforms)) + for i, p := range platforms { + dtos[i] = PlatformDTO{ID: p.ID, Name: p.Name} + } + return &struct{ Body []PlatformDTO }{Body: dtos}, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "create-platform", + Method: http.MethodPost, + Path: "/platforms", + Summary: "Create Platform", + }, func(ctx context.Context, input *struct{ Body PlatformInput }) (*struct{ Body PlatformDTO }, error) { + platform, err := queries.CreatePlatform(ctx, input.Body.Name) + if err != nil { + return nil, huma.Error500InternalServerError("Database error: " + err.Error()) + } + return &struct{ Body PlatformDTO }{Body: PlatformDTO{ID: platform.ID, Name: platform.Name}}, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "update-platform", + Method: http.MethodPut, + Path: "/platforms/{id}", + Summary: "Update Platform", + }, func(ctx context.Context, input *struct { + ID int64 `path:"id"` + Body PlatformInput + }) (*struct{ Body PlatformDTO }, error) { + platform, err := queries.UpdatePlatform(ctx, db.UpdatePlatformParams{ + ID: input.ID, + Name: input.Body.Name, + }) + if err != nil { + return nil, huma.Error500InternalServerError("Database error: " + err.Error()) + } + return &struct{ Body PlatformDTO }{Body: PlatformDTO{ID: platform.ID, Name: platform.Name}}, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "delete-platform", + Method: http.MethodDelete, + Path: "/platforms/{id}", + Summary: "Delete Platform", + }, func(ctx context.Context, input *struct { + ID int64 `path:"id"` + }) (*struct{}, error) { + err := queries.DeletePlatform(ctx, input.ID) + if err != nil { + return nil, huma.Error500InternalServerError("Database error: " + err.Error()) + } + return nil, nil + }) +} + +// --- Game Routes --- + +// GameDTO for API communication (Clean JSON) +type GameDTO struct { + ID int64 `json:"id"` + Name string `json:"name"` + PlatformID int64 `json:"platform_id"` + Score int32 `json:"score"` + ReleaseYear string `json:"release_year" format:"date" doc:"YYYY-MM-DD"` + Finished string `json:"finished,omitempty" format:"date" doc:"YYYY-MM-DD (optional)"` +} + +type GameInput struct { + Name string `json:"name"` + PlatformID int64 `json:"platform_id"` + Score int32 `json:"score"` + ReleaseYear string `json:"release_year" format:"date" doc:"YYYY-MM-DD"` + Finished string `json:"finished,omitempty" format:"date" doc:"YYYY-MM-DD (optional)"` +} + +// Helper to convert DB Game to DTO +func toGameDTO(g db.Game) GameDTO { + dto := GameDTO{ + ID: g.ID, + Name: g.Name, + PlatformID: g.PlatformID, + Score: g.Score, + ReleaseYear: g.ReleaseYear.Time.Format("2006-01-02"), + } + if g.Finished.Valid { + dto.Finished = g.Finished.Time.Format("2006-01-02") + } + return dto +} + +// Helper to parse date string to pgtype.Date +func parseDate(s string) (pgtype.Date, error) { + if s == "" { + return pgtype.Date{}, nil + } + t, err := time.Parse("2006-01-02", s) + if err != nil { + return pgtype.Date{}, err + } + return pgtype.Date{Time: t, Valid: true}, nil +} + +func registerGameRoutes(api huma.API, queries db.Querier) { + huma.Register(api, huma.Operation{ + OperationID: "list-games", + Method: http.MethodGet, + Path: "/games", + Summary: "List Games", + }, func(ctx context.Context, input *struct{}) (*struct{ Body []GameDTO }, error) { + games, err := queries.ListGames(ctx) + if err != nil { + return nil, huma.Error500InternalServerError("Database error: " + err.Error()) + } + dtos := make([]GameDTO, len(games)) + for i, g := range games { + dtos[i] = toGameDTO(g) + } + return &struct{ Body []GameDTO }{Body: dtos}, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "create-game", + Method: http.MethodPost, + Path: "/games", + Summary: "Create Game", + }, func(ctx context.Context, input *struct{ Body GameInput }) (*struct{ Body GameDTO }, error) { + ry, err := parseDate(input.Body.ReleaseYear) + if err != nil { + return nil, huma.Error400BadRequest("Invalid release_year format (expected YYYY-MM-DD)") + } + fin, err := parseDate(input.Body.Finished) + if err != nil { + return nil, huma.Error400BadRequest("Invalid finished format (expected YYYY-MM-DD)") + } + + game, err := queries.CreateGame(ctx, db.CreateGameParams{ + Name: input.Body.Name, + PlatformID: input.Body.PlatformID, + Score: input.Body.Score, + ReleaseYear: ry, + Finished: fin, + }) + if err != nil { + return nil, huma.Error500InternalServerError("Database error: " + err.Error()) + } + return &struct{ Body GameDTO }{Body: toGameDTO(game)}, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "update-game", + Method: http.MethodPut, + Path: "/games/{id}", + Summary: "Update Game", + }, func(ctx context.Context, input *struct { + ID int64 `path:"id"` + Body GameInput + }) (*struct{ Body GameDTO }, error) { + ry, err := parseDate(input.Body.ReleaseYear) + if err != nil { + return nil, huma.Error400BadRequest("Invalid release_year format (expected YYYY-MM-DD)") + } + fin, err := parseDate(input.Body.Finished) + if err != nil { + return nil, huma.Error400BadRequest("Invalid finished format (expected YYYY-MM-DD)") + } + + game, err := queries.UpdateGame(ctx, db.UpdateGameParams{ + ID: input.ID, + Name: input.Body.Name, + PlatformID: input.Body.PlatformID, + Score: input.Body.Score, + ReleaseYear: ry, + Finished: fin, + }) + if err != nil { + return nil, huma.Error500InternalServerError("Database error: " + err.Error()) + } + return &struct{ Body GameDTO }{Body: toGameDTO(game)}, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "delete-game", + Method: http.MethodDelete, + Path: "/games/{id}", + Summary: "Delete Game", + }, func(ctx context.Context, input *struct { + ID int64 `path:"id"` + }) (*struct{}, error) { + err := queries.DeleteGame(ctx, input.ID) + if err != nil { + return nil, huma.Error500InternalServerError("Database error: " + err.Error()) + } + return nil, nil + }) +} diff --git a/internal/api/routes_test.go b/internal/api/routes_test.go new file mode 100644 index 0000000..99bdbf6 --- /dev/null +++ b/internal/api/routes_test.go @@ -0,0 +1,66 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/adapters/humaecho" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + + "completed/internal/db" +) + +func TestListGames(t *testing.T) { + // 1. Setup Mock + mockDB := &MockQuerier{ + ListGamesFunc: func(ctx context.Context) ([]db.Game, error) { + return []db.Game{ + {ID: 1, Name: "Test Game", Score: 90}, + }, nil + }, + } + + // 2. Setup API + e := echo.New() + api := humaecho.New(e, huma.DefaultConfig("Test API", "1.0.0")) + RegisterRoutes(api, mockDB) + + // 3. Execute Request + req := httptest.NewRequest(http.MethodGet, "/games", nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + // 4. Assert + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "Test Game") +} + +func TestCreatePlatform(t *testing.T) { + // 1. Setup Mock + mockDB := &MockQuerier{ + CreatePlatformFunc: func(ctx context.Context, name string) (db.Platform, error) { + return db.Platform{ID: 1, Name: name}, nil + }, + } + + // 2. Setup API + e := echo.New() + api := humaecho.New(e, huma.DefaultConfig("Test API", "1.0.0")) + RegisterRoutes(api, mockDB) + + // 3. Execute Request + req := httptest.NewRequest(http.MethodPost, "/platforms", strings.NewReader(`{"name": "Sega Genesis"}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + // 4. Assert + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "Sega Genesis") + assert.Contains(t, rec.Body.String(), `"id":1`) +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..9d485b5 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/db/models.go b/internal/db/models.go new file mode 100644 index 0000000..48c38f8 --- /dev/null +++ b/internal/db/models.go @@ -0,0 +1,29 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package db + +import ( + "github.com/jackc/pgx/v5/pgtype" +) + +type Game struct { + ID int64 + Name string + PlatformID int64 + Score int32 + ReleaseYear pgtype.Date + Finished pgtype.Date +} + +type Platform struct { + ID int64 + Name string +} + +type User struct { + ID int64 + Name string + Bio pgtype.Text +} diff --git a/internal/db/querier.go b/internal/db/querier.go new file mode 100644 index 0000000..f1e8f05 --- /dev/null +++ b/internal/db/querier.go @@ -0,0 +1,27 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package db + +import ( + "context" +) + +type Querier interface { + CreateGame(ctx context.Context, arg CreateGameParams) (Game, error) + CreatePlatform(ctx context.Context, name string) (Platform, error) + CreateUser(ctx context.Context, arg CreateUserParams) (User, error) + DeleteGame(ctx context.Context, id int64) error + DeletePlatform(ctx context.Context, id int64) error + DeleteUser(ctx context.Context, id int64) error + GetGame(ctx context.Context, id int64) (Game, error) + GetUser(ctx context.Context, id int64) (User, error) + ListGames(ctx context.Context) ([]Game, error) + ListPlatforms(ctx context.Context) ([]Platform, error) + ListUsers(ctx context.Context) ([]User, error) + UpdateGame(ctx context.Context, arg UpdateGameParams) (Game, error) + UpdatePlatform(ctx context.Context, arg UpdatePlatformParams) (Platform, error) +} + +var _ Querier = (*Queries)(nil) diff --git a/internal/db/query.sql.go b/internal/db/query.sql.go new file mode 100644 index 0000000..f0ffc87 --- /dev/null +++ b/internal/db/query.sql.go @@ -0,0 +1,285 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: query.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createGame = `-- name: CreateGame :one +INSERT INTO game ( + name, platform_id, score, release_year, finished +) VALUES ( + $1, $2, $3, $4, $5 +) +RETURNING id, name, platform_id, score, release_year, finished +` + +type CreateGameParams struct { + Name string + PlatformID int64 + Score int32 + ReleaseYear pgtype.Date + Finished pgtype.Date +} + +func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, error) { + row := q.db.QueryRow(ctx, createGame, + arg.Name, + arg.PlatformID, + arg.Score, + arg.ReleaseYear, + arg.Finished, + ) + var i Game + err := row.Scan( + &i.ID, + &i.Name, + &i.PlatformID, + &i.Score, + &i.ReleaseYear, + &i.Finished, + ) + return i, err +} + +const createPlatform = `-- name: CreatePlatform :one +INSERT INTO platform ( + name +) VALUES ( + $1 +) +RETURNING id, name +` + +func (q *Queries) CreatePlatform(ctx context.Context, name string) (Platform, error) { + row := q.db.QueryRow(ctx, createPlatform, name) + var i Platform + err := row.Scan(&i.ID, &i.Name) + return i, err +} + +const createUser = `-- name: CreateUser :one +INSERT INTO users ( + name, bio +) VALUES ( + $1, $2 +) +RETURNING id, name, bio +` + +type CreateUserParams struct { + Name string + Bio pgtype.Text +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { + row := q.db.QueryRow(ctx, createUser, arg.Name, arg.Bio) + var i User + err := row.Scan(&i.ID, &i.Name, &i.Bio) + return i, err +} + +const deleteGame = `-- name: DeleteGame :exec +DELETE FROM game +WHERE id = $1 +` + +func (q *Queries) DeleteGame(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, deleteGame, id) + return err +} + +const deletePlatform = `-- name: DeletePlatform :exec +DELETE FROM platform +WHERE id = $1 +` + +func (q *Queries) DeletePlatform(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, deletePlatform, id) + return err +} + +const deleteUser = `-- name: DeleteUser :exec +DELETE FROM users +WHERE id = $1 +` + +func (q *Queries) DeleteUser(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, deleteUser, id) + return err +} + +const getGame = `-- name: GetGame :one +SELECT id, name, platform_id, score, release_year, finished FROM game +WHERE id = $1 LIMIT 1 +` + +func (q *Queries) GetGame(ctx context.Context, id int64) (Game, error) { + row := q.db.QueryRow(ctx, getGame, id) + var i Game + err := row.Scan( + &i.ID, + &i.Name, + &i.PlatformID, + &i.Score, + &i.ReleaseYear, + &i.Finished, + ) + return i, err +} + +const getUser = `-- name: GetUser :one +SELECT id, name, bio FROM users +WHERE id = $1 LIMIT 1 +` + +func (q *Queries) GetUser(ctx context.Context, id int64) (User, error) { + row := q.db.QueryRow(ctx, getUser, id) + var i User + err := row.Scan(&i.ID, &i.Name, &i.Bio) + return i, err +} + +const listGames = `-- name: ListGames :many +SELECT id, name, platform_id, score, release_year, finished FROM game +ORDER BY name +` + +func (q *Queries) ListGames(ctx context.Context) ([]Game, error) { + rows, err := q.db.Query(ctx, listGames) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Game + for rows.Next() { + var i Game + if err := rows.Scan( + &i.ID, + &i.Name, + &i.PlatformID, + &i.Score, + &i.ReleaseYear, + &i.Finished, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listPlatforms = `-- name: ListPlatforms :many +SELECT id, name FROM platform +ORDER BY name +` + +func (q *Queries) ListPlatforms(ctx context.Context) ([]Platform, error) { + rows, err := q.db.Query(ctx, listPlatforms) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Platform + for rows.Next() { + var i Platform + if err := rows.Scan(&i.ID, &i.Name); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listUsers = `-- name: ListUsers :many +SELECT id, name, bio FROM users +ORDER BY name +` + +func (q *Queries) ListUsers(ctx context.Context) ([]User, error) { + rows, err := q.db.Query(ctx, listUsers) + if err != nil { + return nil, err + } + defer rows.Close() + var items []User + for rows.Next() { + var i User + if err := rows.Scan(&i.ID, &i.Name, &i.Bio); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateGame = `-- name: UpdateGame :one +UPDATE game +SET name = $2, platform_id = $3, score = $4, release_year = $5, finished = $6 +WHERE id = $1 +RETURNING id, name, platform_id, score, release_year, finished +` + +type UpdateGameParams struct { + ID int64 + Name string + PlatformID int64 + Score int32 + ReleaseYear pgtype.Date + Finished pgtype.Date +} + +func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) (Game, error) { + row := q.db.QueryRow(ctx, updateGame, + arg.ID, + arg.Name, + arg.PlatformID, + arg.Score, + arg.ReleaseYear, + arg.Finished, + ) + var i Game + err := row.Scan( + &i.ID, + &i.Name, + &i.PlatformID, + &i.Score, + &i.ReleaseYear, + &i.Finished, + ) + return i, err +} + +const updatePlatform = `-- name: UpdatePlatform :one +UPDATE platform +SET name = $2 +WHERE id = $1 +RETURNING id, name +` + +type UpdatePlatformParams struct { + ID int64 + Name string +} + +func (q *Queries) UpdatePlatform(ctx context.Context, arg UpdatePlatformParams) (Platform, error) { + row := q.db.QueryRow(ctx, updatePlatform, arg.ID, arg.Name) + var i Platform + err := row.Scan(&i.ID, &i.Name) + return i, err +} diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..6dd3c49 --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,11 @@ +version: "2" +sql: + - schema: "db/schema.sql" + queries: "db/query.sql" + engine: "postgresql" + gen: + go: + package: "db" + out: "internal/db" + sql_package: "pgx/v5" + emit_interface: true