Skip to content

Tutorial: build a notes API

This tutorial builds a small JSON API for notes, from an empty directory to a tested app. Each client authenticates with an API key and sees only its own notes. The data lives in a SQLite file, so there is nothing to install beyond Go.

Along the way it uses the Gas core (services, routing, handlers, errors) and five modules: config, logging, database, migrations, and auth. Each step ends with something to run, and links to the page that covers its pieces in depth.

You need Go 1.27.3 or newer (see Installation) and curl. The finished project looks like this:

  • Directorynotesapi/
    • go.mod
    • config.json
    • Directorycmd/
      • main.go
    • Directoryapp/
      • app.go wires the modules and services
      • app_test.go
    • Directorykeys/
      • service.go issues API keys
    • Directorynotes/
      • service.go owns the notes table and routes
      • handlers.go
      • Directorymigrations/
        • 20261021001_create_notes.up.sql
        • 20261021001_create_notes.down.sql

The layout follows Project structure: a thin cmd/ that runs the app, an app/ package that wires it, and one package per service.

Create a module and add the Gas packages this tutorial uses, plus the pure-Go SQLite driver:

Terminal window
mkdir notesapi && cd notesapi
go mod init example.com/notesapi
go get github.com/gasmod/gas \
github.com/gasmod/gas/config \
github.com/gasmod/gas/log \
github.com/gasmod/gas/database \
github.com/gasmod/gas/migrate \
github.com/gasmod/gas/auth \
modernc.org/sqlite

The core and each module are separate Go modules, so an app adds only the ones it uses.

Create config.json in the project root. This is the whole file; the Database and Keys sections are used from step 3 on.

config.json
{
"GasEnv": "development",
"Server": {
"Host": "localhost",
"Port": 8080
},
"Database": {
"Driver": "sqlite",
"DSN": "file:notes.db",
"MaxOpenConns": 1,
"MaxIdleConns": 1
},
"Keys": {
"AdminToken": "change-me-to-a-long-random-string"
}
}

Now the app package. LoadConfig builds the config, and New registers what the app is made of.

app/app.go
// Package app wires the notes API: it loads the config and registers every
// module and service on a gas.App.
package app
import (
"fmt"
"github.com/gasmod/gas"
"github.com/gasmod/gas/config"
"github.com/gasmod/gas/config/providers"
gaslog "github.com/gasmod/gas/log"
)
// LoadConfig reads the JSON file at path, then the environment. Later
// providers win, so KEYS__ADMINTOKEN overrides Keys.AdminToken from the file.
func LoadConfig(path string) (*config.Config, error) {
cfg := config.New(
config.WithProvider(providers.NewJSONProvider(providers.WithJSONFilePath(path))),
config.WithProvider(providers.NewEnvProvider()),
)
if err := cfg.Load(); err != nil {
return nil, fmt.Errorf("loading config: %w", err)
}
return cfg, nil
}
// New registers the modules and services. The caller runs the app.
func New(cfg gas.ConfigProvider) *gas.App {
app := gas.NewApp(
gas.WithServiceInstance[gas.ConfigProvider](cfg),
gas.WithSingletonService[gas.Logger](gaslog.NewSlogLogger()),
)
// Log every request through the app's logger.
app.Router().UseMiddlewareFunc(gas.RequestLogger[gas.Logger](
gas.WithRequestLoggerAppendRequestID(false),
))
return app
}

LoadConfig reads config.json first and the environment second. A later provider overrides the keys it defines, so an environment variable wins over the file: KEYS__ADMINTOKEN sets Keys.AdminToken, with __ separating the levels. Reading from disk, the JSON provider only opens files inside the working directory, so run the app from the project root. See Config for the providers and the naming rules.

New registers two things in the container:

  • the loaded config, as a ready-made instance under gas.ConfigProvider. The app binds its own settings from it at startup (here GasEnv, Server.Host, and Server.Port), and the modules added in later steps bind their sections from it.
  • a logger under gas.Logger. gaslog.NewSlogLogger() writes through the standard library’s log/slog. See Logging.

