Shared building blocks for web services: Go packages and npm packages that several applications use the same way.
| Package | What it is |
|---|---|
problem (Go) |
RFC 9457 problem details with stable, localizable codes, and the pages their type URIs resolve to |
problem/humaproblem (Go) |
Makes huma produce problems, including for request validation |
problem/problemtest (Go) |
Checks that a web app's catalog has a message for every code |
problem/problemrules (Go) |
go-ruleguard rules that keep internal errors out of responses |
server (Go) |
A service's HTTP handler and server: health probes, security headers, CSRF protection, logging, panic recovery, graceful shutdown |
pgdb (Go) |
An application's own PostgreSQL schema: a pool scoped to it, hopper's job tables and goose migrations in it, and a fresh schema per test |
dev (Go, own module) |
go tool dev: an app's development setup in one terminal, with Postgres and S3 without Docker and a hot-reloading server, and the base of an app's own development command |
spa (Go) |
Serves a Vite app from the Go server: the embedded build in production, the Vite dev server in development |
@parallelworks/problem (npm) |
ApiError, useErrorMessage(), the shared codes' messages in five languages, and a Biome lint rule |
@parallelworks/ui (npm) |
React components on one theme contract: primitives, lists, forms, a job graph, a code editor, a log viewer, a file explorer and an AI chat, each on its own subpath |
Every error response is an RFC 9457
problem, served as application/problem+json. Its type says what kind of
problem it is, and resolves to a page documenting it. Clients show a message
for its code in the reader's language: a server with a problem.Localizer
writes detail in that language itself and says which with Content-Language,
so every client, a CLI as much as a web app, can show it. Without
Content-Language, detail is English, for developers and logs, and clients
show their own message for code instead.
There are three kinds of type:
type |
Code | Example |
|---|---|---|
about:blank |
none sent: the client derives it from the status | 404 → not_found |
/problems/validation |
validation; each entry in errors has its rule's type and code |
422 from a form |
/problems/<app>/<code> |
the application's own | /problems/shop/name_taken |
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "/problems/validation",
"title": "Invalid request",
"status": 422,
"detail": "validation failed",
"code": "validation",
"errors": [
{"type": "/problems/too_long", "code": "too_long", "pointer": "#/name",
"params": {"max": 64}, "detail": "Must be at most 64 characters."},
{"type": "/problems/required", "code": "required", "parameter": "team", "in": "query",
"detail": "This field is required."}
]
}Type URIs are relative, with the full path, so each deployment documents the types it actually sends. Namespacing them by application means a host that mounts several applications serves every page without collisions, wherever each one is mounted.
Codes are part of the API contract: never rename a code, a rule or a param.
Define an application's types once, in package variables:
var (
problems = problem.NewRegistry("shop")
NameTaken = problems.Define(problem.Type{
Code: "name_taken",
Status: http.StatusConflict,
Title: "Name taken",
Doc: "Another item in the workspace already has this name. Choose a different one.",
Params: []string{"name"},
})
)Return them from handlers, and use problem.Status for a failure the status
fully describes:
return nil, NameTaken.New("a product named Lamp exists").With("name", "Lamp")
return nil, problem.Status(http.StatusNotFound, "no order 1042")
return nil, problem.ValidationFailed(problem.TooLong.At(problem.Pointer("name"), "too long").With("max", 64))A problem missing a param its type declares is sent as the about:blank problem
for its status (a field error, as invalid), so clients never show a message
with an unfilled placeholder. Resolve returns a problem as it will be sent.
With huma, install the error builder before registering operations and serve the type pages:
humaproblem.Install(humaproblem.Options{
// Domain errors that are not problems, mapped in one place.
Map: func(err error) *problem.Problem {
if errors.Is(err, store.ErrNotFound) {
return problem.Status(http.StatusNotFound, "not found")
}
return nil
},
Debug: cfg.Development, // send a 5xx's cause as its detail
})
cfg.Transformers = append(cfg.Transformers, humaproblem.Report(func(ctx context.Context, p *problem.Problem) {
if p.Status >= 500 {
logger.FromContext(ctx).Error("request failed", "error", p.Unwrap())
}
}))
// ... huma.Register(...)
mux.Handle("/problems/", problem.Handler(problems))The OpenAPI document types code as an open string, not an enum: new codes
are not a breaking change, and clients fall back to the status for codes they
don't know. The catalog at /problems/ lists them.
Each page is also data: asked with Accept: application/json (and not
text/html), /problems/<...>/<code> returns its title, message, why,
fix and links in the reader's language, and /problems/ the list of types,
so a CLI can print how to fix a problem without a browser.
huma's request validation then becomes a validation problem whose entries name
the rule each field failed (required, too_long, below_minimum, ...), each
with that rule's message as its detail.
Nothing huma writes is sent. Its messages, and the errors it reports, such as
encoding/json's text for a malformed body, become the problem's cause, which is
only logged, so a reader sees the status's message. That includes the message
given to huma's own constructors: huma.Error404NotFound("no such team") is
sent as a plain 404. To send words of your own, return a problem:
problem.Status(http.StatusNotFound, "no such team").
problem.Shared holds the messages for the shared codes in English, Spanish,
Japanese, Korean and Chinese; problem.NewCatalog reads an app's own from one
JSON file per language (en.json, ja.json, ...), ICU MessageFormat strings
keyed by code. A problem.Localizer picks the reader's language with Locale
(such as spa.Locales.NegotiateHeader, the cookie then Accept-Language) and
rewrites each problem's detail, and each field error's, as its code's
message:
l := &problem.Localizer{Locale: locales.NegotiateHeader, Catalogs: []*problem.Catalog{catalog}}
cfg.Transformers = append(cfg.Transformers, humaproblem.Report(logProblem), humaproblem.Localize(l))
l.Write(w, r, p) // in a raw handlerAn about:blank problem keeps the detail the server wrote for English readers,
so specific text still reaches them; other languages get the status's message.
problemtest.CheckGoCatalog checks that a catalog covers a registry in
English, and problemtest.CheckGoCatalogComplete in every language the catalog
has, so no reader is shown English in its place.
A problem's detail and params are sent, so they hold only what the client
may see. Everything else stays on the server:
- Attach the underlying error with
WithCause(err). It is never sent;Reporthands it to you with the request's context, to log. - An error a handler returns that is not a problem, and that
Mapdoes not recognize, becomes a 500about:blankwith no detail. AsDenial()marks a problem as a refusal to authorize, andproblem.Denied(err)finds it, so a caller never mistakes an unreachable database for "you may not". The marker is not sent either.- Nothing huma writes is sent (see above): huma's messages and the errors it reports, such as a malformed body's parse error, are the cause.
problemrulesfails lint on an error, or itsError()text, passed toNew,Newf,At,AtParameter,StatusorWith, and on a call to huma's error constructors, whose message is never sent. Import it into your go-ruleguard rules file:
//go:build ruleguard
package gorules
import (
"github.com/parallelworks/foundation/problem/problemrules"
"github.com/quasilyte/go-ruleguard/dsl"
)
func init() { dsl.ImportRules("", problemrules.Bundle) }Test that the web app has a message for every code. The check reads the
catalog as useCodeMessage does (see below): a type's message under its
registry's name, at apiErrors.<name>.<code>, when there is one there, and
otherwise at apiErrors.<code>.
func TestEveryCodeHasAMessage(t *testing.T) {
problemtest.CheckCatalog(t, "../web/src/i18n/locales/en.json", problems)
// or, with apiErrors in its own file, load the map and call
// problemtest.CheckMessages(t, messages, problems), with a message
// under a registry's name at "<name>.<code>"
}import { toApiError, problemMiddleware } from '@parallelworks/problem'
import { useErrorMessage } from '@parallelworks/problem/react'
api.use(problemMiddleware) // openapi-fetch: ask for problem details
const errorMessage = useErrorMessage()
{save.isError && <p role="alert">{errorMessage(save.error)}</p>}useErrorMessage() shows the message for the error's code: the app's own at
apiErrors.<code> in its use-intl catalog, otherwise the shared message
shipped with this package (English, Spanish, Japanese, Korean and Chinese). A
validation problem with one invalid field shows that field's rule; network
and unknown cover failures that never reached the server. toApiError()
turns anything thrown into an ApiError with status, code, params and
fields, whose path (items[0].name) matches form field names.
useCodeMessage() finds a code's message the same way, for a problem that
isn't a failed request, such as a warning a server reports about what it was
given. Its function takes the code, params and type, and gives undefined
when no catalog has the code, so the problem keeps its own text.
An app's own codes can stay at the top level of apiErrors. Another
registry's codes go under its name, so a code both use keeps each one's
message and params. For a problem whose type names an application, such as
/problems/workflow/unknown_input (relative or absolute), the lookup reads
apiErrors.workflow.unknown_input first, then apiErrors.unknown_input, then
the shared message. useErrorMessage() goes by the error's type, and by a
field's own for the field's message. problemtest.CheckCatalog understands
this layout: it checks each registry's codes under its name, or else at the
top level.
{
"apiErrors": {
"unknown_input": "The form has no field \"{field}\".",
"workflow": { "unknown_input": "There's no input \"{input}\"." }
}
}lint/error-text.grit in @parallelworks/problem is a Biome plugin. It fails on an
error's .message rendered in JSX or passed to toast, and on text passed to
setters such as setError (a lowercase identifier like 'loadFailed' is a
code and passes):
{ "plugins": ["./node_modules/@parallelworks/problem/lint/error-text.grit"] }server.New assembles a service's handler around its own routes, and
server.Serve runs it until the context ends:
handler := server.New(server.Options{
Logger: logger,
Routes: func(mux *http.ServeMux) { api.Register(mux, deps) },
Problems: []*problem.Registry{problems},
Ready: map[string]server.Pinger{"database": pool},
Web: web.FS(),
DevServer: cfg.ViteURL, // development only: proxy the app from Vite
HSTS: cfg.Production,
Wrap: sessions.Middleware, // the application's own authentication
})
return server.Serve(ctx, server.Listen{Addr: ":8080", ShutdownTimeout: 20 * time.Second}, handler, logger)Serve binds before it logs, so a taken port is an error rather than a
"listening" line, and it logs the address it got with a url to reach it
there (http://localhost:8080, or https with TLS), which dev shows
as a link.
Besides the application's routes it serves /healthz, /readyz (the Ready
pingers), /problems/, a 404 problem for unknown paths under /api/, and the
single-page app. Every response gets security headers with a strict CSP, and a
state-changing request a browser sent from another site is refused. Requests
are logged; a panic is logged and answered with a 500, a problem under /api/.
In development the CSP accepts spa.DevNonce, which Vite puts on the scripts
and styles it injects when vite.config.ts sets it:
export default defineConfig(({ command }) => ({
...(command === 'serve' && { html: { cspNonce: 'vite-dev' } }),
build: { assetsDir: '_build' },
}))spa.Handler serves the app from the Go server, so it has one origin in
development and production: the same cookies, CSP and routes, no CORS, and no
proxy list in vite.config.ts.
//go:embed all:dist
var dist embed.FS
build, _ := fs.Sub(dist, "dist")
app, err := spa.Handler(build, spa.Options{DevServer: "http://localhost:5173"})
if err != nil {
return err
}
mux.Handle("/", app) // after the API routesWith a build embedded, it serves each file (preferring a .br or .gz
sibling the client accepts), then a
prerendered <path>/index.html, then index.html for client-side routes.
With DevServer set, it proxies everything to the Vite dev server instead,
including the HMR WebSocket, even when dist holds a build from an earlier
pnpm build. Set it only in development, and open the Go server's address
there, not Vite's. Without DevServer, dist must hold a build.
Put Vite's content-hashed output in /_build/, which spa.Handler caches as
immutable; files copied from public/ keep their names and stay revalidatable,
so a changed logo or font is never stuck in a browser cache:
// vite.config.ts
build: { assetsDir: '_build' }Options.Index rewrites index.html for each request (in development too),
for example to add <base href>; Options.NotFound sends the shell with a 404
for paths the app does not know.
Options.Locales sets <html lang> to the reader's language: the cookie's
choice, then Accept-Language, negotiated against the app's locales exactly as
negotiateLocale in @parallelworks/i18n does, so es-MX gets es. Pass it
to the client so the first paint is already in that language:
spa.Options{Locales: spa.Locales{Available: []string{"en", "es", "ja"}, Cookie: "locale"}}const locale = detectLocale(locales, {
fallback: defaultLocale,
injected: document.documentElement.lang,
cookie: 'locale',
})server.Options.Locales does the same for an app served by server.New.
Locales.Negotiate(r) and spa.NegotiateLocale are there for a server that
renders its own shell.
make check runs every linter and test. See CONTRIBUTING.md.