Skip to content

Add an endpoint

Every public route has four parts that change together: the handler, its OpenAPI entry, its row in the authorization matrix, and the web app’s schema for its response. Tests fail when the route, its document or its row is missing, which is the point: a route nobody thought about authorizing is how the 2022 layout got its worst bugs.

  1. Pick the route. Name the resource, not the action: POST /v1/me/email-change, not /v1/changeEmail. A route under /v1/me acts on the caller, which keeps IDs of other users out of URLs.

  2. Check who may call it in the use case. In the user service the app method decides, from the identity in the context, never from the body or the path. app.Actor refuses an anonymous caller with 401, and a client acting for itself or a token without the scopes with 403:

    internal/user/app/app.go
    // Actor returns the user ctx's identity acts for, which must hold every
    // scope in scopes: an apperr.ErrUnauthorized for an anonymous request, and
    // an apperr.ErrForbidden for a client acting for itself or a missing scope.
    func Actor(ctx context.Context, scopes ...string) (uuid.UUID, error) {
    id, ok := authn.FromContext(ctx)
    switch {
    case !ok:
    return uuid.Nil(), errNotSignedIn
    case !id.IsUser():
    return uuid.Nil(), apperr.New(apperr.ErrForbidden, "This needs a user's access token.")
    }
    if err := authn.MissingScope(id, scopes); err != nil {
    return uuid.Nil(), err
    }
    return id.UserID, nil
    }
    internal/user/app/app.go
    // RequestEmailChange mails a confirmation link to email, the address the
    // calling user wants instead of theirs. The address changes only when the
    // link's code comes back through ConfirmEmailChange, and whether another
    // account has it is told only then.
    func (s *Service) RequestEmailChange(ctx context.Context, email string) error {
    id, err := Actor(ctx, ScopeUserWrite)
    if err != nil {
    return err
    }
    addr, err := domain.ParseEmail(email)
    if err != nil {
    return err
    }
    u, err := s.users.ByID(ctx, id)
    if err != nil {
    return fmt.Errorf("request email change: %w", err)
    }
    if u.Email == addr.String() {
    return domain.ErrSameEmail
    }
    now := s.clock()
    code, hash := newCode()
    lc := store.LoginCode{Hash: hash, Purpose: store.PurposeEmailChange, Email: addr.String(), UserID: id, ExpiresAt: now.Add(LoginCodeTTL)}
    link := s.link(url.Values{"code": {code}}, "profile", "confirm-email")
    msg, err := outbox.NewMessage(mail.TopicEmailChange, mail.Link{To: addr.String(), URL: link.String(), ExpiresAt: lc.ExpiresAt}, now)
    if err != nil {
    return fmt.Errorf("request email change: %w", err)
    }
    err = s.uow.Do(ctx, func(tx uow.Tx) error {
    if err := s.codes.Add(ctx, tx, lc); err != nil {
    return err
    }
    return s.outbox.Add(ctx, tx, msg)
    })
    if err != nil {
    return fmt.Errorf("request email change: %w", err)
    }
    return nil
    }

    auth checks in its routes instead, after identify (authn.Authenticate), with the narrowest guard that fits from internal/platform/authn:

    • authn.Require(scopes...): any valid token with these scopes;
    • authn.RequireUser(scopes...): a user’s token, not a client’s;
    • authn.RequireFirstParty(): only the web app’s own session, the firstParty chain in its router.

    In auth, a route with none of these is public. Rate-limit a public route that sends email or creates anything.

  3. Write the handler with httpx.Handle. It returns an error instead of writing one, so every failure goes through the one error table and becomes a problem response. It decodes the body with httpx.DecodeJSON[T](r, maxBytes), which rejects the wrong content type, unknown or duplicate fields, trailing data and oversized bodies, calls one use case, and writes the result with httpx.WriteJSON:

    internal/user/httpapi/httpapi.go
    func (h *handler) requestEmailChange(w http.ResponseWriter, r *http.Request) error {
    body, err := httpx.DecodeJSON[struct {
    Email string `json:"email"`
    }](r, maxBody)
    if err != nil {
    return err
    }
    if err := h.app.RequestEmailChange(r.Context(), body.Email); err != nil {
    return err
    }
    return httpx.WriteJSON(w, http.StatusAccepted, accepted{})
    }
  4. Register it on the service’s gorouter v5 router in internal/<service>/httpapi/httpapi.go. In the user service, a route that has a caller is wrapped in identify (authn.Authenticate), which puts the identity in the context. Without it, app.Actor finds no identity and every call is a 401. A route that takes no caller isn’t wrapped, so a stale cookie can’t fail it. In auth, a route that reads a cookie and changes state also gets cookie (httpx.CrossOrigin). Handlers read path parameters with the standard r.PathValue.

  5. Document it in api/<service>.openapi.yaml (OpenAPI 3.1): request body, every response status, and the problem response for errors. TestOpenAPIDocumentsEveryRoute fails if a route is missing from the file or the file lists a route that doesn’t exist, and the tests’ rig checks every response against its schema.

  6. Add its authorization-matrix row in internal/<service>/httpapi/matrix_test.go. TestAuthorizationMatrix calls every route as each caller that file lists and checks the status each must get, and a route with no row fails it. The user service’s callers are anonymous, client (a client-credentials token, with no user), noScope (the owner, without the route’s scope), other (another user) and owner. auth’s are anonymous, another user, the owner, a token without the scope, a client’s own token, and a third-party app’s token for the user. In the user service, a route that answers with the caller’s user is also checked to return the caller, not someone else. The user service’s rows:

    internal/user/httpapi/matrix_test.go
    // The authorization matrix: every route against every kind of caller. A
    // route added without a row fails TestEveryRouteHasARow.
    var rows = []access{
    {method: http.MethodGet, pattern: "/livez", want: all(http.StatusOK)},
    {method: http.MethodGet, pattern: "/readyz", want: all(http.StatusOK)},
    {method: http.MethodGet, pattern: "/v1/config", want: all(http.StatusOK)},
    {
    method: http.MethodPost, pattern: "/v1/login-links",
    body: func(_ *matrix, n int) any {
    return map[string]string{"email": fmt.Sprintf("someone-%d@example.com", n)}
    },
    want: all(http.StatusAccepted),
    },
    {
    method: http.MethodGet, pattern: "/v1/me", scope: app.ScopeUserRead,
    want: [callers]int{http.StatusUnauthorized, http.StatusForbidden, http.StatusForbidden, http.StatusOK, http.StatusOK},
    },
    {
    method: http.MethodPost, pattern: "/v1/me/email-change", scope: app.ScopeUserWrite,
    body: func(_ *matrix, n int) any { return map[string]string{"email": fmt.Sprintf("new-%d@example.com", n)} },
    want: [callers]int{http.StatusUnauthorized, http.StatusForbidden, http.StatusForbidden, http.StatusAccepted, http.StatusAccepted},
    },
    {
    method: http.MethodPost, pattern: "/v1/me/email-change/confirm", scope: app.ScopeUserWrite,
    body: func(m *matrix, _ int) any { return map[string]string{"code": m.ownerCode} },
    // Another user's code is no code at all to them.
    want: [callers]int{http.StatusUnauthorized, http.StatusForbidden, http.StatusForbidden, http.StatusBadRequest, http.StatusOK},
    },
    {
    method: http.MethodGet, pattern: "/v1/google/start", path: "/v1/google/start?link=1", scope: app.ScopeUserWrite,
    want: [callers]int{http.StatusUnauthorized, http.StatusForbidden, http.StatusForbidden, http.StatusFound, http.StatusFound},
    },
    {
    method: http.MethodGet, pattern: "/v1/google/start",
    want: all(http.StatusFound),
    },
    // Without its flow cookie, the callback only sends the browser back.
    {method: http.MethodGet, pattern: "/v1/google/callback", path: "/v1/google/callback?code=c&state=s", want: all(http.StatusFound)},
    }
  7. Update the web app if it calls the route: the zod schema that validates the response in web/src/api/schemas.ts, which must match the OpenAPI schema, the call in web/src/api/endpoints.ts, and the screen, with every message in both web/src/i18n/en.ts and pl.ts.

  8. Add an end-to-end case in e2e/e2e_test.go for a flow people use, and update this site’s page for the flow. The REST reference is generated from the OpenAPI document, so it follows on its own.

  9. Run the checks:

    Terminal window
    go test -race -shuffle=on ./internal/<service>/...
    make check

Browser requests carry the access_token cookie, so unsafe methods (POST, PATCH, DELETE) go through httpx.CrossOrigin, built on http.CrossOriginProtection: the user service applies it to every route, and auth to each route that reads a cookie. A cross-site form post gets a 403 problem. You don’t need CSRF tokens: the gateway puts the app and the APIs on one origin.