The request logger middleware logs each request’s method, path, status, bytes written, duration, and remote address: at error level when the status is 400 or above, at info level otherwise. Its request ID field is turned off because the ID comes from chi’s RequestID middleware, which this app does not mount.

The entry point loads the config, builds the app, and runs it:

cmd/main.go
// Command notesapi serves the notes API built in the tutorial.
package main
import (
"log"
"example.com/notesapi/app"
)
func main() {
cfg, err := app.LoadConfig("config.json")
if err != nil {
log.Fatal(err)
}
if err := app.New(cfg).Run(); err != nil {
log.Fatal(err)
}
}

Run initializes every registered service, applies pending migrations, starts the HTTP server, and blocks until the process gets SIGINT or SIGTERM. Then it shuts the server down and closes the services in reverse order. See App lifecycle.

Terminal window
go run ./cmd
... INFO server started environment=development addr=localhost:8080

The app has no routes yet, so every path is a 404:

Terminal window
curl -i localhost:8080/
HTTP/1.1 404 Not Found
...
404 page not found

Stop it with Ctrl+C:

... INFO shutdown signal received signal=interrupt
... INFO closing service service=gas/router
... INFO closing service service=gas/eventbus
... INFO shutdown complete

Two modules go into New: the database connection and the migration manager. The highlighted lines are new.

app/app.go
// Package app wires the notes API: it loads the config and registers every
// module and service on a gas.App.
package app
import (
"fmt"
_ "modernc.org/sqlite" // registers the pure-Go "sqlite" database/sql driver
"github.com/gasmod/gas"
"github.com/gasmod/gas/config"
"github.com/gasmod/gas/config/providers"
"github.com/gasmod/gas/database"
gaslog "github.com/gasmod/gas/log"
"github.com/gasmod/gas/migrate"
)
// LoadConfig reads the JSON file at path, then the environment. Later
// providers win, so KEYS__ADMINTOKEN overrides Keys.AdminToken from the file.
func LoadConfig(path string) (*config.Config, error) {
cfg := config.New(
config.WithProvider(providers.NewJSONProvider(providers.WithJSONFilePath(path))),
config.WithProvider(providers.NewEnvProvider()),
)
if err := cfg.Load(); err != nil {
return nil, fmt.Errorf("loading config: %w", err)
}
return cfg, nil
}
// New registers the modules and services. The caller runs the app.
func New(cfg gas.ConfigProvider) *gas.App {
app := gas.NewApp(
gas.WithServiceInstance[gas.ConfigProvider](cfg),
gas.WithSingletonService[gas.Logger](gaslog.NewSlogLogger()),
// The database, and the migration manager that applies every
// service's migrations at startup. Register migrate under
// gas.MigrationManager: that is the type Start looks up.
gas.WithSingletonService[gas.DatabaseProvider](database.New()),
gas.WithSingletonService[gas.MigrationManager](migrate.New()),
)
// Log every request through the app's logger.
app.Router().UseMiddlewareFunc(gas.RequestLogger[gas.Logger](
gas.WithRequestLoggerAppendRequestID(false),
))
return app
}

database.New() reads the Database section of the config when it initializes. Driver is the database/sql driver name, and "sqlite" is the name modernc.org/sqlite registers. gas/database does not import a SQLite driver itself, which is what the blank import is for. DSN is the file to open, relative to the working directory. The service opens the database and pings it, and startup fails if it cannot.

MaxOpenConns and MaxIdleConns are both 1: with one connection, queries take turns instead of contending for SQLite’s write lock. MaxIdleConns must be between 1 and MaxOpenConns. See Database for every key and its default.

migrate.New() takes the gas.DatabaseProvider and, when it initializes, creates the __gas_migrations table that records what has been applied. Services register their migrations with it during their own Init; once every service is initialized, Start applies the pending ones in version order. See Migrations.

Terminal window
go run ./cmd
... INFO SQL database connection initialized service=gas/database
... INFO applying pending migrations
... INFO server started environment=development addr=localhost:8080

notes.db now exists in the project root. If you have the sqlite3 command line tool, it shows the tracking table:

Terminal window
sqlite3 notes.db .tables
__gas_migrations

