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.
Step 1: create the project
Section titled “Step 1: create the project”Create a module and add the Gas packages this tutorial uses, plus the pure-Go SQLite driver:
mkdir notesapi && cd notesapigo mod init example.com/notesapigo 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/sqliteThe core and each module are separate Go modules, so an app adds only the ones it uses.
Step 2: an app with config and logging
Section titled “Step 2: an app with config and logging”Create config.json in the project root. This is the whole file; the
Database and Keys sections are used from step 3 on.
{ "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.
// 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 (hereGasEnv,Server.Host, andServer.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’slog/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:
// 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.
Run it
Section titled “Run it”go run ./cmd... INFO server started environment=development addr=localhost:8080The app has no routes yet, so every path is a 404:
curl -i localhost:8080/HTTP/1.1 404 Not Found...404 page not foundStop it with Ctrl+C:
... INFO shutdown signal received signal=interrupt... INFO closing service service=gas/router... INFO closing service service=gas/eventbus... INFO shutdown completeStep 3: add the database and migrations
Section titled “Step 3: add the database and migrations”Two modules go into New: the database connection and the migration manager.
The highlighted lines are new.
// 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.
Run it
Section titled “Run it”go run ./cmd... INFO SQL database connection initialized service=gas/database... INFO applying pending migrations... INFO server started environment=development addr=localhost:8080notes.db now exists in the project root. If you have the sqlite3 command
line tool, it shows the tracking table:
sqlite3 notes.db .tables__gas_migrationsStep 4: issue API keys
Section titled “Step 4: issue API keys”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.
// 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' tagThe handler completes the file:
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:
// 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.
Run it
Section titled “Run it”go run ./cmdRun 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:8080Create a key for a subject called alice:
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:
export KEY=1cGmFmGFeQzs9S1W0zfAeCAuY3TdyNN_FUfGknLKFKYA wrong token gets a JSON error:
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" }}Step 5: the notes service
Section titled “Step 5: the notes service”The notes service owns a table and four routes. Start with its schema, as a pair of migration files:
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);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:
// 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/*.sqlvar 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:
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:
// 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(¬e.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:
// 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:
// 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)}// 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:
// 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}Run it
Section titled “Run it”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:
curl localhost:8080/notes{ "error": { "status": 401, "code": "unauthorized", "message": "a valid X-API-Key header is required" }}Create two notes:
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:
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:
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:
sqlite3 notes.db 'SELECT version, service FROM __gas_migrations ORDER BY version'20250323002|gas/auth/apikey20260415042824|gas/auth/apikey20261021001|notesStep 6: test it
Section titled “Step 6: test it”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.
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:
// 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:
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) }}Run it
Section titled “Run it”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.
Where to go next
Section titled “Where to go next”- 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.