This repository contains the basic structure for Web and Software Architecture homework project. It has been described in class.
"Fantastic coffee (decaffeinated)" is a simplified version for the WASA course, not suitable for a production environment. The full version can be found in the "Fantastic Coffee" repository.
cmd/contains all executables; Go programs here should only do "executable-stuff", like reading options from the CLI/env, etc.cmd/healthcheckis an example of a daemon for checking the health of servers daemons; useful when the hypervisor is not providing HTTP readiness/liveness probes (e.g., Docker engine)cmd/webapicontains an example of a web API server daemon
demo/contains a demo config filedoc/contains the documentation (usually, for APIs, this means an OpenAPI file)service/has all packages for implementing project-specific functionalitiesservice/apicontains an example of an API server (see "HTTP router" below)service/databasecontains the data access layer: theAppDatabaseinterface and its SQLite implementation. The schema is managed with SQL migrations (see "Database migrations" below).service/globaltimecontains a wrapper package fortime.Time(useful in unit testing)
vendor/is managed by Go, and contains a copy of all dependencieswebui/is an example of a web frontend in Vue.js; it includes:- Bootstrap 5 and Bootstrap Icons (installed with yarn)
- a small dashboard layout (responsive sidebar, light/dark color mode)
- Go code for release embedding
Other project files include:
open-node.shstarts a new (temporary) container usingnode:24image for safe and secure web frontend development. It is the same node version used by us during the evaluation of your app.
A router (or mux) decides which handler function runs for an incoming HTTP
request, based on its method and path, and extracts the variable parts of the
path (for example the id in /users/{id}). Go's standard library includes one
(net/http.ServeMux), but it is deliberately minimal. This project uses
chi, a small router that is fully compatible
with net/http (the HTTP package of the Go standard library): handlers and
middleware are plain
func(http.ResponseWriter, *http.Request) functions, so no framework-specific
types are involved and the standard library stays usable (the examples in the
WASA book use plain net/http, and the same code works here). chi adds conveniences
the standard router does not provide directly, such as route groups and a
middleware ecosystem. The routes and middleware are wired in service/api.
This project uses Go Vendoring. You must use go mod vendor after changing some dependency (go get or go mod tidy) and add all files under vendor/ directory in your commit.
For more information about vendoring:
The database schema is defined by a set of SQL migrations stored in
service/database/migrations/. The files are embedded in the webapi executable at build time and applied automatically at every startup, before the server accepts requests.
A migration is a versioned, append-only change to the database schema. Instead of
editing a schema.sql by hand (or letting an ORM guess the schema), you describe
each change once, in SQL, with a progressive number. Every environment (your
laptop, a teammate's laptop, the grading machine, production) starts from an empty
database and applies the same migrations in the same order, so everyone ends up
with exactly the same schema.
Migration files become the single source of truth for the schema and are versioned together with the rest of the code.
- Migrations live in
service/database/migrations/and are named with a zero-padded numeric prefix:0001_init.sql,0002_add_users.sql, ... - File names are sorted lexicographically, so the prefix defines the execution order. Keep it zero-padded and always increasing.
service/database/migrations.goembeds the whole directory with//go:embedand exposesdatabase.Migrate(ctx, db).cmd/webapi/main.gocallsdatabase.Migrateright after opening the connection and before creating the API router. If a migration fails, the server does not start.- Applied versions are recorded in the
schema_migrationstable (version,applied_at). On the next startup, already-applied migrations are skipped. - Each migration runs inside a single transaction together with the row that records it as applied. Either the whole migration is applied and recorded, or nothing is. SQLite's DDL is transactional, so this also holds for schema changes.
- Migrations are forward-only: there is no automatic "undo". If a migration causes a problem, the usual approach is to fix it with a new migration rather than modifying or reverting the already-applied migration. For example, if 0005_add_status.sql introduces an incorrect schema change, create 0006_fix_status.sql that brings the database into the desired state. If the migration caused data corruption or a destructive change that cannot be safely repaired forward, restore the database from a backup taken before the migration and then apply the migrations again.
- Pick the next number. If the last file is
0002_add_users.sql, create0003_add_posts.sql. - Write plain SQL in it. Multiple statements are allowed.
- Restart the server (
go run ./cmd/webapi/): the migration is applied automatically. - Commit the new
.sqlfile together with the code that uses it.
Example (0003_add_posts.sql):
CREATE TABLE posts (
id INTEGER NOT NULL PRIMARY KEY,
author TEXT NOT NULL,
body TEXT NOT NULL
);
CREATE INDEX idx_posts_author ON posts(author);Keep each migration focused on one logical change, and make sure the whole set of migrations still produces a correct schema on an empty database.
NEVER modify an existing migration. Once a migration has been applied anywhere, its content is frozen. If you edit it, databases that already applied it will not see your change, while fresh databases will — and the schemas will silently diverge. This is the most common and most painful mistake.
- NEVER edit, rename, or delete a migration that has already been applied.
- NEVER reuse or reorder a version number. Two
0003_...files, or inserting a0002after0003was applied, breaks the ordering guarantees. - NEVER make an old migration depend on new code or data.
- NEVER write idempotent DDL (for example
CREATE TABLE IF NOT EXISTS). Each migration is applied exactly once; see "Why migrations are not idempotent" below. - NEVER put
PRAGMAstatements in a migration: pragmas cannot run inside a transaction. Configure them when opening the connection instead. - NEVER commit a migration you have not tested from an empty database.
Need to change something you already migrated? Write a new migration that changes the schema forward (add a column, drop a table, backfill data, ...). For example, to rename a column in SQLite: add the new column, copy the data, then drop the old one — in new migration files.
A migration is applied exactly once. The runner guarantees this by recording
each applied version in schema_migrations and by executing the migration and
its version record inside a single transaction: a migration that fails is not
recorded, and an applied migration is never run again.
Because of that, migrations must not be written to be idempotent. In
particular, do not use CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS, or similar:
- If an object already exists with a different structure,
IF NOT EXISTSsilently succeeds and leaves the database in a state that does not match the migrations. You want that to fail loudly, not to be masked. - Many operations cannot be made idempotent at all (adding a column, backfilling data, inserting seed rows), so idempotency is not a general solution and gives a false sense of safety.
- The migration history in
schema_migrations— not the SQL — is the source of truth for what has been applied.
Write each migration as the single, unconditional change from the previous schema version to the next.
Since migrations only go forward, the easiest way to start over while developing
is to delete the database file (by default /tmp/decaf.db, or whatever
--db-filename points to) and restart the server. The full set of migrations is
re-applied to a fresh database.
This is meant for advanced students only. In the course you are expected to use the migration system described above. But if you are already an experienced backend developer and want to use advanced tools, keep reading
The migration system is the default and recommended data layer. If you already know what you are doing and want a different one, the project is designed so you can plug it in without touching the API handlers:
- All data access goes through the
database.AppDatabaseinterface (service/database/database.go). Handlers depend on that interface, not on the concrete SQLite implementation. - sqlc: point its
schemasetting atservice/database/migrations(keep them forward-only, with no "down" files) and itsqueriessetting at a directory of your own.sqlfiles. Commit and vendor the generated code, then write a small adapter type that implementsAppDatabaseand pass it toapi.Newincmd/webapi/main.go. - GORM: keep running the SQL migrations first and do not enable
AutoMigrate: the migrations are the only source of truth for the schema. Map your models to the migrated tables and implementAppDatabaseas a thin wrapper. - In both cases keep
Migratein the startup path. The handlers and theapipackage should not need any change.
This section is intentionally just a pointer in the right direction: if you take this route, you are expected to work out the details yourself.
However, keep in mind that any significant changes to the Fantastic Coffee project structure or template are made at your own risk. If such changes introduce problems or incompatibilities, students are responsible for resolving them, and we cannot provide support for issues caused by substantial modifications to the provided template.
This repository uses yarn and a vendoring technique that exploits the "Offline mirror". As for the Go vendoring, the dependencies are inside the repository.
You should commit the files inside the .yarn directory.
You need to:
- Change the Go module path to your module path in
go.mod,go.sum, and in*.gofiles around the project - Rewrite the API documentation
doc/api.yaml - If no web frontend is expected, remove
webuiandcmd/webapi/register-webui.go - Update top/package comment inside
cmd/webapi/main.goto reflect the actual project usage, goal, and general info - Update the code in
run()function (cmd/webapi/main.go) to connect to databases or external resources - Write API code inside
service/api, and create any further package insideservice/(or subdirectories)
If you're not using the WebUI, or if you don't want to embed the WebUI into the final executable, then:
go build ./cmd/webapi/If you're using the WebUI and you want to embed it into the final executable:
./open-node.sh
# (here you're inside the container)
yarn run build-embed
exit
# (outside the container)
go build -tags webui ./cmd/webapi/You can launch the backend only using:
go run ./cmd/webapi/If you want to launch the WebUI, open a new tab and launch:
./open-node.sh
# (here you're inside the container)
yarn run dev./open-node.sh
# (here you're inside the container)
yarn run build-prodFor "Web and Software Architecture" students: before committing and pushing your work for grading, please read the section below named "My build works when I use yarn run dev, however there is a Javascript crash in production/grading"
Some errors in the code are somehow not shown in vite development mode. To preview the code that will be used in production/grading settings, use the following commands:
./open-node.sh
# (here you're inside the container)
yarn run build-prod
yarn run previewSee LICENSE.