Clients will authenticate with API keys, which gas/auth/apikey generates, stores, and checks. Something has to hand them out, so the first service is a small one: POST /keys creates a key for a subject, and only callers that present the admin token from the config may use it.

keys/service.go
// Package keys issues API keys to clients that present the admin token.
package keys
import (
"context"
"crypto/subtle"
"fmt"
"net/http"
"github.com/gasmod/gas"
"github.com/gasmod/gas/auth/apikey"
)
// Config is the part of the app config this service reads.
type Config struct {
Keys struct {
// AdminToken must be sent in X-Admin-Token to create a key.
AdminToken string `validate:"required,min=16"`
}
}
// Service owns POST /keys.
type Service struct {
router *gas.Router
cfgProvider gas.ConfigProvider
apikeys apikey.Provider
cfg Config
}
// New is the constructor. The container supplies every parameter.
func New(router *gas.Router, cfgProvider gas.ConfigProvider, apikeys apikey.Provider) *Service {
return &Service{router: router, cfgProvider: cfgProvider, apikeys: apikeys}
}
func (s *Service) Name() string { return "keys" }
// Init binds the config, so a missing or short admin token stops startup,
// and registers the route.
func (s *Service) Init(context.Context) error {
if err := s.cfgProvider.Bind(&s.cfg); err != nil {
return fmt.Errorf("keys: config: %w", err)
}
s.router.Post(s, "/keys", s.create)
return nil
}
func (s *Service) Close(context.Context) error { return nil }

This is the shape of every Gas service. New is a plain constructor, and the container supplies its parameters: the router, the config, and the API key provider. Name identifies the service in logs and in the route map, and marks it as the owner of its routes. Init runs once at startup; this one binds its part of the config and registers its route. Close runs at shutdown and has nothing to release here. See Services and DI.

Bind fills Config from the loaded config and checks its validate tags. A missing admin token, or one shorter than 16 characters, stops startup:

building *keys.Service: init *keys.Service: keys: config: validate config: Key: 'Config.Keys.AdminToken' Error:Field validation for 'AdminToken' failed on the 'min' tag

The handler completes the file:

keys/service.go
type createRequest struct {
Subject string `json:"subject" validate:"required"`
Name string `json:"name" validate:"required"`
}
type createResponse struct {
ID string `json:"id"`
Subject string `json:"subject"`
Name string `json:"name"`
Key string `json:"key"`
}
// create issues a key for a subject. The plaintext key is in this response
// only; the database stores its hash.
func (s *Service) create(ctx gas.Context) error {
token := ctx.Header("X-Admin-Token")
if subtle.ConstantTimeCompare([]byte(token), []byte(s.cfg.Keys.AdminToken)) != 1 {
return gas.Unauthorized("a valid X-Admin-Token header is required")
}
var req createRequest
if err := ctx.BindJSON(&req); err != nil {
return err
}
key, info, err := s.apikeys.Generate(ctx, req.Subject, req.Name, nil)
if err != nil {
return fmt.Errorf("generating api key: %w", err)
}
return ctx.JSON(http.StatusCreated, createResponse{
ID: info.ID,
Subject: info.Subject,
Name: info.Name,
Key: key,
})
}

A handler that takes gas.Context and returns error is a DI-aware handler. A returned *gas.Error, such as the one gas.Unauthorized builds, is written as a JSON error at its status. BindJSON decodes the body and runs the validate tags. Generate returns the plaintext key once; the database keeps its SHA-256 hash and its first 8 characters, not the key. See Handlers and Context and Errors.

Register the API key provider and the new service:

