Created a base to build on

This commit is contained in:
2025-12-22 21:19:31 +01:00
parent a1e33bd403
commit 47a9719eeb
20 changed files with 1590 additions and 0 deletions

45
Dockerfile Normal file
View File

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

65
Justfile Normal file
View File

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

138
cmd/api/main.go Normal file
View File

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

145
cmd/gui/main.go Normal file
View File

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

150
cmd/tui/main.go Normal file
View File

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

View File

@@ -0,0 +1,3 @@
ALTER TABLE users
DROP COLUMN email,
DROP COLUMN password_hash;

View File

@@ -0,0 +1,3 @@
ALTER TABLE users
ADD COLUMN email TEXT NOT NULL UNIQUE,
ADD COLUMN password_hash TEXT NOT NULL;

67
db/query.sql Normal file
View File

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

21
db/schema.sql Normal file
View File

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

28
docker-compose.yaml Normal file
View File

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

52
go.mod Normal file
View File

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

103
go.sum Normal file
View File

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

View File

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

257
internal/api/routes.go Normal file
View File

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

View File

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

32
internal/db/db.go Normal file
View File

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

29
internal/db/models.go Normal file
View File

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

27
internal/db/querier.go Normal file
View File

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

285
internal/db/query.sql.go Normal file
View File

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

11
sqlc.yaml Normal file
View File

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