-
Notifications
You must be signed in to change notification settings - Fork 3
/
callbacks.go
383 lines (324 loc) · 10.3 KB
/
callbacks.go
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
374
375
376
377
378
379
380
381
382
383
package fire
import (
"fmt"
"net/http"
"reflect"
"time"
"github.com/256dpi/jsonapi/v2"
"github.com/256dpi/xo"
"go.mongodb.org/mongo-driver/bson"
"github.com/256dpi/fire/coal"
"github.com/256dpi/fire/stick"
)
// ErrAccessDenied may be returned to indicate unauthorized access.
var ErrAccessDenied = xo.BW(jsonapi.ErrorFromStatus(http.StatusUnauthorized, "access denied"))
// ErrResourceNotFound may be returned to indicate a missing resource.
var ErrResourceNotFound = xo.BW(jsonapi.NotFound("resource not found"))
// ErrDocumentNotUnique may be returned if a provided document does not satisfy
// the required uniqueness constraints.
var ErrDocumentNotUnique = xo.BW(jsonapi.BadRequest("document not unique"))
// BasicAuthorizer authorizes requests based on a simple credentials list.
func BasicAuthorizer(credentials map[string]string) *Callback {
return C("fire/BasicAuthorizer", Authorizer, All(), func(ctx *Context) error {
// check for credentials
user, password, ok := ctx.HTTPRequest.BasicAuth()
if !ok {
return ErrAccessDenied.Wrap()
}
// check if credentials match
if val, ok := credentials[user]; !ok || val != password {
return ErrAccessDenied.Wrap()
}
return nil
})
}
// TimestampModifier will set timestamp fields on create and update operations.
// Missing created timestamps are retroactively set using the timestamp encoded
// in the model ID.
func TimestampModifier(createdField, updatedField string) *Callback {
return C("fire/TimestampModifier", Modifier, Only(Create|Update), func(ctx *Context) error {
// get time
now := time.Now()
// set created timestamp on creation and set missing create timestamps
// to the timestamp inferred from the model ID
if createdField != "" {
if ctx.Operation == Create {
stick.MustSet(ctx.Model, createdField, now)
} else if t := stick.MustGet(ctx.Model, createdField).(time.Time); t.IsZero() {
stick.MustSet(ctx.Model, createdField, ctx.Model.ID().Timestamp())
}
}
// always set updated timestamp
if updatedField != "" {
stick.MustSet(ctx.Model, updatedField, now)
}
return nil
})
}
// NoDefault marks the specified field to have no default that needs to be
// enforced while executing the ProtectedFieldsValidator.
const NoDefault noDefault = iota
type noDefault int
// ProtectedFieldsValidator compares protected fields against their default
// during Create (if provided) or stored value during Update and returns an error
// if they have been changed.
//
// Protected fields are defined by passing pairs of fields and default values:
//
// fire.ProtectedFieldsValidator(map[string]interface{}{
// "Title": NoDefault, // can only be set during Create
// "Link": "", // default is fixed and cannot be changed
// })
//
// The special NoDefault value can be provided to skip the default enforcement
// on Create.
func ProtectedFieldsValidator(pairs map[string]interface{}) *Callback {
return C("fire/ProtectedFieldsValidator", Validator, Only(Create|Update), func(ctx *Context) error {
// handle resource creation
if ctx.Operation == Create {
// check all fields
for field, def := range pairs {
// skip fields that have no default
if def == NoDefault {
continue
}
// check equality
if !reflect.DeepEqual(stick.MustGet(ctx.Model, field), def) {
return xo.SF("field " + field + " is protected")
}
}
}
// handle resource updates
if ctx.Operation == Update {
// check all fields
for field := range pairs {
if ctx.Modified(field) {
return xo.SF("field " + field + " is protected")
}
}
}
return nil
})
}
// DependentResourcesValidator counts related documents and returns an error if
// some are found. This callback is meant to protect resources from breaking
// relations when requested to be deleted.
//
// Dependent resources are defined by passing pairs of models and fields that
// reference the current model.
//
// fire.DependentResourcesValidator(map[coal.Model]string{
// &Post{}: "Author",
// &Comment{}: "Author",
// })
//
// The callback supports models that use the soft delete mechanism.
func DependentResourcesValidator(pairs map[coal.Model]string) *Callback {
return C("fire/DependentResourcesValidator", Validator, Only(Delete), func(ctx *Context) error {
// check all relations
for model, field := range pairs {
// prepare query
query := bson.M{
field: ctx.Model.ID(),
}
// exclude soft deleted documents if supported
if sdf := coal.L(model, "fire-soft-delete", false); sdf != "" {
query[sdf] = nil
}
// count referencing documents
count, err := ctx.Store.M(model).Count(ctx, query, 0, 1, false)
if err != nil {
return err
}
// return error if documents are found
if count != 0 {
return xo.SF("resource has dependent resources")
}
}
// pass validation
return nil
})
}
// ReferencedResourcesValidator makes sure all references in the document are
// existing by counting the referenced documents.
//
// References are defined by passing pairs of fields and models which are
// referenced by the current model:
//
// fire.ReferencedResourcesValidator(map[string]coal.Model{
// "Post": &Post{},
// "Author": &User{},
// })
//
// The callbacks supports to-one, optional to-one and to-many relationships.
func ReferencedResourcesValidator(pairs map[string]coal.Model) *Callback {
return C("fire/ReferencedResourcesValidator", Validator, Only(Create|Update), func(ctx *Context) error {
// check all references
for field, collection := range pairs {
// read referenced ID
ref := stick.MustGet(ctx.Model, field)
// continue if reference is not set
if id, ok := ref.(*coal.ID); ok && id == nil {
continue
}
// continue if slice is empty
if ids, ok := ref.([]coal.ID); ok && ids == nil {
continue
}
// handle to-many relationships
if ids, ok := ref.([]coal.ID); ok {
// prepare query
query := bson.M{
"_id": bson.M{
"$in": ids,
},
}
// count entities in database
count, err := ctx.Store.M(collection).Count(ctx, query, 0, 0, false)
if err != nil {
return err
}
// check for existence
if int(count) != len(ids) {
return xo.SF("missing references for field " + field)
}
continue
}
// handle to-one relationships
// count entities in database
count, err := ctx.Store.M(collection).Count(ctx, bson.M{
"_id": ref,
}, 0, 1, false)
if err != nil {
return err
}
// check for existence
if count != 1 {
return xo.SF("missing reference for field " + field)
}
}
// pass validation
return nil
})
}
// RelationshipValidator makes sure all relationships of a model are correct and
// in place. It does so by combining a DependentResourcesValidator and a
// ReferencedResourcesValidator based on the specified model and catalog.
func RelationshipValidator(model coal.Model, models []coal.Model, exclude ...string) *Callback {
// build index
index := make(map[string]coal.Model, len(models))
for _, model := range models {
index[coal.GetMeta(model).PluralName] = model
}
// prepare lists
resources := make(map[coal.Model]string)
references := make(map[string]coal.Model)
// iterate through all fields
for _, field := range coal.GetMeta(model).Relationships {
// continue if relationship is excluded
if stick.Contains(exclude, field.Name) {
continue
}
// handle has-one and has-many relationships
if field.HasOne || field.HasMany {
// get related model
relatedModel := index[field.RelType]
if relatedModel == nil {
panic(fmt.Sprintf(`fire: missing model: "%s"`, field.RelType))
}
// get related field
relatedField := ""
for _, relationship := range coal.GetMeta(relatedModel).Relationships {
if relationship.RelName == field.RelInverse {
relatedField = relationship.Name
}
}
if relatedField == "" {
panic(fmt.Sprintf(`fire: missing field for inverse relationship: "%s"`, field.RelInverse))
}
// add resource
resources[relatedModel] = relatedField
}
// handle to-one and to-many relationships
if field.ToOne || field.ToMany {
// get related model
relatedModel := index[field.RelType]
if relatedModel == nil {
panic(fmt.Sprintf(`fire: missing model in catalog: "%s"`, field.RelType))
}
// add reference
references[field.Name] = relatedModel
}
}
// create callbacks
drv := DependentResourcesValidator(resources)
rrv := ReferencedResourcesValidator(references)
// combine callbacks
cb := Combine("fire/RelationshipValidator", Validator, drv, rrv)
return cb
}
// MatchingReferencesValidator compares the model with one related model or all
// related models and checks if the specified references are shared exactly.
//
// The target is defined by passing the reference on the current model and the
// target model. The matcher is defined by passing pairs of fields on the current
// and target model:
//
// fire.MatchingReferencesValidator("Blog", &Blog{}, map[string]string{
// "Owner": "Owner",
// })
//
// To-many, optional to-many and has-many relationships are supported both for
// the initial reference and in the matchers.
func MatchingReferencesValidator(reference string, target coal.Model, matcher map[string]string) *Callback {
return C("fire/MatchingReferencesValidator", Validator, Only(Create|Update), func(ctx *Context) error {
// prepare IDs
var ids []coal.ID
// get reference
ref := stick.MustGet(ctx.Model, reference)
// handle to-one reference
if id, ok := ref.(coal.ID); ok {
ids = []coal.ID{id}
}
// handle optional to-one reference
if id, ok := ref.(*coal.ID); ok {
// return immediately if not set
if id == nil {
return nil
}
// set ID
ids = []coal.ID{*id}
}
// handle to-many reference
if list, ok := ref.([]coal.ID); ok {
// return immediately if empty
if len(list) == 0 {
return nil
}
// set list
ids = list
}
// ensure list is unique
ids = stick.Unique(ids)
// prepare query
query := bson.M{
"_id": bson.M{
"$in": ids,
},
}
// add matchers
for sourceField, targetField := range matcher {
query[targetField] = stick.MustGet(ctx.Model, sourceField)
}
// find matching documents
count, err := ctx.Store.M(target).Count(ctx, query, 0, 0, false)
if err != nil {
return err
}
// return error if a document is missing (does not match)
if int(count) != len(ids) {
return xo.SF("references do not match")
}
return nil
})
}