app/app.go
// Package app wires the notes API: it loads the config and registers every
// module and service on a gas.App.
package app
import (
"fmt"
_ "modernc.org/sqlite" // registers the pure-Go "sqlite" database/sql driver
"github.com/gasmod/gas"
"github.com/gasmod/gas/auth/apikey"
"github.com/gasmod/gas/config"
"github.com/gasmod/gas/config/providers"
"github.com/gasmod/gas/database"
gaslog "github.com/gasmod/gas/log"
"github.com/gasmod/gas/migrate"
"example.com/notesapi/keys"
)
// LoadConfig reads the JSON file at path, then the environment. Later
// providers win, so KEYS__ADMINTOKEN overrides Keys.AdminToken from the file.
func LoadConfig(path string) (*config.Config, error) {
cfg := config.New(
config.WithProvider(providers.NewJSONProvider(providers.WithJSONFilePath(path))),
config.WithProvider(providers.NewEnvProvider()),
)
if err := cfg.Load(); err != nil {
return nil, fmt.Errorf("loading config: %w", err)
}
return cfg, nil
}
// New registers the modules and services. The caller runs the app.
func New(cfg gas.ConfigProvider) *gas.App {
app := gas.NewApp(
gas.WithServiceInstance[gas.ConfigProvider](cfg),
gas.WithSingletonService[gas.Logger](gaslog.NewSlogLogger()),
// The database, and the migration manager that applies every
// service's migrations at startup. Register migrate under
// gas.MigrationManager: that is the type Start looks up.
gas.WithSingletonService[gas.DatabaseProvider](database.New()),
gas.WithSingletonService[gas.MigrationManager](migrate.New()),
// API keys, stored in the same database, and the endpoint that
// issues them.
gas.WithSingletonService[apikey.Provider](apikey.New()),
gas.WithSingletonService[*keys.Service](keys.New),
)
// Log every request through the app's logger.
app.Router().UseMiddlewareFunc(gas.RequestLogger[gas.Logger](
gas.WithRequestLoggerAppendRequestID(false),
))
return app
}

apikey.New() takes a gas.DatabaseProvider, gas.Logger, gas.MigrationManager, and gas.ConfigProvider from the container. In Init it reads the APIKey section of the config (nothing to set here: the header defaults to X-API-Key) and registers its own migrations for the configured driver, which create its table. It is registered under the apikey.Provider interface, and the services that use it ask for that type. See Auth.

Terminal window
go run ./cmd

Run logs the route map at startup. With GasEnv set to development, it is a table:

... INFO SQL database connection initialized service=gas/database
... INFO service initialized service=gas/auth/apikey
... INFO applying pending migrations
... INFO route map (1 routes across 1 services):
─────────────────
[keys]:
POST /keys
─────────────────
... INFO server started environment=development addr=localhost:8080

Create a key for a subject called alice:

Terminal window
curl -X POST localhost:8080/keys \
-H 'X-Admin-Token: change-me-to-a-long-random-string' \
-d '{"subject":"alice","name":"laptop"}'
{"id":"aea43e9f-3da1-435c-861d-ea7ed68aba45","subject":"alice","name":"laptop","key":"1cGmFmGFeQzs9S1W0zfAeCAuY3TdyNN_FUfGknLKFKY"}

Your id and key will differ. Copy the key into a shell variable for the next step:

Terminal window
export KEY=1cGmFmGFeQzs9S1W0zfAeCAuY3TdyNN_FUfGknLKFKY

A wrong token gets a JSON error:

Terminal window
curl -X POST localhost:8080/keys -H 'X-Admin-Token: wrong' -d '{}'
{
"error": {
"status": 401,
"code": "unauthorized",
"message": "a valid X-Admin-Token header is required"
}
}

The notes service owns a table and four routes. Start with its schema, as a pair of migration files:

notes/migrations/20261021001_create_notes.up.sql
CREATE TABLE notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL,
created_at DATETIME NOT NULL
);
CREATE INDEX notes_owner_idx ON notes (owner);
notes/migrations/20261021001_create_notes.down.sql
DROP TABLE notes;

A migration file is named {version}_{description}.up.sql, and every .up.sql needs a matching .down.sql. The version is everything before the first underscore. Versions from every service are applied in one global order, so a timestamp keeps them unique.

The service embeds those files and registers them, then registers its routes behind the API key middleware:

