-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathcontext.go
More file actions
373 lines (332 loc) · 10.9 KB
/
context.go
File metadata and controls
373 lines (332 loc) · 10.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
package livetemplate
import (
"context"
"log/slog"
"net/http"
"github.com/go-playground/validator/v10"
"github.com/livetemplate/livetemplate/internal/uploadtypes"
)
// UploadAccessor provides access to upload entries during action handling.
type UploadAccessor interface {
HasUploads(name string) bool
GetCompletedUploads(name string) []*uploadtypes.UploadEntry
}
// FlashSetter allows setting flash messages from action handlers.
// Flash messages are page-level notifications (success, info, warning, error)
// that don't affect ResponseMetadata.Success (unlike field validation errors).
//
// The setFlash method is intentionally unexported to ensure flash messages
// are only set through the Context.SetFlash() public API, maintaining
// consistent behavior and preventing direct message map manipulation.
type FlashSetter interface {
setFlash(key, message string)
}
// broadcastRequest represents a deferred broadcast action to be dispatched
// to other connections in the same session group after the current action completes.
type broadcastRequest struct {
Action string
Data map[string]interface{}
}
// Context provides unified context for all controller lifecycle methods.
// It embeds context.Context for cancellation, timeout, and request-scoped values.
//
// Context replaces ActionContext with a single type used across:
// - Mount(state, ctx) - session initialization
// - OnConnect(state, ctx) - WebSocket connect
// - Action methods(state, ctx) - user interactions
type Context struct {
context.Context
action string
data *ActionData
userID string
session Session
uploads UploadAccessor
flashSetter FlashSetter
formSchema *FormSchema
broadcasts []broadcastRequest
// HTTP context (nil for WebSocket actions)
w http.ResponseWriter
r *http.Request
redirected *bool // shared across With*() copies so mount.go sees the flag
}
// NewContext creates a new Context for action handling.
func NewContext(ctx context.Context, action string, data map[string]interface{}) *Context {
redirected := false
return &Context{
Context: ctx,
action: action,
data: newActionData(data),
redirected: &redirected,
}
}
// Action returns the action name that triggered this context.
func (c *Context) Action() string {
return c.action
}
// UserID returns the authenticated user's ID.
func (c *Context) UserID() string {
return c.userID
}
// WithUserID returns a new Context with the given user ID.
func (c *Context) WithUserID(userID string) *Context {
newCtx := *c
newCtx.userID = userID
return &newCtx
}
// Session returns the Session for server-initiated actions.
func (c *Context) Session() Session {
return c.session
}
// WithSession returns a new Context with the given session.
func (c *Context) WithSession(session Session) *Context {
newCtx := *c
newCtx.session = session
return &newCtx
}
// Data extraction methods (delegate to ActionData)
func (c *Context) GetString(key string) string {
if c.data == nil {
return ""
}
return c.data.GetString(key)
}
func (c *Context) GetInt(key string) int {
if c.data == nil {
return 0
}
return c.data.GetInt(key)
}
func (c *Context) GetFloat(key string) float64 {
if c.data == nil {
return 0
}
return c.data.GetFloat(key)
}
func (c *Context) GetBool(key string) bool {
if c.data == nil {
return false
}
return c.data.GetBool(key)
}
func (c *Context) Has(key string) bool {
if c.data == nil {
return false
}
return c.data.Has(key)
}
func (c *Context) Get(key string) interface{} {
if c.data == nil {
return nil
}
return c.data.Get(key)
}
// Bind unmarshals the action data into a struct.
func (c *Context) Bind(v interface{}) error {
if c.data == nil {
return nil
}
return c.data.Bind(v)
}
// BindAndValidate binds data to struct and validates it in one step.
// Uses the provided go-playground/validator instance for validation.
func (c *Context) BindAndValidate(v interface{}, validate *validator.Validate) error {
if c.data == nil {
return nil
}
return c.data.BindAndValidate(v, validate)
}
// HTTP Methods (same as ActionContext)
func (c *Context) IsHTTP() bool {
return c.w != nil && c.r != nil
}
// SetCookie sets an HTTP cookie on the response.
// Returns ErrNoHTTPContext if called from a WebSocket action.
func (c *Context) SetCookie(cookie *http.Cookie) error {
if c.w == nil {
return ErrNoHTTPContext
}
http.SetCookie(c.w, cookie)
return nil
}
// DeleteCookie removes an HTTP cookie by setting MaxAge to -1.
// Returns ErrNoHTTPContext if called from a WebSocket action.
func (c *Context) DeleteCookie(name string) error {
if c.w == nil {
return ErrNoHTTPContext
}
http.SetCookie(c.w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
MaxAge: -1,
})
return nil
}
// GetCookie retrieves an HTTP cookie from the request.
// Returns ErrNoHTTPContext if called from a WebSocket action.
func (c *Context) GetCookie(name string) (*http.Cookie, error) {
if c.r == nil {
return nil, ErrNoHTTPContext
}
return c.r.Cookie(name)
}
// Redirect sends an HTTP redirect response.
// Returns ErrNoHTTPContext if called from a WebSocket action.
// Returns ErrInvalidRedirectCode if code is not 3xx.
// Returns ErrInvalidRedirectURL if URL is not a valid relative path.
func (c *Context) Redirect(url string, code int) error {
if c.w == nil || c.r == nil {
return ErrNoHTTPContext
}
if code < 300 || code >= 400 {
return ErrInvalidRedirectCode
}
if !isValidRedirectURL(url) {
return ErrInvalidRedirectURL
}
http.Redirect(c.w, c.r, url, code)
if c.redirected != nil {
*c.redirected = true
}
return nil
}
// WithHTTP returns a new Context with HTTP request/response.
func (c *Context) WithHTTP(w http.ResponseWriter, r *http.Request) *Context {
newCtx := *c
newCtx.w = w
newCtx.r = r
return &newCtx
}
// WithAction returns a new Context with the given action name.
func (c *Context) WithAction(action string) *Context {
newCtx := *c
newCtx.action = action
return &newCtx
}
// WithData returns a new Context with the given data.
func (c *Context) WithData(data map[string]interface{}) *Context {
newCtx := *c
newCtx.data = newActionData(data)
return &newCtx
}
// WithUploads returns a new Context with the given upload accessor.
func (c *Context) WithUploads(uploads UploadAccessor) *Context {
newCtx := *c
newCtx.uploads = uploads
return &newCtx
}
// WithFormSchema returns a new Context with the given form validation schema.
func (c *Context) WithFormSchema(schema *FormSchema) *Context {
newCtx := *c
newCtx.formSchema = schema
return &newCtx
}
// ValidateForm validates form data against HTML attributes inferred from the template.
// Uses validation rules extracted from HTML attributes like required, pattern, min, max,
// minlength, maxlength, and input type (email, url, number).
// Returns MultiError with field-level errors, or nil if all fields are valid.
//
// Note: the schema must be set via WithFormSchema(ExtractFormSchema(statics)).
// If no schema is set, returns nil (no validation). For production validation
// with complex rules, use BindAndValidate() with go-playground/validator tags.
//
// Known limitation: ExtractFormSchema merges all forms in a template into one
// schema. If your template has multiple forms, use BindAndValidate() instead.
func (c *Context) ValidateForm() error {
if c.formSchema == nil {
return nil
}
return c.formSchema.Validate(c.data.Raw())
}
// HasUploads checks if there are any uploads for the given field name.
func (c *Context) HasUploads(name string) bool {
if c.uploads == nil {
return false
}
return c.uploads.HasUploads(name)
}
// GetCompletedUploads returns all completed upload entries for the given field name.
func (c *Context) GetCompletedUploads(name string) []*uploadtypes.UploadEntry {
if c.uploads == nil {
return nil
}
return c.uploads.GetCompletedUploads(name)
}
// WithFlashSetter returns a new Context with the given flash setter.
func (c *Context) WithFlashSetter(setter FlashSetter) *Context {
newCtx := *c
newCtx.flashSetter = setter
return &newCtx
}
// SetFlash sets a flash message that will be available in templates via .lvt.Flash(key).
// Flash messages are page-level notifications (success, info, warning, error).
// Unlike field errors, flash messages don't affect ResponseMetadata.Success.
// Flash messages are cleared after each render, so they appear only once.
//
// Common keys: "success", "error", "info", "warning"
//
// Key conventions:
// - Use simple, lowercase keys (e.g., "success", "error")
// - Avoid keys containing colons or special characters
// - Do not use keys starting with "_flash:" (reserved for internal use)
//
// Example:
//
// ctx.SetFlash("success", "Changes saved successfully!")
// ctx.SetFlash("error", "Failed to process your request.")
func (c *Context) SetFlash(key, message string) {
if c.flashSetter != nil {
c.flashSetter.setFlash(key, message)
}
}
// BroadcastAction queues a broadcast to all other connections in the same
// session group. The named action is dispatched on each receiving connection
// after the current action completes successfully.
//
// Each receiving connection runs the named action with its own per-connection
// state via DispatchWithState, preserving per-connection fields (e.g., CurrentUser).
//
// Broadcasts are deferred: they execute only after the triggering action returns
// without error. If the action returns an error, queued broadcasts are discarded.
//
// Constraints:
// - Dispatched actions run with context.Background() — middleware-injected
// request values (auth tokens, tracing spans) are not available.
// - BroadcastAction calls inside a dispatched action are ignored to prevent
// infinite broadcast storms.
// - Context.With*() methods create shallow copies. Broadcasts queued after the
// copy diverge (append allocates a new backing array once capacity is exceeded).
//
// Example:
//
// func (c *ChatController) Send(state ChatState, ctx *livetemplate.Context) (ChatState, error) {
// c.mu.Lock()
// c.messages = append(c.messages, msg)
// c.mu.Unlock()
// state.Messages = c.copyMessages()
// ctx.BroadcastAction("RefreshMessages", nil)
// return state, nil
// }
//
// MaxBroadcastsPerAction is the maximum number of BroadcastAction calls
// allowed per action invocation. Excess calls are dropped with an error log.
const MaxBroadcastsPerAction = 100
func (c *Context) BroadcastAction(action string, data map[string]interface{}) {
if action == "" {
return
}
if len(c.broadcasts) >= MaxBroadcastsPerAction {
slog.Error("BroadcastAction cap reached, dropping",
slog.String("action", action),
slog.Int("limit", MaxBroadcastsPerAction))
return
}
c.broadcasts = append(c.broadcasts, broadcastRequest{Action: action, Data: data})
}
// pendingBroadcasts returns and clears pending broadcast requests.
// Called by the mount handler after action dispatch to process deferred broadcasts.
func (c *Context) pendingBroadcasts() []broadcastRequest {
b := c.broadcasts
c.broadcasts = nil
return b
}