| name | http-api-patterns |
| description | Chronicle HTTP API conventions using go-chi router. Covers request/response handling
with httpapi helpers, JWT authentication via chronauth, SDK type definitions that
auto-generate TypeScript, and database-to-SDK conversion patterns. Essential for
adding or modifying API endpoints.
|
HTTP API Patterns
When to Use This Skill
Use this skill when:
- Adding new API endpoints
- Modifying existing HTTP handlers
- Working with authentication/authorization
- Adding new SDK types that need TypeScript generation
- Converting database models to API responses
Key Files
| Path | Purpose |
|---|
api/api.go | Route definitions, middleware setup |
api/httpapi/httpapi.go | Write, Read, InternalServerError helpers |
api/chronauth/ | OAuth flow, JWT sessions, claims |
api/httpmw/ | Middleware (auth, prometheus, recover) |
api/chroniclesdk/ | SDK types (generates TypeScript) |
api/db2sdk/convert.go | Database → SDK conversion functions |
scripts/apitypings/main.go | TypeScript type generator |
Adding a New Endpoint (Workflow)
1. Define SDK Types
Add request/response types in api/chroniclesdk/:
package chroniclesdk
type CreateExampleRequest struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
}
type ExampleResponse struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
}
2. Create the Handler
Add handler method in api/:
package api
import (
"net/http"
"github.com/Emyrk/chronicle/api/chronauth"
"github.com/Emyrk/chronicle/api/chroniclesdk"
"github.com/Emyrk/chronicle/api/httpapi"
)
func (a *API) CreateExample(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req chroniclesdk.CreateExampleRequest
if !httpapi.Read(ctx, w, r, &req) {
return
}
claims := chronauth.MustAuthenticatedClaims(ctx)
userID := claims.Subject
result, err := a.Opts.DB.CreateExample(ctx, database.CreateExampleParams{
Name: req.Name,
Owner: userID,
})
if err != nil {
httpapi.InternalServerError(w, err)
return
}
httpapi.Write(ctx, w, http.StatusCreated, chroniclesdk.ExampleResponse{
ID: result.ID,
Name: result.Name,
CreatedAt: result.CreatedAt.Time,
})
}
3. Register the Route
Add to api/api.go in the Routes() method:
r.Route("/api/v1", func(r chi.Router) {
r.Use(api.Auth.AuthenticationMiddleware)
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
httpapi.Write(r.Context(), w, http.StatusOK, "OK")
})
r.Group(func(r chi.Router) {
r.Use(api.Auth.Authenticated(false))
r.Post("/examples", api.CreateExample)
})
r.Group(func(r chi.Router) {
r.Use(
api.Auth.Authenticated(false),
httpmw.Can(api.Zed, policy.New().GlobalChronicle().CanAdmin_users_User),
)
r.Get("/admin/examples", api.AdminListExamples)
})
})
4. Generate TypeScript Types
make gen
go run -C ./scripts/apitypings main.go > frontend/chronicle/src/api/typesGenerated.ts
Request/Response Handling
Writing Responses
Always use httpapi.Write for JSON responses:
httpapi.Write(ctx, w, http.StatusOK, chroniclesdk.SomeResponse{...})
httpapi.Write(ctx, w, http.StatusNoContent, nil)
httpapi.Write(ctx, w, http.StatusBadRequest, chroniclesdk.Response{
Message: "Invalid request parameters.",
Detail: err.Error(),
})
httpapi.InternalServerError(w, err)
httpapi.Forbidden(w, err)
Reading Request Bodies
Use httpapi.Read which handles JSON parsing and error responses:
var req chroniclesdk.SomeRequest
if !httpapi.Read(ctx, w, r, &req) {
return
}
Response Type
The standard error response type:
type Response struct {
Message string `json:"message"`
CallToAction string `json:"call_to_action,omitempty"`
Link string `json:"link,omitempty"`
LinkText string `json:"link_text,omitempty"`
Detail string `json:"detail,omitempty"`
}
Authentication
Middleware Chain
r.Route("/api/v1", func(r chi.Router) {
r.Use(api.Auth.AuthenticationMiddleware)
r.Group(func(r chi.Router) {
r.Use(api.Auth.Authenticated(false))
r.Use(httpmw.Can(api.Zed, policy.New().GlobalChronicle().CanUpload_log_User))
r.Post("/upload", api.Upload)
})
})
Accessing Claims in Handlers
claims := chronauth.MustAuthenticatedClaims(ctx)
userID := claims.Subject
claims, ok := chronauth.AuthenticatedClaims(ctx)
if !ok {
}
state := chronauth.AuthenticationState(r)
if state.Error != nil {
}
Claims Structure
type Claims struct {
Subject uuid.UUID
SessionID uuid.UUID
UserAuthID uuid.UUID
Provider string
Expiry *jwt.NumericDate
}
Permission Checks
Use httpmw.Can middleware or manual checks:
r.Use(httpmw.Can(api.Zed, policy.New().GlobalChronicle().CanAdmin_users_User))
actor, _ := authz.ActorFromContext(ctx)
can, err := a.Zed.CheckOne(ctx, nil,
policy.New().GlobalChronicle().CanSet_user_data_limit_User(actor))
if err != nil || !can {
httpapi.Forbidden(w, err)
return
}
SDK Types and TypeScript Generation
SDK Type Guidelines
- JSON tags required - all exported fields need
json:"field_name"
- Use
omitempty for optional fields
- Use primitive types - avoid complex nested types when possible
- Add to chroniclesdk package - not scattered across other packages
type WoWLogGroup struct {
ID uuid.UUID `json:"id"`
Owner uuid.UUID `json:"owner"`
CreatedAt time.Time `json:"created_at"`
Files []WoWLogFile `json:"files"`
ProcessingOutput *string `json:"processing_output,omitempty"`
}
TypeScript Generator
The generator at scripts/apitypings/main.go:
- Uses
github.com/coder/guts to parse Go types
- Generates types from
api/chroniclesdk package
- Maps special types (uuid.UUID → string, time.Time → string)
- Outputs to
frontend/chronicle/src/api/typesGenerated.ts
Custom type mappings:
gen.IncludeCustom(map[string]string{
"github.com/google/uuid.UUID": "string",
"github.com/jackc/pgx/v5/pgtype.Timestamptz": "string",
"github.com/Emyrk/chronicle/api/chroniclesdk.GUIDString": "string",
})
Regenerating Types
make gen
Database to SDK Conversion
Use api/db2sdk/convert.go for converting database models:
func User(user database.ChronicleUser, roles []string) chroniclesdk.User {
return chroniclesdk.User{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Roles: roles,
CreatedAt: user.CreatedAt.Time,
UpdatedAt: user.UpdatedAt.Time,
MaxStorageBytes: user.MaxStorageBytes.Int64,
ConsumedStorageBytes: user.ConsumedStorageBytes,
}
}
Usage in handlers:
user, err := a.Opts.DB.GetUserByID(ctx, userID)
if err != nil {
httpapi.InternalServerError(w, err)
return
}
roles, err := a.Opts.Zed.UserChronicleRoles(ctx, userID)
if err != nil {
httpapi.InternalServerError(w, err)
return
}
httpapi.Write(ctx, w, http.StatusOK, db2sdk.User(user, roles))
URL Parameters
Path Parameters
Use chi.URLParam:
userIDStr := chi.URLParam(r, "userID")
userID, err := uuid.Parse(userIDStr)
if err != nil {
httpapi.Write(ctx, w, http.StatusBadRequest, chroniclesdk.Response{
Message: "Invalid user ID",
Detail: err.Error(),
})
return
}
Middleware for Common Parameters
func LogIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
})
}
r.Route("/{logID}", func(r chi.Router) {
r.Use(httpmw.LogIDMiddleware)
r.Get("/", api.GetLog)
})
Anti-Patterns
❌ Don't Use http.Error Directly
http.Error(w, "something went wrong", http.StatusInternalServerError)
httpapi.InternalServerError(w, err)
❌ Don't Return Raw Maps
httpapi.Write(ctx, w, http.StatusOK, map[string]string{
"message": "Success",
})
httpapi.Write(ctx, w, http.StatusOK, chroniclesdk.Response{
Message: "Success",
})
❌ Don't Forget Error Handling After Read
httpapi.Read(ctx, w, r, &req)
if !httpapi.Read(ctx, w, r, &req) {
return
}
❌ Don't Mix Auth Patterns
if state.Claims == nil {
http.Error(w, "unauthorized", 401)
return
}
claims := chronauth.MustAuthenticatedClaims(ctx)
claims, ok := chronauth.AuthenticatedClaims(ctx)
❌ Don't Skip db2sdk for Complex Types
httpapi.Write(ctx, w, http.StatusOK, chroniclesdk.User{
ID: user.ID,
})
httpapi.Write(ctx, w, http.StatusOK, db2sdk.User(user, roles))
Testing
See internal/testutil/ for test helpers:
func TestCreateExample(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
db, _ := dbtestutil.NewDB(t)
api, err := api.New(ctx, api.Options{
DB: db,
})
require.NoError(t, err)
req := httptest.NewRequest("POST", "/api/v1/examples",
strings.NewReader(`{"name":"test"}`))
rec := httptest.NewRecorder()
api.Routes().ServeHTTP(rec, req)
require.Equal(t, http.StatusCreated, rec.Code)
}