notes/service.go
// Package notes owns the notes table and the /notes routes.
package notes
import (
"context"
"embed"
"fmt"
"net/http"
"github.com/gasmod/gas"
"github.com/gasmod/gas/auth"
"github.com/gasmod/gas/auth/apikey"
)
//go:embed migrations/*.sql
var migrations embed.FS
// Service owns the notes table and the /notes routes.
type Service struct {
router *gas.Router
db gas.DatabaseProvider
migrations gas.MigrationManager
apikeys apikey.Provider
logger gas.Logger
}
// New is the constructor. The container supplies every parameter.
func New(
router *gas.Router,
db gas.DatabaseProvider,
migrations gas.MigrationManager,
apikeys apikey.Provider,
logger gas.Logger,
) *Service {
return &Service{
router: router,
db: db,
migrations: migrations,
apikeys: apikeys,
logger: logger,
}
}
func (s *Service) Name() string { return "notes" }
// Init registers the service's migrations and routes. Migrations run after
// every service's Init, before the server accepts requests.
func (s *Service) Init(context.Context) error {
if err := s.migrations.RegisterFS(s, migrations); err != nil {
return fmt.Errorf("notes: migrations: %w", err)
}
s.router.Route("/notes", func(r *gas.Router) {
r.UseMiddlewareFunc(auth.Middleware(s.apikeys, auth.WithOnError(s.unauthorized)))
r.Post(s, "/", s.create)
r.Get(s, "/", s.list)
r.Get(s, "/{id}", s.get)
r.Delete(s, "/{id}", s.delete)
})
return nil
}
func (s *Service) Close(context.Context) error { return nil }
// unauthorized answers a request without a valid key in the same JSON
// error shape the handlers use.
func (s *Service) unauthorized(w http.ResponseWriter, r *http.Request, err error) {
resp := gas.Unauthorized("a valid X-API-Key header is required").WithCause(err)
if wErr := gas.WriteError(w, r, resp); wErr != nil {
s.logger.Error("writing 401 response").Err("error", wErr).Send()
}
}

Because New takes an apikey.Provider, the container builds the API key service before this one. RegisterFS reads the embedded files and registers them as migrations owned by this service; Start applies them after every Init has run.

auth.Middleware authenticates each request with the API key provider, which reads the key from the X-API-Key header. On success it puts the principal on the request context and calls the handler. On failure it calls the WithOnError function, which here writes the same JSON error shape the handlers produce; without WithOnError, the middleware writes a plain-text 401. The middleware is added inside Route("/notes", ...), so it applies to the notes routes and not to POST /keys. See Routing and Middleware.

The handlers go in a second file, which starts with the types:

