diff --git a/README.md b/README.md index 62da0e5..88e6c75 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ The snippets are generally broken up into functional areas, with each folder cov ### Custom Directives - [@dbquery](dbquery/README.md) - Use `@dbquery` for connecting to databases, including pagination and filtering. +- [@inject](injection/README.md) - Use `@inject` for dependency injection, extracting context variables and making them available to multiple fields. - [@materializer](materializer) - Use of `@materializer` to extend types by linking disparate backends into a single unified view. - @rest - Connects to REST APIs - [rest](rest/README.md) - Use of `@rest` for connecting to REST endpoints, including pagination. diff --git a/injection/README.md b/injection/README.md new file mode 100644 index 0000000..89cecc8 --- /dev/null +++ b/injection/README.md @@ -0,0 +1,27 @@ +# @inject + +The `@inject` directive enables dependency injection in GraphQL schemas by allowing one field to inject expansion variables into other fields within the same request. + +## How @inject Works + +1. A field annotated with `@inject` is resolved first when any target field is accessed +2. The injecting field must return an object with key-value pairs +3. These pairs become expansion variables available to target fields +4. Target fields can access these variables using the standard expansion variable syntax +5. The `on` argument specifies which fields have access to the injected variables + +## Schema Structure + +```graphql +# Inject electronic product (JSON) data as expansion variables available to any Query field +_inject_electronic_products: JSON + @inject(on: [{ expose: true, types: "Query", fields: ".*" }]) + @materializer( + query: "products" + arguments: { name: "category", const: "electronics" } + ) +``` + +## Snippets + +- [user-context](user-context) - Demonstrates simple user context injection for regional e-commerce operations with role-based filtering and currency conversion. diff --git a/injection/user-context/README.md b/injection/user-context/README.md new file mode 100644 index 0000000..f936050 --- /dev/null +++ b/injection/user-context/README.md @@ -0,0 +1,89 @@ +# Inject user context + +This example demonstrates how to use the `@inject` directive.It provides a powerful way to extract context information (like user preferences, regional settings, and role) and make it available as expansion variables to multiple fields in a single request. This enables clean, reusable context patterns without repetitive parameter passing. + +- **Injection field**: `_inject_user_context` - automatically resolved without parameters +- **Target fields**: `orders`, `products`, `recommendations` - can access injected variables +- **Context variables**: `preferred_region`, `currency`, `role`, `language`, `default_user_id` + +## How It Works + +1. **Context Extraction**: The `_inject_user_context` field provides default user context +2. **Automatic Injection**: Target fields automatically receive context as expansion variables +3. **Flexible Usage**: Target fields can use optional `userId` parameters to override defaults +4. **Shared Context**: Multiple operations in one request share the same injected context + +**Note**: The injection field cannot have required parameters - it must be resolvable without arguments. + +## Schema Structure + +```graphql +_inject_user_context: JSON + @inject(on: [{ expose: true, types: "Query", fields: "orders|products|recommendations" }]) + @value(script: { ... }) # Returns default context +``` + +## Example operations + +### Using Default Context + +```graphql +query UserDashboardDefault { + orders(limit: 3) { # Uses injected context + id + customerName + total + } + products(category: "electronics") { # Uses injected context + id + name + price + } +} +``` + +### Overriding with Explicit Parameters + +```graphql +query UserDashboardExplicit($userId: ID!) { + orders(userId: $userId, limit: 3) { # Overrides default userId + id + customerName + total + } +} +``` + +## Try it out + +Deploy the schema from `injection/user-context`: + +```bash +stepzen deploy +``` + +### Sample Operations + +1. **Get Orders by UserID:** + +```bash +stepzen request -f operations.graphql --operation-name=UserOrdersExplicit --var userId=1 +``` + +2. **Get Products by UserID:** + +```bash +stepzen request -f operations.graphql --operation-name=UserProductsExplicit --var userId=2 --var category="electronics" +``` + +3. **Get Recommendations by userId:** + +```bash +stepzen request -f operations.graphql --operation-name=UserRecommendationsExplicit --var userId=2 --var count=2 +``` + +4. **multiple injected operations:** + +```bash +stepzen request -f operations.graphql --operation-name=UserDashboardDefault +``` diff --git a/injection/user-context/api.graphql b/injection/user-context/api.graphql new file mode 100644 index 0000000..35ac16e --- /dev/null +++ b/injection/user-context/api.graphql @@ -0,0 +1,219 @@ +# A simple user context injection example to demonstrate the @inject directive. +# This shows how user context can be (extracted and) injected into multiple operations. + +type Order { + id: ID! + customerName: String! + total: Float! + status: String! + region: String! +} + +type Product { + id: ID! + name: String! + category: String! + price: Float! + region: String! + inStock: Boolean! +} + +type Recommendation { + productId: ID! + productName: String! + score: Float! + reason: String! +} + +type Query { + """ + default user context becomes available as expansion variables to any field matching the visibility pattern. + """ + _inject_user_context: JSON + @inject( + on: [ + { + expose: true + types: "Query" + fields: "orders|products|recommendations" + } + ] + ) + @value( + script: { + language: ECMASCRIPT + src: """ + function getValue() { + // In real applications, this could come from headers or other sources. + return { + "preferred_region": "US_WEST", + "currency": "USD", + "role": "premium", + "language": "en", + "default_user_id": "1" + }; + } + getValue() + """ + } + ) + + """ + Get orders filtered by user's preferred region and role. + Uses injected expansion variables: preferred_region, role, default_user_id etc., + """ + orders(userId: ID, limit: Int = 10): [Order] + @rest( + endpoint: "stepzen:empty" + ecmascript: """ + function transformREST(s) { + // Access injected expansion variables + var region = get('preferred_region'); + var role = get('role'); + var defaultUserId = get('default_user_id'); + var userId = get('userId') || defaultUserId; + var limit = get('limit'); + + // Mock orders data + var allOrders = [ + {id: "1", customerName: "Acme Corp", total: 1500.0, status: "completed", region: "US_WEST", userId: "1"}, + {id: "2", customerName: "Tech Solutions", total: 850.0, status: "pending", region: "US_EAST", userId: "2"}, + {id: "3", customerName: "Euro Marketing", total: 1200.0, status: "completed", region: "EU_WEST", userId: "2"}, + {id: "4", customerName: "Asia Dynamics", total: 2500.0, status: "processing", region: "ASIA", userId: "3"}, + {id: "5", customerName: "West Coast Inc", total: 1800.0, status: "completed", region: "US_WEST", userId: "1"}, + {id: "6", customerName: "London Ltd", total: 950.0, status: "pending", region: "EU_WEST", userId: "2"} + ]; + + // Filter by user ID first + var userOrders = allOrders.filter(function(order) { + return order.userId === userId; + }); + + // Filter by user's preferred region + var filteredOrders = userOrders.filter(function(order) { + return order.region === region; + }); + + // Role-based filtering + if (role === "standard") { + filteredOrders = filteredOrders.filter(function(order) { + return order.status === "completed"; + }); + } + + // Apply limit + if (limit && limit > 0) { + filteredOrders = filteredOrders.slice(0, limit); + } + + return JSON.stringify(filteredOrders); + } + """ + ) + + """ + Get products available in user's region with currency conversion. + Uses injected expansion variables: preferred_region, currency etc., + """ + products(userId: ID, category: String): [Product] + @rest( + endpoint: "stepzen:empty" + ecmascript: """ + function transformREST(s) { + var region = get('preferred_region'); + var currency = get('currency'); + var defaultUserId = get('default_user_id'); + var userId = get('userId') || defaultUserId; + var category = get('category'); + + // Mock products data + var allProducts = [ + {id: "p1", name: "Laptop Pro", category: "electronics", price: 1299.99, region: "US_WEST", inStock: true}, + {id: "p2", name: "Office Chair", category: "furniture", price: 299.99, region: "US_WEST", inStock: true}, + {id: "p3", name: "EU Laptop", category: "electronics", price: 1199.99, region: "EU_WEST", inStock: true}, + {id: "p4", name: "EU Desk", category: "furniture", price: 399.99, region: "EU_WEST", inStock: false}, + {id: "p5", name: "Asia Tablet", category: "electronics", price: 599.99, region: "ASIA", inStock: true}, + {id: "p6", name: "Monitor 4K", category: "electronics", price: 499.99, region: "US_WEST", inStock: true} + ]; + + // Filter by user's preferred region + var filteredProducts = allProducts.filter(function(product) { + return product.region === region; + }); + + // Filter by category if provided + if (category) { + filteredProducts = filteredProducts.filter(function(product) { + return product.category === category; + }); + } + + // Convert currency for EUR users + if (currency === "EUR") { + filteredProducts = filteredProducts.map(function(product) { + return Object.assign({}, product, { + price: Math.round(product.price * 0.85 * 100) / 100 + }); + }); + } + + return JSON.stringify(filteredProducts); + } + """ + ) + + """ + Get personalized product recommendations based on user context. + Uses injected expansion variables: preferred_region, role, language etc., + """ + recommendations(userId: ID, count: Int = 5): [Recommendation] + @rest( + endpoint: "stepzen:empty" + ecmascript: """ + function transformREST(s) { + var region = get('preferred_region'); + var role = get('role'); + var language = get('language'); + var defaultUserId = get('default_user_id'); + var userId = get('userId') || defaultUserId; + var count = get('count') || 5; + + // Mock recommendations based on region (from injected context) + var recommendations = []; + + if (region === "US_WEST") { + recommendations = [ + {productId: "p1", productName: "Laptop Pro", score: 0.95, reason: "Popular in your region"}, + {productId: "p6", productName: "Monitor 4K", score: 0.88, reason: "Great for productivity"}, + {productId: "p2", productName: "Office Chair", score: 0.82, reason: "Highly rated locally"} + ]; + } else if (region === "EU_WEST") { + recommendations = [ + {productId: "p3", productName: "EU Laptop", score: 0.92, reason: "EU optimized model"}, + {productId: "p4", productName: "EU Desk", score: 0.79, reason: "Matches local preferences"}, + {productId: "p7", productName: "EU Monitor", score: 0.85, reason: "Energy efficient"} + ]; + } else if (region === "ASIA") { + recommendations = [ + {productId: "p5", productName: "Asia Tablet", score: 0.90, reason: "Regional bestseller"}, + {productId: "p8", productName: "Wireless Mouse", score: 0.84, reason: "Compact design"}, + {productId: "p9", productName: "Keyboard Pro", score: 0.81, reason: "Multi-language support"} + ]; + } + + // Premium users get enhanced recommendations (from injected context) + if (role === "premium") { + recommendations.forEach(function(rec) { + rec.score += 0.05; + rec.reason = "Premium: " + rec.reason; + }); + } + + // Limit count + recommendations = recommendations.slice(0, count); + + return JSON.stringify(recommendations); + } + """ + ) +} diff --git a/injection/user-context/index.graphql b/injection/user-context/index.graphql new file mode 100644 index 0000000..ec590ac --- /dev/null +++ b/injection/user-context/index.graphql @@ -0,0 +1,19 @@ +schema + @sdl( + files: ["api.graphql"] + # Visibility controls how fields are exposed to GraphQL introspection + # and field references through @materializer, @inject, etc. + # + # Only expose the main query fields that users should interact with. + # The _inject_user_context field is hidden from external schema but + # still accessible for injection into the target fields. + visibility: [ + { + expose: true + types: "Query" + fields: "orders|products|recommendations" + } + ] + ) { + query: Query +} diff --git a/injection/user-context/operations.graphql b/injection/user-context/operations.graphql new file mode 100644 index 0000000..1f508b5 --- /dev/null +++ b/injection/user-context/operations.graphql @@ -0,0 +1,111 @@ +# Sample operations to test the @inject directive functionality + +# Query with explicit userId (overrides injected default) +query UserOrdersExplicit($userId: ID!) { + orders(userId: $userId, limit: 5) { + id + customerName + total + status + region + } +} + +# Query using injected default user context +query UserOrdersDefault { + orders(limit: 5) { + id + customerName + total + status + region + } +} + +# Product query with explicit userId +query UserProductsExplicit($userId: ID!, $category: String) { + products(userId: $userId, category: $category) { + id + name + category + price + region + inStock + } +} + +# Product query using injected default context +query UserProductsDefault($category: String) { + products(category: $category) { + id + name + category + price + region + inStock + } +} + +# Recommendations with explicit userId +query UserRecommendationsExplicit($userId: ID!, $count: Int) { + recommendations(userId: $userId, count: $count) { + productId + productName + score + reason + } +} + +# Recommendations using injected default context +query UserRecommendationsDefault($count: Int) { + recommendations(count: $count) { + productId + productName + score + reason + } +} + +# Combined query demonstrating multiple injected operations with default user context +query UserDashboardDefault { + orders(limit: 3) { + id + customerName + total + status + } + products(category: "electronics") { + id + name + price + inStock + } + recommendations(count: 3) { + productId + productName + score + reason + } +} + +# Combined query with explicit userId (shows override capability) +query UserDashboardExplicit($userId: ID!) { + orders(userId: $userId, limit: 3) { + id + customerName + total + status + } + products(userId: $userId, category: "electronics") { + id + name + price + inStock + } + recommendations(userId: $userId, count: 3) { + productId + productName + score + reason + } +} diff --git a/injection/user-context/stepzen.config.json b/injection/user-context/stepzen.config.json new file mode 100644 index 0000000..af1c0ea --- /dev/null +++ b/injection/user-context/stepzen.config.json @@ -0,0 +1,3 @@ +{ + "endpoint": "api/miscellaneous" +} diff --git a/injection/user-context/tests/Test.js b/injection/user-context/tests/Test.js new file mode 100644 index 0000000..4351440 --- /dev/null +++ b/injection/user-context/tests/Test.js @@ -0,0 +1,162 @@ +const fs = require("fs"); +const path = require("node:path"); +const { + deployAndRun, + stepzen, + getTestDescription, +} = require("../../../tests/gqltest.js"); +const { gqltest } = require("../../../tests/gqltest"); + +testDescription = getTestDescription("snippets", __dirname); + +describe(testDescription, function () { + const tests = [ + { + label: "Should inject default user context for orders", + query: ` + query { + orders(limit: 3) { + id + customerName + total + status + region + } + } + `, + expected: { + orders: [ + { + id: "1", + customerName: "Acme Corp", + total: 1500.0, + status: "completed", + region: "US_WEST", + }, + { + id: "5", + customerName: "West Coast Inc", + total: 1800.0, + status: "completed", + region: "US_WEST", + }, + ], + }, + }, + { + label: "Should inject default user context for products", + query: ` + query { + products(category: "electronics") { + id + name + category + price + region + inStock + } + } + `, + expected: { + products: [ + { + id: "p1", + name: "Laptop Pro", + category: "electronics", + price: 1299.99, + region: "US_WEST", + inStock: true, + }, + { + id: "p6", + name: "Monitor 4K", + category: "electronics", + price: 499.99, + region: "US_WEST", + inStock: true, + }, + ], + }, + }, + { + label: "Should inject default user context for recommendations", + query: ` + query { + recommendations(count: 2) { + productId + productName + score + reason + } + } + `, + expected: { + recommendations: [ + { + productId: "p1", + productName: "Laptop Pro", + score: 1.0, + reason: "Premium: Popular in your region", + }, + { + productId: "p6", + productName: "Monitor 4K", + score: 0.93, + reason: "Premium: Great for productivity", + }, + ], + }, + }, + { + label: "Should use shared injection context across multiple operations", + query: ` + query { + orders(limit: 1) { + id + region + } + products(category: "electronics") { + id + region + } + } + `, + expected: { + orders: [ + { + id: "1", + region: "US_WEST", + }, + ], + products: [ + { + id: "p1", + region: "US_WEST", + }, + { + id: "p6", + region: "US_WEST", + }, + ], + }, + }, + { + label: "Should override default context with explicit userId", + query: ` + query GetUserOrders($userId: ID!) { + orders(userId: $userId, limit: 5) { + id + customerName + region + } + } + `, + variables: { userId: "2" }, + expected: { + orders: [], // User 2's orders are in EU_WEST but injection provides US_WEST context, so no matches + }, + }, + ]; + + return deployAndRun(__dirname, tests, stepzen.admin); +});