A Go REST API starter template with authentication, permissions, email, and more π
This starter template provides the following features to build on:
- π Authentication with stateful tokens
- πββοΈ Authorization with roles (by default
admin
andsuperadmin
) - π Middleware for authentication, permission checks, and panic recovery
- π§ Emails for welcoming new users and resetting passwords
- π Graceful shutdown that waits for background tasks to finish
- π§ͺ Testing package makes it easy to write integration tests (see TestAuthE2E)
- β° Centralized error handling β always return
ServerError
orClientError
and send it withrest.Error(err)
Do a bulk find and replace for go-rest-starter.jtbergman.me
and replace it with your desired module name.
Create a .env
file with this format. Both Docker and Make rely on these values. SMTP optional for local
.
# Env: local | dev | prod
#
# local will log email data to STDOUT, SMTP not required
ENV="local"
# Server
PORT=4000
# Docker
#
# PROJECT_NAME is the group name used by Docker
PROJECT_NAME="go-rest-starter"
# Postgres
#
# Used to create DSN for `make run`. This data persists after stop.
DB_NAME="postgres"
DB_USER="postgres"
DB_PASSWORD="password"
# Postgres Tests
#
# Used to create DSN for `make tests`. This data is not persisted after stop.
TEST_DB_NAME="tests"
TEST_DB_USER="tests"
TEST_DB_PASSWORD="password"
# SMTP
#
# These values are provided by SMTP service, can skip while using local
SMTP_HOST=""
SMTP_PORT=25
SMTP_USERNAME=""
SMTP_PASSWORD=""
SMTP_SENDER="Go Rest Starter <[email protected]>"
To run the application, just run make run
. Alternatively, run make
to see all the commands.
Usage:
# These automatically start the database and apply migrations
run run API
tests run tests
tests/short run tests skipping integration
tests/cover run tests with code coverage
# Manually start and stop the database
db/start start the API database
db/start/tests start the Tests database
db/stop stop the API database
db/stop/tests stop the Tests database
# Connect to the database to inspect with SQL
sql connect to the API database with psql
sql/tests connect to the Tests database with psql
# Manage databae migrations (requires go-migrate)
mig/new name=$1 create a new database migration
mig/up migrate to a specific version, or apply all migrations
mig/down apply all down database migrations
mig/force version=$1 force the database to a migration version
# Count the lines of code in your application
util/loc lists the total lines of code
# Build the app, check the version (date+git hash)
build build the API
version Output version of current binary
The supported routes are demonstrated using HTTPie syntax.
/v1/auth/register
Create a user with an email and password.
http POST localhost:4000/v1/auth/register \
email="[email protected]" \
password="password"
/v1/auth/activate
Activate a user using the activation token (see previous logs)
http PUT localhost:4000/v1/auth/activate \
token="<Activation Token (See Server Logs)>"
/v1/auth/login
Login to get an authentication token.
http POST localhost:4000/v1/auth/login \
email="[email protected]" \
password="password"
/v1/auth/logout
Logout your user (authentication required)
http POST localhost:4000/v1/auth/logout \
"Authorization: Bearer <Authentication Token>
/v1/auth/rest
request and create new passwords
# Request Reset
http POST localhost:4000/v1/auth/reset \
email="[email protected]"
# Rest with token (see server logs)
http PUT localhost:4000/v1/auth/reset \
token="<Reset Token (See Server Logs)" \
password="pa55word"
/v1/auth/delete
Delete your account (authentication required)
http POST localhost:4000/v1/auth/delete \
email="[email protected]" \
password="pa55word" \
"Authorization: Bearer <New Authentication Token>
/v1/debug/vars
Check server metrics (admin user required)
# Create a user, activate, and login
$ http localhost:4000/v1/auth/register email="[email protected]" password="password"
$ http PUT localhost:4000/v1/auth/activate token=<Activation Token (See Server Logs)>
$ http POST localhost:4000/v1/auth/login email="[email protected]" password="password"
# You cannot see /v1/debug/vars
$ http localhost:4000/v1/debug/vars "Authorization: Bearer <Login Token>"
# Connect to database, grant admin
$ make sql
> SELECT * FROM users;
> SELECT * FROM permissions;
> INSERT INTO user_permissions (user_id, permission_id) VALUES (1, 1);
# Now you can
$ http localhost:4000/v1/debug/vars "Authorization: Bearer <Login Token>"
To define new routes, create a new package in internal/routes
.
A route package should specify its dependencies with a struct using interfaces where possible.
// Encapsulates the Application dependencies required by routes
type Auth struct {
bg app.Backgrounder
logger xlogger.Logger
mailer mailer.Mailer
rest *rest.Rest
tokens tokens.TokensRepository
users users.UsersRepository
}
Create a New
function that takes the App
dependencies type and initializes itself.
func New(app *app.App) *Auth {
return &Auth{
bg: app.BG,
logger: app.Logger,
mailer: app.Mailer,
rest: app.Rest,
tokens: app.Models.Tokens,
users: app.Models.Users,
}
}
Define a Route
function with the following signature and register your routes.
func (auth *Auth) Route(mux *http.ServeMux, mw *middleware.Middleware) {
mux.HandleFunc(ActivateRoute, auth.Activate)
mux.HandleFunc(DeleteRoute, mw.Authenticated(auth.Delete))
mux.HandleFunc(LoginRoute, auth.Login)
mux.HandleFunc(LogoutRoute, mw.Authenticated(auth.Logout))
mux.HandleFunc(RegisterRoute, auth.Register)
mux.HandleFunc(ResetRoute, auth.Reset)
}
I prefer to use the switch r.Method
approach when defining my routes.
const ResetRoute = "/v1/auth/reset"
func (app *Auth) Reset(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
http.ServeFile(w, r, "static/reset.html")
case "POST":
app.resetPost(w, r)
case "PUT":
app.resetPut(w, r)
default:
app.rest.MethodNotAllowed(w, r, "GET, POST, PUT")
}
}
Route handlers are defined on the dependencies struct (i.e. Auth
).
The Rest
dependency makes it easy to read JSON, write JSON, and handle errors.
func (auth *Auth) registerPost(w http.ResponseWriter, r *http.Request) {
var input struct {
Email string `json:"email"`
Password string `json:"password"`
}
// Parse request
if err := auth.rest.ReadJSON(w, r, "auth.registerPost", &input); err != nil {
auth.rest.Error(w, err)
return
}
// Create user
user, err := auth.users.New(input.Email, input.Password)
if err != nil {
auth.rest.Error(w, err)
return
}
// Insert user
if err := auth.users.Insert(user); err != nil {
if err.Matches(xerrors.ErrUniqueViolation) {
err.Data = "That email is already taken"
}
auth.rest.Error(w, err)
return
}
// ...
}
To interact with the database, create a new package in internal/models
Create a file named service.go
that defines and implements an interface.
// Defines a mockable interface for user operations
type UsersRepository interface {
Delete(user *User) (int64, *xerrors.AppError)
GetByEmail(email string) (*User, *xerrors.AppError)
GetByToken(plaintext string) (*User, *xerrors.AppError)
Insert(user *User) *xerrors.AppError
New(email, plaintext string) (*User, *xerrors.AppError)
Update(user *User) *xerrors.AppError
}
Create a concrete instance that depends on core.Queryable
. This allows the same service to use transactions and *sql.DB
without additional code.
// Provides access to User database methods
type Users struct {
DB core.Queryable
}
Provide a Repository
method to make service initialization consistent.
func Repository(db core.Queryable) UsersRepository {
return &Users{DB: db}
}
Use core.RowsAffected
and xerrors.DatabaseError
to simplify error handling.
// Deletes a user
func (m Users) Delete(user *User) (int64, *xerrors.AppError) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
result, err := m.DB.ExecContext(ctx, `DELETE FROM users WHERE id = $1`, user.ID)
if err != nil {
return 0, xerrors.DatabaseError(err, "users.Delete")
}
return core.RowsAffected(result, "users.Delete")
}
This template includes helpers for writing integration test. To create an App
with mocked dependencies, just call mocks.App()
. You can then easily create a test handler using the Routes
method from your package. For an example, see auth_test.go
. Use functions from the assert
package to easily write integration tests. Example:
func TestDelete(t *testing.T) {
// Mark an integration test (skipped with make tests/short)
assert.Integration(t)
// Easily create dependencies
app := mocks.App(t)
handler := authHandler(app)
credentials := `{"email": "[email protected]", "password": "password"}`
// Seed β create user, activate user, login user
assert.Check(t, registerUser(handler, credentials))
assert.Check(t, activateUser(handler, app))
token := loginUser(handler, credentials)
assert.Check(t, len(token) > 0)
// Auth Required
assert.RunHandlerTestCase[failures](t, handler, "POST", DeleteRoute, assert.HandlerTestCase[failures]{
Name: "Delete/AuthRequired",
Body: credentials,
Status: http.StatusUnauthorized,
})
// User Not Found
assert.RunHandlerTestCase[failures](t, handler, "POST", DeleteRoute, assert.HandlerTestCase[failures]{
Name: "Delete/UserNotFound",
Body: `{"email": "[email protected]", "password": "password"}`,
Auth: token,
Status: http.StatusNotFound,
})
// Credentials Invalid
assert.RunHandlerTestCase[failures](t, handler, "POST", DeleteRoute, assert.HandlerTestCase[failures]{
Name: "Delete/CredentialsInvalid",
Body: `{"email": "[email protected]", "password": "pa55word"}`,
Auth: token,
Status: http.StatusUnauthorized,
})
// Success
assert.RunHandlerTestCase[message](t, handler, "POST", DeleteRoute, assert.HandlerTestCase[message]{
Name: "Delete/CredentialsInvalid",
Body: credentials,
Auth: token,
Status: http.StatusOK,
FN: func(t *testing.T, result message) {
assert.Equal(t, result.Message, "Your account has been deleted")
},
})
}
If you have a failing test, use the following to inspect server logs from the test
mocks.Logger(app).Begin()
assert.RunHandlerTestCase[message](t, handler, "POST", DeleteRoute, assert.HandlerTestCase[message]{
Name: "Delete/CredentialsInvalid",
Body: credentials,
Auth: token,
Status: http.StatusOK,
FN: func(t *testing.T, result message) {
assert.Equal(t, result.Message, "Your account has been deleted")
},
})
mocks.Logger(app).End()