Examples

notes-starter

A starter app you can keep and grow

Browse on GitHub
$ git clone https://github.com/angvp/tango
$ cd tango/examples/notes-starter
$ go run .

Notes starter

This example is a small tanGO app you could keep as a starting point: one model, JSON routes, generated migrations, the HTML admin, and ratelimit.Middleware on the note-creation route.

Run locally

git clone https://github.com/angvp/tango
cd tango/examples/notes-starter
go run . -check
go run . -migrate
go run . -tango-admin-create=admin
go run .

-tango-admin-create prompts for a password on stdin and creates the account with a bcrypt-hashed password — there is no static credential to set in .env. Open http://localhost:8000/admin/ and log in with that username/password; you'll land on /admin/login/ first since admin auth is a real session-cookie login, not HTTP Basic Auth.

Try the JSON API:

curl -X POST http://localhost:8000/api/notes/ \
  -H 'Content-Type: application/json' \
  -d '{"Title":"Hello","Body":"First note","Private":false}'

curl http://localhost:8000/api/notes/

POST /api/notes/ is rate limited per client IP (5 requests, refilling one every 10 seconds, via ratelimit.NewLimiter/ratelimit.Middleware in main.go) — exceeding it returns 429 with a Retry-After header. GET /api/notes/ is not limited. See rate limiting for the full model; this example's limits are deliberately small so you can trigger the 429 locally without waiting.

Configuration

  • The address is TANGO_ADDR, else the PORT hosting platforms set, else :8000. Ctrl-C or SIGTERM shuts the server down gracefully.
  • TANGO_DB_DSN picks the database by its scheme and defaults to sqlite://app.db (a file in the working directory). sqlite:///var/data/app.db is an absolute path and sqlite://:memory: an in-memory database.

To adapt this example to Postgres, swap the modernc.org/sqlite driver import for github.com/jackc/pgx/v5/stdlib and set TANGO_DB_DSN=postgres://user:password@host:5432/dbname; the dialect and driver name come from the DSN. New projects can generate that shape directly with tango newproject --dialect=postgres.

examples/notes-starter/main.go
package main

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/angvp/tango"
	"github.com/angvp/tango/admin"
	"github.com/angvp/tango/db"
	"github.com/angvp/tango/model"
	"github.com/angvp/tango/ratelimit"

	"notes-starter/migrations"

	_ "modernc.org/sqlite"
)

// createLimiter throttles note creation per client IP: 5 notes, refilling
// one every 10 seconds. A real app would tune this to its own traffic
// shape — see docs/guides/rate-limiting.md.
var createLimiter = mustNewCreateLimiter()

func mustNewCreateLimiter() *ratelimit.Limiter {
	limiter, err := ratelimit.NewLimiter(ratelimit.Options{Limit: 5, Refill: 10 * time.Second})
	if err != nil {
		panic(err)
	}
	return limiter
}

type Note struct {
	ID      int64 `tango:"pk"`
	Title   string
	Body    string
	Private bool
}

type NotesApp struct {
	store *db.Store
	meta  model.ModelMeta
}

func (a *NotesApp) Name() string { return "notes" }

func (a *NotesApp) Register(registry *tango.Registry) error {
	if err := registry.Models().Register(Note{}); err != nil {
		return err
	}
	meta, ok := registry.Models().Get("Note")
	if !ok {
		return errors.New("Note model is not registered")
	}
	a.meta = meta
	if err := registry.Admin().Register(Note{}, admin.Options{
		ListDisplay: []string{"Title", "Private"},
		Search:      []string{"Title", "Body"},
		Ordering:    []string{"Title"},
	}); err != nil {
		return err
	}
	return registry.Routes().Include("/api/notes/", tango.URLs{
		tango.Path("GET", "/", a.list, tango.Name("list")),
		tango.Path("POST", "/", a.create, tango.Name("create"),
			tango.Use(ratelimit.Middleware(createLimiter, ratelimit.RemoteIPKey()))),
	})
}

func (a *NotesApp) list(ctx *tango.Context) error {
	var notes []Note
	if err := a.store.List(ctx.Context(), a.meta, db.Query{Limit: 50, OrderBy: []string{"Title"}}, &notes); err != nil {
		return err
	}
	return ctx.JSON(200, notes)
}

func (a *NotesApp) create(ctx *tango.Context) error {
	var note Note
	if err := ctx.Bind(&note); err != nil {
		return err
	}
	if err := a.store.Create(ctx.Context(), a.meta, &note); err != nil {
		return err
	}
	return ctx.JSON(201, note)
}

func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

func run() error {
	if err := tango.LoadEnvFile(".env"); err != nil {
		return err
	}

	dsn, err := tango.LoadDBConfigFromEnv()
	if err != nil {
		return err
	}

	sqlDB, err := sql.Open(dsn.Driver, dsn.Source)
	if err != nil {
		return err
	}
	defer sqlDB.Close()

	store := db.NewStore(sqlDB, dsn.Dialect)
	config := appConfig(store)

	if handled, err := admin.HandleCLI(context.Background(), store, os.Args[1:], os.Stdin, os.Stdout, os.Stderr); handled || err != nil {
		return err
	}

	handled, err := tango.DispatchFlags(config, sqlDB, dsn.Dialect, migrations.Migrations)
	if handled || err != nil {
		return err
	}

	// Ctrl-C or SIGTERM cancels ctx, and ServeContext shuts down gracefully.
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	fmt.Println("listening on", config.Addr)
	return tango.ServeContext(ctx, config, sqlDB, dsn.Dialect)
}

func appConfig(store *db.Store) tango.Config {
	// The address is TANGO_ADDR, else the PORT hosting platforms set, else :8000.
	config := tango.LoadConfigFromEnv(tango.WithPortFromEnv())
	config.InstalledApps = []tango.App{
		&NotesApp{store: store},
		admin.New(store),
	}
	return config
}

// buildHandler compiles config into a servable http.Handler exactly the
// way tango.Serve does (including wiring store into the registry), split
// out here so tests can exercise real routing/middleware (including
// createLimiter) against an httptest server without going through main's
// flag/env/serve plumbing.
func buildHandler(config tango.Config, store *db.Store) (http.Handler, error) {
	registry, err := tango.BuildRegistry(config)
	if err != nil {
		return nil, err
	}
	if err := registry.RunRegistration(); err != nil {
		return nil, err
	}
	registry.SetStore(store)
	return registry.Routes().Handler()
}