notes/handlers.go
package notes
import (
"database/sql"
"errors"
"fmt"
"net/http"
"strconv"
"time"
"github.com/gasmod/gas"
)
// Note is a note as the API returns it.
type Note struct {
ID int64 `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
CreatedAt time.Time `json:"created_at"`
}
type createRequest struct {
Title string `json:"title" validate:"required,max=200"`
Body string `json:"body" validate:"max=10000"`
}

create stores a note under the caller’s subject:

notes/handlers.go
// create stores a note owned by the caller. The owner is the subject of the
// API key the request authenticated with.
func (s *Service) create(ctx gas.Context) error {
owner, err := ctx.Principal()
if err != nil {
return err
}
var req createRequest
if err := ctx.BindJSON(&req); err != nil {
return err
}
note := Note{Title: req.Title, Body: req.Body, CreatedAt: time.Now().UTC()}
err = s.db.DB().QueryRowContext(ctx,
`INSERT INTO notes (owner, title, body, created_at) VALUES (?, ?, ?, ?) RETURNING id`,
owner.Subject(), note.Title, note.Body, note.CreatedAt,
).Scan(&note.ID)
if err != nil {
return fmt.Errorf("inserting note: %w", err)
}
return ctx.JSON(http.StatusCreated, note)
}

ctx.Principal() returns the principal the middleware stored. The subject of an API key principal is the subject the key was generated for, alice in step 4. ctx is also a context.Context, so it goes straight into the query. s.db.DB() is the *sql.DB that gas/database opened.

list returns the caller’s notes:

notes/handlers.go
// list returns the caller's notes, oldest first.
func (s *Service) list(ctx gas.Context) (err error) {
owner, err := ctx.Principal()
if err != nil {
return err
}
rows, err := s.db.DB().QueryContext(ctx,
`SELECT id, title, body, created_at FROM notes WHERE owner = ? ORDER BY id`,
owner.Subject(),
)
if err != nil {
return fmt.Errorf("listing notes: %w", err)
}
defer func() { err = errors.Join(err, rows.Close()) }()
notes := []Note{}
for rows.Next() {
var n Note
if err := rows.Scan(&n.ID, &n.Title, &n.Body, &n.CreatedAt); err != nil {
return fmt.Errorf("scanning note: %w", err)
}
notes = append(notes, n)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("listing notes: %w", err)
}
return ctx.JSON(http.StatusOK, notes)
}

get and delete look a note up by id and by owner, so a note that belongs to another subject is a 404, the same as one that does not exist:

notes/handlers.go
// get returns one of the caller's notes. Another owner's note is a 404, the
// same as a note that does not exist.
func (s *Service) get(ctx gas.Context) error {
owner, err := ctx.Principal()
if err != nil {
return err
}
id, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err != nil {
return gas.BadRequest("id must be an integer")
}
var n Note
err = s.db.DB().QueryRowContext(ctx,
`SELECT id, title, body, created_at FROM notes WHERE id = ? AND owner = ?`,
id, owner.Subject(),
).Scan(&n.ID, &n.Title, &n.Body, &n.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return gas.NotFound("note not found")
}
if err != nil {
return fmt.Errorf("reading note: %w", err)
}
return ctx.JSON(http.StatusOK, n)
}
notes/handlers.go
// delete removes one of the caller's notes.
func (s *Service) delete(ctx gas.Context) error {
owner, err := ctx.Principal()
if err != nil {
return err
}
id, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err != nil {
return gas.BadRequest("id must be an integer")
}
res, err := s.db.DB().ExecContext(ctx,
`DELETE FROM notes WHERE id = ? AND owner = ?`,
id, owner.Subject(),
)
if err != nil {
return fmt.Errorf("deleting note: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("deleting note: %w", err)
}
if n == 0 {
return gas.NotFound("note not found")
}
return ctx.NoContent()
}

A returned error that is not a *gas.Error, such as the wrapped database errors, becomes a 500 with the message internal server error. The real error is logged, and never sent to the client.

Last, register the service:

app/app.go
// Package app wires the notes API: it loads the config and registers every
// module and service on a gas.App.
package app
import (
"fmt"
_ "modernc.org/sqlite" // registers the pure-Go "sqlite" database/sql driver
"github.com/gasmod/gas"
"github.com/gasmod/gas/auth/apikey"
"github.com/gasmod/gas/config"
"github.com/gasmod/gas/config/providers"
"github.com/gasmod/gas/database"
gaslog "github.com/gasmod/gas/log"
"github.com/gasmod/gas/migrate"
"example.com/notesapi/keys"
"example.com/notesapi/notes"
)
// LoadConfig reads the JSON file at path, then the environment. Later
// providers win, so KEYS__ADMINTOKEN overrides Keys.AdminToken from the file.
func LoadConfig(path string) (*config.Config, error) {
cfg := config.New(
config.WithProvider(providers.NewJSONProvider(providers.WithJSONFilePath(path))),
config.WithProvider(providers.NewEnvProvider()),
)
if err := cfg.Load(); err != nil {
return nil, fmt.Errorf("loading config: %w", err)
}
return cfg, nil
}
// New registers the modules and services. The caller runs the app.
func New(cfg gas.ConfigProvider) *gas.App {
app := gas.NewApp(
gas.WithServiceInstance[gas.ConfigProvider](cfg),
gas.WithSingletonService[gas.Logger](gaslog.NewSlogLogger()),
// The database, and the migration manager that applies every
// service's migrations at startup. Register migrate under
// gas.MigrationManager: that is the type Start looks up.
gas.WithSingletonService[gas.DatabaseProvider](database.New()),
gas.WithSingletonService[gas.MigrationManager](migrate.New()),
// API keys, stored in the same database, and the endpoint that
// issues them.
gas.WithSingletonService[apikey.Provider](apikey.New()),
gas.WithSingletonService[*keys.Service](keys.New),
gas.WithSingletonService[*notes.Service](notes.New),
)
// Log every request through the app's logger.
app.Router().UseMiddlewareFunc(gas.RequestLogger[gas.Logger](
gas.WithRequestLoggerAppendRequestID(false),
))
return app
}

Restart the server (Ctrl+C, then go run ./cmd). The route map now lists the notes routes:

... INFO route map (5 routes across 2 services):
─────────────────────────
[keys]:
POST /keys
[notes]:
POST /notes/
GET /notes/
GET /notes/{id}
DELETE /notes/{id}
─────────────────────────

The key from step 4 still works, because it is stored in notes.db. Without a key, the notes routes answer 401:

Terminal window
curl localhost:8080/notes
{
"error": {
"status": 401,
"code": "unauthorized",
"message": "a valid X-API-Key header is required"
}
}

Create two notes:

Terminal window
curl -X POST localhost:8080/notes -H "X-API-Key: $KEY" \
-d '{"title":"First note","body":"Hello from Gas"}'
curl -X POST localhost:8080/notes -H "X-API-Key: $KEY" \
-d '{"title":"Second note","body":"SQLite underneath"}'
{"id":1,"title":"First note","body":"Hello from Gas","created_at":"2026-10-21T16:05:42.844152Z"}
{"id":2,"title":"Second note","body":"SQLite underneath","created_at":"2026-10-21T16:05:42.852999Z"}

A note without a title fails validation with a 422 that names the field:

Terminal window
curl -X POST localhost:8080/notes -H "X-API-Key: $KEY" -d '{"body":"no title"}'
{
"error": {
"status": 422,
"code": "validation_failed",
"message": "request validation failed",
"fields": [
{
"field": "title",
"rule": "required",
"message": "is required"
}
]
}
}

List, get, delete, and get again:

Terminal window
curl localhost:8080/notes -H "X-API-Key: $KEY"
curl localhost:8080/notes/1 -H "X-API-Key: $KEY"
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE localhost:8080/notes/1 -H "X-API-Key: $KEY"
curl localhost:8080/notes/1 -H "X-API-Key: $KEY"
[{"id":1,"title":"First note","body":"Hello from Gas","created_at":"2026-10-21T16:05:42.844152Z"},{"id":2,"title":"Second note","body":"SQLite underneath","created_at":"2026-10-21T16:05:42.852999Z"}]
{"id":1,"title":"First note","body":"Hello from Gas","created_at":"2026-10-21T16:05:42.844152Z"}
204
{
"error": {
"status": 404,
"code": "not_found",
"message": "note not found"
}
}

With sqlite3, the tracking table shows the migrations from both services in version order:

Terminal window
sqlite3 notes.db 'SELECT version, service FROM __gas_migrations ORDER BY version'
20250323002|gas/auth/apikey
20260415042824|gas/auth/apikey
20261021001|notes

The test starts the real app, with its real modules, on a fresh SQLite file in a temporary directory, and sends requests to it through httptest. Nothing is mocked.

app/app_test.go
package app_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"example.com/notesapi/app"
)
const adminToken = "test-admin-token-0123456789"
// newServer starts the real app on a fresh SQLite file and serves its
// handler from an httptest server.
func newServer(t *testing.T) *httptest.Server {
t.Helper()
// The JSON provider only reads files inside the working directory, and
// the database file is relative to it too.
t.Chdir(t.TempDir())
cfgJSON := `{
"Database": {"Driver": "sqlite", "DSN": "file:notes.db", "MaxOpenConns": 1, "MaxIdleConns": 1},
"Keys": {"AdminToken": "` + adminToken + `"}
}`
if err := os.WriteFile("config.json", []byte(cfgJSON), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := app.LoadConfig("config.json")
if err != nil {
t.Fatal(err)
}
// Start initializes the services and runs the migrations, without
// listening on a port.
a := app.New(cfg)
if err := a.Start(t.Context()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := a.Stop(context.Background()); err != nil {
t.Error(err)
}
})
srv := httptest.NewServer(a.Handler())
t.Cleanup(srv.Close)
return srv
}

Start does what Run does before serving: it initializes the services, applies the migrations, and binds the config. It does not listen on a port. Handler returns the handler the app’s own server uses, which httptest.NewServer serves on a local port. Stop closes the services when the test ends. The test changes into its temporary directory because the JSON provider only opens files inside the working directory, and the database file is relative to it too.

A small helper sends JSON and decodes JSON:

app/app_test.go
// call sends a JSON request and decodes a JSON response into out, unless
// out is nil. It returns the status code.
func call(t *testing.T, srv *httptest.Server, method, path string, headers map[string]string, in, out any) int {
t.Helper()
var body bytes.Buffer
if in != nil {
if err := json.NewEncoder(&body).Encode(in); err != nil {
t.Fatal(err)
}
}
req, err := http.NewRequestWithContext(t.Context(), method, srv.URL+path, &body)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := resp.Body.Close(); err != nil {
t.Error(err)
}
}()
if out != nil {
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
t.Fatal(err)
}
}
return resp.StatusCode
}

The test walks the same flow as the curl session, and checks that a second subject cannot read the first one’s note:

app/app_test.go
func TestNotesAPI(t *testing.T) {
srv := newServer(t)
// Issue a key for alice.
var key struct {
Key string `json:"key"`
}
status := call(t, srv, http.MethodPost, "/keys",
map[string]string{"X-Admin-Token": adminToken},
map[string]string{"subject": "alice", "name": "test"}, &key)
if status != http.StatusCreated {
t.Fatalf("POST /keys: status %d, want 201", status)
}
alice := map[string]string{"X-API-Key": key.Key}
// Without a key, the notes routes answer 401.
if status := call(t, srv, http.MethodGet, "/notes", nil, nil, nil); status != http.StatusUnauthorized {
t.Fatalf("GET /notes without a key: status %d, want 401", status)
}
// Create, list, get, delete.
var created struct {
ID int64 `json:"id"`
}
status = call(t, srv, http.MethodPost, "/notes", alice,
map[string]string{"title": "first", "body": "hello"}, &created)
if status != http.StatusCreated {
t.Fatalf("POST /notes: status %d, want 201", status)
}
var list []struct {
Title string `json:"title"`
}
if status := call(t, srv, http.MethodGet, "/notes", alice, nil, &list); status != http.StatusOK {
t.Fatalf("GET /notes: status %d, want 200", status)
}
if len(list) != 1 || list[0].Title != "first" {
t.Fatalf("GET /notes: got %+v, want one note titled first", list)
}
path := fmt.Sprintf("/notes/%d", created.ID)
if status := call(t, srv, http.MethodGet, path, alice, nil, nil); status != http.StatusOK {
t.Fatalf("GET %s: status %d, want 200", path, status)
}
// Another subject's key cannot see alice's note.
status = call(t, srv, http.MethodPost, "/keys",
map[string]string{"X-Admin-Token": adminToken},
map[string]string{"subject": "bob", "name": "test"}, &key)
if status != http.StatusCreated {
t.Fatalf("POST /keys: status %d, want 201", status)
}
bob := map[string]string{"X-API-Key": key.Key}
if status := call(t, srv, http.MethodGet, path, bob, nil, nil); status != http.StatusNotFound {
t.Fatalf("GET %s as bob: status %d, want 404", path, status)
}
if status := call(t, srv, http.MethodDelete, path, alice, nil, nil); status != http.StatusNoContent {
t.Fatalf("DELETE %s: status %d, want 204", path, status)
}
if status := call(t, srv, http.MethodGet, path, alice, nil, nil); status != http.StatusNotFound {
t.Fatalf("GET %s after delete: status %d, want 404", path, status)
}
}
Terminal window
go test ./...
ok example.com/notesapi/app 0.368s
? example.com/notesapi/cmd [no test files]
? example.com/notesapi/keys [no test files]
? example.com/notesapi/notes [no test files]

See Testing for the mock packages and for testing a service on its own.

  • Services and DI: lifetimes, constructors, and how the container resolves dependencies.
  • Routing and Handlers and Context: groups, path parameters, and what a handler can return.
  • Auth: JWT, sessions, scopes, and revoking keys.
  • Database: transactions and PostgreSQL.
  • Deployment: graceful shutdown, health and readiness, and configuration per environment.