Design: REST API Layer (SPEC-0005)
Context
joe-links is primarily a server-rendered HTMX web application (📝 ADR-0007). The web UI satisfies browser-based interactions but leaves programmatic clients entirely unserved — HTMX endpoints return HTML fragments, not machine-readable data. Browser extensions need to register go-links from the current tab, CLI tools need bulk import/export, and CI/CD pipelines need to create deployment links. A JSON REST API fills this gap without disrupting the existing web UI.
The API is designed as a second consumer of the existing store layer, not a replacement for the HTMX interface. Both surfaces share the same LinkStore, UserStore, and TagStore interfaces and the same authorization logic.
Related: 📝 ADR-0008 (REST API Layer), 📝 ADR-0001 (tech stack), 📝 ADR-0003 (auth), 📝 ADR-0005 (data model), SPEC-0006 (API token auth), SPEC-0007 (OpenAPI/Swagger).
Goals / Non-Goals
Goals
- Expose all link management, tag, and admin operations via a JSON REST API at
/api/v1 - Maintain full feature parity between the web UI and the API for all CRUD operations
- Add the API without any modification to existing HTMX handlers or routes
- Apply versioning via URL prefix to enable future
/api/v2without breaking clients - Share authorization logic between web and API handlers via the request context
- Serve cursor-based pagination on all list endpoints
Non-Goals
- GraphQL, gRPC, or RPC-style APIs — REST over HTTP is sufficient
- Real-time subscriptions or WebSocket endpoints
- A public API for unauthenticated slug resolution (the existing
GET /{slug}redirect serves this) - SDK generation — the OpenAPI spec is sufficient for clients to generate their own
Decisions
chi Sub-Router for Isolation
Choice: Mount a dedicated chi sub-router at /api/v1 with its own middleware chain.
Rationale: The API and HTMX layers need independent middleware — the API needs BearerTokenMiddleware and JSON content-type enforcement; the web layer needs SCS session loading and HTMX-specific headers. A chi sub-router provides clean separation without middleware leakage. The existing NewRouter function grows a NewAPIRouter sibling, both wired in serve.go.
Alternatives considered:
- Single router with per-route middleware: messy — every route would need explicit middleware decoration
- Separate HTTP server on a different port: over-engineered and breaks single-binary model
New internal/api/ Package for API Handlers
Choice: API handlers live in internal/api/, separate from the HTMX handlers in internal/handler/.
Rationale: HTMX handlers and API handlers have fundamentally different response patterns (HTML fragments vs JSON), different error handling (redirect vs {"error": "..."} body), and different middleware assumptions. Keeping them in the same package would require proliferation of isAPIRequest() checks and messy dual-path logic.
Alternatives considered:
- One handler package with shared code: forces coupling between HTML and JSON response paths
- Code generation from OpenAPI spec: inverts the workflow; the codebase is code-first
Shared Store Layer, No New Persistence Logic
Choice: API handlers call the same *store.LinkStore, *store.UserStore, *store.TagStore methods that HTMX handlers use. No new query methods are needed for the initial API surface.
Rationale: The store interfaces already model the complete data operations. Adding a JSON serialization layer on top reuses all existing query logic, validation, and database portability.
Cursor-Based Pagination (Keyset)
Choice: ?cursor=<opaque> + ?limit=N (default 50, max 200) with "next_cursor" in responses.
Rationale: Keyset/cursor pagination is stable under concurrent writes — offset pagination drifts when rows are inserted or deleted between pages. For a list of links that a team is actively editing, offset pagination would produce duplicate or skipped results. The cursor is an opaque base64-encoded representation of the last row's sort key (e.g., created_at DESC, id ASC).
Alternatives considered:
- Offset/limit (
?page=N&per_page=N): simple but produces inconsistent results under concurrent writes - No pagination: unacceptable for large link collections (admin view of all links)
Consistent Error Shape
Choice: All errors return {"error": "<human message>", "code": "<SCREAMING_SNAKE>"}.
Rationale: Machine-readable error codes allow clients to handle specific errors programmatically (e.g., show a "slug taken" message vs a generic alert) without string matching. The human message provides context for logs and developer debugging.
Architecture
Request Flow: API Request
Package Structure
internal/
api/
router.go # NewAPIRouter(deps) — mounts sub-router
links.go # LinksAPIHandler — CRUD + owners
tags.go # TagsAPIHandler — list + filter
users.go # UsersAPIHandler — /me
admin.go # AdminAPIHandler — users + links
tokens.go # TokensAPIHandler — PAT management
types.go # Request/response structs (used by swaggo)
errors.go # writeError(w, code, msg, status) helper
paginate.go # cursor encode/decode helpers
Risks / Trade-offs
- Handler duplication — some validation logic is repeated between
internal/handler(HTMX) andinternal/api(JSON). Mitigated by extracting shared validation intointernal/storeor ainternal/validationpackage. - API version drift — future schema changes must be reflected in both the store layer and the API response types. Mitigated by the OpenAPI CI freshness check (SPEC-0007).
- Admin endpoint exposure —
/api/v1/admin/*routes expose bulk operations. Mitigated by the same role-based middleware as the HTMX admin routes; API tokens carry the user's role from their user record.
Migration Plan
This is a greenfield addition — no existing data or routes are modified. Deployment steps:
- Add
internal/api/package andNewAPIRouter - Wire
NewAPIRouterininternal/handler/router.goat/api/v1 - Add
docs/swagger/generated files - Register
/api/docs/*handler on the main router
Rollback: remove the /api/v1 mount and /api/docs/* handler — no schema changes involved.
Open Questions
- Should the API support
GET /{slug}resolution (just return the target URL as JSON rather than redirecting)? Currently scoped out — the existing redirect serves unauthenticated resolution. - Should list endpoints support server-side filtering (
?tag=engineering,?q=jira)? Deferred to a follow-up spec; the initial API returns full lists with client-side filtering.