|
| 1 | +--- |
| 2 | +name: cosmos-contract |
| 3 | +description: > |
| 4 | + Interfaces and helpers for the Cosmos HTTP framework contract module |
| 5 | + (github.com/studiolambda/cosmos/contract). Use when consuming or |
| 6 | + implementing Cosmos contracts: Cache, Database, Encrypter, Hasher, |
| 7 | + Session, SessionDriver, Events, Hooks. Also covers the request and |
| 8 | + response helper packages for reading HTTP inputs and writing outputs. |
| 9 | +--- |
| 10 | + |
| 11 | +# Cosmos Contract |
| 12 | + |
| 13 | +The contract module defines all service interfaces and HTTP helper functions |
| 14 | +for the Cosmos framework. It is the foundation layer — every other Cosmos |
| 15 | +module depends on it. |
| 16 | + |
| 17 | +``` |
| 18 | +go get github.com/studiolambda/cosmos/contract |
| 19 | +``` |
| 20 | + |
| 21 | +## Architecture |
| 22 | + |
| 23 | +``` |
| 24 | +contract/ |
| 25 | +├── cache.go # Cache interface (10 methods) |
| 26 | +├── crypto.go # Encrypter interface |
| 27 | +├── database.go # Database interface (transactions, named queries) |
| 28 | +├── event.go # Events interface + type aliases |
| 29 | +├── hash.go # Hasher interface |
| 30 | +├── hooks.go # Hooks interface (lifecycle hooks) |
| 31 | +├── session.go # Session + SessionDriver interfaces |
| 32 | +├── request/ # HTTP request helpers (params, query, body, headers, cookies, sessions) |
| 33 | +├── response/ # HTTP response helpers (JSON, HTML, XML, SSE, redirects) |
| 34 | +└── mock/ # Mockery-generated mocks for all interfaces |
| 35 | +``` |
| 36 | + |
| 37 | +## Interfaces |
| 38 | + |
| 39 | +### Cache |
| 40 | + |
| 41 | +```go |
| 42 | +type Cache interface { |
| 43 | + Get(ctx context.Context, key string) (any, error) |
| 44 | + Put(ctx context.Context, key string, value any, ttl time.Duration) error |
| 45 | + Delete(ctx context.Context, key string) error |
| 46 | + Has(ctx context.Context, key string) (bool, error) |
| 47 | + Pull(ctx context.Context, key string) (any, error) |
| 48 | + Forever(ctx context.Context, key string, value any) error |
| 49 | + Increment(ctx context.Context, key string, by int64) (int64, error) |
| 50 | + Decrement(ctx context.Context, key string, by int64) (int64, error) |
| 51 | + Remember(ctx context.Context, key string, ttl time.Duration, compute func() (any, error)) (any, error) |
| 52 | + RememberForever(ctx context.Context, key string, compute func() (any, error)) (any, error) |
| 53 | +} |
| 54 | +``` |
| 55 | + |
| 56 | +Sentinel errors: `ErrCacheKeyNotFound`, `ErrCacheUnsupportedOperation`. |
| 57 | + |
| 58 | +### Database |
| 59 | + |
| 60 | +```go |
| 61 | +type Database interface { |
| 62 | + Close() error |
| 63 | + Ping(ctx context.Context) error |
| 64 | + Exec(ctx context.Context, query string, args ...any) (int64, error) |
| 65 | + ExecNamed(ctx context.Context, query string, arg any) (int64, error) |
| 66 | + Select(ctx context.Context, query string, dest any, args ...any) error |
| 67 | + SelectNamed(ctx context.Context, query string, dest any, arg any) error |
| 68 | + Find(ctx context.Context, query string, dest any, args ...any) error |
| 69 | + FindNamed(ctx context.Context, query string, dest any, arg any) error |
| 70 | + WithTransaction(ctx context.Context, fn func(Database) error) error |
| 71 | +} |
| 72 | +``` |
| 73 | + |
| 74 | +Sentinel errors: `ErrDatabaseNoRows`, `ErrDatabaseNestedTransaction`. |
| 75 | +Transactions cannot be nested. `Find` joins `sql.ErrNoRows` with |
| 76 | +`ErrDatabaseNoRows` — check with either via `errors.Is`. |
| 77 | + |
| 78 | +### Encrypter |
| 79 | + |
| 80 | +```go |
| 81 | +type Encrypter interface { |
| 82 | + Encrypt(value []byte) ([]byte, error) |
| 83 | + Decrypt(value []byte) ([]byte, error) |
| 84 | +} |
| 85 | +``` |
| 86 | + |
| 87 | +### Hasher |
| 88 | + |
| 89 | +```go |
| 90 | +type Hasher interface { |
| 91 | + Hash(value []byte) ([]byte, error) |
| 92 | + Check(value, hash []byte) (bool, error) |
| 93 | +} |
| 94 | +``` |
| 95 | + |
| 96 | +### Session & SessionDriver |
| 97 | + |
| 98 | +```go |
| 99 | +type Session interface { |
| 100 | + SessionID() string |
| 101 | + OriginalSessionID() string |
| 102 | + Get(key string) (any, bool) |
| 103 | + Put(key string, value any) |
| 104 | + Delete(key string) |
| 105 | + Extend(expiresAt time.Time) |
| 106 | + Regenerate() error |
| 107 | + Clear() |
| 108 | + ExpiresAt() time.Time |
| 109 | + HasExpired() bool |
| 110 | + ExpiresSoon(delta time.Duration) bool |
| 111 | + HasChanged() bool |
| 112 | + HasRegenerated() bool |
| 113 | + MarkAsUnchanged() |
| 114 | +} |
| 115 | + |
| 116 | +type SessionDriver interface { |
| 117 | + Get(ctx context.Context, id string) (Session, error) |
| 118 | + Save(ctx context.Context, session Session, ttl time.Duration) error |
| 119 | + Delete(ctx context.Context, id string) error |
| 120 | +} |
| 121 | +``` |
| 122 | + |
| 123 | +### Events |
| 124 | + |
| 125 | +```go |
| 126 | +type EventPayload = func(dest any) error |
| 127 | +type EventHandler = func(payload EventPayload) |
| 128 | +type EventUnsubscribeFunc = func() error |
| 129 | + |
| 130 | +type Events interface { |
| 131 | + Publish(ctx context.Context, event string, payload any) error |
| 132 | + Subscribe(ctx context.Context, event string, handler EventHandler) (EventUnsubscribeFunc, error) |
| 133 | + Close() error |
| 134 | +} |
| 135 | +``` |
| 136 | + |
| 137 | +### Hooks |
| 138 | + |
| 139 | +```go |
| 140 | +type AfterResponseHook = func(err error) |
| 141 | +type BeforeWriteHeaderHook = func(w http.ResponseWriter, status int) |
| 142 | +type BeforeWriteHook = func(w http.ResponseWriter, content []byte) |
| 143 | + |
| 144 | +type Hooks interface { |
| 145 | + AfterResponse(hooks ...AfterResponseHook) |
| 146 | + AfterResponseFuncs() []AfterResponseHook |
| 147 | + BeforeWrite(hooks ...BeforeWriteHook) |
| 148 | + BeforeWriteFuncs() []BeforeWriteHook |
| 149 | + BeforeWriteHeader(hooks ...BeforeWriteHeaderHook) |
| 150 | + BeforeWriteHeaderFuncs() []BeforeWriteHeaderHook |
| 151 | +} |
| 152 | +``` |
| 153 | + |
| 154 | +Hooks are injected into request context by the framework. Access via |
| 155 | +`request.Hooks(r)`. Hook `*Funcs()` methods return reversed clones (LIFO |
| 156 | +execution order). |
| 157 | + |
| 158 | +## Request Helpers |
| 159 | + |
| 160 | +All functions in `contract/request` take `*http.Request` as first argument. |
| 161 | + |
| 162 | +### Parameters & Query |
| 163 | + |
| 164 | +```go |
| 165 | +request.Param(r, "id") // URL path parameter |
| 166 | +request.ParamOr(r, "id", "default") // with fallback |
| 167 | +request.Query(r, "page") // query string value |
| 168 | +request.QueryOr(r, "page", "1") // with fallback |
| 169 | +request.HasQuery(r, "verbose") // existence check |
| 170 | +``` |
| 171 | + |
| 172 | +### Headers & Cookies |
| 173 | + |
| 174 | +```go |
| 175 | +request.Header(r, "Authorization") |
| 176 | +request.HeaderOr(r, "Accept", "application/json") |
| 177 | +request.HasHeader(r, "X-Custom") |
| 178 | +request.HeaderValues(r, "Accept") // []string |
| 179 | +request.Cookie(r, "session") // *http.Cookie |
| 180 | +request.CookieValue(r, "session") // string |
| 181 | +request.CookieValueOr(r, "session", "") // with fallback |
| 182 | +``` |
| 183 | + |
| 184 | +### Body Parsing (generics) |
| 185 | + |
| 186 | +```go |
| 187 | +bytes, err := request.Bytes(r) |
| 188 | +text, err := request.String(r) |
| 189 | +user, err := request.JSON[User](r) // generic — type param required |
| 190 | +order, err := request.XML[Order](r) // generic — type param required |
| 191 | +``` |
| 192 | + |
| 193 | +Body is consumed on first read — call only once per request. |
| 194 | + |
| 195 | +### Sessions |
| 196 | + |
| 197 | +```go |
| 198 | +sess, ok := request.Session(r) // returns (Session, bool) |
| 199 | +sess := request.MustSession(r) // panics if no session middleware |
| 200 | +sess, ok := request.SessionKeyed(r, key) // custom context key |
| 201 | +sess := request.MustSessionKeyed(r, key) // panics variant |
| 202 | +``` |
| 203 | + |
| 204 | +`MustSession` and `MustSessionKeyed` panic when session middleware is not |
| 205 | +applied — these are programmer errors caught during development. |
| 206 | + |
| 207 | +## Response Helpers |
| 208 | + |
| 209 | +All functions in `contract/response` return `error` — usable as direct |
| 210 | +handler return values. |
| 211 | + |
| 212 | +```go |
| 213 | +return response.Status(w, http.StatusNoContent) |
| 214 | +return response.String(w, http.StatusOK, "hello") |
| 215 | +return response.HTML(w, http.StatusOK, "<h1>hi</h1>") |
| 216 | +return response.JSON(w, http.StatusOK, user) // generic |
| 217 | +return response.XML(w, http.StatusOK, order) |
| 218 | +return response.Bytes(w, http.StatusOK, data) |
| 219 | +return response.Raw(w, http.StatusOK, rawBytes) |
| 220 | +return response.Redirect(w, http.StatusFound, "/login") |
| 221 | + |
| 222 | +// Templates |
| 223 | +return response.StringTemplate(w, http.StatusOK, tmpl, data) |
| 224 | +return response.HTMLTemplate(w, http.StatusOK, tmpl, data) |
| 225 | + |
| 226 | +// Streaming |
| 227 | +return response.Stream(w, r, dataChan) // raw streaming |
| 228 | +return response.SSE(w, r, eventChan) // Server-Sent Events |
| 229 | +``` |
| 230 | + |
| 231 | +`response.JSON` uses `json.NewEncoder` which appends a trailing newline. |
| 232 | + |
| 233 | +## Mocks |
| 234 | + |
| 235 | +Generated mocks in `contract/mock/` for all interfaces. Each has a |
| 236 | +`New<Name>Mock(t)` constructor that auto-registers cleanup. |
| 237 | + |
| 238 | +```go |
| 239 | +cache := mock.NewCacheMock(t) |
| 240 | +cache.On("Get", mock.Anything, "key").Return("value", nil) |
| 241 | +``` |
| 242 | + |
| 243 | +## Gotchas |
| 244 | + |
| 245 | +- `request.Hooks(r)` **panics** without hooks context (only present inside |
| 246 | + framework's handler pipeline). |
| 247 | +- `request.MustSession(r)` **panics** without session middleware. |
| 248 | +- Body parsing functions consume the request body — one call per request. |
| 249 | +- `response.JSON` appends a trailing newline (standard `json.NewEncoder` |
| 250 | + behavior). |
| 251 | +- `EventPayload` is a function — call it with a pointer to unmarshal: |
| 252 | + `payload(&user)`. |
| 253 | +- Context keys (`HooksKey`, `SessionKey`) are exported variables of |
| 254 | + unexported types — use the provided helpers, don't construct context |
| 255 | + values directly. |
0 commit comments