├── .env ├── Dockerfile ├── README.md ├── bin ├── backup ├── css ├── db └── deploy ├── common.go ├── db ├── db.go ├── logger.go ├── models.go ├── query.sql ├── query.sql.go ├── schema.sql ├── seeds.sql └── sqlc.yaml ├── docker-compose.yml ├── go.mod ├── go.sum ├── main.go └── public └── style.css /.env: -------------------------------------------------------------------------------- 1 | POSTGRES_DB=projectname 2 | POSTGRES_PASSWORD=YourPasswordHere 3 | POSTGRES_USER=postgres 4 | DATABASE_URL=postgres://$POSTGRES_USER:$POSTGRES_PASSWORD@localhost/projectname?sslmode=disable 5 | SESSION_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 6 | BACKUPS_PATH=/path/to/backups 7 | BACKUPS_LIMIT=30 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | #Build stage 2 | FROM golang:1.22.2-bullseye 3 | ENV LANG=C.UTF-8 4 | RUN apt-get update && apt-get install -qq -y postgresql-client 5 | ENV app /app 6 | RUN mkdir -p $app 7 | WORKDIR $app 8 | ADD . $app 9 | RUN go build -o main 10 | #Remove all unnecessary files 11 | RUN rm *go *.mod *.sum 12 | CMD ./main 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | GO SERVER 2 | ========= 3 | 4 | A template for Go servers. I found that I reuse some code everytime I write a go service. This is a starting point so I can clone and start new projects faster. 5 | 6 | ## Background 7 | 8 | It started when I was [converting a ruby service to go](https://www.emadelsaid.com/converting-Ruby-sinatra-project-to-Go/). I wanted to write the minimum that gives me same ruby sinatra framework feeling without using a framework. for that I broke all community rules while writing this code but at the end it worked and felt good to achieve this result. 9 | 10 | ## Features 11 | 12 | - Postgresql operations (create, drop, setup, seed, migrate up/down) 13 | - Sinatra shorthand functions to define routes (GET, POST, DELETE) 14 | - Session/Cookies 15 | - Logging requests to STDOUT + response time 16 | - Logging DB queries + execution time 17 | - Method override with `_method` param. 18 | - Sqlc setup for converting queries to go 19 | - Docker and docker compose setups 20 | - Deployment script 21 | - Backup script 22 | 23 | ## Dependencies 24 | 25 | - Go 26 | - Postgresql 27 | - [SQLc](https://sqlc.dev/) 28 | - Docker, Docker-compose (if you want to deploy with docker) 29 | - [Sass](https://sass-lang.com/install) 30 | - wget 31 | - unzip 32 | 33 | ## Usage 34 | 35 | - This is meant to be cloned 36 | - Edit `.env` values and make sure it's loaded to your environment 37 | - Edit the `common.go` constants. 38 | - Generate database code with [Sqlc](main) `bin/db setup` 39 | - Run `bin/css` to download and compile css and icons 40 | - Use `router` gorilla router or `GET`, `POST` shorthand functions...etc. 41 | 42 | ## Routes 43 | 44 | `common.go` has couple functions to modify the `http.Handler` struct. 45 | 46 | The most generic one is `ROUTE` which defines a function that gets executed if all `RouteCheck`s functions are true. 47 | ```go 48 | func ROUTE(route http.HandlerFunc, checks ...RouteCheck) 49 | ``` 50 | `RouteCheck` function is a function that takes the request and returns true if the request should be executed with that handler function. 51 | 52 | `GET`, `POST`, `DELETE` functions defined a route to a handler based on request path. 53 | 54 | ```go 55 | func GET(path string, handler HandlerFunc, middlewares ...func(http.HandlerFunc) http.HandlerFunc) 56 | func POST(path string, handler HandlerFunc, middlewares ...func(http.HandlerFunc) http.HandlerFunc) 57 | func DELETE(path string, handler HandlerFunc, middlewares ...func(http.HandlerFunc) http.HandlerFunc) 58 | ``` 59 | 60 | for example, this will match only the GET requests to `/` path and execute the function. 61 | 62 | ```go 63 | GET("/", func(w Response, r Request) Output { 64 | return Render("layout", "index", Locals{}) 65 | }) 66 | ``` 67 | 68 | You can also pass middleware functions as the last parameter to execute them in order before the handler function. 69 | 70 | 71 | ## Handler functions 72 | 73 | The code started by a simple `net/http.HandlerFunc` but this interface doesn't have a return type. so to redirect and return for example that costs you two lines. to have more compact handler functions I created another interface 74 | 75 | ```go 76 | type HandlerFunc func(http.ResponseWriter, *http.Request) http.HandlerFunc 77 | ``` 78 | 79 | Which then can be converted to `net/http.HandlerFunc` itself using `handlerFuncToHttpHandler` function. 80 | 81 | Also `http.ResponseWriter` and `*http.Request` are aliased to `Response` and `Request` so your function should look like so 82 | 83 | 84 | ```go 85 | func Users(w Response, r Request) Output { 86 | return Render("layout", "index", Locals{}) 87 | } 88 | ``` 89 | 90 | returned function `Output` is an alias to `http.HandlerFunc` and there are couple functions that can be used as return response like `Redirect, Render, NotFound, BadRequest, Unauthorized, InternalServerError` `Redirect` function for example is defined as follows 91 | 92 | 93 | ```go 94 | func Redirect(url string) http.HandlerFunc { 95 | return func(w http.ResponseWriter, r *http.Request) { 96 | http.Redirect(w, r, url, http.StatusFound) 97 | } 98 | } 99 | ``` 100 | 101 | ## Logging 102 | 103 | Method `Log` can be used to log a line with label. 104 | ```go 105 | Log(DEBUG, "View", name) 106 | ``` 107 | 108 | 109 | Method `LogDuration` can be used to log execution time a colored label and a string. adding this line to a function will print something like 110 | 111 | ```go 112 | defer LogDuration(DEBUG, "View", name)() 113 | ``` 114 | 115 | `LogDuration` will return a function that when executed by `defer` it knows the time it was created an the time it's executed and will print that time difference + `View` label colored with `DEBUG` color and the value of `name` at the end. 116 | 117 | ``` 118 | 16:30:39 View (40.916µs) index 119 | ``` 120 | 121 | There are two colors defined so far `DEBUG` and `INFO` these are constants that defines shell escape characters for coloring the following text. 122 | 123 | ## Views 124 | 125 | To create views you can use [gomponents](https://github.com/maragudk/gomponents) 126 | to avoid writing separate html template files, embed, parse them. instead 127 | gomponents allow generating html from Go. 128 | 129 | ## Session 130 | 131 | the code depends on `gorilla/session` . `SESSION` function returns an instance of the current request session. 132 | 133 | ## Assets 134 | 135 | `bin/css` is a shell script that will download bulma.io and fontawesome and compile them into one css file written under `public/style.css` and will copy fontawesome fonts to `public/fonts`. 136 | 137 | `public` directory is served if there is no matching route for the request. 138 | 139 | everytime you change `bin/css` or any css file that it imports you'll need to run it again to generate the new `style.css` file. 140 | 141 | the layout include the `style.css` file and will compute the `sha` hash and include it as part of the url to force cache flushing when the file changes. 142 | 143 | ## Running 144 | 145 | All the code is in `main` package in this directory. 146 | 147 | ``` 148 | go generate // in case you changed db/query.sql 149 | go run *.go 150 | ``` 151 | 152 | ## Deployment 153 | 154 | a Dockerfile is included and `docker-compose.yml` to run a database, server and backup script containers. 155 | 156 | - Edit variables in `db/deploy` 157 | - Edit `docker-compose.yml` file to change volumes paths 158 | - run `bin/deploy master user@server` to deploy master branch to server with ssh 159 | 160 | ## Database 161 | 162 | SQLX package is used and PQ package to connect to postgres database. the database URL is read from the environment. 163 | 164 | `bin/db` is a shell script that include basic commands needed to manage database migrations. similar to `rails db` tasks. (create, seed, setup, migrate, rollback, dump schema, load schema, create_migration, drop database, reset). a small ~150 LOC. 165 | 166 | If you don't need the database then remove: 167 | 168 | - `db` directory 169 | - `bin/db` file 170 | - `go generate` line from `common.go` 171 | - remove `sqlx` code from `common.go` 172 | 173 | ## Backups 174 | 175 | a shell script in `bin/backup` will run as a service on server to backup the database everyday. 176 | 177 | ## Guidelines 178 | 179 | - Respect no "best practice" unless there is a reason it's better for this code/server 180 | - Reduce dependencies as much as possible 181 | - Reduce code to the minimum 182 | - All code is in main package no subpackages 183 | - Should provide main features we're used to like db connection, migrations, logging. monitoring...etc 184 | -------------------------------------------------------------------------------- /bin/backup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | function sleep_until { 4 | # Use $* to eliminate need for quotes 5 | seconds=$(( $(date -d "$*" +%s) - $(date +%s) )) 6 | 7 | # if it passed today, get it tomorrow 8 | if [ $seconds -le 0 ] 9 | then 10 | seconds=$(( $(date -d "tomorrow $*" +%s) - $(date +%s) )) 11 | fi 12 | 13 | echo "Sleeping for $seconds seconds" 14 | sleep $seconds 15 | } 16 | 17 | while [ true ] 18 | do 19 | sleep_until '4:00' 20 | 21 | mkdir -p $BACKUPS_PATH 22 | filename="${BACKUPS_PATH}/`date +%F`.dump" 23 | 24 | echo "Taking backup ${filename}" 25 | pg_dump -f $filename -Fc $DATABASE_URL 26 | sync 27 | 28 | # delete backups older than the limit 29 | echo "Deleting old backup older than ${BACKUPS_LIMIT}" 30 | find $BACKUPS_PATH -mtime +$BACKUPS_LIMIT -exec rm {} \; 31 | done -------------------------------------------------------------------------------- /bin/css: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | wget --no-clobber --output-document=bulma.zip https://github.com/jgthms/bulma/releases/download/0.9.2/bulma-0.9.2.zip 4 | unzip -u bulma.zip 5 | 6 | wget --no-clobber --output-document=fontawesome.zip https://use.fontawesome.com/releases/v6.0.0/fontawesome-free-6.0.0-web.zip 7 | unzip -u fontawesome.zip 8 | mv fontawesome-free* fontawesome 9 | 10 | cat << EOT > bulma.scss 11 | @charset "utf-8"; 12 | 13 | \$body-size: 13px; 14 | \$fa-font-path: "fonts"; 15 | 16 | @import "bulma/bulma.sass"; 17 | @import "./fontawesome/scss/fontawesome.scss"; 18 | @import "./fontawesome/scss/solid.scss"; 19 | @import "./fontawesome/scss/brands.scss"; 20 | 21 | #menu-switch { 22 | display: none; 23 | } 24 | #menu-switch:checked + .navbar-brand .navbar-burger span:nth-child(1) { 25 | transform: translateY(5px) rotate(45deg); 26 | } 27 | #menu-switch:checked + .navbar-brand .navbar-burger span:nth-child(2) { 28 | opacity: 0; 29 | } 30 | #menu-switch:checked + .navbar-brand .navbar-burger span:nth-child(3) { 31 | transform: translateY(-5px) rotate(-45deg); 32 | } 33 | #menu-switch:checked ~ .navbar-menu{ 34 | display: block; 35 | } 36 | EOT 37 | 38 | sass --sourcemap=none \ 39 | --style compressed \ 40 | bulma.scss:public/style.css 41 | 42 | rm -rf public/fonts 43 | mv fontawesome/webfonts public/fonts 44 | rm -rf bulma* .sass-cache fontawesome* 45 | -------------------------------------------------------------------------------- /bin/db: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | mkdir -p db/migrate 5 | 6 | PGUSER=`echo $DATABASE_URL | sed 's/postgres:\/\///' | awk -F"[@/?:]" '{ print $1 }'` 7 | PGPASSWORD=`echo $DATABASE_URL | sed 's/postgres:\/\///' | awk -F"[@/?:]" '{ print $2 }'` 8 | PGHOST=`echo $DATABASE_URL | sed 's/postgres:\/\///' | awk -F"[@/?:]" '{ print $3 }'` 9 | PGDATABASE=`echo $DATABASE_URL | sed 's/postgres:\/\///' | awk -F"[@/?:]" '{ print $4 }'` 10 | 11 | create_schema_migrations() { 12 | echo "CREATE TABLE IF NOT EXISTS schema_migrations (version character varying NOT NULL UNIQUE);" \ 13 | | psql -v ON_ERROR_STOP=1 --no-psqlrc --quiet $DATABASE_URL 14 | } 15 | 16 | create() { 17 | echo "Create database $PGDATABASE" 18 | createdb --host=$PGHOST --username=$PGUSER $PGDATABASE 19 | } 20 | 21 | drop() { 22 | echo "Dropping database $PGDATABASE" 23 | dropdb --host=$PGHOST --username=$PGUSER $PGDATABASE 24 | } 25 | 26 | seed() { 27 | echo "Loading seeds..." 28 | psql -v ON_ERROR_STOP=1 --no-psqlrc --quiet $DATABASE_URL \ 29 | < db/seeds.sql 30 | } 31 | 32 | load() { 33 | echo "Loading schema..." 34 | psql -v ON_ERROR_STOP=1 --no-psqlrc --quiet $DATABASE_URL \ 35 | < db/schema.sql 36 | } 37 | 38 | up() { 39 | f=`ls db/migrate/$1_*.sql` 40 | echo "Migrating $f" 41 | awk '/-- up/{flag=1; next} /-- down/{flag=0} flag' $f \ 42 | | tee \ 43 | | psql -v ON_ERROR_STOP=1 --no-psqlrc --echo-queries $DATABASE_URL 44 | create_schema_migrations 45 | psql -v ON_ERROR_STOP=1 --no-psqlrc $DATABASE_URL --command "INSERT INTO schema_migrations (version) VALUES($1);" 46 | dump 47 | } 48 | 49 | down() { 50 | f=`ls db/migrate/$1_*.sql` 51 | echo "Rolling back $f" 52 | awk '/-- down/{flag=1; next} /-- up/{flag=0} flag' $f \ 53 | | tee \ 54 | | psql -v ON_ERROR_STOP=1 --no-psqlrc --echo-queries $DATABASE_URL 55 | create_schema_migrations 56 | psql -v ON_ERROR_STOP=1 --no-psqlrc $DATABASE_URL --command "DELETE FROM schema_migrations WHERE version='$1';" 57 | dump 58 | } 59 | 60 | dump() { 61 | echo "Dumping database schema..." 62 | # dump schema of all tables 63 | pg_dump --format=plain --schema-only --no-owner $DATABASE_URL \ 64 | > db/schema.sql 65 | # dump schema_migrations table 66 | pg_dump --format=plain --inserts --data-only -t schema_migrations $DATABASE_URL \ 67 | >> db/schema.sql 68 | } 69 | 70 | create_migration() { 71 | ts=`date +"%Y%m%d%H%M%S"` 72 | f="db/migrate/${ts}_$1.sql" 73 | touch $f 74 | echo "-- up" >> $f 75 | echo "-- down" >> $f 76 | echo "Migration created: $f" 77 | } 78 | 79 | migrate(){ 80 | create_schema_migrations 81 | 82 | # List all migrations from the database 83 | psql --no-psqlrc --echo-queries --tuples-only $DATABASE_URL --command "SELECT * from schema_migrations" \ 84 | | awk '{ print $1 }' \ 85 | > /tmp/migrated 86 | 87 | # List all migrations files 88 | ls db/migrate/*.sql \ 89 | | awk -F"/" '{ print $3 }' \ 90 | | awk -F"_" '{ print $1 }' \ 91 | > /tmp/migrations 92 | 93 | # list of timestamps yet to be migrated 94 | uplist=`comm -23 <(sort /tmp/migrations) <(sort /tmp/migrated)` 95 | 96 | # migrate up every one of them in order 97 | for ts in $uplist; do 98 | up $ts 99 | done 100 | } 101 | 102 | rollback() { 103 | version=`psql --no-psqlrc --echo-queries --tuples-only $DATABASE_URL --command "SELECT * from schema_migrations ORDER BY version DESC LIMIT 1" | awk '{ print $1 }'` 104 | down $version 105 | } 106 | 107 | case $1 in 108 | "create") create;; 109 | "drop") drop;; 110 | "status") status;; 111 | "create_migration") create_migration $2;; 112 | "dump") dump;; 113 | "load") load;; 114 | "seed") seed;; 115 | "migrate") migrate;; 116 | "rollback") rollback;; 117 | "setup") 118 | create 119 | load 120 | seed 121 | ;; 122 | "reset") 123 | drop 124 | create 125 | load 126 | seed 127 | ;; 128 | esac 129 | -------------------------------------------------------------------------------- /bin/deploy: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Usage: deploy 4 | # example: deploy master root@123.123.123.123 db web 5 | 6 | set -e 7 | 8 | BRANCH=$1 9 | SERVER=$2 10 | SERVICES=${@:3} 11 | 12 | APP=/root/projects/go-server 13 | REPO=git@github.com:emad-elsaid/go-server.git 14 | ENVFILE=/root/env/go-server/.env 15 | 16 | sshin() { 17 | ssh -o LogLevel=QUIET -t $SERVER "cd $APP; $@" 18 | } 19 | 20 | echo "[*] Deleting old files" 21 | ssh -o LogLevel=QUIET -t $SERVER rm -rf $APP 22 | 23 | echo "[*] Clone branch" 24 | ssh -o LogLevel=QUIET -t $SERVER git clone --depth=1 --branch $BRANCH $REPO $APP 25 | 26 | echo "[*] Copy .env file" 27 | ssh -o LogLevel=QUIET -t $SERVER cp $ENVFILE $APP/.env 28 | 29 | echo "[*] Pulling new docker images" 30 | sshin docker-compose pull 31 | 32 | echo "[*] Building images" 33 | sshin docker-compose build $SERVICES 34 | 35 | echo "[*] Migrating database" 36 | sshin docker-compose run -T --rm web bin/db migrate 37 | 38 | echo "[*] Stop old containers" 39 | sshin docker-compose stop $SERVICES 40 | 41 | echo "[*] Bring up new containers" 42 | sshin docker-compose up -d $SERVICES 43 | 44 | echo "[*] Clean docker" 45 | sshin docker system prune 46 | -------------------------------------------------------------------------------- /common.go: -------------------------------------------------------------------------------- 1 | //go:generate sqlc generate --file db/sqlc.yaml 2 | package main 3 | 4 | import ( 5 | "context" 6 | "crypto/rand" 7 | "crypto/sha256" 8 | "fmt" 9 | "io" 10 | "log" 11 | "log/slog" 12 | "net/http" 13 | "os" 14 | "path" 15 | "strings" 16 | "time" 17 | 18 | "github.com/emad-elsaid/go-server/db" 19 | "github.com/gorilla/csrf" 20 | "github.com/gorilla/sessions" 21 | "github.com/jackc/pgx/v5/pgxpool" 22 | "github.com/jackc/pgx/v5/tracelog" 23 | _ "github.com/lib/pq" 24 | "github.com/lmittmann/tint" 25 | "maragu.dev/gomponents" 26 | ) 27 | 28 | func init() { 29 | slog.SetDefault(slog.New( 30 | tint.NewHandler(os.Stdout, &tint.Options{ 31 | Level: slog.LevelDebug, 32 | TimeFormat: time.Kitchen, 33 | }), 34 | )) 35 | 36 | wd, err := os.Getwd() 37 | if err != nil { 38 | wd = "/app" 39 | } 40 | 41 | NewApp(path.Base(wd), "0.0.0.0:3000") 42 | } 43 | 44 | var DefaultApp *App 45 | 46 | type App struct { 47 | Name string 48 | Address string 49 | PublicPath string 50 | Mux *http.ServeMux 51 | DB *db.Queries 52 | Session *sessions.CookieStore 53 | } 54 | 55 | func NewApp(name, address string) *App { 56 | DefaultApp = &App{ 57 | Name: name, 58 | Address: address, 59 | PublicPath: "public", 60 | Mux: http.NewServeMux(), 61 | Session: sessions.NewCookieStore([]byte(os.Getenv("SESSION_SECRET"))), 62 | } 63 | 64 | DefaultApp.Session.Options.HttpOnly = true 65 | 66 | return DefaultApp 67 | } 68 | 69 | func defaultMiddlewares() []func(http.Handler) http.Handler { 70 | csrfCookieName := DefaultApp.Name + "_csrf" 71 | 72 | crsfOpts := []csrf.Option{ 73 | csrf.Path("/"), 74 | csrf.FieldName("csrf"), 75 | csrf.CookieName(csrfCookieName), 76 | } 77 | 78 | sessionSecret := []byte(os.Getenv("SESSION_SECRET")) 79 | if len(sessionSecret) == 0 { 80 | sessionSecret = make([]byte, 128) 81 | rand.Read(sessionSecret) 82 | } 83 | 84 | middlewares := []func(http.Handler) http.Handler{ 85 | methodOverrideMiddleware, 86 | csrf.Protect(sessionSecret, crsfOpts...), 87 | requestLoggerMiddleware, 88 | } 89 | 90 | return middlewares 91 | } 92 | 93 | // Some aliases to make it shorter to write handlers 94 | type ( 95 | Request = *http.Request 96 | Response = http.HandlerFunc 97 | ) 98 | 99 | func Start() { 100 | ctx := context.Background() 101 | 102 | config, err := pgxpool.ParseConfig(os.Getenv("DATABASE_URL")) 103 | if err != nil { 104 | log.Fatal(err) 105 | } 106 | 107 | config.ConnConfig.Tracer = &tracelog.TraceLog{ 108 | Logger: (*db.Logger)(slog.Default()), 109 | LogLevel: tracelog.LogLevelInfo, 110 | } 111 | 112 | pool, err := pgxpool.NewWithConfig(ctx, config) 113 | if err != nil { 114 | log.Fatal(err) 115 | } 116 | 117 | DefaultApp.DB = db.New(pool) 118 | DefaultApp.Mux.HandleFunc("GET /"+DefaultApp.PublicPath+"/", staticDirectoryMiddleware()) 119 | 120 | var handler http.Handler = DefaultApp.Mux 121 | for _, v := range defaultMiddlewares() { 122 | handler = v(handler) 123 | } 124 | 125 | srv := &http.Server{ 126 | Handler: handler, 127 | Addr: DefaultApp.Address, 128 | WriteTimeout: 15 * time.Second, 129 | ReadTimeout: 15 * time.Second, 130 | } 131 | 132 | slog.Info("Server listening", "address", DefaultApp.Address) 133 | slog.Info("Server closing", "error", srv.ListenAndServe()) 134 | } 135 | 136 | // LOGGING =============================================== 137 | 138 | func LogDuration() func(msg string, args ...interface{}) { 139 | start := time.Now() 140 | 141 | return func(msg string, args ...interface{}) { 142 | slog. 143 | With("duration", time.Now().Sub(start)). 144 | With(args...). 145 | Debug(msg) 146 | } 147 | } 148 | 149 | // Responses functions ========================================== 150 | 151 | type HandlerFunc func(Request) Response 152 | 153 | func handlerFuncToHttpHandler(handler HandlerFunc) http.HandlerFunc { 154 | return func(w http.ResponseWriter, r *http.Request) { 155 | handler(r)(w, r) 156 | } 157 | } 158 | 159 | func Ok(out gomponents.Node) Response { 160 | return func(w http.ResponseWriter, r *http.Request) { 161 | out.Render(w) 162 | } 163 | } 164 | 165 | func NotFound(w http.ResponseWriter, r *http.Request) { 166 | http.Error(w, "", http.StatusNotFound) 167 | } 168 | 169 | func BadRequest(w http.ResponseWriter, r *http.Request) { 170 | http.Error(w, "", http.StatusBadRequest) 171 | } 172 | 173 | func Unauthorized(w http.ResponseWriter, r *http.Request) { 174 | http.Error(w, "", http.StatusUnauthorized) 175 | } 176 | 177 | func InternalServerError(err error) http.HandlerFunc { 178 | return func(w http.ResponseWriter, r *http.Request) { 179 | http.Error(w, err.Error(), http.StatusInternalServerError) 180 | } 181 | } 182 | 183 | func Redirect(url string) http.HandlerFunc { 184 | return func(w http.ResponseWriter, r *http.Request) { 185 | http.Redirect(w, r, url, http.StatusFound) 186 | } 187 | } 188 | 189 | // ROUTES functions ========================================== 190 | 191 | func Get(path string, handler HandlerFunc, middlewares ...func(http.HandlerFunc) http.HandlerFunc) { 192 | DefaultApp.Mux.HandleFunc("GET "+path, 193 | applyMiddlewares(handlerFuncToHttpHandler(handler), middlewares...), 194 | ) 195 | } 196 | 197 | func Post(path string, handler HandlerFunc, middlewares ...func(http.HandlerFunc) http.HandlerFunc) { 198 | DefaultApp.Mux.HandleFunc("POST "+path, 199 | applyMiddlewares(handlerFuncToHttpHandler(handler), middlewares...), 200 | ) 201 | } 202 | 203 | func Delete(path string, handler HandlerFunc, middlewares ...func(http.HandlerFunc) http.HandlerFunc) { 204 | DefaultApp.Mux.HandleFunc("DELETE "+path, 205 | applyMiddlewares(handlerFuncToHttpHandler(handler), middlewares...), 206 | ) 207 | } 208 | 209 | // SESSION ================================= 210 | 211 | func Session(r *http.Request) *sessions.Session { 212 | cookieName := DefaultApp.Name + "_session" 213 | s, _ := DefaultApp.Session.Get(r, cookieName) 214 | return s 215 | } 216 | 217 | // MIDDLEWARES ============================== 218 | 219 | // First middleware gets executed first 220 | func applyMiddlewares(handler http.HandlerFunc, middlewares ...func(http.HandlerFunc) http.HandlerFunc) http.HandlerFunc { 221 | for i := len(middlewares) - 1; i >= 0; i-- { 222 | handler = middlewares[i](handler) 223 | } 224 | return handler 225 | } 226 | 227 | func staticDirectoryMiddleware() http.HandlerFunc { 228 | dir := http.Dir(DefaultApp.PublicPath) 229 | server := http.FileServer(dir) 230 | handler := http.StripPrefix("/"+DefaultApp.PublicPath, server) 231 | 232 | return func(w http.ResponseWriter, r *http.Request) { 233 | if strings.HasSuffix(r.URL.Path, "/") { 234 | http.NotFound(w, r) 235 | return 236 | } 237 | 238 | handler.ServeHTTP(w, r) 239 | } 240 | } 241 | 242 | // Derived from Gorilla middleware https://github.com/gorilla/handlers/blob/v1.5.1/handlers.go#L134 243 | func methodOverrideMiddleware(h http.Handler) http.Handler { 244 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 245 | if r.Method == "POST" { 246 | om := r.FormValue("_method") 247 | if om == "PUT" || om == "PATCH" || om == "DELETE" { 248 | r.Method = om 249 | } 250 | } 251 | h.ServeHTTP(w, r) 252 | }) 253 | } 254 | 255 | func requestLoggerMiddleware(h http.Handler) http.Handler { 256 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 257 | defer LogDuration()(r.URL.Path, "method", r.Method) 258 | h.ServeHTTP(w, r) 259 | }) 260 | } 261 | 262 | // HELPERS FUNCTIONS ====================== 263 | 264 | var CSRF = csrf.TemplateField 265 | 266 | var sha256cache = map[string]string{} 267 | 268 | func Sha256(p string) string { 269 | if v, ok := sha256cache[p]; ok { 270 | return v 271 | } 272 | 273 | f, err := os.Open(p) 274 | if err != nil { 275 | return err.Error() 276 | } 277 | 278 | d, err := io.ReadAll(f) 279 | if err != nil { 280 | return err.Error() 281 | } 282 | 283 | sha256cache[p] = fmt.Sprintf("%x", sha256.Sum256(d)) 284 | return sha256cache[p] 285 | } 286 | -------------------------------------------------------------------------------- /db/db.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.27.0 4 | 5 | package db 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/jackc/pgx/v5" 11 | "github.com/jackc/pgx/v5/pgconn" 12 | ) 13 | 14 | type DBTX interface { 15 | Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) 16 | Query(context.Context, string, ...interface{}) (pgx.Rows, error) 17 | QueryRow(context.Context, string, ...interface{}) pgx.Row 18 | } 19 | 20 | func New(db DBTX) *Queries { 21 | return &Queries{db: db} 22 | } 23 | 24 | type Queries struct { 25 | db DBTX 26 | } 27 | 28 | func (q *Queries) WithTx(tx pgx.Tx) *Queries { 29 | return &Queries{ 30 | db: tx, 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /db/logger.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | 6 | "log/slog" 7 | 8 | "github.com/jackc/pgx/v5/tracelog" 9 | ) 10 | 11 | type Logger slog.Logger 12 | 13 | func (l *Logger) Log(ctx context.Context, level tracelog.LogLevel, msg string, data map[string]interface{}) { 14 | attrs := make([]slog.Attr, 0, len(data)) 15 | for k, v := range data { 16 | attrs = append(attrs, slog.Any(k, v)) 17 | } 18 | 19 | var lvl slog.Level 20 | switch level { 21 | case tracelog.LogLevelTrace: 22 | lvl = slog.LevelDebug - 1 23 | attrs = append(attrs, slog.Any("PGX_LOG_LEVEL", level)) 24 | case tracelog.LogLevelDebug: 25 | lvl = slog.LevelDebug 26 | case tracelog.LogLevelInfo: 27 | lvl = slog.LevelInfo 28 | case tracelog.LogLevelWarn: 29 | lvl = slog.LevelWarn 30 | case tracelog.LogLevelError: 31 | lvl = slog.LevelError 32 | default: 33 | lvl = slog.LevelError 34 | attrs = append(attrs, slog.Any("INVALID_PGX_LOG_LEVEL", level)) 35 | } 36 | (*slog.Logger)(l).LogAttrs(ctx, lvl, msg, attrs...) 37 | } 38 | -------------------------------------------------------------------------------- /db/models.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.27.0 4 | 5 | package db 6 | 7 | type SchemaMigration struct { 8 | Version string 9 | } 10 | -------------------------------------------------------------------------------- /db/query.sql: -------------------------------------------------------------------------------- 1 | -- name: Migrations :many 2 | select * from schema_migrations; 3 | -------------------------------------------------------------------------------- /db/query.sql.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.27.0 4 | // source: query.sql 5 | 6 | package db 7 | 8 | import ( 9 | "context" 10 | ) 11 | 12 | const migrations = `-- name: Migrations :many 13 | select version from schema_migrations 14 | ` 15 | 16 | func (q *Queries) Migrations(ctx context.Context) ([]string, error) { 17 | rows, err := q.db.Query(ctx, migrations) 18 | if err != nil { 19 | return nil, err 20 | } 21 | defer rows.Close() 22 | var items []string 23 | for rows.Next() { 24 | var version string 25 | if err := rows.Scan(&version); err != nil { 26 | return nil, err 27 | } 28 | items = append(items, version) 29 | } 30 | if err := rows.Err(); err != nil { 31 | return nil, err 32 | } 33 | return items, nil 34 | } 35 | -------------------------------------------------------------------------------- /db/schema.sql: -------------------------------------------------------------------------------- 1 | -- 2 | -- PostgreSQL database dump 3 | -- 4 | 5 | -- Dumped from database version 13.5 6 | -- Dumped by pg_dump version 13.5 7 | 8 | SET statement_timeout = 0; 9 | SET lock_timeout = 0; 10 | SET idle_in_transaction_session_timeout = 0; 11 | SET client_encoding = 'UTF8'; 12 | SET standard_conforming_strings = on; 13 | SELECT pg_catalog.set_config('search_path', '', false); 14 | SET check_function_bodies = false; 15 | SET xmloption = content; 16 | SET client_min_messages = warning; 17 | SET row_security = off; 18 | 19 | SET default_tablespace = ''; 20 | 21 | SET default_table_access_method = heap; 22 | 23 | -- 24 | -- Name: schema_migrations; Type: TABLE; Schema: public; Owner: - 25 | -- 26 | 27 | CREATE TABLE public.schema_migrations ( 28 | version character varying NOT NULL 29 | ); 30 | 31 | 32 | -- 33 | -- Name: schema_migrations schema_migrations_version_key; Type: CONSTRAINT; Schema: public; Owner: - 34 | -- 35 | 36 | ALTER TABLE ONLY public.schema_migrations 37 | ADD CONSTRAINT schema_migrations_version_key UNIQUE (version); 38 | 39 | 40 | -- 41 | -- PostgreSQL database dump complete 42 | -- 43 | 44 | -- 45 | -- PostgreSQL database dump 46 | -- 47 | 48 | -- Dumped from database version 13.5 49 | -- Dumped by pg_dump version 13.5 50 | 51 | SET statement_timeout = 0; 52 | SET lock_timeout = 0; 53 | SET idle_in_transaction_session_timeout = 0; 54 | SET client_encoding = 'UTF8'; 55 | SET standard_conforming_strings = on; 56 | SELECT pg_catalog.set_config('search_path', '', false); 57 | SET check_function_bodies = false; 58 | SET xmloption = content; 59 | SET client_min_messages = warning; 60 | SET row_security = off; 61 | 62 | -- 63 | -- Data for Name: schema_migrations; Type: TABLE DATA; Schema: public; Owner: postgres 64 | -- 65 | 66 | 67 | 68 | -- 69 | -- PostgreSQL database dump complete 70 | -- 71 | 72 | -------------------------------------------------------------------------------- /db/seeds.sql: -------------------------------------------------------------------------------- 1 | -- Add your seeding SQL here 2 | -------------------------------------------------------------------------------- /db/sqlc.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | sql: 3 | - engine: "postgresql" 4 | queries: "query.sql" 5 | schema: "schema.sql" 6 | gen: 7 | go: 8 | out: "." 9 | package: "db" 10 | sql_package: "pgx/v5" 11 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | image: "postgres:16.3" 4 | restart: always 5 | env_file: 6 | - .env 7 | expose: 8 | - '5432' 9 | ports: 10 | - '5432:5432' 11 | volumes: 12 | - /root/data/projectname/db:/var/lib/postgresql/data 13 | shm_size: '2gb' 14 | logging: 15 | driver: journald 16 | 17 | web: 18 | build: . 19 | depends_on: 20 | - db 21 | links: 22 | - db 23 | restart: always 24 | ports: 25 | - '127.0.0.1:3000:3000' 26 | env_file: 27 | - .env 28 | logging: 29 | driver: journald 30 | 31 | backup: 32 | build: . 33 | command: bin/backup 34 | restart: always 35 | volumes: 36 | - /root/data/projectname/backups:/backups 37 | depends_on: 38 | - db 39 | links: 40 | - db 41 | env_file: 42 | - .env 43 | logging: 44 | driver: journald 45 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/emad-elsaid/go-server 2 | 3 | go 1.22.2 4 | 5 | require ( 6 | github.com/gorilla/csrf v1.7.2 7 | github.com/gorilla/sessions v1.2.2 8 | github.com/jackc/pgx/v5 v5.6.0 9 | github.com/lib/pq v1.10.9 10 | github.com/lmittmann/tint v1.0.4 11 | github.com/willoma/bulma-gomponents v0.13.0 12 | maragu.dev/gomponents v1.0.0 13 | maragu.dev/gomponents-htmx v0.6.1 14 | ) 15 | 16 | require ( 17 | github.com/gorilla/securecookie v1.1.2 // indirect 18 | github.com/jackc/pgpassfile v1.0.0 // indirect 19 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect 20 | github.com/jackc/puddle/v2 v2.2.1 // indirect 21 | github.com/willoma/gomplements v0.8.0 // indirect 22 | golang.org/x/crypto v0.17.0 // indirect 23 | golang.org/x/sync v0.1.0 // indirect 24 | golang.org/x/text v0.14.0 // indirect 25 | ) 26 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 5 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 6 | github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI= 7 | github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= 8 | github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 9 | github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 10 | github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY= 11 | github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ= 12 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 13 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 14 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= 15 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 16 | github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= 17 | github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= 18 | github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= 19 | github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 20 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 21 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 22 | github.com/lmittmann/tint v1.0.4 h1:LeYihpJ9hyGvE0w+K2okPTGUdVLfng1+nDNVR4vWISc= 23 | github.com/lmittmann/tint v1.0.4/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= 24 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 25 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 26 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 27 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 28 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 29 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 30 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 31 | github.com/willoma/bulma-gomponents v0.13.0 h1:2l3t354lcG2P3bGzCscaoTBzhENZxt+fNFIeUb4l1+Q= 32 | github.com/willoma/bulma-gomponents v0.13.0/go.mod h1:UyHpsnXNr1ekHlF+730EoNPBTlJfexR8/dd2UWnZQD0= 33 | github.com/willoma/gomplements v0.8.0 h1:WIf8K18LHDiGbhYFg2OL8d8NrNnLgtym5MLF2IkMSCw= 34 | github.com/willoma/gomplements v0.8.0/go.mod h1:oCdic+eK6q5s8EfGrqnimTYB2qOWrOt8PaI2twu4tFM= 35 | golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= 36 | golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= 37 | golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= 38 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 39 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 40 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 41 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 42 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 43 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 44 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 45 | maragu.dev/gomponents v1.0.0 h1:eeLScjq4PqP1l+r5z/GC+xXZhLHXa6RWUWGW7gSfLh4= 46 | maragu.dev/gomponents v1.0.0/go.mod h1:oEDahza2gZoXDoDHhw8jBNgH+3UR5ni7Ur648HORydM= 47 | maragu.dev/gomponents-htmx v0.6.1 h1:vXXOkvqEDKYxSwD1UwqmVp12YwFSuM6u8lsRn7Evyng= 48 | maragu.dev/gomponents-htmx v0.6.1/go.mod h1:51nXX+dTGff3usM7AJvbeOcQjzjpSycod+60CYeEP/M= 49 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | b "github.com/willoma/bulma-gomponents" 5 | x "maragu.dev/gomponents-htmx" 6 | 7 | . "maragu.dev/gomponents" 8 | . "maragu.dev/gomponents/html" 9 | ) 10 | 11 | func main() { 12 | Get("/{$}", func(r Request) Response { 13 | return Ok( 14 | Layout( 15 | Navbar(), 16 | b.Section(Text("Hello World!")), 17 | ), 18 | ) 19 | }) 20 | 21 | Get("/about", func(r Request) Response { 22 | return Ok( 23 | Layout( 24 | Navbar(), 25 | b.Section(Text("About")), 26 | ), 27 | ) 28 | }) 29 | 30 | Start() 31 | } 32 | 33 | func Navbar() Node { 34 | return b.Navbar( 35 | b.Dark, 36 | x.Boost("true"), 37 | b.NavbarStart( 38 | b.NavbarAHref("/", "Home"), 39 | b.NavbarAHref("/about", "About"), 40 | ), 41 | ) 42 | } 43 | 44 | func Layout(view ...Node) Node { 45 | return b.HTML( 46 | Lang("en"), 47 | b.HTitle("Hello World!"), 48 | b.Stylesheet("/public/style.css?v="+Sha256("public/style.css")), 49 | b.Script("https://unpkg.com/htmx.org@2.0.3"), 50 | Group(view), 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /public/style.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emad-elsaid/go-server/52132673f0a3fadfe8eb54a8589ea777759448c8/public/style.css --------------------------------------------------------------------------------