Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,6 @@ coverage.*
# Editors/OS
.DS_Store
.vscode/

#db files
data
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ A lightweight Go backend service built from scratch to explore **distributed sys

Run the server:
```bash
go run main.go
go run ./cmd/server
1 change: 0 additions & 1 deletion api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ paths:
type: object
required:
- url
- expiry
properties:
url:
type: string
Expand Down
14 changes: 13 additions & 1 deletion cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,21 @@ import (
"github.com/go-chi/chi/v5/middleware"

apphttp "github.com/joeynolan/go-http-server/internal/http"
handlers "github.com/joeynolan/go-http-server/internal/http/handlers"
"github.com/joeynolan/go-http-server/internal/platform/config"
ilog "github.com/joeynolan/go-http-server/internal/platform/log"

db "github.com/joeynolan/go-http-server/internal/db"
)

func main() {
sqlDB, err := db.OpenAndMigrate("./data/app.db")
if err != nil {
fmt.Fprintf(os.Stderr, "failed to open database: %v\n", err)
os.Exit(1)
}
defer sqlDB.Close()

// config + logger
cfg := config.Load()
logger := ilog.New()
Expand All @@ -29,7 +39,9 @@ func main() {
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)

apphttp.Register(r)
h := handlers.NewHandler(sqlDB)

apphttp.Register(r, h)

srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port),
Expand Down
10 changes: 9 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,12 @@ module github.com/joeynolan/go-http-server

go 1.25.2

require github.com/go-chi/chi/v5 v5.2.3
require (
github.com/go-chi/chi/v5 v5.2.3
github.com/mattn/go-sqlite3 v1.14.32 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/pressly/goose/v3 v3.26.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sync v0.16.0 // indirect
)
12 changes: 12 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,2 +1,14 @@
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
42 changes: 42 additions & 0 deletions internal/db/db.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package db

import (
"database/sql"
"embed"
"fmt"

_ "github.com/mattn/go-sqlite3"
"github.com/pressly/goose/v3"
)

//go:embed migrations/*.sql
var embedMigrations embed.FS

func OpenAndMigrate(dbPath string) (*sql.DB, error) {
// connect to SQLite
dsn := fmt.Sprintf("%s?_foreign_keys=on", dbPath)
sqlDB, err := sql.Open("sqlite3", dsn)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}

// Verify connection
if err := sqlDB.Ping(); err != nil {
_ = sqlDB.Close()
return nil, fmt.Errorf("failed to ping database: %w", err)
}

// set goose to use embedded migrations
goose.SetBaseFS(embedMigrations)
if err := goose.SetDialect("sqlite3"); err != nil {
return nil, fmt.Errorf("set goose dialect: %w", err)
}

// run migrations
if err := goose.Up(sqlDB, "migrations"); err != nil {
_ = sqlDB.Close()
return nil, fmt.Errorf("failed to run migrations: %w", err)
}

return sqlDB, nil
}
14 changes: 14 additions & 0 deletions internal/db/migrations/20251118161342_links.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
url TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- +goose StatementEnd

-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS links;
-- +goose StatementEnd
13 changes: 13 additions & 0 deletions internal/http/handlers/handlers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package handlers

import (
"database/sql"
)

type Handler struct {
DB *sql.DB
}

func NewHandler(db *sql.DB) *Handler {
return &Handler{DB: db}
}
26 changes: 23 additions & 3 deletions internal/http/handlers/redirect.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,39 @@
package handlers

import (
"database/sql"
"net/http"
"strings"

"github.com/go-chi/chi/v5"
ilog "github.com/joeynolan/go-http-server/internal/platform/log"
)

func RedirectHandler(w http.ResponseWriter, r *http.Request) {
func (h *Handler) RedirectHandler(w http.ResponseWriter, r *http.Request) {
logger := ilog.New()

code := chi.URLParam(r, "code")
if code == "" {
WriteError(w, http.StatusBadRequest, "missing code")
return
}

resp := map[string]string{"message": "redirect link"}
WriteJSON(w, http.StatusFound, resp)
var url string
logger.Infof("Code: %s", code)
err := h.DB.QueryRowContext(r.Context(), "SELECT url FROM links WHERE code = ?", code).Scan(&url)
logger.Infof("URL: %s", url)
if err == sql.ErrNoRows {
WriteError(w, http.StatusNotFound, "code not found")
return
}
if err != nil {
WriteError(w, http.StatusInternalServerError, "lookup failed")
return
}

target := url
if !strings.HasPrefix(target, "http://") && !strings.HasPrefix(target, "https://") {
target = "https://" + target
}
http.Redirect(w, r, target, http.StatusFound)
}
43 changes: 41 additions & 2 deletions internal/http/handlers/shorten.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package handlers

import (
"crypto/rand"
"encoding/json"
"fmt"
"net/http"
)

func ShortenHandler(w http.ResponseWriter, r *http.Request) {
func (h *Handler) ShortenHandler(w http.ResponseWriter, r *http.Request) {

var req struct {
URL string `json:"url"`
Expand All @@ -22,8 +24,45 @@ func ShortenHandler(w http.ResponseWriter, r *http.Request) {
return
}

// Generate a random code; insert with the URL; retry on collision
var code string
for attempts := 0; attempts < 3; attempts++ {
var err error
code, err = randomCode(6)
if err != nil {
WriteError(w, http.StatusInternalServerError, "failed to generate code")
return
}

_, err = h.DB.ExecContext(r.Context(), "INSERT INTO links (code, url) VALUES (?, ?)", code, req.URL)
if err == nil {
break
}

// throw an error if max attempts reached
if attempts == 2 {
WriteError(w, http.StatusInternalServerError, "failed to generate unique code")
return
}
}

resp := map[string]string{
"short": "https://short.example/abc123", // generated value
"short": fmt.Sprintf("https://short.example/%s", code), // generated value
}
WriteJSON(w, http.StatusCreated, resp)
}

func randomCode(length int) (string, error) {
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"

b := make([]byte, length)
if _, err := rand.Read(b); err != nil {
return "", err
}

for i := 0; i < length; i++ {
b[i] = letters[int(b[i])%len(letters)]
}

return string(b), nil
}
6 changes: 3 additions & 3 deletions internal/http/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import (
"github.com/joeynolan/go-http-server/internal/http/handlers"
)

func Register(r chi.Router) {
func Register(r chi.Router, h *handlers.Handler) {
r.Get("/health", handlers.HealthHandler)
r.Get("/v1/r/{code}", handlers.RedirectHandler)
r.Post("/v1/shorten", handlers.ShortenHandler)
r.Get("/v1/r/{code}", h.RedirectHandler)
r.Post("/v1/shorten", h.ShortenHandler)
}