@simtlix/simfinity-js 2.4.2 → 2.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.cursor/rules/simfinity-architecture.mdc +71 -0
- package/.cursor/rules/simfinity-auth-module.mdc +100 -0
- package/.cursor/rules/simfinity-coding-standards.mdc +62 -0
- package/.cursor/rules/simfinity-core-functions.mdc +82 -0
- package/.cursor/rules/simfinity-documentation.mdc +59 -0
- package/.cursor/rules/simfinity-extensions.mdc +121 -0
- package/.cursor/rules/simfinity-testing.mdc +90 -0
- package/README.md +1428 -1446
- package/package.json +1 -1
- package/src/auth/index.js +6 -6
- package/src/index.js +4 -1
package/README.md
CHANGED
|
@@ -16,31 +16,20 @@ A powerful Node.js framework that automatically generates GraphQL schemas from y
|
|
|
16
16
|
- [Automatic Mutation Generation](#automatic-mutation-generation)
|
|
17
17
|
- [Filtering and Querying](#filtering-and-querying)
|
|
18
18
|
- [Collection Field Filtering](#collection-field-filtering)
|
|
19
|
-
- [Middlewares](#-middlewares)
|
|
20
|
-
- [Adding Middlewares](#adding-middlewares)
|
|
21
|
-
- [Middleware Parameters](#middleware-parameters)
|
|
22
|
-
- [Common Use Cases](#common-use-cases)
|
|
23
|
-
- [Authorization](#-authorization)
|
|
24
|
-
- [Quick Start](#quick-start-1)
|
|
25
|
-
- [Permission Schema](#permission-schema)
|
|
26
|
-
- [Rule Helpers](#rule-helpers)
|
|
27
|
-
- [Policy Expressions (JSON AST)](#policy-expressions-json-ast)
|
|
28
|
-
- [Integration with GraphQL Yoga / Envelop](#integration-with-graphql-yoga--envelop)
|
|
29
|
-
- [Legacy: Integration with graphql-middleware](#legacy-integration-with-graphql-middleware)
|
|
30
19
|
- [Relationships](#-relationships)
|
|
31
20
|
- [Defining Relationships](#defining-relationships)
|
|
32
21
|
- [Auto-Generated Resolve Methods](#auto-generated-resolve-methods)
|
|
33
22
|
- [Adding Types Without Endpoints](#adding-types-without-endpoints)
|
|
34
23
|
- [Embedded vs Referenced Relationships](#embedded-vs-referenced-relationships)
|
|
35
24
|
- [Querying Relationships](#querying-relationships)
|
|
36
|
-
- [Controllers & Lifecycle Hooks](#️-controllers--lifecycle-hooks)
|
|
37
|
-
- [Hook Parameters](#hook-parameters)
|
|
38
|
-
- [State Machines](#-state-machines)
|
|
39
25
|
- [Validations](#-validations)
|
|
40
26
|
- [Field-Level Validations](#field-level-validations)
|
|
41
27
|
- [Type-Level Validations](#type-level-validations)
|
|
42
28
|
- [Custom Validated Scalar Types](#custom-validated-scalar-types)
|
|
43
29
|
- [Custom Error Classes](#custom-error-classes)
|
|
30
|
+
- [State Machines](#-state-machines)
|
|
31
|
+
- [Controllers & Lifecycle Hooks](#️-controllers--lifecycle-hooks)
|
|
32
|
+
- [Hook Parameters](#hook-parameters)
|
|
44
33
|
- [Query Scope](#-query-scope)
|
|
45
34
|
- [Overview](#overview)
|
|
46
35
|
- [Defining Scope](#defining-scope)
|
|
@@ -48,6 +37,17 @@ A powerful Node.js framework that automatically generates GraphQL schemas from y
|
|
|
48
37
|
- [Scope for Aggregate Operations](#scope-for-aggregate-operations)
|
|
49
38
|
- [Scope for Get By ID Operations](#scope-for-get-by-id-operations)
|
|
50
39
|
- [Scope Function Parameters](#scope-function-parameters)
|
|
40
|
+
- [Authorization](#-authorization)
|
|
41
|
+
- [Quick Start](#quick-start-1)
|
|
42
|
+
- [Permission Schema](#permission-schema)
|
|
43
|
+
- [Rule Helpers](#rule-helpers)
|
|
44
|
+
- [Policy Expressions (JSON AST)](#policy-expressions-json-ast)
|
|
45
|
+
- [Integration with GraphQL Yoga / Envelop](#integration-with-graphql-yoga--envelop)
|
|
46
|
+
- [Legacy: Integration with graphql-middleware](#legacy-integration-with-graphql-middleware)
|
|
47
|
+
- [Middlewares](#-middlewares)
|
|
48
|
+
- [Adding Middlewares](#adding-middlewares)
|
|
49
|
+
- [Middleware Parameters](#middleware-parameters)
|
|
50
|
+
- [Common Use Cases](#common-use-cases)
|
|
51
51
|
- [Advanced Features](#-advanced-features)
|
|
52
52
|
- [Field Extensions](#field-extensions)
|
|
53
53
|
- [Custom Mutations](#custom-mutations)
|
|
@@ -58,10 +58,6 @@ A powerful Node.js framework that automatically generates GraphQL schemas from y
|
|
|
58
58
|
- [Resources](#-resources)
|
|
59
59
|
- [License](#-license)
|
|
60
60
|
- [Contributing](#-contributing)
|
|
61
|
-
- [Query Examples from Series-Sample](#-query-examples-from-series-sample)
|
|
62
|
-
- [State Machine Example from Series-Sample](#-state-machine-example-from-series-sample)
|
|
63
|
-
- [Plugins for Count in Extensions](#-plugins-for-count-in-extensions)
|
|
64
|
-
- [API Reference](#-api-reference)
|
|
65
61
|
|
|
66
62
|
## ✨ Features
|
|
67
63
|
|
|
@@ -175,6 +171,8 @@ query {
|
|
|
175
171
|
}
|
|
176
172
|
```
|
|
177
173
|
|
|
174
|
+
> For a full working application, see the [Series Sample Project](https://github.com/simtlix/series-sample) -- a complete TV series microservice with types, relationships, state machines, controllers, and authorization.
|
|
175
|
+
|
|
178
176
|
## 🔧 Core Concepts
|
|
179
177
|
|
|
180
178
|
### Connecting Models
|
|
@@ -383,1005 +381,813 @@ query {
|
|
|
383
381
|
|
|
384
382
|
**Note**: Collection field filtering uses the exact same format as main query filtering, ensuring consistency across your GraphQL API. All available operators (`EQ`, `NE`, `GT`, `LT`, `GTE`, `LTE`, `LIKE`, `IN`, `NIN`, `BTW`) work with collection fields.
|
|
385
383
|
|
|
386
|
-
##
|
|
387
|
-
|
|
388
|
-
Middlewares provide a powerful way to intercept and process all GraphQL operations before they execute. Use them for cross-cutting concerns like authentication, logging, validation, and performance monitoring.
|
|
384
|
+
## 🔗 Relationships
|
|
389
385
|
|
|
390
|
-
###
|
|
386
|
+
### Defining Relationships
|
|
391
387
|
|
|
392
|
-
|
|
388
|
+
Use the `extensions.relation` field to define relationships between types:
|
|
393
389
|
|
|
394
390
|
```javascript
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
391
|
+
const AuthorType = new GraphQLObjectType({
|
|
392
|
+
name: 'Author',
|
|
393
|
+
fields: () => ({
|
|
394
|
+
id: { type: new GraphQLNonNull(GraphQLID) },
|
|
395
|
+
name: { type: new GraphQLNonNull(GraphQLString) },
|
|
396
|
+
books: {
|
|
397
|
+
type: new GraphQLList(BookType),
|
|
398
|
+
extensions: {
|
|
399
|
+
relation: {
|
|
400
|
+
connectionField: 'author',
|
|
401
|
+
displayField: 'title'
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
// resolve method automatically generated! 🎉
|
|
405
|
+
},
|
|
406
|
+
}),
|
|
399
407
|
});
|
|
400
|
-
```
|
|
401
|
-
|
|
402
|
-
### Middleware Parameters
|
|
403
408
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
// Always call next() to continue the middleware chain
|
|
420
|
-
next();
|
|
409
|
+
const BookType = new GraphQLObjectType({
|
|
410
|
+
name: 'Book',
|
|
411
|
+
fields: () => ({
|
|
412
|
+
id: { type: new GraphQLNonNull(GraphQLID) },
|
|
413
|
+
title: { type: new GraphQLNonNull(GraphQLString) },
|
|
414
|
+
author: {
|
|
415
|
+
type: AuthorType,
|
|
416
|
+
extensions: {
|
|
417
|
+
relation: {
|
|
418
|
+
displayField: 'name'
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
// resolve method automatically generated! 🎉
|
|
422
|
+
},
|
|
423
|
+
}),
|
|
421
424
|
});
|
|
422
425
|
```
|
|
423
426
|
|
|
424
|
-
###
|
|
427
|
+
### Relationship Configuration
|
|
425
428
|
|
|
426
|
-
|
|
429
|
+
- `connectionField`: **(Required for collections)** The field storing the related object's ID - only needed for one-to-many relationships (GraphQLList). For single object relationships, the field name is automatically inferred from the GraphQL field name.
|
|
430
|
+
- `displayField`: **(Optional)** Field to use for display in UI components
|
|
431
|
+
- `embedded`: **(Optional)** Whether the relation is embedded (default: false)
|
|
427
432
|
|
|
428
|
-
|
|
429
|
-
simfinity.use((params, next) => {
|
|
430
|
-
const { context, operation, type } = params;
|
|
431
|
-
|
|
432
|
-
// Skip authentication for read operations
|
|
433
|
-
if (operation === 'get_by_id' || operation === 'find') {
|
|
434
|
-
return next();
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
// Check if user is authenticated
|
|
438
|
-
if (!context.user) {
|
|
439
|
-
throw new simfinity.SimfinityError('Authentication required', 'UNAUTHORIZED', 401);
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
// Check permissions for specific types
|
|
443
|
-
if (type?.name === 'User' && context.user.role !== 'admin') {
|
|
444
|
-
throw new simfinity.SimfinityError('Admin access required', 'FORBIDDEN', 403);
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
next();
|
|
448
|
-
});
|
|
449
|
-
```
|
|
433
|
+
### Auto-Generated Resolve Methods
|
|
450
434
|
|
|
451
|
-
|
|
435
|
+
🎉 **NEW**: Simfinity.js automatically generates resolve methods for relationship fields when types are connected, eliminating the need for manual resolver boilerplate.
|
|
436
|
+
|
|
437
|
+
#### Before (Manual Resolvers)
|
|
452
438
|
|
|
453
439
|
```javascript
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
440
|
+
const BookType = new GraphQLObjectType({
|
|
441
|
+
name: 'Book',
|
|
442
|
+
fields: () => ({
|
|
443
|
+
id: { type: new GraphQLNonNull(GraphQLID) },
|
|
444
|
+
title: { type: new GraphQLNonNull(GraphQLString) },
|
|
445
|
+
author: {
|
|
446
|
+
type: AuthorType,
|
|
447
|
+
extensions: {
|
|
448
|
+
relation: {
|
|
449
|
+
displayField: 'name'
|
|
450
|
+
},
|
|
451
|
+
},
|
|
452
|
+
// You had to manually write this
|
|
453
|
+
resolve(parent) {
|
|
454
|
+
return simfinity.getModel(AuthorType).findById(parent.author);
|
|
455
|
+
}
|
|
456
|
+
},
|
|
457
|
+
comments: {
|
|
458
|
+
type: new GraphQLList(CommentType),
|
|
459
|
+
extensions: {
|
|
460
|
+
relation: {
|
|
461
|
+
connectionField: 'bookId',
|
|
462
|
+
displayField: 'text'
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
// You had to manually write this too
|
|
466
|
+
resolve(parent) {
|
|
467
|
+
return simfinity.getModel(CommentType).find({ bookId: parent.id });
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}),
|
|
465
471
|
});
|
|
466
472
|
```
|
|
467
473
|
|
|
468
|
-
####
|
|
474
|
+
#### After (Auto-Generated Resolvers)
|
|
469
475
|
|
|
470
476
|
```javascript
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
477
|
+
const BookType = new GraphQLObjectType({
|
|
478
|
+
name: 'Book',
|
|
479
|
+
fields: () => ({
|
|
480
|
+
id: { type: new GraphQLNonNull(GraphQLID) },
|
|
481
|
+
title: { type: new GraphQLNonNull(GraphQLString) },
|
|
482
|
+
author: {
|
|
483
|
+
type: AuthorType,
|
|
484
|
+
extensions: {
|
|
485
|
+
relation: {
|
|
486
|
+
displayField: 'name'
|
|
487
|
+
},
|
|
488
|
+
},
|
|
489
|
+
// resolve method automatically generated! 🎉
|
|
490
|
+
},
|
|
491
|
+
comments: {
|
|
492
|
+
type: new GraphQLList(CommentType),
|
|
493
|
+
extensions: {
|
|
494
|
+
relation: {
|
|
495
|
+
connectionField: 'bookId',
|
|
496
|
+
displayField: 'text'
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
// resolve method automatically generated! 🎉
|
|
486
500
|
}
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
next();
|
|
501
|
+
}),
|
|
490
502
|
});
|
|
491
503
|
```
|
|
492
504
|
|
|
493
|
-
####
|
|
505
|
+
#### How It Works
|
|
506
|
+
|
|
507
|
+
- **Single Object Relationships**: Automatically generates `findById()` resolvers using the field name or `connectionField`
|
|
508
|
+
- **Collection Relationships**: Automatically generates `find()` resolvers using the `connectionField` to query related objects
|
|
509
|
+
- **Lazy Loading**: Models are looked up at runtime, so types can be connected in any order
|
|
510
|
+
- **Backwards Compatible**: Existing manual resolve methods are preserved and not overwritten
|
|
511
|
+
- **Type Safety**: Clear error messages if related types aren't properly connected
|
|
512
|
+
|
|
513
|
+
#### Connect Your Types
|
|
494
514
|
|
|
495
515
|
```javascript
|
|
496
|
-
|
|
516
|
+
// Connect all your types to Simfinity
|
|
517
|
+
simfinity.connect(null, AuthorType, 'author', 'authors');
|
|
518
|
+
simfinity.connect(null, BookType, 'book', 'books');
|
|
519
|
+
simfinity.connect(null, CommentType, 'comment', 'comments');
|
|
497
520
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
const userId = context.user?.id || context.ip;
|
|
501
|
-
const now = Date.now();
|
|
502
|
-
const windowMs = 60000; // 1 minute
|
|
503
|
-
const maxRequests = 100;
|
|
504
|
-
|
|
505
|
-
// Only apply rate limiting to mutations
|
|
506
|
-
if (operation === 'save' || operation === 'update' || operation === 'delete') {
|
|
507
|
-
const userRequests = requestCounts.get(userId) || [];
|
|
508
|
-
const recentRequests = userRequests.filter(time => now - time < windowMs);
|
|
509
|
-
|
|
510
|
-
if (recentRequests.length >= maxRequests) {
|
|
511
|
-
throw new simfinity.SimfinityError('Rate limit exceeded', 'TOO_MANY_REQUESTS', 429);
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
recentRequests.push(now);
|
|
515
|
-
requestCounts.set(userId, recentRequests);
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
next();
|
|
519
|
-
});
|
|
521
|
+
// Or use addNoEndpointType for types that don't need direct queries/mutations
|
|
522
|
+
simfinity.addNoEndpointType(AuthorType);
|
|
520
523
|
```
|
|
521
524
|
|
|
522
|
-
|
|
525
|
+
That's it! All relationship resolvers are automatically generated when you connect your types.
|
|
526
|
+
|
|
527
|
+
### Adding Types Without Endpoints
|
|
528
|
+
|
|
529
|
+
Use `addNoEndpointType()` for types that should be included in the GraphQL schema but don't need their own CRUD operations:
|
|
523
530
|
|
|
524
531
|
```javascript
|
|
525
|
-
simfinity.
|
|
526
|
-
const { operation, type, args, context } = params;
|
|
527
|
-
|
|
528
|
-
// Log all mutations for audit purposes
|
|
529
|
-
if (operation === 'save' || operation === 'update' || operation === 'delete') {
|
|
530
|
-
const auditEntry = {
|
|
531
|
-
timestamp: new Date(),
|
|
532
|
-
user: context.user?.id,
|
|
533
|
-
operation,
|
|
534
|
-
type: type?.name,
|
|
535
|
-
entityId: args.id || 'new',
|
|
536
|
-
data: operation === 'delete' ? null : args.input,
|
|
537
|
-
ip: context.ip,
|
|
538
|
-
userAgent: context.userAgent
|
|
539
|
-
};
|
|
540
|
-
|
|
541
|
-
// Save to audit log (could be database, file, or external service)
|
|
542
|
-
console.log('AUDIT:', JSON.stringify(auditEntry));
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
next();
|
|
546
|
-
});
|
|
532
|
+
simfinity.addNoEndpointType(TypeName);
|
|
547
533
|
```
|
|
548
534
|
|
|
549
|
-
|
|
535
|
+
**When to use `addNoEndpointType()` vs `connect()`:**
|
|
550
536
|
|
|
551
|
-
|
|
537
|
+
| Method | Use Case | Creates Endpoints | Use Example |
|
|
538
|
+
|--------|----------|-------------------|-------------|
|
|
539
|
+
| `connect()` | Types that need CRUD operations | ✅ Yes | User, Product, Order |
|
|
540
|
+
| `addNoEndpointType()` | Types only used in relationships | ❌ No | Address, Settings, Director |
|
|
552
541
|
|
|
553
|
-
|
|
554
|
-
// Middleware 1: Authentication
|
|
555
|
-
simfinity.use((params, next) => {
|
|
556
|
-
console.log('1. Checking authentication...');
|
|
557
|
-
// Authentication logic here
|
|
558
|
-
next(); // Continue to next middleware
|
|
559
|
-
});
|
|
542
|
+
#### Perfect Example: TV Series with Embedded Director
|
|
560
543
|
|
|
561
|
-
|
|
562
|
-
simfinity.use((params, next) => {
|
|
563
|
-
console.log('2. Checking permissions...');
|
|
564
|
-
// Authorization logic here
|
|
565
|
-
next(); // Continue to next middleware
|
|
566
|
-
});
|
|
544
|
+
From the [series-sample](https://github.com/simtlix/series-sample) project:
|
|
567
545
|
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
546
|
+
```javascript
|
|
547
|
+
// Director type - Used only as embedded data, no direct API access needed
|
|
548
|
+
const directorType = new GraphQLObjectType({
|
|
549
|
+
name: 'director',
|
|
550
|
+
fields: () => ({
|
|
551
|
+
id: { type: GraphQLID },
|
|
552
|
+
name: { type: new GraphQLNonNull(GraphQLString) },
|
|
553
|
+
country: { type: GraphQLString }
|
|
554
|
+
})
|
|
573
555
|
});
|
|
574
|
-
```
|
|
575
556
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
Middlewares can throw errors to stop the operation:
|
|
557
|
+
// Add to schema WITHOUT creating endpoints
|
|
558
|
+
simfinity.addNoEndpointType(directorType);
|
|
579
559
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
560
|
+
// Serie type - Has its own endpoints and embeds director data
|
|
561
|
+
const serieType = new GraphQLObjectType({
|
|
562
|
+
name: 'serie',
|
|
563
|
+
fields: () => ({
|
|
564
|
+
id: { type: GraphQLID },
|
|
565
|
+
name: { type: new GraphQLNonNull(GraphQLString) },
|
|
566
|
+
categories: { type: new GraphQLList(GraphQLString) },
|
|
567
|
+
director: {
|
|
568
|
+
type: new GraphQLNonNull(directorType),
|
|
569
|
+
extensions: {
|
|
570
|
+
relation: {
|
|
571
|
+
embedded: true, // Director data stored within serie document
|
|
572
|
+
displayField: 'name'
|
|
573
|
+
}
|
|
574
|
+
}
|
|
588
575
|
}
|
|
589
|
-
|
|
590
|
-
next(); // Continue only if validation passes
|
|
591
|
-
} catch (error) {
|
|
592
|
-
// Error automatically bubbles up to GraphQL error handling
|
|
593
|
-
throw error;
|
|
594
|
-
}
|
|
576
|
+
})
|
|
595
577
|
});
|
|
596
|
-
```
|
|
597
578
|
|
|
598
|
-
|
|
579
|
+
// Create full CRUD endpoints for series
|
|
580
|
+
simfinity.connect(null, serieType, 'serie', 'series');
|
|
581
|
+
```
|
|
599
582
|
|
|
600
|
-
|
|
583
|
+
**Result:**
|
|
584
|
+
- ✅ `addserie`, `updateserie`, `deleteserie` mutations available
|
|
585
|
+
- ✅ `serie`, `series` queries available
|
|
586
|
+
- ❌ No `adddirector`, `director`, `directors` endpoints (director is embedded)
|
|
601
587
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
588
|
+
**Usage:**
|
|
589
|
+
```graphql
|
|
590
|
+
mutation {
|
|
591
|
+
addserie(input: {
|
|
592
|
+
name: "Breaking Bad"
|
|
593
|
+
categories: ["crime", "drama", "thriller"]
|
|
594
|
+
director: {
|
|
595
|
+
name: "Vince Gilligan"
|
|
596
|
+
country: "United States"
|
|
597
|
+
}
|
|
598
|
+
}) {
|
|
599
|
+
id
|
|
600
|
+
name
|
|
601
|
+
director {
|
|
602
|
+
name
|
|
603
|
+
country
|
|
611
604
|
}
|
|
612
605
|
}
|
|
613
|
-
|
|
614
|
-
// Only apply to mutation operations
|
|
615
|
-
if (['save', 'update', 'delete', 'state_changed'].includes(operation)) {
|
|
616
|
-
// Mutation-specific logic
|
|
617
|
-
console.log(`Mutation ${operation} executing...`);
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
next();
|
|
621
|
-
});
|
|
606
|
+
}
|
|
622
607
|
```
|
|
623
608
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
1. **Always call `next()`**: Failing to call `next()` will hang the request
|
|
627
|
-
2. **Handle errors gracefully**: Use try-catch blocks for error-prone operations
|
|
628
|
-
3. **Keep middlewares focused**: Each middleware should handle one concern
|
|
629
|
-
4. **Order matters**: Register middlewares in logical order (auth → validation → logging)
|
|
630
|
-
5. **Performance consideration**: Middlewares run on every operation, keep them lightweight
|
|
631
|
-
6. **Use context wisely**: Store request-specific data in the GraphQL context object
|
|
609
|
+
#### When to Use Each Approach
|
|
632
610
|
|
|
633
|
-
|
|
611
|
+
**Use `addNoEndpointType()` for:**
|
|
612
|
+
- Simple data objects with few fields
|
|
613
|
+
- Data that doesn't need CRUD operations
|
|
614
|
+
- Objects that belong to a single parent (1:1 relationships)
|
|
615
|
+
- Configuration or settings objects
|
|
616
|
+
- **Examples**: Address, Director info, Product specifications
|
|
634
617
|
|
|
635
|
-
|
|
618
|
+
**Use `connect()` for:**
|
|
619
|
+
- Complex entities that need their own endpoints
|
|
620
|
+
- Data that needs CRUD operations
|
|
621
|
+
- Objects shared between multiple parents (many:many relationships)
|
|
622
|
+
- Objects with business logic (controllers, state machines)
|
|
623
|
+
- **Examples**: User, Product, Order, Season, Episode
|
|
636
624
|
|
|
637
|
-
###
|
|
625
|
+
### Embedded vs Referenced Relationships
|
|
638
626
|
|
|
627
|
+
**Referenced Relationships** (default):
|
|
639
628
|
```javascript
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
//
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
Mutation: {
|
|
652
|
-
publishPost: requireRole('EDITOR'),
|
|
653
|
-
},
|
|
654
|
-
User: {
|
|
655
|
-
'*': requireAuth(), // Wildcard: all fields require auth
|
|
656
|
-
email: requireRole('ADMIN'), // Override: email requires ADMIN role
|
|
657
|
-
},
|
|
658
|
-
Post: {
|
|
659
|
-
'*': requireAuth(),
|
|
660
|
-
content: async (post, _args, ctx) => {
|
|
661
|
-
// Custom logic: allow if published OR if author
|
|
662
|
-
if (post.published) return true;
|
|
663
|
-
if (post.authorId === ctx.user?.id) return true;
|
|
664
|
-
return false;
|
|
665
|
-
},
|
|
666
|
-
},
|
|
667
|
-
};
|
|
629
|
+
// Stores author ID in the book document
|
|
630
|
+
author: {
|
|
631
|
+
type: AuthorType,
|
|
632
|
+
extensions: {
|
|
633
|
+
relation: {
|
|
634
|
+
// connectionField not needed for single object relationships
|
|
635
|
+
embedded: false // This is the default
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
```
|
|
668
640
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
641
|
+
**Embedded Relationships**:
|
|
642
|
+
```javascript
|
|
643
|
+
// Stores the full publisher object in the book document
|
|
644
|
+
publisher: {
|
|
645
|
+
type: PublisherType,
|
|
646
|
+
extensions: {
|
|
647
|
+
relation: {
|
|
648
|
+
embedded: true
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
672
652
|
```
|
|
673
653
|
|
|
674
|
-
###
|
|
654
|
+
### Querying Relationships
|
|
675
655
|
|
|
676
|
-
|
|
656
|
+
Query nested relationships with dot notation:
|
|
677
657
|
|
|
678
|
-
```
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
658
|
+
```graphql
|
|
659
|
+
query {
|
|
660
|
+
books(author: {
|
|
661
|
+
terms: [
|
|
662
|
+
{
|
|
663
|
+
path: "country.name",
|
|
664
|
+
operator: EQ,
|
|
665
|
+
value: "England"
|
|
666
|
+
}
|
|
667
|
+
]
|
|
668
|
+
}) {
|
|
669
|
+
id
|
|
670
|
+
title
|
|
671
|
+
author {
|
|
672
|
+
name
|
|
673
|
+
country {
|
|
674
|
+
name
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
691
679
|
```
|
|
692
680
|
|
|
693
|
-
|
|
694
|
-
1. Check exact field rule: `permissions[TypeName][fieldName]`
|
|
695
|
-
2. Fallback to wildcard: `permissions[TypeName]['*']`
|
|
696
|
-
3. Apply default policy (ALLOW or DENY)
|
|
681
|
+
### Creating Objects with Relationships
|
|
697
682
|
|
|
698
|
-
**
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
const {
|
|
716
|
-
resolvePath, // Utility to resolve dotted paths in objects
|
|
717
|
-
requireAuth, // Requires ctx.user to exist
|
|
718
|
-
requireRole, // Requires specific role(s)
|
|
719
|
-
requirePermission, // Requires specific permission(s)
|
|
720
|
-
composeRules, // Combine rules (AND logic)
|
|
721
|
-
anyRule, // Combine rules (OR logic)
|
|
722
|
-
isOwner, // Check resource ownership
|
|
723
|
-
allow, // Always allow
|
|
724
|
-
deny, // Always deny
|
|
725
|
-
createRule, // Create custom rule
|
|
726
|
-
} = auth;
|
|
727
|
-
```
|
|
728
|
-
|
|
729
|
-
#### requireAuth(userPath?)
|
|
730
|
-
|
|
731
|
-
Requires the user to be authenticated. Supports custom user paths in context:
|
|
732
|
-
|
|
733
|
-
```javascript
|
|
734
|
-
const permissions = {
|
|
735
|
-
Query: {
|
|
736
|
-
// Default: checks ctx.user
|
|
737
|
-
me: requireAuth(),
|
|
738
|
-
|
|
739
|
-
// Custom path: checks ctx.auth.currentUser
|
|
740
|
-
profile: requireAuth('auth.currentUser'),
|
|
741
|
-
|
|
742
|
-
// Deep path: checks ctx.session.data.user
|
|
743
|
-
settings: requireAuth('session.data.user'),
|
|
744
|
-
},
|
|
745
|
-
};
|
|
746
|
-
```
|
|
747
|
-
|
|
748
|
-
#### requireRole(role, options?)
|
|
749
|
-
|
|
750
|
-
Requires the user to have a specific role. Supports custom paths:
|
|
751
|
-
|
|
752
|
-
```javascript
|
|
753
|
-
const permissions = {
|
|
754
|
-
Query: {
|
|
755
|
-
// Default: checks ctx.user.role
|
|
756
|
-
adminDashboard: requireRole('ADMIN'),
|
|
757
|
-
modTools: requireRole(['ADMIN', 'MODERATOR']), // Any of these roles
|
|
758
|
-
|
|
759
|
-
// Custom paths: checks ctx.auth.user.profile.role
|
|
760
|
-
superAdmin: requireRole('SUPER_ADMIN', {
|
|
761
|
-
userPath: 'auth.user',
|
|
762
|
-
rolePath: 'profile.role',
|
|
763
|
-
}),
|
|
764
|
-
},
|
|
765
|
-
};
|
|
683
|
+
**Link to existing objects:**
|
|
684
|
+
```graphql
|
|
685
|
+
mutation {
|
|
686
|
+
addBook(input: {
|
|
687
|
+
title: "New Book"
|
|
688
|
+
author: {
|
|
689
|
+
id: "existing_author_id"
|
|
690
|
+
}
|
|
691
|
+
}) {
|
|
692
|
+
id
|
|
693
|
+
title
|
|
694
|
+
author {
|
|
695
|
+
name
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
766
699
|
```
|
|
767
700
|
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
}
|
|
701
|
+
**Create embedded objects:**
|
|
702
|
+
```graphql
|
|
703
|
+
mutation {
|
|
704
|
+
addBook(input: {
|
|
705
|
+
title: "New Book"
|
|
706
|
+
publisher: {
|
|
707
|
+
name: "Penguin Books"
|
|
708
|
+
location: "London"
|
|
709
|
+
}
|
|
710
|
+
}) {
|
|
711
|
+
id
|
|
712
|
+
title
|
|
713
|
+
publisher {
|
|
714
|
+
name
|
|
715
|
+
location
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
786
719
|
```
|
|
787
720
|
|
|
788
|
-
|
|
721
|
+
### Collection Fields
|
|
789
722
|
|
|
790
|
-
|
|
723
|
+
Work with arrays of related objects:
|
|
791
724
|
|
|
792
|
-
```
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
725
|
+
```graphql
|
|
726
|
+
mutation {
|
|
727
|
+
updateBook(input: {
|
|
728
|
+
id: "book_id"
|
|
729
|
+
reviews: {
|
|
730
|
+
added: [
|
|
731
|
+
{ rating: 5, comment: "Amazing!" }
|
|
732
|
+
{ rating: 4, comment: "Good read" }
|
|
733
|
+
]
|
|
734
|
+
updated: [
|
|
735
|
+
{ id: "review_id", rating: 3 }
|
|
736
|
+
]
|
|
737
|
+
deleted: ["review_id_to_delete"]
|
|
738
|
+
}
|
|
739
|
+
}) {
|
|
740
|
+
id
|
|
741
|
+
title
|
|
742
|
+
reviews {
|
|
743
|
+
rating
|
|
744
|
+
comment
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
802
748
|
```
|
|
803
749
|
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
Combines multiple rules with OR logic (any must pass):
|
|
750
|
+
## ✅ Validations
|
|
807
751
|
|
|
808
|
-
|
|
809
|
-
const permissions = {
|
|
810
|
-
Post: {
|
|
811
|
-
content: anyRule(
|
|
812
|
-
requireRole('ADMIN'),
|
|
813
|
-
async (post, args, ctx) => post.authorId === ctx.user.id,
|
|
814
|
-
),
|
|
815
|
-
},
|
|
816
|
-
};
|
|
817
|
-
```
|
|
752
|
+
### Declarative Validation Helpers
|
|
818
753
|
|
|
819
|
-
|
|
754
|
+
Simfinity.js provides built-in validation helpers to simplify common validation patterns, eliminating verbose boilerplate code.
|
|
820
755
|
|
|
821
|
-
|
|
756
|
+
#### Using Validators
|
|
822
757
|
|
|
823
758
|
```javascript
|
|
824
|
-
const
|
|
825
|
-
Post: {
|
|
826
|
-
'*': composeRules(
|
|
827
|
-
requireAuth(),
|
|
828
|
-
isOwner('authorId', 'id'), // Compares post.authorId with ctx.user.id
|
|
829
|
-
),
|
|
830
|
-
},
|
|
831
|
-
};
|
|
832
|
-
```
|
|
833
|
-
|
|
834
|
-
### Policy Expressions (JSON AST)
|
|
835
|
-
|
|
836
|
-
For declarative rules, use JSON AST policy expressions:
|
|
759
|
+
const { validators } = require('@simtlix/simfinity-js');
|
|
837
760
|
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
761
|
+
const PersonType = new GraphQLObjectType({
|
|
762
|
+
name: 'Person',
|
|
763
|
+
fields: () => ({
|
|
764
|
+
id: { type: GraphQLID },
|
|
765
|
+
name: {
|
|
766
|
+
type: GraphQLString,
|
|
767
|
+
extensions: {
|
|
768
|
+
validations: validators.stringLength('Name', 2, 100)
|
|
769
|
+
}
|
|
846
770
|
},
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
| Operator | Description | Example |
|
|
854
|
-
|----------|-------------|---------|
|
|
855
|
-
| `eq` | Equals | `{ eq: [{ ref: 'parent.status' }, 'active'] }` |
|
|
856
|
-
| `in` | Value in array | `{ in: [{ ref: 'ctx.user.role' }, ['ADMIN', 'MOD']] }` |
|
|
857
|
-
| `allOf` | All must be true (AND) | `{ allOf: [expr1, expr2] }` |
|
|
858
|
-
| `anyOf` | Any must be true (OR) | `{ anyOf: [expr1, expr2] }` |
|
|
859
|
-
| `not` | Negation | `{ not: { eq: [{ ref: 'parent.deleted' }, true] } }` |
|
|
860
|
-
|
|
861
|
-
**References:**
|
|
862
|
-
|
|
863
|
-
Use `{ ref: 'path' }` to reference values:
|
|
864
|
-
- `parent.*` - Parent resolver result (the object being resolved)
|
|
865
|
-
- `args.*` - GraphQL arguments
|
|
866
|
-
- `ctx.*` - GraphQL context
|
|
867
|
-
|
|
868
|
-
**Security:**
|
|
869
|
-
- Only `parent`, `args`, and `ctx` roots are allowed
|
|
870
|
-
- Unknown operators fail closed (deny)
|
|
871
|
-
- No `eval()` or `Function()` - pure object traversal
|
|
872
|
-
|
|
873
|
-
### Integration with GraphQL Yoga / Envelop
|
|
874
|
-
|
|
875
|
-
The recommended way to use the auth system is via the Envelop plugin, which works natively with GraphQL Yoga and any Envelop-based server. The plugin wraps resolvers in-place without rebuilding the schema, avoiding compatibility issues.
|
|
876
|
-
|
|
877
|
-
```javascript
|
|
878
|
-
const { createYoga } = require('graphql-yoga');
|
|
879
|
-
const { createServer } = require('http');
|
|
880
|
-
const simfinity = require('@simtlix/simfinity-js');
|
|
881
|
-
|
|
882
|
-
const { auth } = simfinity;
|
|
883
|
-
const { createAuthPlugin, requireAuth, requireRole, requirePermission } = auth;
|
|
884
|
-
|
|
885
|
-
// Define your types and connect them
|
|
886
|
-
simfinity.connect(null, UserType, 'user', 'users');
|
|
887
|
-
simfinity.connect(null, PostType, 'post', 'posts');
|
|
888
|
-
|
|
889
|
-
// Create base schema
|
|
890
|
-
const schema = simfinity.createSchema();
|
|
891
|
-
|
|
892
|
-
// Define permissions
|
|
893
|
-
const permissions = {
|
|
894
|
-
Query: {
|
|
895
|
-
users: requireAuth(),
|
|
896
|
-
user: requireAuth(),
|
|
897
|
-
posts: requireAuth(),
|
|
898
|
-
post: requireAuth(),
|
|
899
|
-
},
|
|
900
|
-
Mutation: {
|
|
901
|
-
adduser: requireRole('ADMIN'),
|
|
902
|
-
updateuser: requireRole('ADMIN'),
|
|
903
|
-
deleteuser: requireRole('ADMIN'),
|
|
904
|
-
addpost: requireAuth(),
|
|
905
|
-
updatepost: composeRules(requireAuth(), isOwner('authorId')),
|
|
906
|
-
deletepost: requireRole('ADMIN'),
|
|
907
|
-
},
|
|
908
|
-
User: {
|
|
909
|
-
'*': requireAuth(),
|
|
910
|
-
email: requireRole('ADMIN'),
|
|
911
|
-
password: deny('Password field is not accessible'),
|
|
912
|
-
},
|
|
913
|
-
Post: {
|
|
914
|
-
'*': requireAuth(),
|
|
915
|
-
content: {
|
|
916
|
-
anyOf: [
|
|
917
|
-
{ eq: [{ ref: 'parent.published' }, true] },
|
|
918
|
-
{ eq: [{ ref: 'parent.authorId' }, { ref: 'ctx.user.id' }] },
|
|
919
|
-
],
|
|
771
|
+
email: {
|
|
772
|
+
type: GraphQLString,
|
|
773
|
+
extensions: {
|
|
774
|
+
validations: validators.email()
|
|
775
|
+
}
|
|
920
776
|
},
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
server.listen(4000);
|
|
941
|
-
```
|
|
942
|
-
|
|
943
|
-
### Legacy: Integration with graphql-middleware
|
|
944
|
-
|
|
945
|
-
> **Deprecated:** `applyMiddleware` from `graphql-middleware` rebuilds the schema via `mapSchema`,
|
|
946
|
-
> which can cause `"Schema must contain uniquely named types"` errors with Simfinity schemas.
|
|
947
|
-
> Use `createAuthPlugin` with GraphQL Yoga / Envelop instead.
|
|
948
|
-
|
|
949
|
-
```javascript
|
|
950
|
-
const { applyMiddleware } = require('graphql-middleware');
|
|
951
|
-
const simfinity = require('@simtlix/simfinity-js');
|
|
952
|
-
|
|
953
|
-
const { auth } = simfinity;
|
|
954
|
-
const { createAuthMiddleware, requireAuth, requireRole } = auth;
|
|
955
|
-
|
|
956
|
-
const baseSchema = simfinity.createSchema();
|
|
957
|
-
|
|
958
|
-
const authMiddleware = createAuthMiddleware(permissions, {
|
|
959
|
-
defaultPolicy: 'DENY',
|
|
777
|
+
website: {
|
|
778
|
+
type: GraphQLString,
|
|
779
|
+
extensions: {
|
|
780
|
+
validations: validators.url()
|
|
781
|
+
}
|
|
782
|
+
},
|
|
783
|
+
age: {
|
|
784
|
+
type: GraphQLInt,
|
|
785
|
+
extensions: {
|
|
786
|
+
validations: validators.numberRange('Age', 0, 120)
|
|
787
|
+
}
|
|
788
|
+
},
|
|
789
|
+
price: {
|
|
790
|
+
type: GraphQLFloat,
|
|
791
|
+
extensions: {
|
|
792
|
+
validations: validators.positive('Price')
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
})
|
|
960
796
|
});
|
|
961
|
-
|
|
962
|
-
const schema = applyMiddleware(baseSchema, authMiddleware);
|
|
963
797
|
```
|
|
964
798
|
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
```javascript
|
|
968
|
-
const plugin = createAuthPlugin(permissions, {
|
|
969
|
-
defaultPolicy: 'DENY', // 'ALLOW' or 'DENY' (default: 'DENY')
|
|
970
|
-
debug: false, // Enable debug logging
|
|
971
|
-
});
|
|
972
|
-
```
|
|
799
|
+
#### Available Validators
|
|
973
800
|
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
801
|
+
**String Validators:**
|
|
802
|
+
- `validators.stringLength(name, min, max)` - Validates string length with min/max bounds (required for CREATE)
|
|
803
|
+
- `validators.maxLength(name, max)` - Validates maximum string length
|
|
804
|
+
- `validators.pattern(name, regex, message)` - Validates against a regex pattern
|
|
805
|
+
- `validators.email()` - Validates email format
|
|
806
|
+
- `validators.url()` - Validates URL format
|
|
978
807
|
|
|
979
|
-
|
|
808
|
+
**Number Validators:**
|
|
809
|
+
- `validators.numberRange(name, min, max)` - Validates number range
|
|
810
|
+
- `validators.positive(name)` - Ensures number is positive
|
|
980
811
|
|
|
981
|
-
|
|
812
|
+
**Array Validators:**
|
|
813
|
+
- `validators.arrayLength(name, maxItems, itemValidator)` - Validates array length and optionally each item
|
|
982
814
|
|
|
983
|
-
|
|
984
|
-
|
|
815
|
+
**Date Validators:**
|
|
816
|
+
- `validators.dateFormat(name, format)` - Validates date format
|
|
817
|
+
- `validators.futureDate(name)` - Ensures date is in the future
|
|
985
818
|
|
|
986
|
-
|
|
819
|
+
#### Validator Features
|
|
987
820
|
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
821
|
+
- **Automatic Operation Handling**: Validators work for both `CREATE` (save) and `UPDATE` operations
|
|
822
|
+
- **Smart Validation**: For CREATE operations, values are required. For UPDATE operations, undefined/null values are allowed (field might not be updated)
|
|
823
|
+
- **Consistent Error Messages**: All validators throw `SimfinityError` with appropriate messages
|
|
991
824
|
|
|
992
|
-
|
|
825
|
+
#### Example: Multiple Validators
|
|
993
826
|
|
|
994
827
|
```javascript
|
|
995
|
-
const
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
828
|
+
const ProductType = new GraphQLObjectType({
|
|
829
|
+
name: 'Product',
|
|
830
|
+
fields: () => ({
|
|
831
|
+
id: { type: GraphQLID },
|
|
832
|
+
name: {
|
|
833
|
+
type: GraphQLString,
|
|
834
|
+
extensions: {
|
|
835
|
+
validations: validators.stringLength('Product Name', 3, 200)
|
|
1003
836
|
}
|
|
1004
|
-
return true;
|
|
1005
837
|
},
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
```
|
|
1009
|
-
|
|
1010
|
-
### Best Practices
|
|
1011
|
-
|
|
1012
|
-
1. **Default to DENY**: Use `defaultPolicy: 'DENY'` for security
|
|
1013
|
-
2. **Use wildcards wisely**: `'*'` rules provide baseline security per type
|
|
1014
|
-
3. **Prefer helper rules**: Use `requireAuth()`, `requireRole()` over custom functions
|
|
1015
|
-
4. **Fail closed**: Custom rules should deny on unexpected conditions
|
|
1016
|
-
5. **Keep rules simple**: Complex logic belongs in controllers, not auth rules
|
|
1017
|
-
6. **Test thoroughly**: Auth rules are critical - test all scenarios
|
|
1018
|
-
|
|
1019
|
-
## 🔗 Relationships
|
|
1020
|
-
|
|
1021
|
-
### Defining Relationships
|
|
1022
|
-
|
|
1023
|
-
Use the `extensions.relation` field to define relationships between types:
|
|
1024
|
-
|
|
1025
|
-
```javascript
|
|
1026
|
-
const AuthorType = new GraphQLObjectType({
|
|
1027
|
-
name: 'Author',
|
|
1028
|
-
fields: () => ({
|
|
1029
|
-
id: { type: new GraphQLNonNull(GraphQLID) },
|
|
1030
|
-
name: { type: new GraphQLNonNull(GraphQLString) },
|
|
1031
|
-
books: {
|
|
1032
|
-
type: new GraphQLList(BookType),
|
|
838
|
+
sku: {
|
|
839
|
+
type: GraphQLString,
|
|
1033
840
|
extensions: {
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
displayField: 'title'
|
|
1037
|
-
},
|
|
1038
|
-
},
|
|
1039
|
-
// resolve method automatically generated! 🎉
|
|
841
|
+
validations: validators.pattern('SKU', /^[A-Z0-9-]+$/, 'SKU must be uppercase alphanumeric with hyphens')
|
|
842
|
+
}
|
|
1040
843
|
},
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
const BookType = new GraphQLObjectType({
|
|
1045
|
-
name: 'Book',
|
|
1046
|
-
fields: () => ({
|
|
1047
|
-
id: { type: new GraphQLNonNull(GraphQLID) },
|
|
1048
|
-
title: { type: new GraphQLNonNull(GraphQLString) },
|
|
1049
|
-
author: {
|
|
1050
|
-
type: AuthorType,
|
|
844
|
+
price: {
|
|
845
|
+
type: GraphQLFloat,
|
|
1051
846
|
extensions: {
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
},
|
|
1055
|
-
},
|
|
1056
|
-
// resolve method automatically generated! 🎉
|
|
847
|
+
validations: validators.positive('Price')
|
|
848
|
+
}
|
|
1057
849
|
},
|
|
1058
|
-
|
|
850
|
+
tags: {
|
|
851
|
+
type: new GraphQLList(GraphQLString),
|
|
852
|
+
extensions: {
|
|
853
|
+
validations: validators.arrayLength('Tags', 10)
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
})
|
|
1059
857
|
});
|
|
1060
858
|
```
|
|
1061
859
|
|
|
1062
|
-
###
|
|
1063
|
-
|
|
1064
|
-
- `connectionField`: **(Required for collections)** The field storing the related object's ID - only needed for one-to-many relationships (GraphQLList). For single object relationships, the field name is automatically inferred from the GraphQL field name.
|
|
1065
|
-
- `displayField`: **(Optional)** Field to use for display in UI components
|
|
1066
|
-
- `embedded`: **(Optional)** Whether the relation is embedded (default: false)
|
|
860
|
+
### Field-Level Validations (Manual)
|
|
1067
861
|
|
|
1068
|
-
|
|
862
|
+
For custom validation logic, you can still write manual validators:
|
|
1069
863
|
|
|
1070
|
-
|
|
864
|
+
```javascript
|
|
865
|
+
const { SimfinityError } = require('@simtlix/simfinity-js');
|
|
1071
866
|
|
|
1072
|
-
|
|
867
|
+
const validateAge = {
|
|
868
|
+
validate: async (typeName, fieldName, value, session) => {
|
|
869
|
+
if (value < 0 || value > 120) {
|
|
870
|
+
throw new SimfinityError(`Invalid age: ${value}`, 'VALIDATION_ERROR', 400);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
};
|
|
1073
874
|
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
name: 'Book',
|
|
875
|
+
const PersonType = new GraphQLObjectType({
|
|
876
|
+
name: 'Person',
|
|
1077
877
|
fields: () => ({
|
|
1078
|
-
id: { type:
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
type: AuthorType,
|
|
878
|
+
id: { type: GraphQLID },
|
|
879
|
+
name: {
|
|
880
|
+
type: GraphQLString,
|
|
1082
881
|
extensions: {
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
882
|
+
validations: {
|
|
883
|
+
save: [{
|
|
884
|
+
validate: async (typeName, fieldName, value, session) => {
|
|
885
|
+
if (!value || value.length < 2) {
|
|
886
|
+
throw new SimfinityError('Name must be at least 2 characters', 'VALIDATION_ERROR', 400);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}],
|
|
890
|
+
update: [{
|
|
891
|
+
validate: async (typeName, fieldName, value, session) => {
|
|
892
|
+
if (value && value.length < 2) {
|
|
893
|
+
throw new SimfinityError('Name must be at least 2 characters', 'VALIDATION_ERROR', 400);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}]
|
|
897
|
+
}
|
|
1090
898
|
}
|
|
1091
899
|
},
|
|
1092
|
-
|
|
1093
|
-
type:
|
|
900
|
+
age: {
|
|
901
|
+
type: GraphQLInt,
|
|
1094
902
|
extensions: {
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
}
|
|
1099
|
-
},
|
|
1100
|
-
// You had to manually write this too
|
|
1101
|
-
resolve(parent) {
|
|
1102
|
-
return simfinity.getModel(CommentType).find({ bookId: parent.id });
|
|
903
|
+
validations: {
|
|
904
|
+
save: [validateAge],
|
|
905
|
+
update: [validateAge]
|
|
906
|
+
}
|
|
1103
907
|
}
|
|
1104
908
|
}
|
|
1105
|
-
})
|
|
909
|
+
})
|
|
1106
910
|
});
|
|
1107
911
|
```
|
|
1108
912
|
|
|
1109
|
-
|
|
913
|
+
### Type-Level Validations
|
|
914
|
+
|
|
915
|
+
Validate objects as a whole:
|
|
1110
916
|
|
|
1111
917
|
```javascript
|
|
1112
|
-
const
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
author: {
|
|
1118
|
-
type: AuthorType,
|
|
1119
|
-
extensions: {
|
|
1120
|
-
relation: {
|
|
1121
|
-
displayField: 'name'
|
|
1122
|
-
},
|
|
1123
|
-
},
|
|
1124
|
-
// resolve method automatically generated! 🎉
|
|
1125
|
-
},
|
|
1126
|
-
comments: {
|
|
1127
|
-
type: new GraphQLList(CommentType),
|
|
1128
|
-
extensions: {
|
|
1129
|
-
relation: {
|
|
1130
|
-
connectionField: 'bookId',
|
|
1131
|
-
displayField: 'text'
|
|
1132
|
-
},
|
|
1133
|
-
},
|
|
1134
|
-
// resolve method automatically generated! 🎉
|
|
918
|
+
const orderValidator = {
|
|
919
|
+
validate: async (typeName, args, modelArgs, session) => {
|
|
920
|
+
// Cross-field validation
|
|
921
|
+
if (modelArgs.deliveryDate < modelArgs.orderDate) {
|
|
922
|
+
throw new SimfinityError('Delivery date cannot be before order date', 'VALIDATION_ERROR', 400);
|
|
1135
923
|
}
|
|
1136
|
-
|
|
924
|
+
|
|
925
|
+
// Business rule validation
|
|
926
|
+
if (modelArgs.items.length === 0) {
|
|
927
|
+
throw new SimfinityError('Order must contain at least one item', 'BUSINESS_ERROR', 400);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
};
|
|
931
|
+
|
|
932
|
+
const OrderType = new GraphQLObjectType({
|
|
933
|
+
name: 'Order',
|
|
934
|
+
extensions: {
|
|
935
|
+
validations: {
|
|
936
|
+
save: [orderValidator],
|
|
937
|
+
update: [orderValidator]
|
|
938
|
+
}
|
|
939
|
+
},
|
|
940
|
+
fields: () => ({
|
|
941
|
+
// ... fields
|
|
942
|
+
})
|
|
1137
943
|
});
|
|
1138
944
|
```
|
|
1139
945
|
|
|
1140
|
-
|
|
946
|
+
### Custom Validated Scalar Types
|
|
1141
947
|
|
|
1142
|
-
|
|
1143
|
-
- **Collection Relationships**: Automatically generates `find()` resolvers using the `connectionField` to query related objects
|
|
1144
|
-
- **Lazy Loading**: Models are looked up at runtime, so types can be connected in any order
|
|
1145
|
-
- **Backwards Compatible**: Existing manual resolve methods are preserved and not overwritten
|
|
1146
|
-
- **Type Safety**: Clear error messages if related types aren't properly connected
|
|
948
|
+
Create custom scalar types with built-in validation. The generated type names follow the pattern `{name}_{baseScalarTypeName}`.
|
|
1147
949
|
|
|
1148
|
-
####
|
|
950
|
+
#### Pre-built Scalars
|
|
951
|
+
|
|
952
|
+
Simfinity.js provides ready-to-use validated scalars for common patterns:
|
|
1149
953
|
|
|
1150
954
|
```javascript
|
|
1151
|
-
|
|
1152
|
-
simfinity.connect(null, AuthorType, 'author', 'authors');
|
|
1153
|
-
simfinity.connect(null, BookType, 'book', 'books');
|
|
1154
|
-
simfinity.connect(null, CommentType, 'comment', 'comments');
|
|
955
|
+
const { scalars } = require('@simtlix/simfinity-js');
|
|
1155
956
|
|
|
1156
|
-
|
|
1157
|
-
|
|
957
|
+
const UserType = new GraphQLObjectType({
|
|
958
|
+
name: 'User',
|
|
959
|
+
fields: () => ({
|
|
960
|
+
id: { type: GraphQLID },
|
|
961
|
+
email: { type: scalars.EmailScalar }, // Type name: Email_String
|
|
962
|
+
website: { type: scalars.URLScalar }, // Type name: URL_String
|
|
963
|
+
age: { type: scalars.PositiveIntScalar }, // Type name: PositiveInt_Int
|
|
964
|
+
price: { type: scalars.PositiveFloatScalar } // Type name: PositiveFloat_Float
|
|
965
|
+
}),
|
|
966
|
+
});
|
|
1158
967
|
```
|
|
1159
968
|
|
|
1160
|
-
|
|
969
|
+
**Available Pre-built Scalars:**
|
|
970
|
+
- `scalars.EmailScalar` - Validates email format (`Email_String`)
|
|
971
|
+
- `scalars.URLScalar` - Validates URL format (`URL_String`)
|
|
972
|
+
- `scalars.PositiveIntScalar` - Validates positive integers (`PositiveInt_Int`)
|
|
973
|
+
- `scalars.PositiveFloatScalar` - Validates positive floats (`PositiveFloat_Float`)
|
|
1161
974
|
|
|
1162
|
-
|
|
975
|
+
#### Factory Functions for Custom Scalars
|
|
1163
976
|
|
|
1164
|
-
|
|
977
|
+
Create custom validated scalars with parameters:
|
|
1165
978
|
|
|
1166
979
|
```javascript
|
|
1167
|
-
simfinity
|
|
1168
|
-
```
|
|
980
|
+
const { scalars } = require('@simtlix/simfinity-js');
|
|
1169
981
|
|
|
1170
|
-
|
|
982
|
+
// Create a bounded string scalar (name length between 2-100 characters)
|
|
983
|
+
const NameScalar = scalars.createBoundedStringScalar('Name', 2, 100);
|
|
1171
984
|
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
| `connect()` | Types that need CRUD operations | ✅ Yes | User, Product, Order |
|
|
1175
|
-
| `addNoEndpointType()` | Types only used in relationships | ❌ No | Address, Settings, Director |
|
|
985
|
+
// Create a bounded integer scalar (age between 0-120)
|
|
986
|
+
const AgeScalar = scalars.createBoundedIntScalar('Age', 0, 120);
|
|
1176
987
|
|
|
1177
|
-
|
|
988
|
+
// Create a bounded float scalar (rating between 0-10)
|
|
989
|
+
const RatingScalar = scalars.createBoundedFloatScalar('Rating', 0, 10);
|
|
1178
990
|
|
|
1179
|
-
|
|
991
|
+
// Create a pattern-based string scalar (phone number format)
|
|
992
|
+
const PhoneScalar = scalars.createPatternStringScalar(
|
|
993
|
+
'Phone',
|
|
994
|
+
/^\+?[\d\s\-()]+$/,
|
|
995
|
+
'Invalid phone number format'
|
|
996
|
+
);
|
|
1180
997
|
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
name: 'director',
|
|
998
|
+
// Use in your types
|
|
999
|
+
const PersonType = new GraphQLObjectType({
|
|
1000
|
+
name: 'Person',
|
|
1185
1001
|
fields: () => ({
|
|
1186
1002
|
id: { type: GraphQLID },
|
|
1187
|
-
name: { type:
|
|
1188
|
-
|
|
1189
|
-
|
|
1003
|
+
name: { type: NameScalar }, // Type name: Name_String
|
|
1004
|
+
age: { type: AgeScalar }, // Type name: Age_Int
|
|
1005
|
+
rating: { type: RatingScalar }, // Type name: Rating_Float
|
|
1006
|
+
phone: { type: PhoneScalar } // Type name: Phone_String
|
|
1007
|
+
}),
|
|
1190
1008
|
});
|
|
1009
|
+
```
|
|
1191
1010
|
|
|
1192
|
-
|
|
1193
|
-
|
|
1011
|
+
**Available Factory Functions:**
|
|
1012
|
+
- `scalars.createBoundedStringScalar(name, min, max)` - String with length bounds
|
|
1013
|
+
- `scalars.createBoundedIntScalar(name, min, max)` - Integer with range validation
|
|
1014
|
+
- `scalars.createBoundedFloatScalar(name, min, max)` - Float with range validation
|
|
1015
|
+
- `scalars.createPatternStringScalar(name, pattern, message)` - String with regex pattern validation
|
|
1194
1016
|
|
|
1195
|
-
|
|
1196
|
-
const serieType = new GraphQLObjectType({
|
|
1197
|
-
name: 'serie',
|
|
1198
|
-
fields: () => ({
|
|
1199
|
-
id: { type: GraphQLID },
|
|
1200
|
-
name: { type: new GraphQLNonNull(GraphQLString) },
|
|
1201
|
-
categories: { type: new GraphQLList(GraphQLString) },
|
|
1202
|
-
director: {
|
|
1203
|
-
type: new GraphQLNonNull(directorType),
|
|
1204
|
-
extensions: {
|
|
1205
|
-
relation: {
|
|
1206
|
-
embedded: true, // Director data stored within serie document
|
|
1207
|
-
displayField: 'name'
|
|
1208
|
-
}
|
|
1209
|
-
}
|
|
1210
|
-
}
|
|
1211
|
-
})
|
|
1212
|
-
});
|
|
1017
|
+
#### Creating Custom Scalars Manually
|
|
1213
1018
|
|
|
1214
|
-
|
|
1215
|
-
simfinity.connect(null, serieType, 'serie', 'series');
|
|
1216
|
-
```
|
|
1019
|
+
You can also create custom scalars using `createValidatedScalar` directly:
|
|
1217
1020
|
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
- ❌ No `adddirector`, `director`, `directors` endpoints (director is embedded)
|
|
1021
|
+
```javascript
|
|
1022
|
+
const { GraphQLString, GraphQLInt } = require('graphql');
|
|
1023
|
+
const { createValidatedScalar } = require('@simtlix/simfinity-js');
|
|
1222
1024
|
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1025
|
+
// Email scalar with validation (generates type name: Email_String)
|
|
1026
|
+
const EmailScalar = createValidatedScalar(
|
|
1027
|
+
'Email',
|
|
1028
|
+
'A valid email address',
|
|
1029
|
+
GraphQLString,
|
|
1030
|
+
(value) => {
|
|
1031
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
1032
|
+
if (!emailRegex.test(value)) {
|
|
1033
|
+
throw new Error('Invalid email format');
|
|
1232
1034
|
}
|
|
1233
|
-
}
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1035
|
+
}
|
|
1036
|
+
);
|
|
1037
|
+
|
|
1038
|
+
// Positive integer scalar (generates type name: PositiveInt_Int)
|
|
1039
|
+
const PositiveIntScalar = createValidatedScalar(
|
|
1040
|
+
'PositiveInt',
|
|
1041
|
+
'A positive integer',
|
|
1042
|
+
GraphQLInt,
|
|
1043
|
+
(value) => {
|
|
1044
|
+
if (value <= 0) {
|
|
1045
|
+
throw new Error('Value must be positive');
|
|
1239
1046
|
}
|
|
1240
1047
|
}
|
|
1241
|
-
|
|
1048
|
+
);
|
|
1049
|
+
|
|
1050
|
+
// Use in your types
|
|
1051
|
+
const UserType = new GraphQLObjectType({
|
|
1052
|
+
name: 'User',
|
|
1053
|
+
fields: () => ({
|
|
1054
|
+
id: { type: GraphQLID },
|
|
1055
|
+
email: { type: EmailScalar }, // Type name: Email_String
|
|
1056
|
+
age: { type: PositiveIntScalar }, // Type name: PositiveInt_Int
|
|
1057
|
+
}),
|
|
1058
|
+
});
|
|
1242
1059
|
```
|
|
1243
1060
|
|
|
1244
|
-
|
|
1061
|
+
### Custom Error Classes
|
|
1245
1062
|
|
|
1246
|
-
|
|
1247
|
-
- Simple data objects with few fields
|
|
1248
|
-
- Data that doesn't need CRUD operations
|
|
1249
|
-
- Objects that belong to a single parent (1:1 relationships)
|
|
1250
|
-
- Configuration or settings objects
|
|
1251
|
-
- **Examples**: Address, Director info, Product specifications
|
|
1063
|
+
Create domain-specific error classes:
|
|
1252
1064
|
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
- Data that needs CRUD operations
|
|
1256
|
-
- Objects shared between multiple parents (many:many relationships)
|
|
1257
|
-
- Objects with business logic (controllers, state machines)
|
|
1258
|
-
- **Examples**: User, Product, Order, Season, Episode
|
|
1065
|
+
```javascript
|
|
1066
|
+
const { SimfinityError } = require('@simtlix/simfinity-js');
|
|
1259
1067
|
|
|
1260
|
-
|
|
1068
|
+
// Business logic error
|
|
1069
|
+
class BusinessError extends SimfinityError {
|
|
1070
|
+
constructor(message) {
|
|
1071
|
+
super(message, 'BUSINESS_ERROR', 400);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1261
1074
|
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
type: AuthorType,
|
|
1267
|
-
extensions: {
|
|
1268
|
-
relation: {
|
|
1269
|
-
// connectionField not needed for single object relationships
|
|
1270
|
-
embedded: false // This is the default
|
|
1271
|
-
}
|
|
1075
|
+
// Authorization error
|
|
1076
|
+
class AuthorizationError extends SimfinityError {
|
|
1077
|
+
constructor(message) {
|
|
1078
|
+
super(message, 'UNAUTHORIZED', 401);
|
|
1272
1079
|
}
|
|
1273
1080
|
}
|
|
1274
|
-
```
|
|
1275
1081
|
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
type: PublisherType,
|
|
1281
|
-
extensions: {
|
|
1282
|
-
relation: {
|
|
1283
|
-
embedded: true
|
|
1284
|
-
}
|
|
1082
|
+
// Not found error
|
|
1083
|
+
class NotFoundError extends SimfinityError {
|
|
1084
|
+
constructor(message) {
|
|
1085
|
+
super(message, 'NOT_FOUND', 404);
|
|
1285
1086
|
}
|
|
1286
1087
|
}
|
|
1287
1088
|
```
|
|
1288
1089
|
|
|
1289
|
-
|
|
1090
|
+
## 🔄 State Machines
|
|
1290
1091
|
|
|
1291
|
-
|
|
1092
|
+
Implement declarative state machine workflows:
|
|
1292
1093
|
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
author {
|
|
1307
|
-
name
|
|
1308
|
-
country {
|
|
1309
|
-
name
|
|
1310
|
-
}
|
|
1311
|
-
}
|
|
1094
|
+
### 1. Define States
|
|
1095
|
+
|
|
1096
|
+
```javascript
|
|
1097
|
+
const { GraphQLEnumType } = require('graphql');
|
|
1098
|
+
|
|
1099
|
+
const OrderState = new GraphQLEnumType({
|
|
1100
|
+
name: 'OrderState',
|
|
1101
|
+
values: {
|
|
1102
|
+
PENDING: { value: 'PENDING' },
|
|
1103
|
+
PROCESSING: { value: 'PROCESSING' },
|
|
1104
|
+
SHIPPED: { value: 'SHIPPED' },
|
|
1105
|
+
DELIVERED: { value: 'DELIVERED' },
|
|
1106
|
+
CANCELLED: { value: 'CANCELLED' }
|
|
1312
1107
|
}
|
|
1313
|
-
}
|
|
1108
|
+
});
|
|
1314
1109
|
```
|
|
1315
1110
|
|
|
1316
|
-
###
|
|
1111
|
+
### 2. Define Type with State Field
|
|
1317
1112
|
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
id
|
|
1328
|
-
title
|
|
1329
|
-
author {
|
|
1330
|
-
name
|
|
1331
|
-
}
|
|
1332
|
-
}
|
|
1333
|
-
}
|
|
1113
|
+
```javascript
|
|
1114
|
+
const OrderType = new GraphQLObjectType({
|
|
1115
|
+
name: 'Order',
|
|
1116
|
+
fields: () => ({
|
|
1117
|
+
id: { type: GraphQLID },
|
|
1118
|
+
customer: { type: GraphQLString },
|
|
1119
|
+
state: { type: OrderState }
|
|
1120
|
+
})
|
|
1121
|
+
});
|
|
1334
1122
|
```
|
|
1335
1123
|
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1124
|
+
### 3. Configure State Machine
|
|
1125
|
+
|
|
1126
|
+
```javascript
|
|
1127
|
+
const stateMachine = {
|
|
1128
|
+
initialState: { name: 'PENDING', value: 'PENDING' },
|
|
1129
|
+
actions: {
|
|
1130
|
+
process: {
|
|
1131
|
+
from: { name: 'PENDING', value: 'PENDING' },
|
|
1132
|
+
to: { name: 'PROCESSING', value: 'PROCESSING' },
|
|
1133
|
+
description: 'Process the order',
|
|
1134
|
+
action: async (args, session) => {
|
|
1135
|
+
// Business logic for processing
|
|
1136
|
+
console.log(`Processing order ${args.id}`);
|
|
1137
|
+
// You can perform additional operations here
|
|
1138
|
+
}
|
|
1139
|
+
},
|
|
1140
|
+
ship: {
|
|
1141
|
+
from: { name: 'PROCESSING', value: 'PROCESSING' },
|
|
1142
|
+
to: { name: 'SHIPPED', value: 'SHIPPED' },
|
|
1143
|
+
description: 'Ship the order',
|
|
1144
|
+
action: async (args, session) => {
|
|
1145
|
+
// Business logic for shipping
|
|
1146
|
+
console.log(`Shipping order ${args.id}`);
|
|
1147
|
+
}
|
|
1148
|
+
},
|
|
1149
|
+
deliver: {
|
|
1150
|
+
from: { name: 'SHIPPED', value: 'SHIPPED' },
|
|
1151
|
+
to: { name: 'DELIVERED', value: 'DELIVERED' },
|
|
1152
|
+
description: 'Mark as delivered'
|
|
1153
|
+
},
|
|
1154
|
+
cancel: {
|
|
1155
|
+
from: { name: 'PENDING', value: 'PENDING' },
|
|
1156
|
+
to: { name: 'CANCELLED', value: 'CANCELLED' },
|
|
1157
|
+
description: 'Cancel the order'
|
|
1351
1158
|
}
|
|
1352
1159
|
}
|
|
1353
|
-
}
|
|
1160
|
+
};
|
|
1354
1161
|
```
|
|
1355
1162
|
|
|
1356
|
-
###
|
|
1163
|
+
### 4. Connect with State Machine
|
|
1357
1164
|
|
|
1358
|
-
|
|
1165
|
+
```javascript
|
|
1166
|
+
simfinity.connect(null, OrderType, 'order', 'orders', null, null, stateMachine);
|
|
1167
|
+
```
|
|
1168
|
+
|
|
1169
|
+
### 5. Use State Machine Mutations
|
|
1170
|
+
|
|
1171
|
+
The state machine automatically generates mutations for each action:
|
|
1359
1172
|
|
|
1360
1173
|
```graphql
|
|
1361
1174
|
mutation {
|
|
1362
|
-
|
|
1363
|
-
id: "
|
|
1364
|
-
reviews: {
|
|
1365
|
-
added: [
|
|
1366
|
-
{ rating: 5, comment: "Amazing!" }
|
|
1367
|
-
{ rating: 4, comment: "Good read" }
|
|
1368
|
-
]
|
|
1369
|
-
updated: [
|
|
1370
|
-
{ id: "review_id", rating: 3 }
|
|
1371
|
-
]
|
|
1372
|
-
deleted: ["review_id_to_delete"]
|
|
1373
|
-
}
|
|
1175
|
+
process_order(input: {
|
|
1176
|
+
id: "order_id"
|
|
1374
1177
|
}) {
|
|
1375
1178
|
id
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
rating
|
|
1379
|
-
comment
|
|
1380
|
-
}
|
|
1179
|
+
state
|
|
1180
|
+
customer
|
|
1381
1181
|
}
|
|
1382
1182
|
}
|
|
1383
1183
|
```
|
|
1384
1184
|
|
|
1185
|
+
**Important Notes**:
|
|
1186
|
+
- The `state` field is automatically read-only and managed by the state machine
|
|
1187
|
+
- State transitions are only allowed based on the defined actions
|
|
1188
|
+
- Business logic in the `action` function is executed during transitions
|
|
1189
|
+
- Invalid transitions throw errors automatically
|
|
1190
|
+
|
|
1385
1191
|
## 🎛️ Controllers & Lifecycle Hooks
|
|
1386
1192
|
|
|
1387
1193
|
Controllers provide fine-grained control over operations with lifecycle hooks:
|
|
@@ -1482,781 +1288,956 @@ const documentController = {
|
|
|
1482
1288
|
if (context && context.user) {
|
|
1483
1289
|
doc.owner = context.user.id;
|
|
1484
1290
|
}
|
|
1485
|
-
}
|
|
1486
|
-
};
|
|
1291
|
+
}
|
|
1292
|
+
};
|
|
1293
|
+
```
|
|
1294
|
+
|
|
1295
|
+
**Example: Role-Based Authorization**
|
|
1296
|
+
|
|
1297
|
+
```javascript
|
|
1298
|
+
const adminOnlyController = {
|
|
1299
|
+
onUpdating: async (id, doc, session, context) => {
|
|
1300
|
+
if (!context || !context.user || context.user.role !== 'admin') {
|
|
1301
|
+
throw new simfinity.SimfinityError('Admin access required', 'FORBIDDEN', 403);
|
|
1302
|
+
}
|
|
1303
|
+
},
|
|
1304
|
+
|
|
1305
|
+
onDelete: async (doc, session, context) => {
|
|
1306
|
+
if (!context || !context.user || context.user.role !== 'admin') {
|
|
1307
|
+
throw new simfinity.SimfinityError('Admin access required', 'FORBIDDEN', 403);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
};
|
|
1311
|
+
```
|
|
1312
|
+
|
|
1313
|
+
**Note**: When using `saveObject` programmatically (outside of GraphQL), the `context` parameter is optional and may be `undefined`. Always check for context existence before accessing its properties.
|
|
1314
|
+
|
|
1315
|
+
## 🔒 Query Scope
|
|
1316
|
+
|
|
1317
|
+
### Overview
|
|
1318
|
+
|
|
1319
|
+
Query scope allows you to automatically modify query arguments based on context (e.g., user permissions). This enables automatic filtering so that users can only see documents they're authorized to access. Scope functions are executed after middleware and before query execution, allowing you to append filter conditions to queries and aggregations.
|
|
1320
|
+
|
|
1321
|
+
### Defining Scope
|
|
1322
|
+
|
|
1323
|
+
Define scope in the type extensions, similar to how validations are defined:
|
|
1324
|
+
|
|
1325
|
+
```javascript
|
|
1326
|
+
const EpisodeType = new GraphQLObjectType({
|
|
1327
|
+
name: 'episode',
|
|
1328
|
+
extensions: {
|
|
1329
|
+
validations: {
|
|
1330
|
+
create: [validateEpisodeFields],
|
|
1331
|
+
update: [validateEpisodeBusinessRules]
|
|
1332
|
+
},
|
|
1333
|
+
scope: {
|
|
1334
|
+
find: async ({ type, args, operation, context }) => {
|
|
1335
|
+
// Modify args in place to add filter conditions
|
|
1336
|
+
args.owner = {
|
|
1337
|
+
terms: [
|
|
1338
|
+
{
|
|
1339
|
+
path: 'id',
|
|
1340
|
+
operator: 'EQ',
|
|
1341
|
+
value: context.user.id
|
|
1342
|
+
}
|
|
1343
|
+
]
|
|
1344
|
+
};
|
|
1345
|
+
},
|
|
1346
|
+
aggregate: async ({ type, args, operation, context }) => {
|
|
1347
|
+
// Apply same scope to aggregate queries
|
|
1348
|
+
args.owner = {
|
|
1349
|
+
terms: [
|
|
1350
|
+
{
|
|
1351
|
+
path: 'id',
|
|
1352
|
+
operator: 'EQ',
|
|
1353
|
+
value: context.user.id
|
|
1354
|
+
}
|
|
1355
|
+
]
|
|
1356
|
+
};
|
|
1357
|
+
},
|
|
1358
|
+
get_by_id: async ({ type, args, operation, context }) => {
|
|
1359
|
+
// For get_by_id, scope is automatically merged with id filter
|
|
1360
|
+
args.owner = {
|
|
1361
|
+
terms: [
|
|
1362
|
+
{
|
|
1363
|
+
path: 'id',
|
|
1364
|
+
operator: 'EQ',
|
|
1365
|
+
value: context.user.id
|
|
1366
|
+
}
|
|
1367
|
+
]
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
},
|
|
1372
|
+
fields: () => ({
|
|
1373
|
+
id: { type: GraphQLID },
|
|
1374
|
+
name: { type: GraphQLString },
|
|
1375
|
+
owner: {
|
|
1376
|
+
type: new GraphQLNonNull(simfinity.getType('user')),
|
|
1377
|
+
extensions: {
|
|
1378
|
+
relation: {
|
|
1379
|
+
connectionField: 'owner',
|
|
1380
|
+
displayField: 'name'
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
})
|
|
1385
|
+
});
|
|
1487
1386
|
```
|
|
1488
1387
|
|
|
1489
|
-
|
|
1388
|
+
### Scope for Find Operations
|
|
1389
|
+
|
|
1390
|
+
Scope functions for `find` operations modify the query arguments that are passed to `buildQuery`. The modified arguments are automatically used to filter results:
|
|
1490
1391
|
|
|
1491
1392
|
```javascript
|
|
1492
|
-
const
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1393
|
+
const DocumentType = new GraphQLObjectType({
|
|
1394
|
+
name: 'Document',
|
|
1395
|
+
extensions: {
|
|
1396
|
+
scope: {
|
|
1397
|
+
find: async ({ type, args, operation, context }) => {
|
|
1398
|
+
// Only show documents owned by the current user
|
|
1399
|
+
args.owner = {
|
|
1400
|
+
terms: [
|
|
1401
|
+
{
|
|
1402
|
+
path: 'id',
|
|
1403
|
+
operator: 'EQ',
|
|
1404
|
+
value: context.user.id
|
|
1405
|
+
}
|
|
1406
|
+
]
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1496
1409
|
}
|
|
1497
1410
|
},
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1411
|
+
fields: () => ({
|
|
1412
|
+
id: { type: GraphQLID },
|
|
1413
|
+
title: { type: GraphQLString },
|
|
1414
|
+
owner: {
|
|
1415
|
+
type: new GraphQLNonNull(simfinity.getType('user')),
|
|
1416
|
+
extensions: {
|
|
1417
|
+
relation: {
|
|
1418
|
+
connectionField: 'owner',
|
|
1419
|
+
displayField: 'name'
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1502
1422
|
}
|
|
1503
|
-
}
|
|
1504
|
-
};
|
|
1423
|
+
})
|
|
1424
|
+
});
|
|
1505
1425
|
```
|
|
1506
1426
|
|
|
1507
|
-
**
|
|
1508
|
-
|
|
1509
|
-
## 🔄 State Machines
|
|
1510
|
-
|
|
1511
|
-
Implement declarative state machine workflows:
|
|
1512
|
-
|
|
1513
|
-
### 1. Define States
|
|
1514
|
-
|
|
1515
|
-
```javascript
|
|
1516
|
-
const { GraphQLEnumType } = require('graphql');
|
|
1427
|
+
**Result**: All `documents` queries will automatically filter to only return documents where `owner.id` equals `context.user.id`.
|
|
1517
1428
|
|
|
1518
|
-
|
|
1519
|
-
name: 'OrderState',
|
|
1520
|
-
values: {
|
|
1521
|
-
PENDING: { value: 'PENDING' },
|
|
1522
|
-
PROCESSING: { value: 'PROCESSING' },
|
|
1523
|
-
SHIPPED: { value: 'SHIPPED' },
|
|
1524
|
-
DELIVERED: { value: 'DELIVERED' },
|
|
1525
|
-
CANCELLED: { value: 'CANCELLED' }
|
|
1526
|
-
}
|
|
1527
|
-
});
|
|
1528
|
-
```
|
|
1429
|
+
### Scope for Aggregate Operations
|
|
1529
1430
|
|
|
1530
|
-
|
|
1431
|
+
Scope functions for `aggregate` operations work the same way, ensuring aggregation queries also respect the scope:
|
|
1531
1432
|
|
|
1532
1433
|
```javascript
|
|
1533
1434
|
const OrderType = new GraphQLObjectType({
|
|
1534
1435
|
name: 'Order',
|
|
1436
|
+
extensions: {
|
|
1437
|
+
scope: {
|
|
1438
|
+
aggregate: async ({ type, args, operation, context }) => {
|
|
1439
|
+
// Only aggregate orders for the current user's organization
|
|
1440
|
+
args.organization = {
|
|
1441
|
+
terms: [
|
|
1442
|
+
{
|
|
1443
|
+
path: 'id',
|
|
1444
|
+
operator: 'EQ',
|
|
1445
|
+
value: context.user.organizationId
|
|
1446
|
+
}
|
|
1447
|
+
]
|
|
1448
|
+
};
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
},
|
|
1535
1452
|
fields: () => ({
|
|
1536
|
-
|
|
1537
|
-
customer: { type: GraphQLString },
|
|
1538
|
-
state: { type: OrderState }
|
|
1453
|
+
// ... fields
|
|
1539
1454
|
})
|
|
1540
1455
|
});
|
|
1541
1456
|
```
|
|
1542
1457
|
|
|
1543
|
-
|
|
1458
|
+
**Result**: All `orders_aggregate` queries will automatically filter to only aggregate orders from the user's organization.
|
|
1459
|
+
|
|
1460
|
+
### Scope for Get By ID Operations
|
|
1461
|
+
|
|
1462
|
+
For `get_by_id` operations, scope functions modify a temporary query arguments object that includes the id filter. The system automatically combines the id filter with scope filters:
|
|
1544
1463
|
|
|
1545
1464
|
```javascript
|
|
1546
|
-
const
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
to: { name: 'SHIPPED', value: 'SHIPPED' },
|
|
1562
|
-
description: 'Ship the order',
|
|
1563
|
-
action: async (args, session) => {
|
|
1564
|
-
// Business logic for shipping
|
|
1565
|
-
console.log(`Shipping order ${args.id}`);
|
|
1465
|
+
const PrivateDocumentType = new GraphQLObjectType({
|
|
1466
|
+
name: 'PrivateDocument',
|
|
1467
|
+
extensions: {
|
|
1468
|
+
scope: {
|
|
1469
|
+
get_by_id: async ({ type, args, operation, context }) => {
|
|
1470
|
+
// Ensure user can only access their own documents
|
|
1471
|
+
args.owner = {
|
|
1472
|
+
terms: [
|
|
1473
|
+
{
|
|
1474
|
+
path: 'id',
|
|
1475
|
+
operator: 'EQ',
|
|
1476
|
+
value: context.user.id
|
|
1477
|
+
}
|
|
1478
|
+
]
|
|
1479
|
+
};
|
|
1566
1480
|
}
|
|
1567
|
-
},
|
|
1568
|
-
deliver: {
|
|
1569
|
-
from: { name: 'SHIPPED', value: 'SHIPPED' },
|
|
1570
|
-
to: { name: 'DELIVERED', value: 'DELIVERED' },
|
|
1571
|
-
description: 'Mark as delivered'
|
|
1572
|
-
},
|
|
1573
|
-
cancel: {
|
|
1574
|
-
from: { name: 'PENDING', value: 'PENDING' },
|
|
1575
|
-
to: { name: 'CANCELLED', value: 'CANCELLED' },
|
|
1576
|
-
description: 'Cancel the order'
|
|
1577
1481
|
}
|
|
1578
|
-
}
|
|
1579
|
-
|
|
1482
|
+
},
|
|
1483
|
+
fields: () => ({
|
|
1484
|
+
// ... fields
|
|
1485
|
+
})
|
|
1486
|
+
});
|
|
1580
1487
|
```
|
|
1581
1488
|
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
```
|
|
1489
|
+
**Result**: When querying `privatedocument(id: "some_id")`, the system will:
|
|
1490
|
+
1. Create a query that includes both the id filter and the owner scope filter
|
|
1491
|
+
2. Only return the document if it matches both conditions
|
|
1492
|
+
3. Return `null` if the document exists but doesn't match the scope
|
|
1587
1493
|
|
|
1588
|
-
###
|
|
1494
|
+
### Scope Function Parameters
|
|
1589
1495
|
|
|
1590
|
-
|
|
1496
|
+
Scope functions receive the same parameters as middleware for consistency:
|
|
1591
1497
|
|
|
1592
|
-
```
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
state
|
|
1599
|
-
customer
|
|
1600
|
-
}
|
|
1498
|
+
```javascript
|
|
1499
|
+
{
|
|
1500
|
+
type, // Type information (model, gqltype, controller, etc.)
|
|
1501
|
+
args, // GraphQL arguments passed to the operation (modify this object)
|
|
1502
|
+
operation, // Operation type: 'find', 'aggregate', or 'get_by_id'
|
|
1503
|
+
context // GraphQL context object (includes request info, user data, etc.)
|
|
1601
1504
|
}
|
|
1602
1505
|
```
|
|
1603
1506
|
|
|
1604
|
-
|
|
1605
|
-
- The `state` field is automatically read-only and managed by the state machine
|
|
1606
|
-
- State transitions are only allowed based on the defined actions
|
|
1607
|
-
- Business logic in the `action` function is executed during transitions
|
|
1608
|
-
- Invalid transitions throw errors automatically
|
|
1507
|
+
### Filter Structure
|
|
1609
1508
|
|
|
1610
|
-
|
|
1509
|
+
When modifying `args` in scope functions, use the appropriate filter structure:
|
|
1611
1510
|
|
|
1612
|
-
|
|
1511
|
+
**For scalar fields:**
|
|
1512
|
+
```javascript
|
|
1513
|
+
args.fieldName = {
|
|
1514
|
+
operator: 'EQ',
|
|
1515
|
+
value: 'someValue'
|
|
1516
|
+
};
|
|
1517
|
+
```
|
|
1613
1518
|
|
|
1614
|
-
|
|
1519
|
+
**For object/relation fields (QLTypeFilterExpression):**
|
|
1520
|
+
```javascript
|
|
1521
|
+
args.relationField = {
|
|
1522
|
+
terms: [
|
|
1523
|
+
{
|
|
1524
|
+
path: 'fieldName',
|
|
1525
|
+
operator: 'EQ',
|
|
1526
|
+
value: 'someValue'
|
|
1527
|
+
}
|
|
1528
|
+
]
|
|
1529
|
+
};
|
|
1530
|
+
```
|
|
1615
1531
|
|
|
1616
|
-
|
|
1532
|
+
### Complete Example
|
|
1617
1533
|
|
|
1618
|
-
|
|
1619
|
-
const { validators } = require('@simtlix/simfinity-js');
|
|
1534
|
+
Here's a complete example showing scope for all query operations:
|
|
1620
1535
|
|
|
1621
|
-
|
|
1622
|
-
|
|
1536
|
+
```javascript
|
|
1537
|
+
const EpisodeType = new GraphQLObjectType({
|
|
1538
|
+
name: 'episode',
|
|
1539
|
+
extensions: {
|
|
1540
|
+
validations: {
|
|
1541
|
+
save: [validateEpisodeFields],
|
|
1542
|
+
update: [validateEpisodeBusinessRules]
|
|
1543
|
+
},
|
|
1544
|
+
scope: {
|
|
1545
|
+
find: async ({ type, args, operation, context }) => {
|
|
1546
|
+
// Only show episodes from seasons the user has access to
|
|
1547
|
+
args.season = {
|
|
1548
|
+
terms: [
|
|
1549
|
+
{
|
|
1550
|
+
path: 'owner.id',
|
|
1551
|
+
operator: 'EQ',
|
|
1552
|
+
value: context.user.id
|
|
1553
|
+
}
|
|
1554
|
+
]
|
|
1555
|
+
};
|
|
1556
|
+
},
|
|
1557
|
+
aggregate: async ({ type, args, operation, context }) => {
|
|
1558
|
+
// Apply same scope to aggregations
|
|
1559
|
+
args.season = {
|
|
1560
|
+
terms: [
|
|
1561
|
+
{
|
|
1562
|
+
path: 'owner.id',
|
|
1563
|
+
operator: 'EQ',
|
|
1564
|
+
value: context.user.id
|
|
1565
|
+
}
|
|
1566
|
+
]
|
|
1567
|
+
};
|
|
1568
|
+
},
|
|
1569
|
+
get_by_id: async ({ type, args, operation, context }) => {
|
|
1570
|
+
// Ensure user can only access their own episodes
|
|
1571
|
+
args.owner = {
|
|
1572
|
+
terms: [
|
|
1573
|
+
{
|
|
1574
|
+
path: 'id',
|
|
1575
|
+
operator: 'EQ',
|
|
1576
|
+
value: context.user.id
|
|
1577
|
+
}
|
|
1578
|
+
]
|
|
1579
|
+
};
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
},
|
|
1623
1583
|
fields: () => ({
|
|
1624
1584
|
id: { type: GraphQLID },
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
}
|
|
1630
|
-
},
|
|
1631
|
-
email: {
|
|
1632
|
-
type: GraphQLString,
|
|
1633
|
-
extensions: {
|
|
1634
|
-
validations: validators.email()
|
|
1635
|
-
}
|
|
1636
|
-
},
|
|
1637
|
-
website: {
|
|
1638
|
-
type: GraphQLString,
|
|
1639
|
-
extensions: {
|
|
1640
|
-
validations: validators.url()
|
|
1641
|
-
}
|
|
1642
|
-
},
|
|
1643
|
-
age: {
|
|
1644
|
-
type: GraphQLInt,
|
|
1585
|
+
number: { type: GraphQLInt },
|
|
1586
|
+
name: { type: GraphQLString },
|
|
1587
|
+
season: {
|
|
1588
|
+
type: new GraphQLNonNull(simfinity.getType('season')),
|
|
1645
1589
|
extensions: {
|
|
1646
|
-
|
|
1590
|
+
relation: {
|
|
1591
|
+
connectionField: 'season',
|
|
1592
|
+
displayField: 'number'
|
|
1593
|
+
}
|
|
1647
1594
|
}
|
|
1648
1595
|
},
|
|
1649
|
-
|
|
1650
|
-
type:
|
|
1596
|
+
owner: {
|
|
1597
|
+
type: new GraphQLNonNull(simfinity.getType('user')),
|
|
1651
1598
|
extensions: {
|
|
1652
|
-
|
|
1599
|
+
relation: {
|
|
1600
|
+
connectionField: 'owner',
|
|
1601
|
+
displayField: 'name'
|
|
1602
|
+
}
|
|
1653
1603
|
}
|
|
1654
1604
|
}
|
|
1655
1605
|
})
|
|
1656
1606
|
});
|
|
1657
1607
|
```
|
|
1658
1608
|
|
|
1659
|
-
|
|
1609
|
+
### Important Notes
|
|
1660
1610
|
|
|
1661
|
-
**
|
|
1662
|
-
-
|
|
1663
|
-
-
|
|
1664
|
-
-
|
|
1665
|
-
- `
|
|
1666
|
-
- `
|
|
1611
|
+
- **Execution Order**: Scope functions are executed **after** middleware, so middleware can set up context (e.g., user info) that scope functions can use
|
|
1612
|
+
- **Modify Args In Place**: Scope functions should modify the `args` object directly
|
|
1613
|
+
- **Filter Structure**: Use the correct filter structure (`QLFilter` for scalars, `QLTypeFilterExpression` for relations)
|
|
1614
|
+
- **All Query Operations**: Scope applies to `find`, `aggregate`, and `get_by_id` operations
|
|
1615
|
+
- **Automatic Merging**: For `get_by_id`, the id filter is automatically combined with scope filters
|
|
1616
|
+
- **Context Access**: Use `context.user`, `context.ip`, or other context properties to determine scope
|
|
1667
1617
|
|
|
1668
|
-
|
|
1669
|
-
- `validators.numberRange(name, min, max)` - Validates number range
|
|
1670
|
-
- `validators.positive(name)` - Ensures number is positive
|
|
1618
|
+
### Use Cases
|
|
1671
1619
|
|
|
1672
|
-
**
|
|
1673
|
-
-
|
|
1620
|
+
- **Multi-tenancy**: Filter documents by organization or tenant
|
|
1621
|
+
- **User-specific data**: Only show documents owned by the current user
|
|
1622
|
+
- **Role-based access**: Filter based on user roles or permissions
|
|
1623
|
+
- **Department/Team scoping**: Show only data relevant to user's department
|
|
1624
|
+
- **Geographic scoping**: Filter by user's location or region
|
|
1674
1625
|
|
|
1675
|
-
|
|
1676
|
-
- `validators.dateFormat(name, format)` - Validates date format
|
|
1677
|
-
- `validators.futureDate(name)` - Ensures date is in the future
|
|
1626
|
+
## 🔐 Authorization
|
|
1678
1627
|
|
|
1679
|
-
|
|
1628
|
+
Simfinity.js provides production-grade centralized GraphQL authorization supporting RBAC/ABAC, function-based rules, declarative policy expressions (JSON AST), wildcard permissions, and configurable default policies. It ships as a native Envelop plugin for GraphQL Yoga (recommended) and also supports the legacy graphql-middleware approach.
|
|
1680
1629
|
|
|
1681
|
-
|
|
1682
|
-
- **Smart Validation**: For CREATE operations, values are required. For UPDATE operations, undefined/null values are allowed (field might not be updated)
|
|
1683
|
-
- **Consistent Error Messages**: All validators throw `SimfinityError` with appropriate messages
|
|
1630
|
+
### Quick Start
|
|
1684
1631
|
|
|
1685
|
-
|
|
1632
|
+
```javascript
|
|
1633
|
+
const { auth } = require('@simtlix/simfinity-js');
|
|
1634
|
+
const { createYoga } = require('graphql-yoga');
|
|
1635
|
+
|
|
1636
|
+
const { createAuthPlugin, requireAuth, requireRole } = auth;
|
|
1637
|
+
|
|
1638
|
+
// Define your permission schema
|
|
1639
|
+
// Query/Mutation names match the ones generated by simfinity.connect()
|
|
1640
|
+
const permissions = {
|
|
1641
|
+
Query: {
|
|
1642
|
+
series: requireAuth(),
|
|
1643
|
+
seasons: requireAuth(),
|
|
1644
|
+
},
|
|
1645
|
+
Mutation: {
|
|
1646
|
+
deleteserie: requireRole('admin'),
|
|
1647
|
+
deletestar: requireRole('admin'),
|
|
1648
|
+
},
|
|
1649
|
+
serie: {
|
|
1650
|
+
'*': requireAuth(), // Wildcard: all fields require auth
|
|
1651
|
+
},
|
|
1652
|
+
};
|
|
1653
|
+
|
|
1654
|
+
// Create the Envelop auth plugin and pass it to your server
|
|
1655
|
+
const authPlugin = createAuthPlugin(permissions, { defaultPolicy: 'ALLOW' });
|
|
1656
|
+
const yoga = createYoga({ schema, plugins: [authPlugin] });
|
|
1657
|
+
```
|
|
1658
|
+
|
|
1659
|
+
### Permission Schema
|
|
1660
|
+
|
|
1661
|
+
The permission schema defines authorization rules per type and field:
|
|
1686
1662
|
|
|
1687
1663
|
```javascript
|
|
1688
|
-
const
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
extensions: {
|
|
1701
|
-
validations: validators.pattern('SKU', /^[A-Z0-9-]+$/, 'SKU must be uppercase alphanumeric with hyphens')
|
|
1702
|
-
}
|
|
1703
|
-
},
|
|
1704
|
-
price: {
|
|
1705
|
-
type: GraphQLFloat,
|
|
1706
|
-
extensions: {
|
|
1707
|
-
validations: validators.positive('Price')
|
|
1708
|
-
}
|
|
1709
|
-
},
|
|
1710
|
-
tags: {
|
|
1711
|
-
type: new GraphQLList(GraphQLString),
|
|
1712
|
-
extensions: {
|
|
1713
|
-
validations: validators.arrayLength('Tags', 10)
|
|
1714
|
-
}
|
|
1715
|
-
}
|
|
1716
|
-
})
|
|
1717
|
-
});
|
|
1664
|
+
const permissions = {
|
|
1665
|
+
// Operation types (Query, Mutation, Subscription)
|
|
1666
|
+
Query: {
|
|
1667
|
+
fieldName: ruleOrRules,
|
|
1668
|
+
},
|
|
1669
|
+
|
|
1670
|
+
// Object types
|
|
1671
|
+
TypeName: {
|
|
1672
|
+
'*': wildcardRule, // Applies to all fields unless overridden
|
|
1673
|
+
fieldName: specificRule, // Overrides wildcard for this field
|
|
1674
|
+
},
|
|
1675
|
+
};
|
|
1718
1676
|
```
|
|
1719
1677
|
|
|
1720
|
-
|
|
1678
|
+
**Resolution Order:**
|
|
1679
|
+
1. Check exact field rule: `permissions[TypeName][fieldName]`
|
|
1680
|
+
2. Fallback to wildcard: `permissions[TypeName]['*']`
|
|
1681
|
+
3. Apply default policy (ALLOW or DENY)
|
|
1721
1682
|
|
|
1722
|
-
|
|
1683
|
+
**Rule Types:**
|
|
1684
|
+
- **Function**: `(parent, args, ctx, info) => boolean | void | Promise<boolean | void>`
|
|
1685
|
+
- **Array of functions**: All rules must pass (AND logic)
|
|
1686
|
+
- **Policy expression**: JSON AST object (see below)
|
|
1687
|
+
|
|
1688
|
+
**Rule Semantics:**
|
|
1689
|
+
- `return true` or `return void` → allow
|
|
1690
|
+
- `return false` → deny
|
|
1691
|
+
- `throw Error` → deny with error
|
|
1692
|
+
|
|
1693
|
+
### Rule Helpers
|
|
1694
|
+
|
|
1695
|
+
Simfinity.js provides reusable rule builders:
|
|
1723
1696
|
|
|
1724
1697
|
```javascript
|
|
1725
|
-
const {
|
|
1698
|
+
const { auth } = require('@simtlix/simfinity-js');
|
|
1726
1699
|
|
|
1727
|
-
const
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1700
|
+
const {
|
|
1701
|
+
resolvePath, // Utility to resolve dotted paths in objects
|
|
1702
|
+
requireAuth, // Requires ctx.user to exist
|
|
1703
|
+
requireRole, // Requires specific role(s)
|
|
1704
|
+
requirePermission, // Requires specific permission(s)
|
|
1705
|
+
composeRules, // Combine rules (AND logic)
|
|
1706
|
+
anyRule, // Combine rules (OR logic)
|
|
1707
|
+
isOwner, // Check resource ownership
|
|
1708
|
+
allow, // Always allow
|
|
1709
|
+
deny, // Always deny
|
|
1710
|
+
createRule, // Create custom rule
|
|
1711
|
+
} = auth;
|
|
1712
|
+
```
|
|
1713
|
+
|
|
1714
|
+
#### requireAuth(userPath?)
|
|
1715
|
+
|
|
1716
|
+
Requires the user to be authenticated. Supports custom user paths in context:
|
|
1717
|
+
|
|
1718
|
+
```javascript
|
|
1719
|
+
const permissions = {
|
|
1720
|
+
Query: {
|
|
1721
|
+
// Default: checks ctx.user
|
|
1722
|
+
me: requireAuth(),
|
|
1723
|
+
|
|
1724
|
+
// Custom path: checks ctx.auth.currentUser
|
|
1725
|
+
profile: requireAuth('auth.currentUser'),
|
|
1726
|
+
|
|
1727
|
+
// Deep path: checks ctx.session.data.user
|
|
1728
|
+
settings: requireAuth('session.data.user'),
|
|
1729
|
+
},
|
|
1733
1730
|
};
|
|
1731
|
+
```
|
|
1734
1732
|
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
throw new SimfinityError('Name must be at least 2 characters', 'VALIDATION_ERROR', 400);
|
|
1754
|
-
}
|
|
1755
|
-
}
|
|
1756
|
-
}]
|
|
1757
|
-
}
|
|
1758
|
-
}
|
|
1759
|
-
},
|
|
1760
|
-
age: {
|
|
1761
|
-
type: GraphQLInt,
|
|
1762
|
-
extensions: {
|
|
1763
|
-
validations: {
|
|
1764
|
-
save: [validateAge],
|
|
1765
|
-
update: [validateAge]
|
|
1766
|
-
}
|
|
1767
|
-
}
|
|
1768
|
-
}
|
|
1769
|
-
})
|
|
1770
|
-
});
|
|
1733
|
+
#### requireRole(role, options?)
|
|
1734
|
+
|
|
1735
|
+
Requires the user to have a specific role. Supports custom paths:
|
|
1736
|
+
|
|
1737
|
+
```javascript
|
|
1738
|
+
const permissions = {
|
|
1739
|
+
Query: {
|
|
1740
|
+
// Default: checks ctx.user.role
|
|
1741
|
+
adminDashboard: requireRole('ADMIN'),
|
|
1742
|
+
modTools: requireRole(['ADMIN', 'MODERATOR']), // Any of these roles
|
|
1743
|
+
|
|
1744
|
+
// Custom paths: checks ctx.auth.user.profile.role
|
|
1745
|
+
superAdmin: requireRole('SUPER_ADMIN', {
|
|
1746
|
+
userPath: 'auth.user',
|
|
1747
|
+
rolePath: 'profile.role',
|
|
1748
|
+
}),
|
|
1749
|
+
},
|
|
1750
|
+
};
|
|
1771
1751
|
```
|
|
1772
1752
|
|
|
1773
|
-
|
|
1753
|
+
#### requirePermission(permission, options?)
|
|
1774
1754
|
|
|
1775
|
-
|
|
1755
|
+
Requires the user to have specific permission(s). Supports custom paths:
|
|
1776
1756
|
|
|
1777
1757
|
```javascript
|
|
1778
|
-
const
|
|
1779
|
-
|
|
1780
|
-
//
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
}
|
|
1758
|
+
const permissions = {
|
|
1759
|
+
Mutation: {
|
|
1760
|
+
// Default: checks ctx.user.permissions
|
|
1761
|
+
deletePost: requirePermission('posts:delete'),
|
|
1762
|
+
manageUsers: requirePermission(['users:read', 'users:write']), // All required
|
|
1784
1763
|
|
|
1785
|
-
//
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1764
|
+
// Custom paths: checks ctx.session.user.access.grants
|
|
1765
|
+
admin: requirePermission('admin:all', {
|
|
1766
|
+
userPath: 'session.user',
|
|
1767
|
+
permissionsPath: 'access.grants',
|
|
1768
|
+
}),
|
|
1769
|
+
},
|
|
1790
1770
|
};
|
|
1771
|
+
```
|
|
1791
1772
|
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1773
|
+
#### composeRules(...rules)
|
|
1774
|
+
|
|
1775
|
+
Combines multiple rules with AND logic (all must pass):
|
|
1776
|
+
|
|
1777
|
+
```javascript
|
|
1778
|
+
const permissions = {
|
|
1779
|
+
Mutation: {
|
|
1780
|
+
updatePost: composeRules(
|
|
1781
|
+
requireAuth(),
|
|
1782
|
+
requireRole('EDITOR'),
|
|
1783
|
+
async (post, args, ctx) => post.authorId === ctx.user.id,
|
|
1784
|
+
),
|
|
1799
1785
|
},
|
|
1800
|
-
|
|
1801
|
-
// ... fields
|
|
1802
|
-
})
|
|
1803
|
-
});
|
|
1786
|
+
};
|
|
1804
1787
|
```
|
|
1805
1788
|
|
|
1806
|
-
|
|
1789
|
+
#### anyRule(...rules)
|
|
1807
1790
|
|
|
1808
|
-
|
|
1791
|
+
Combines multiple rules with OR logic (any must pass):
|
|
1809
1792
|
|
|
1810
|
-
|
|
1793
|
+
```javascript
|
|
1794
|
+
const permissions = {
|
|
1795
|
+
Post: {
|
|
1796
|
+
content: anyRule(
|
|
1797
|
+
requireRole('ADMIN'),
|
|
1798
|
+
async (post, args, ctx) => post.authorId === ctx.user.id,
|
|
1799
|
+
),
|
|
1800
|
+
},
|
|
1801
|
+
};
|
|
1802
|
+
```
|
|
1811
1803
|
|
|
1812
|
-
|
|
1804
|
+
#### isOwner(ownerField, userIdField)
|
|
1805
|
+
|
|
1806
|
+
Checks if the authenticated user owns the resource:
|
|
1813
1807
|
|
|
1814
1808
|
```javascript
|
|
1815
|
-
const
|
|
1809
|
+
const permissions = {
|
|
1810
|
+
Post: {
|
|
1811
|
+
'*': composeRules(
|
|
1812
|
+
requireAuth(),
|
|
1813
|
+
isOwner('authorId', 'id'), // Compares post.authorId with ctx.user.id
|
|
1814
|
+
),
|
|
1815
|
+
},
|
|
1816
|
+
};
|
|
1817
|
+
```
|
|
1816
1818
|
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
}
|
|
1819
|
+
### Policy Expressions (JSON AST)
|
|
1820
|
+
|
|
1821
|
+
For declarative rules, use JSON AST policy expressions:
|
|
1822
|
+
|
|
1823
|
+
```javascript
|
|
1824
|
+
const permissions = {
|
|
1825
|
+
Post: {
|
|
1826
|
+
content: {
|
|
1827
|
+
anyOf: [
|
|
1828
|
+
{ eq: [{ ref: 'parent.published' }, true] },
|
|
1829
|
+
{ eq: [{ ref: 'parent.authorId' }, { ref: 'ctx.user.id' }] },
|
|
1830
|
+
],
|
|
1831
|
+
},
|
|
1832
|
+
},
|
|
1833
|
+
};
|
|
1827
1834
|
```
|
|
1828
1835
|
|
|
1829
|
-
**
|
|
1830
|
-
- `scalars.EmailScalar` - Validates email format (`Email_String`)
|
|
1831
|
-
- `scalars.URLScalar` - Validates URL format (`URL_String`)
|
|
1832
|
-
- `scalars.PositiveIntScalar` - Validates positive integers (`PositiveInt_Int`)
|
|
1833
|
-
- `scalars.PositiveFloatScalar` - Validates positive floats (`PositiveFloat_Float`)
|
|
1836
|
+
**Supported Operators:**
|
|
1834
1837
|
|
|
1835
|
-
|
|
1838
|
+
| Operator | Description | Example |
|
|
1839
|
+
|----------|-------------|---------|
|
|
1840
|
+
| `eq` | Equals | `{ eq: [{ ref: 'parent.status' }, 'active'] }` |
|
|
1841
|
+
| `in` | Value in array | `{ in: [{ ref: 'ctx.user.role' }, ['ADMIN', 'MOD']] }` |
|
|
1842
|
+
| `allOf` | All must be true (AND) | `{ allOf: [expr1, expr2] }` |
|
|
1843
|
+
| `anyOf` | Any must be true (OR) | `{ anyOf: [expr1, expr2] }` |
|
|
1844
|
+
| `not` | Negation | `{ not: { eq: [{ ref: 'parent.deleted' }, true] } }` |
|
|
1836
1845
|
|
|
1837
|
-
|
|
1846
|
+
**References:**
|
|
1847
|
+
|
|
1848
|
+
Use `{ ref: 'path' }` to reference values:
|
|
1849
|
+
- `parent.*` - Parent resolver result (the object being resolved)
|
|
1850
|
+
- `args.*` - GraphQL arguments
|
|
1851
|
+
- `ctx.*` - GraphQL context
|
|
1852
|
+
|
|
1853
|
+
**Security:**
|
|
1854
|
+
- Only `parent`, `args`, and `ctx` roots are allowed
|
|
1855
|
+
- Unknown operators fail closed (deny)
|
|
1856
|
+
- No `eval()` or `Function()` - pure object traversal
|
|
1857
|
+
|
|
1858
|
+
### Integration with GraphQL Yoga / Envelop
|
|
1859
|
+
|
|
1860
|
+
The recommended way to use the auth system is via the Envelop plugin, which works natively with GraphQL Yoga and any Envelop-based server. The plugin wraps resolvers in-place without rebuilding the schema, avoiding compatibility issues.
|
|
1838
1861
|
|
|
1839
1862
|
```javascript
|
|
1840
|
-
const {
|
|
1863
|
+
const { createYoga } = require('graphql-yoga');
|
|
1864
|
+
const { createServer } = require('http');
|
|
1865
|
+
const simfinity = require('@simtlix/simfinity-js');
|
|
1841
1866
|
|
|
1842
|
-
|
|
1843
|
-
const
|
|
1867
|
+
const { auth } = simfinity;
|
|
1868
|
+
const { createAuthPlugin, requireAuth, requireRole, composeRules, isOwner, deny } = auth;
|
|
1844
1869
|
|
|
1845
|
-
//
|
|
1846
|
-
|
|
1870
|
+
// Define your types and connect them
|
|
1871
|
+
simfinity.connect(null, SerieType, 'serie', 'series');
|
|
1872
|
+
simfinity.connect(null, SeasonType, 'season', 'seasons');
|
|
1873
|
+
simfinity.connect(null, StarType, 'star', 'stars');
|
|
1847
1874
|
|
|
1848
|
-
// Create
|
|
1849
|
-
const
|
|
1875
|
+
// Create base schema
|
|
1876
|
+
const schema = simfinity.createSchema();
|
|
1850
1877
|
|
|
1851
|
-
//
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
)
|
|
1878
|
+
// Define permissions
|
|
1879
|
+
// Query/Mutation names match the ones generated by simfinity.connect()
|
|
1880
|
+
const permissions = {
|
|
1881
|
+
Query: {
|
|
1882
|
+
series: requireAuth(),
|
|
1883
|
+
seasons: requireAuth(),
|
|
1884
|
+
stars: requireAuth(),
|
|
1885
|
+
},
|
|
1886
|
+
Mutation: {
|
|
1887
|
+
addserie: requireAuth(),
|
|
1888
|
+
updateserie: composeRules(requireAuth(), isOwner('createdBy')),
|
|
1889
|
+
deleteserie: requireRole('admin'),
|
|
1890
|
+
deletestar: requireRole('admin'),
|
|
1891
|
+
},
|
|
1892
|
+
serie: {
|
|
1893
|
+
'*': requireAuth(),
|
|
1894
|
+
},
|
|
1895
|
+
season: {
|
|
1896
|
+
'*': requireAuth(),
|
|
1897
|
+
},
|
|
1898
|
+
};
|
|
1857
1899
|
|
|
1858
|
-
//
|
|
1859
|
-
const
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1900
|
+
// Create auth plugin
|
|
1901
|
+
const authPlugin = createAuthPlugin(permissions, {
|
|
1902
|
+
defaultPolicy: 'ALLOW',
|
|
1903
|
+
debug: false,
|
|
1904
|
+
});
|
|
1905
|
+
|
|
1906
|
+
// Setup Yoga with the auth plugin
|
|
1907
|
+
const yoga = createYoga({
|
|
1908
|
+
schema,
|
|
1909
|
+
plugins: [authPlugin],
|
|
1910
|
+
context: (req) => ({
|
|
1911
|
+
user: req.user, // Set by your authentication layer
|
|
1867
1912
|
}),
|
|
1868
1913
|
});
|
|
1869
|
-
```
|
|
1870
1914
|
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
- `scalars.createBoundedFloatScalar(name, min, max)` - Float with range validation
|
|
1875
|
-
- `scalars.createPatternStringScalar(name, pattern, message)` - String with regex pattern validation
|
|
1915
|
+
const server = createServer(yoga);
|
|
1916
|
+
server.listen(4000);
|
|
1917
|
+
```
|
|
1876
1918
|
|
|
1877
|
-
|
|
1919
|
+
### Legacy: Integration with graphql-middleware
|
|
1878
1920
|
|
|
1879
|
-
|
|
1921
|
+
> **Deprecated:** `applyMiddleware` from `graphql-middleware` rebuilds the schema via `mapSchema`,
|
|
1922
|
+
> which can cause `"Schema must contain uniquely named types"` errors with Simfinity schemas.
|
|
1923
|
+
> Use `createAuthPlugin` with GraphQL Yoga / Envelop instead.
|
|
1880
1924
|
|
|
1881
1925
|
```javascript
|
|
1882
|
-
const {
|
|
1883
|
-
const
|
|
1926
|
+
const { applyMiddleware } = require('graphql-middleware');
|
|
1927
|
+
const simfinity = require('@simtlix/simfinity-js');
|
|
1884
1928
|
|
|
1885
|
-
|
|
1886
|
-
const
|
|
1887
|
-
'Email',
|
|
1888
|
-
'A valid email address',
|
|
1889
|
-
GraphQLString,
|
|
1890
|
-
(value) => {
|
|
1891
|
-
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
1892
|
-
if (!emailRegex.test(value)) {
|
|
1893
|
-
throw new Error('Invalid email format');
|
|
1894
|
-
}
|
|
1895
|
-
}
|
|
1896
|
-
);
|
|
1929
|
+
const { auth } = simfinity;
|
|
1930
|
+
const { createAuthMiddleware, requireAuth, requireRole } = auth;
|
|
1897
1931
|
|
|
1898
|
-
|
|
1899
|
-
const PositiveIntScalar = createValidatedScalar(
|
|
1900
|
-
'PositiveInt',
|
|
1901
|
-
'A positive integer',
|
|
1902
|
-
GraphQLInt,
|
|
1903
|
-
(value) => {
|
|
1904
|
-
if (value <= 0) {
|
|
1905
|
-
throw new Error('Value must be positive');
|
|
1906
|
-
}
|
|
1907
|
-
}
|
|
1908
|
-
);
|
|
1932
|
+
const baseSchema = simfinity.createSchema();
|
|
1909
1933
|
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1934
|
+
const authMiddleware = createAuthMiddleware(permissions, {
|
|
1935
|
+
defaultPolicy: 'DENY',
|
|
1936
|
+
});
|
|
1937
|
+
|
|
1938
|
+
const schema = applyMiddleware(baseSchema, authMiddleware);
|
|
1939
|
+
```
|
|
1940
|
+
|
|
1941
|
+
### Plugin / Middleware Options
|
|
1942
|
+
|
|
1943
|
+
```javascript
|
|
1944
|
+
const plugin = createAuthPlugin(permissions, {
|
|
1945
|
+
defaultPolicy: 'DENY', // 'ALLOW' or 'DENY' (default: 'DENY')
|
|
1946
|
+
debug: false, // Enable debug logging
|
|
1918
1947
|
});
|
|
1919
1948
|
```
|
|
1920
1949
|
|
|
1921
|
-
|
|
1950
|
+
| Option | Type | Default | Description |
|
|
1951
|
+
|--------|------|---------|-------------|
|
|
1952
|
+
| `defaultPolicy` | `'ALLOW' \| 'DENY'` | `'DENY'` | Policy when no rule matches |
|
|
1953
|
+
| `debug` | `boolean` | `false` | Log authorization decisions |
|
|
1922
1954
|
|
|
1923
|
-
|
|
1955
|
+
### Error Handling
|
|
1956
|
+
|
|
1957
|
+
The auth middleware uses Simfinity error classes:
|
|
1924
1958
|
|
|
1925
1959
|
```javascript
|
|
1926
|
-
const {
|
|
1960
|
+
const { auth } = require('@simtlix/simfinity-js');
|
|
1927
1961
|
|
|
1928
|
-
|
|
1929
|
-
class BusinessError extends SimfinityError {
|
|
1930
|
-
constructor(message) {
|
|
1931
|
-
super(message, 'BUSINESS_ERROR', 400);
|
|
1932
|
-
}
|
|
1933
|
-
}
|
|
1962
|
+
const { UnauthenticatedError, ForbiddenError } = auth;
|
|
1934
1963
|
|
|
1935
|
-
//
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
super(message, 'UNAUTHORIZED', 401);
|
|
1939
|
-
}
|
|
1940
|
-
}
|
|
1964
|
+
// UnauthenticatedError: code 'UNAUTHENTICATED', status 401
|
|
1965
|
+
// ForbiddenError: code 'FORBIDDEN', status 403
|
|
1966
|
+
```
|
|
1941
1967
|
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1968
|
+
Custom error handling in rules:
|
|
1969
|
+
|
|
1970
|
+
```javascript
|
|
1971
|
+
const permissions = {
|
|
1972
|
+
Mutation: {
|
|
1973
|
+
deleteserie: async (parent, args, ctx) => {
|
|
1974
|
+
if (!ctx.user) {
|
|
1975
|
+
throw new auth.UnauthenticatedError('Please log in');
|
|
1976
|
+
}
|
|
1977
|
+
if (ctx.user.role !== 'admin') {
|
|
1978
|
+
throw new auth.ForbiddenError('Only admins can delete series');
|
|
1979
|
+
}
|
|
1980
|
+
return true;
|
|
1981
|
+
},
|
|
1982
|
+
},
|
|
1983
|
+
};
|
|
1948
1984
|
```
|
|
1949
1985
|
|
|
1950
|
-
|
|
1986
|
+
### Best Practices
|
|
1951
1987
|
|
|
1952
|
-
|
|
1988
|
+
1. **Default to DENY**: Use `defaultPolicy: 'DENY'` for security
|
|
1989
|
+
2. **Use wildcards wisely**: `'*'` rules provide baseline security per type
|
|
1990
|
+
3. **Prefer helper rules**: Use `requireAuth()`, `requireRole()` over custom functions
|
|
1991
|
+
4. **Fail closed**: Custom rules should deny on unexpected conditions
|
|
1992
|
+
5. **Keep rules simple**: Complex logic belongs in controllers, not auth rules
|
|
1993
|
+
6. **Test thoroughly**: Auth rules are critical - test all scenarios
|
|
1953
1994
|
|
|
1954
|
-
|
|
1995
|
+
## 🔧 Middlewares
|
|
1955
1996
|
|
|
1956
|
-
|
|
1997
|
+
Middlewares provide a powerful way to intercept and process all GraphQL operations before they execute. Use them for cross-cutting concerns like authentication, logging, validation, and performance monitoring.
|
|
1957
1998
|
|
|
1958
|
-
|
|
1999
|
+
### Adding Middlewares
|
|
2000
|
+
|
|
2001
|
+
Register middlewares using `simfinity.use()`. Middlewares execute in the order they're registered:
|
|
1959
2002
|
|
|
1960
2003
|
```javascript
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
create: [validateEpisodeFields],
|
|
1966
|
-
update: [validateEpisodeBusinessRules]
|
|
1967
|
-
},
|
|
1968
|
-
scope: {
|
|
1969
|
-
find: async ({ type, args, operation, context }) => {
|
|
1970
|
-
// Modify args in place to add filter conditions
|
|
1971
|
-
args.owner = {
|
|
1972
|
-
terms: [
|
|
1973
|
-
{
|
|
1974
|
-
path: 'id',
|
|
1975
|
-
operator: 'EQ',
|
|
1976
|
-
value: context.user.id
|
|
1977
|
-
}
|
|
1978
|
-
]
|
|
1979
|
-
};
|
|
1980
|
-
},
|
|
1981
|
-
aggregate: async ({ type, args, operation, context }) => {
|
|
1982
|
-
// Apply same scope to aggregate queries
|
|
1983
|
-
args.owner = {
|
|
1984
|
-
terms: [
|
|
1985
|
-
{
|
|
1986
|
-
path: 'id',
|
|
1987
|
-
operator: 'EQ',
|
|
1988
|
-
value: context.user.id
|
|
1989
|
-
}
|
|
1990
|
-
]
|
|
1991
|
-
};
|
|
1992
|
-
},
|
|
1993
|
-
get_by_id: async ({ type, args, operation, context }) => {
|
|
1994
|
-
// For get_by_id, scope is automatically merged with id filter
|
|
1995
|
-
args.owner = {
|
|
1996
|
-
terms: [
|
|
1997
|
-
{
|
|
1998
|
-
path: 'id',
|
|
1999
|
-
operator: 'EQ',
|
|
2000
|
-
value: context.user.id
|
|
2001
|
-
}
|
|
2002
|
-
]
|
|
2003
|
-
};
|
|
2004
|
-
}
|
|
2005
|
-
}
|
|
2006
|
-
},
|
|
2007
|
-
fields: () => ({
|
|
2008
|
-
id: { type: GraphQLID },
|
|
2009
|
-
name: { type: GraphQLString },
|
|
2010
|
-
owner: {
|
|
2011
|
-
type: new GraphQLNonNull(simfinity.getType('user')),
|
|
2012
|
-
extensions: {
|
|
2013
|
-
relation: {
|
|
2014
|
-
connectionField: 'owner',
|
|
2015
|
-
displayField: 'name'
|
|
2016
|
-
}
|
|
2017
|
-
}
|
|
2018
|
-
}
|
|
2019
|
-
})
|
|
2004
|
+
// Basic logging middleware
|
|
2005
|
+
simfinity.use((params, next) => {
|
|
2006
|
+
console.log(`Executing ${params.operation} on ${params.type?.name || 'custom mutation'}`);
|
|
2007
|
+
next();
|
|
2020
2008
|
});
|
|
2021
2009
|
```
|
|
2022
2010
|
|
|
2023
|
-
###
|
|
2011
|
+
### Middleware Parameters
|
|
2024
2012
|
|
|
2025
|
-
|
|
2013
|
+
Each middleware receives a `params` object containing:
|
|
2026
2014
|
|
|
2027
2015
|
```javascript
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
};
|
|
2043
|
-
}
|
|
2044
|
-
}
|
|
2045
|
-
},
|
|
2046
|
-
fields: () => ({
|
|
2047
|
-
id: { type: GraphQLID },
|
|
2048
|
-
title: { type: GraphQLString },
|
|
2049
|
-
owner: {
|
|
2050
|
-
type: new GraphQLNonNull(simfinity.getType('user')),
|
|
2051
|
-
extensions: {
|
|
2052
|
-
relation: {
|
|
2053
|
-
connectionField: 'owner',
|
|
2054
|
-
displayField: 'name'
|
|
2055
|
-
}
|
|
2056
|
-
}
|
|
2057
|
-
}
|
|
2058
|
-
})
|
|
2016
|
+
simfinity.use((params, next) => {
|
|
2017
|
+
// params object contains:
|
|
2018
|
+
const {
|
|
2019
|
+
type, // Type information (model, gqltype, controller, etc.)
|
|
2020
|
+
args, // GraphQL arguments passed to the operation
|
|
2021
|
+
operation, // Operation type: 'save', 'update', 'delete', 'get_by_id', 'find', 'state_changed', 'custom_mutation'
|
|
2022
|
+
context, // GraphQL context object (includes request info, user data, etc.)
|
|
2023
|
+
actionName, // For state machine actions (only present for state_changed operations)
|
|
2024
|
+
actionField, // State machine action details (only present for state_changed operations)
|
|
2025
|
+
entry // Custom mutation name (only present for custom_mutation operations)
|
|
2026
|
+
} = params;
|
|
2027
|
+
|
|
2028
|
+
// Always call next() to continue the middleware chain
|
|
2029
|
+
next();
|
|
2059
2030
|
});
|
|
2060
2031
|
```
|
|
2061
2032
|
|
|
2062
|
-
|
|
2033
|
+
### Common Use Cases
|
|
2063
2034
|
|
|
2064
|
-
|
|
2035
|
+
#### 1. Authentication & Authorization
|
|
2065
2036
|
|
|
2066
|
-
|
|
2037
|
+
```javascript
|
|
2038
|
+
simfinity.use((params, next) => {
|
|
2039
|
+
const { context, operation, type } = params;
|
|
2040
|
+
|
|
2041
|
+
// Skip authentication for read operations
|
|
2042
|
+
if (operation === 'get_by_id' || operation === 'find') {
|
|
2043
|
+
return next();
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
// Check if user is authenticated
|
|
2047
|
+
if (!context.user) {
|
|
2048
|
+
throw new simfinity.SimfinityError('Authentication required', 'UNAUTHORIZED', 401);
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
// Check permissions for specific types
|
|
2052
|
+
if (type?.name === 'User' && context.user.role !== 'admin') {
|
|
2053
|
+
throw new simfinity.SimfinityError('Admin access required', 'FORBIDDEN', 403);
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
next();
|
|
2057
|
+
});
|
|
2058
|
+
```
|
|
2059
|
+
|
|
2060
|
+
#### 2. Request Logging & Monitoring
|
|
2067
2061
|
|
|
2068
2062
|
```javascript
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2063
|
+
simfinity.use((params, next) => {
|
|
2064
|
+
const { operation, type, args, context } = params;
|
|
2065
|
+
const startTime = Date.now();
|
|
2066
|
+
|
|
2067
|
+
console.log(`[${new Date().toISOString()}] Starting ${operation}${type ? ` on ${type.name}` : ''}`);
|
|
2068
|
+
|
|
2069
|
+
// Continue with the operation
|
|
2070
|
+
next();
|
|
2071
|
+
|
|
2072
|
+
const duration = Date.now() - startTime;
|
|
2073
|
+
console.log(`[${new Date().toISOString()}] Completed ${operation} in ${duration}ms`);
|
|
2074
|
+
});
|
|
2075
|
+
```
|
|
2076
|
+
|
|
2077
|
+
#### 3. Input Validation & Sanitization
|
|
2078
|
+
|
|
2079
|
+
```javascript
|
|
2080
|
+
simfinity.use((params, next) => {
|
|
2081
|
+
const { operation, args, type } = params;
|
|
2082
|
+
|
|
2083
|
+
// Validate input for save operations
|
|
2084
|
+
if (operation === 'save' && args.input) {
|
|
2085
|
+
// Trim string fields
|
|
2086
|
+
Object.keys(args.input).forEach(key => {
|
|
2087
|
+
if (typeof args.input[key] === 'string') {
|
|
2088
|
+
args.input[key] = args.input[key].trim();
|
|
2084
2089
|
}
|
|
2085
|
-
}
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
+
});
|
|
2091
|
+
|
|
2092
|
+
// Validate required business rules
|
|
2093
|
+
if (type?.name === 'Book' && args.input.title && args.input.title.length < 3) {
|
|
2094
|
+
throw new simfinity.SimfinityError('Book title must be at least 3 characters', 'VALIDATION_ERROR', 400);
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
next();
|
|
2090
2099
|
});
|
|
2091
2100
|
```
|
|
2092
2101
|
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
### Scope for Get By ID Operations
|
|
2096
|
-
|
|
2097
|
-
For `get_by_id` operations, scope functions modify a temporary query arguments object that includes the id filter. The system automatically combines the id filter with scope filters:
|
|
2102
|
+
#### 4. Rate Limiting
|
|
2098
2103
|
|
|
2099
2104
|
```javascript
|
|
2100
|
-
const
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2105
|
+
const requestCounts = new Map();
|
|
2106
|
+
|
|
2107
|
+
simfinity.use((params, next) => {
|
|
2108
|
+
const { context, operation } = params;
|
|
2109
|
+
const userId = context.user?.id || context.ip;
|
|
2110
|
+
const now = Date.now();
|
|
2111
|
+
const windowMs = 60000; // 1 minute
|
|
2112
|
+
const maxRequests = 100;
|
|
2113
|
+
|
|
2114
|
+
// Only apply rate limiting to mutations
|
|
2115
|
+
if (operation === 'save' || operation === 'update' || operation === 'delete') {
|
|
2116
|
+
const userRequests = requestCounts.get(userId) || [];
|
|
2117
|
+
const recentRequests = userRequests.filter(time => now - time < windowMs);
|
|
2118
|
+
|
|
2119
|
+
if (recentRequests.length >= maxRequests) {
|
|
2120
|
+
throw new simfinity.SimfinityError('Rate limit exceeded', 'TOO_MANY_REQUESTS', 429);
|
|
2116
2121
|
}
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
}
|
|
2122
|
+
|
|
2123
|
+
recentRequests.push(now);
|
|
2124
|
+
requestCounts.set(userId, recentRequests);
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
next();
|
|
2121
2128
|
});
|
|
2122
2129
|
```
|
|
2123
2130
|
|
|
2124
|
-
|
|
2125
|
-
1. Create a query that includes both the id filter and the owner scope filter
|
|
2126
|
-
2. Only return the document if it matches both conditions
|
|
2127
|
-
3. Return `null` if the document exists but doesn't match the scope
|
|
2128
|
-
|
|
2129
|
-
### Scope Function Parameters
|
|
2130
|
-
|
|
2131
|
-
Scope functions receive the same parameters as middleware for consistency:
|
|
2131
|
+
#### 5. Audit Trail
|
|
2132
2132
|
|
|
2133
2133
|
```javascript
|
|
2134
|
-
{
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2134
|
+
simfinity.use((params, next) => {
|
|
2135
|
+
const { operation, type, args, context } = params;
|
|
2136
|
+
|
|
2137
|
+
// Log all mutations for audit purposes
|
|
2138
|
+
if (operation === 'save' || operation === 'update' || operation === 'delete') {
|
|
2139
|
+
const auditEntry = {
|
|
2140
|
+
timestamp: new Date(),
|
|
2141
|
+
user: context.user?.id,
|
|
2142
|
+
operation,
|
|
2143
|
+
type: type?.name,
|
|
2144
|
+
entityId: args.id || 'new',
|
|
2145
|
+
data: operation === 'delete' ? null : args.input,
|
|
2146
|
+
ip: context.ip,
|
|
2147
|
+
userAgent: context.userAgent
|
|
2148
|
+
};
|
|
2149
|
+
|
|
2150
|
+
// Save to audit log (could be database, file, or external service)
|
|
2151
|
+
console.log('AUDIT:', JSON.stringify(auditEntry));
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
next();
|
|
2155
|
+
});
|
|
2140
2156
|
```
|
|
2141
2157
|
|
|
2142
|
-
###
|
|
2158
|
+
### Multiple Middlewares
|
|
2143
2159
|
|
|
2144
|
-
|
|
2160
|
+
Middlewares execute in registration order. Each middleware must call `next()` to continue the chain:
|
|
2145
2161
|
|
|
2146
|
-
**For scalar fields:**
|
|
2147
2162
|
```javascript
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2163
|
+
// Middleware 1: Authentication
|
|
2164
|
+
simfinity.use((params, next) => {
|
|
2165
|
+
console.log('1. Checking authentication...');
|
|
2166
|
+
// Authentication logic here
|
|
2167
|
+
next(); // Continue to next middleware
|
|
2168
|
+
});
|
|
2169
|
+
|
|
2170
|
+
// Middleware 2: Authorization
|
|
2171
|
+
simfinity.use((params, next) => {
|
|
2172
|
+
console.log('2. Checking permissions...');
|
|
2173
|
+
// Authorization logic here
|
|
2174
|
+
next(); // Continue to next middleware
|
|
2175
|
+
});
|
|
2176
|
+
|
|
2177
|
+
// Middleware 3: Logging
|
|
2178
|
+
simfinity.use((params, next) => {
|
|
2179
|
+
console.log('3. Logging request...');
|
|
2180
|
+
// Logging logic here
|
|
2181
|
+
next(); // Continue to GraphQL operation
|
|
2182
|
+
});
|
|
2152
2183
|
```
|
|
2153
2184
|
|
|
2154
|
-
|
|
2185
|
+
### Error Handling in Middlewares
|
|
2186
|
+
|
|
2187
|
+
Middlewares can throw errors to stop the operation:
|
|
2188
|
+
|
|
2155
2189
|
```javascript
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2190
|
+
simfinity.use((params, next) => {
|
|
2191
|
+
const { context, operation } = params;
|
|
2192
|
+
|
|
2193
|
+
try {
|
|
2194
|
+
// Validation logic
|
|
2195
|
+
if (!context.user && operation !== 'find') {
|
|
2196
|
+
throw new simfinity.SimfinityError('Authentication required', 'UNAUTHORIZED', 401);
|
|
2162
2197
|
}
|
|
2163
|
-
|
|
2164
|
-
|
|
2198
|
+
|
|
2199
|
+
next(); // Continue only if validation passes
|
|
2200
|
+
} catch (error) {
|
|
2201
|
+
// Error automatically bubbles up to GraphQL error handling
|
|
2202
|
+
throw error;
|
|
2203
|
+
}
|
|
2204
|
+
});
|
|
2165
2205
|
```
|
|
2166
2206
|
|
|
2167
|
-
###
|
|
2207
|
+
### Conditional Middleware Execution
|
|
2168
2208
|
|
|
2169
|
-
|
|
2209
|
+
Execute middleware logic conditionally based on operation type or context:
|
|
2170
2210
|
|
|
2171
2211
|
```javascript
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
find: async ({ type, args, operation, context }) => {
|
|
2181
|
-
// Only show episodes from seasons the user has access to
|
|
2182
|
-
args.season = {
|
|
2183
|
-
terms: [
|
|
2184
|
-
{
|
|
2185
|
-
path: 'owner.id',
|
|
2186
|
-
operator: 'EQ',
|
|
2187
|
-
value: context.user.id
|
|
2188
|
-
}
|
|
2189
|
-
]
|
|
2190
|
-
};
|
|
2191
|
-
},
|
|
2192
|
-
aggregate: async ({ type, args, operation, context }) => {
|
|
2193
|
-
// Apply same scope to aggregations
|
|
2194
|
-
args.season = {
|
|
2195
|
-
terms: [
|
|
2196
|
-
{
|
|
2197
|
-
path: 'owner.id',
|
|
2198
|
-
operator: 'EQ',
|
|
2199
|
-
value: context.user.id
|
|
2200
|
-
}
|
|
2201
|
-
]
|
|
2202
|
-
};
|
|
2203
|
-
},
|
|
2204
|
-
get_by_id: async ({ type, args, operation, context }) => {
|
|
2205
|
-
// Ensure user can only access their own episodes
|
|
2206
|
-
args.owner = {
|
|
2207
|
-
terms: [
|
|
2208
|
-
{
|
|
2209
|
-
path: 'id',
|
|
2210
|
-
operator: 'EQ',
|
|
2211
|
-
value: context.user.id
|
|
2212
|
-
}
|
|
2213
|
-
]
|
|
2214
|
-
};
|
|
2215
|
-
}
|
|
2216
|
-
}
|
|
2217
|
-
},
|
|
2218
|
-
fields: () => ({
|
|
2219
|
-
id: { type: GraphQLID },
|
|
2220
|
-
number: { type: GraphQLInt },
|
|
2221
|
-
name: { type: GraphQLString },
|
|
2222
|
-
season: {
|
|
2223
|
-
type: new GraphQLNonNull(simfinity.getType('season')),
|
|
2224
|
-
extensions: {
|
|
2225
|
-
relation: {
|
|
2226
|
-
connectionField: 'season',
|
|
2227
|
-
displayField: 'number'
|
|
2228
|
-
}
|
|
2229
|
-
}
|
|
2230
|
-
},
|
|
2231
|
-
owner: {
|
|
2232
|
-
type: new GraphQLNonNull(simfinity.getType('user')),
|
|
2233
|
-
extensions: {
|
|
2234
|
-
relation: {
|
|
2235
|
-
connectionField: 'owner',
|
|
2236
|
-
displayField: 'name'
|
|
2237
|
-
}
|
|
2238
|
-
}
|
|
2212
|
+
simfinity.use((params, next) => {
|
|
2213
|
+
const { operation, type, context } = params;
|
|
2214
|
+
|
|
2215
|
+
// Only apply to specific types
|
|
2216
|
+
if (type?.name === 'SensitiveData') {
|
|
2217
|
+
// Special handling for sensitive data
|
|
2218
|
+
if (!context.user?.hasHighSecurity) {
|
|
2219
|
+
throw new simfinity.SimfinityError('High security clearance required', 'FORBIDDEN', 403);
|
|
2239
2220
|
}
|
|
2240
|
-
}
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
// Only apply to mutation operations
|
|
2224
|
+
if (['save', 'update', 'delete', 'state_changed'].includes(operation)) {
|
|
2225
|
+
// Mutation-specific logic
|
|
2226
|
+
console.log(`Mutation ${operation} executing...`);
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
next();
|
|
2241
2230
|
});
|
|
2242
2231
|
```
|
|
2243
2232
|
|
|
2244
|
-
###
|
|
2245
|
-
|
|
2246
|
-
- **Execution Order**: Scope functions are executed **after** middleware, so middleware can set up context (e.g., user info) that scope functions can use
|
|
2247
|
-
- **Modify Args In Place**: Scope functions should modify the `args` object directly
|
|
2248
|
-
- **Filter Structure**: Use the correct filter structure (`QLFilter` for scalars, `QLTypeFilterExpression` for relations)
|
|
2249
|
-
- **All Query Operations**: Scope applies to `find`, `aggregate`, and `get_by_id` operations
|
|
2250
|
-
- **Automatic Merging**: For `get_by_id`, the id filter is automatically combined with scope filters
|
|
2251
|
-
- **Context Access**: Use `context.user`, `context.ip`, or other context properties to determine scope
|
|
2252
|
-
|
|
2253
|
-
### Use Cases
|
|
2233
|
+
### Best Practices
|
|
2254
2234
|
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2235
|
+
1. **Always call `next()`**: Failing to call `next()` will hang the request
|
|
2236
|
+
2. **Handle errors gracefully**: Use try-catch blocks for error-prone operations
|
|
2237
|
+
3. **Keep middlewares focused**: Each middleware should handle one concern
|
|
2238
|
+
4. **Order matters**: Register middlewares in logical order (auth → validation → logging)
|
|
2239
|
+
5. **Performance consideration**: Middlewares run on every operation, keep them lightweight
|
|
2240
|
+
6. **Use context wisely**: Store request-specific data in the GraphQL context object
|
|
2260
2241
|
|
|
2261
2242
|
## 🔧 Advanced Features
|
|
2262
2243
|
|
|
@@ -2705,6 +2686,7 @@ app.listen(4000, () => {
|
|
|
2705
2686
|
|
|
2706
2687
|
## 🔗 Resources
|
|
2707
2688
|
|
|
2689
|
+
- **[Series Sample Project](https://github.com/simtlix/series-sample)** - A complete TV series microservice built with Simfinity.js demonstrating types, relationships, state machines, controllers, and authorization
|
|
2708
2690
|
- **[Samples Repository](https://github.com/simtlix/simfinity.js-samples)** - Complete examples and use cases
|
|
2709
2691
|
- **[MongoDB Query Language](https://docs.mongodb.com/manual/tutorial/query-documents/)** - Learn about MongoDB querying
|
|
2710
2692
|
- **[GraphQL Documentation](https://graphql.org/learn/)** - Learn about GraphQL
|