diff --git a/.cursor/rules/components.mdc b/.cursor/rules/components.mdc deleted file mode 100644 index ed03125..0000000 --- a/.cursor/rules/components.mdc +++ /dev/null @@ -1,179 +0,0 @@ -# Component Creation Patterns - -## Class Structure - -### Extending GraphQLComponent -- Always extend `GraphQLComponent` class -- Implement constructor with options spread pattern -- Use TypeScript for type safety - -```typescript -import GraphQLComponent from 'graphql-component'; -import { types } from './types'; -import { resolvers } from './resolvers'; -import MyDataSource from './datasource'; - -export default class MyComponent extends GraphQLComponent { - constructor({ dataSources = [new MyDataSource()], ...options } = {}) { - super({ types, resolvers, dataSources, ...options }); - } -} -``` - -### Constructor Pattern -- Default empty object parameter: `= {}` -- Default data sources with spread: `dataSources = [new MyDataSource()]` -- Spread remaining options: `...options` -- Pass all to super: `super({ types, resolvers, dataSources, ...options })` - -### Component References (for Delegation) -- Store component instances as properties when needed for delegation -- Initialize imported components in constructor - -```typescript -export default class ListingComponent extends GraphQLComponent { - propertyComponent: PropertyComponent; - reviewsComponent: ReviewsComponent; - - constructor(options) { - const propertyComponent = new PropertyComponent(); - const reviewsComponent = new ReviewsComponent(); - - super({ - types, - resolvers, - imports: [propertyComponent, reviewsComponent], - ...options - }); - - this.propertyComponent = propertyComponent; - this.reviewsComponent = reviewsComponent; - } -} -``` - -## File Organization - -### Standard Structure -``` -my-component/ -├── index.ts # Component class (default export) -├── types.ts # Schema loader -├── resolvers.ts # Resolver map (named export) -├── schema.graphql # GraphQL SDL -└── datasource.ts # Data source class (default export) -``` - -### Schema Loading Pattern -- Use fs.readFileSync for .graphql files -- Export as named export `types` - -```typescript -// types.ts -import fs from 'fs'; -import path from 'path'; - -export const types = fs.readFileSync( - path.resolve(path.join(__dirname, 'schema.graphql')), - 'utf-8' -); -``` - -### Resolver Export Pattern -- Export as named export `resolvers` -- Use object literal format - -```typescript -// resolvers.ts -export const resolvers = { - Query: { - myField(_, args, context) { - return context.dataSources.MyDataSource.getData(args.id); - } - } -}; -``` - -## Federation vs Composition - -### Composition Components -- Use `imports` to include other components -- Use `delegateToSchema` for cross-component calls -- No federation flag needed - -```typescript -const component = new GraphQLComponent({ - types, - resolvers, - imports: [childComponent1, childComponent2] -}); -``` - -### Federation Components -- Set `federation: true` -- Include federation directives in schema -- Implement `__resolveReference` resolvers - -```typescript -const component = new GraphQLComponent({ - types, - resolvers, - dataSources: [new MyDataSource()], - federation: true // Enable federation -}); -``` - -## Resolver Delegation - -### Cross-Component Calls -- Use `delegateToSchema` from `@graphql-tools/delegate` -- Reference component schema via `this.componentName.schema` -- Pass through context and info - -```typescript -import { delegateToSchema } from '@graphql-tools/delegate'; - -export const resolvers = { - Listing: { - property(root, args, context, info) { - return delegateToSchema({ - schema: this.propertyComponent.schema, - fieldName: 'propertyById', - args: { id: root.id }, - context, - info - }); - } - } -}; -``` - -## Context Usage - -### Accessing Data Sources -- Use destructuring: `{ dataSources }` from context -- Access by class name: `dataSources.MyDataSource` - -```typescript -const resolvers = { - Query: { - user(_, { id }, { dataSources }) { - return dataSources.UserDataSource.getUser(id); - } - } -}; -``` - -### Federation Resolvers -- Include `__resolveReference` for entity resolution -- Use typed parameters for clarity - -```typescript -const resolvers = { - Property: { - __resolveReference(ref: { id: string }, { dataSources }: ComponentContext) { - return dataSources.PropertyDataSource.getPropertyById(ref.id); - } - } -}; -``` diff --git a/.cursor/rules/datasources.mdc b/.cursor/rules/datasources.mdc deleted file mode 100644 index 1510587..0000000 --- a/.cursor/rules/datasources.mdc +++ /dev/null @@ -1,166 +0,0 @@ -# Data Source Patterns - -## Two Data Access Patterns - -### Pattern 1: Injected Data Sources (Recommended) -- Pass via constructor `dataSources` option -- Access via `context.dataSources.name` -- Automatic context injection via proxy -- Easy testing with `dataSourceOverrides` - -### Pattern 2: Private Data Sources (Alternative) -- Create as component instance properties -- Access via `this.dataSourceName` in resolvers -- Resolvers are bound to component instance -- Manual context passing required -- **Limitation**: No `dataSourceOverrides` support -- **Limitation**: No runtime configuration flexibility - -## Implementation Rules - -### Injected Data Sources -- Always implement `DataSourceDefinition` and `IDataSource` -- Include `name` property (string) for identification -- Context parameter MUST be first in all methods - -```typescript -class MyDataSource implements DataSourceDefinition, IDataSource { - name = 'MyDataSource'; // Required - - // Context MUST be first parameter - async getData(context: ComponentContext, id: string) { - return { id }; - } -} -``` - -### Private Data Sources -- No special interfaces required -- Store as component properties -- Use regular functions (not arrow functions) in resolvers for `this` binding - -```typescript -class MyComponent extends GraphQLComponent { - private myDataSource: MyDataSource; - - constructor(options = {}) { - super({ - resolvers: { - Query: { - // Use regular function for 'this' binding - data(_, { id }, context) { - return this.myDataSource.getData(id, context); - } - } - }, - ...options - }); - - this.myDataSource = new MyDataSource(); - } -} -``` - -### Typing Pattern -- Use generic self-reference: `DataSourceDefinition` -- Import `ComponentContext` from the main library -- Define interfaces for return types when complex - -```typescript -import { DataSourceDefinition, ComponentContext, IDataSource } from 'graphql-component'; - -interface User { - id: string; - name: string; -} - -class UserDataSource implements DataSourceDefinition, IDataSource { - name = 'users'; - - async getUser(context: ComponentContext, id: string): Promise { - // Implementation - } -} -``` - -## Usage in Components - -### Constructor Pattern -- Use default data sources with spread operator -- Allow override through constructor options - -```typescript -export default class MyComponent extends GraphQLComponent { - constructor({ dataSources = [new MyDataSource()], ...options } = {}) { - super({ types, resolvers, dataSources, ...options }); - } -} -``` - -### Resolver Usage -- **NEVER** pass context manually to data source methods -- Context is automatically injected by proxy -- Access via `context.dataSources.DataSourceName` - -```typescript -const resolvers = { - Query: { - user(_, { id }, context) { - // ✅ Correct - context injected automatically - return context.dataSources.users.getUser(id); - - // ❌ Wrong - don't pass context manually - // return context.dataSources.users.getUser(context, id); - } - } -}; -``` - -## Testing Patterns - -### Basic Data Source Testing -```typescript -test('data source injection', async (t) => { - const component = new GraphQLComponent({ - types: `type Query { test: String }`, - dataSources: [new TestDataSource()] - }); - - const context = await component.context({ testValue: 'test' }); - const result = context.dataSources.TestDataSource.getData('arg'); - - t.equal(result, 'expected', 'data source method works'); - t.end(); -}); -``` - -### Override Testing -```typescript -test('data source overrides', async (t) => { - const mockDataSource = new MockDataSource(); - - const component = new GraphQLComponent({ - imports: [originalComponent], - dataSourceOverrides: [mockDataSource] - }); - - const context = await component.context({}); - // Original data source is replaced by mock -}); -``` - -## File Organization - -### Structure -``` -component/ -├── datasource.ts # Data source implementation -├── index.ts # Component class -├── resolvers.ts # Resolver functions -├── schema.graphql # GraphQL schema -└── types.ts # Schema loader -``` - -### Export Pattern -- Default export the data source class -- Keep implementation in separate file from component diff --git a/.cursor/rules/examples.mdc b/.cursor/rules/examples.mdc deleted file mode 100644 index 931de5d..0000000 --- a/.cursor/rules/examples.mdc +++ /dev/null @@ -1,272 +0,0 @@ -# Example Creation Patterns - -## Example Structure - -### Directory Organization -- Create examples under `/examples` directory -- Use descriptive folder names (composition, federation, etc.) -- Include working server implementations - -``` -examples/ -├── composition/ # Schema stitching example -│ ├── server/ # Server implementation -│ ├── listing-component/ -│ ├── property-component/ -│ └── reviews-component/ -└── federation/ # Federation example - ├── gateway/ # Federation gateway - ├── property-service/ - └── reviews-service/ -``` - -### Component Structure -- Follow standard component file organization -- Use realistic data and schemas -- Include proper TypeScript types - -``` -example-component/ -├── index.ts # Component class -├── types.ts # Schema loader -├── resolvers.ts # Resolvers with business logic -├── schema.graphql # GraphQL schema definition -└── datasource.ts # Mock/example data source -``` - -## Server Examples - -### Composition Server -- Use ApolloServer for GraphQL endpoint -- Import main component for schema -- Keep configuration minimal but functional - -```typescript -const { ApolloServer } = require('apollo-server'); -const ListingComponent = require('../listing-component'); - -const { schema, context } = new ListingComponent(); - -const server = new ApolloServer({ - schema, - context, - tracing: false -}); - -server.listen().then(({ url }) => { - console.log(`🚀 Server ready at ${url}`); -}); -``` - -### Federation Services -- Create separate services for each domain -- Use distinct ports (4001, 4002, etc.) -- Include federation gateway - -```typescript -// Property service -const run = async function (): Promise => { - const { schema, context } = new PropertyComponent({ - types, - resolvers, - dataSources: [new PropertyDataSource()], - federation: true - }); - - const server = new ApolloServer({ schema, context }); - const { url } = await server.listen({port: 4001}); - console.log(`🚀 Property service ready at ${url}`); -}; -``` - -### Federation Gateway -- Use ApolloGateway for service composition -- List all federated services -- Standard port 4000 for gateway - -```typescript -import { ApolloServer } from 'apollo-server'; -import { ApolloGateway } from '@apollo/gateway'; - -const gateway = new ApolloGateway({ - serviceList: [ - { name: 'property', url: 'http://localhost:4001' }, - { name: 'reviews', url: 'http://localhost:4002' } - ] -}); - -const server = new ApolloServer({ gateway }); -const { url } = await server.listen({port: 4000}); -``` - -## Data Source Examples - -### Mock Data Pattern -- Use in-memory objects for demo data -- Include realistic structure and relationships -- Comment data relationships - -```typescript -// reviews indexed by property id -const reviewsDB: Record = { - 1: [ - { id: 'rev-id-1-a', content: 'this property was great'}, - { id: 'rev-id-1-b', content: 'this property was terrible'} - ], - 2: [ - { id: 'rev-id-2-a', content: 'This property was amazing'}, - { id: 'rev-id-2-b', content: 'I loved the proximity to the beach'} - ] -}; -``` - -### Proper TypeScript Interfaces -- Define interfaces for data structures -- Use in data source implementations -- Export for reuse in resolvers - -```typescript -interface Property { - id: number; - geo: string[]; -} - -interface Review { - id: string; - content: string; -} -``` - -## Schema Examples - -### Composition Schemas -- Show realistic business domains -- Include relationships between components -- Use meaningful field names - -```graphql -# Listing schema - aggregates other components -type Listing { - id: ID! - property: Property # From property component - reviews: [Review] # From reviews component -} - -type Query { - listing(id: ID!): Listing -} -``` - -### Federation Schemas -- Include proper federation directives -- Show entity extensions -- Use @key, @external, @requires properly - -```graphql -# Property service -type Property @key(fields: "id") { - id: ID! - geo: [String] -} - -# Reviews service extending Property -extend type Property @key(fields: "id") { - id: ID! @external - reviews: [Review] @requires(fields: "id") -} -``` - -## Resolver Examples - -### Delegation Patterns -- Show proper use of delegateToSchema -- Include error handling -- Pass context and info correctly - -```typescript -export const resolvers = { - Listing: { - property(root, args, context, info) { - return delegateToSchema({ - schema: this.propertyComponent.schema, - fieldName: 'propertyById', - args: { id: root.id }, - context, - info - }); - } - } -}; -``` - -### Federation Resolvers -- Include __resolveReference implementations -- Show proper typing -- Use context for data source access - -```typescript -const resolvers = { - Property: { - __resolveReference(ref: { id: string }, { dataSources }: ComponentContext) { - return dataSources.PropertyDataSource.getPropertyById(ref.id); - } - } -}; -``` - -## Package Scripts - -### Example Scripts -- Add npm scripts for running examples -- Include clear naming - -```json -{ - "scripts": { - "start-composition": "ts-node examples/composition/server/index.ts", - "start-federation": "ts-node examples/federation/run-federation-example.ts" - } -} -``` - -## Documentation - -### Example README Sections -- Include clear instructions to run examples -- Explain what each example demonstrates -- Provide GraphQL playground URLs - -```markdown -## Examples - -### Local Schema Composition -```bash -npm run start-composition -``` -This example shows how to compose multiple GraphQL components into a single schema using schema stitching. - -### Federation Example -```bash -npm run start-federation -``` -This example demonstrates building Apollo Federation subgraphs using GraphQL components. - -Both examples are accessible at `http://localhost:4000/graphql` when running. -``` - -## Best Practices - -### Keep Examples Simple -- Focus on demonstrating specific concepts -- Avoid unnecessary complexity -- Use realistic but minimal data - -### Make Examples Runnable -- Include all necessary dependencies -- Provide clear setup instructions -- Test examples regularly - -### Show Real-World Patterns -- Use meaningful business domains -- Include proper error handling -- Demonstrate best practices diff --git a/.cursor/rules/overview.mdc b/.cursor/rules/overview.mdc deleted file mode 100644 index f64b435..0000000 --- a/.cursor/rules/overview.mdc +++ /dev/null @@ -1,97 +0,0 @@ ---- -alwaysApply: true ---- - -# Project Overview - -`graphql-component` is a TypeScript library for building modular and composable GraphQL schemas through a component-based architecture. It enables developers to create large-scale GraphQL APIs by composing smaller, focused components that each encapsulate their own schema definitions, resolvers, and data sources. - -The library supports both traditional schema stitching for monolithic applications and Apollo Federation for microservice architectures, making it versatile for different deployment patterns. - -# Key Concepts - -## Component Architecture -- **GraphQLComponent**: Core class that encapsulates schema, resolvers, and data sources -- **Schema Composition**: Components can import other components to build larger schemas -- **Isolation**: Each component manages its own concerns independently - -## Data Source Management -- **Proxy-based Injection**: Automatic context injection into data source methods -- **Override Support**: Ability to replace data sources for testing or different environments -- **Type Safety**: TypeScript interfaces ensure proper data source implementation - -## Schema Construction -- **With Imports**: Creates aggregate schemas by combining imported components -- **Without Imports**: Uses makeExecutableSchema for standalone components -- **Federation Support**: Builds Apollo Federation subgraphs when enabled - -## Context & Middleware -- **Context Middleware**: Chainable middleware for authentication, logging, etc. -- **Namespace Support**: Organized context with component namespaces -- **Global Context**: Shared context across all imported components - -# Tech Stack - -## Core Dependencies -- **TypeScript** - Primary language for type safety and modern JavaScript features -- **GraphQL** - Core GraphQL implementation (peer dependency ^16.0.0) -- **@graphql-tools ecosystem** - Schema manipulation and utilities - - `@graphql-tools/schema` - Schema creation - - `@graphql-tools/stitch` - Schema stitching - - `@graphql-tools/merge` - Type definition merging - - `@graphql-tools/utils` - Common utilities and types - - `@graphql-tools/delegate` - Schema delegation - - `@graphql-tools/mock` - Schema mocking -- **@apollo/federation** - Apollo Federation support for microservices - -## Development Tools -- **tape** - Testing framework for unit tests -- **eslint** - Code linting with TypeScript support -- **prettier** - Code formatting -- **ts-node** - TypeScript execution for examples and development - -## Example Dependencies -- **apollo-server** - GraphQL server for examples -- **@apollo/gateway** - Federation gateway for examples - -# Primary Goals - -## Modularity & Composition -- Enable building large GraphQL schemas from smaller, manageable components -- Support both horizontal (feature-based) and vertical (layer-based) composition patterns -- Maintain clear separation of concerns between components - -## Developer Experience -- Provide type-safe APIs with comprehensive TypeScript support -- Offer intuitive patterns for common GraphQL use cases -- Minimize boilerplate while maintaining flexibility - -## Architecture Flexibility -- Support both monolithic and microservice deployment patterns -- Enable gradual migration between architectural approaches -- Provide escape hatches for advanced use cases - -## Production Readiness -- Ensure performance through schema optimization and caching -- Support debugging and monitoring through middleware and logging -- Maintain backward compatibility and clear migration paths - -# Coding Standards - -- 2 spaces for indenting -- space between statements and parenthesis, example: `if (condition)` and `function ()` and `switch ()` -- `else` and `if else` always on new lines -- prefer early returns in if/else rather than many `if else` and `else` conditions -- assign functions to constants instead of just defining functions - -# Contributions - -- Always write tests when introducing new code -- Always keep a changelog -- Update documentation according to changes -- Warn about breaking changes -- Always make the minimal change possible to achieve the requested outcome -- Avoid introducing new dependencies, and ask before you do -- Optimize for code readability and keep comments to a minimum -- Create examples under `/examples` directory to demonstrate complex ideas -- Call out potential performance, security, and resilience issues when you encounter them \ No newline at end of file diff --git a/.cursor/rules/testing.mdc b/.cursor/rules/testing.mdc deleted file mode 100644 index 90182ad..0000000 --- a/.cursor/rules/testing.mdc +++ /dev/null @@ -1,306 +0,0 @@ -# Testing Patterns - -## Test Structure - -### Using Tape Framework -- Use `tape` for all tests (import as `test`) -- Use nested tests with `t.test()` -- Always call `t.plan()` or `t.end()` -- Use descriptive test names - -```typescript -import test from 'tape'; -import GraphQLComponent from '../src'; - -test('component feature tests', (t) => { - t.test('should create basic component', (t) => { - t.plan(1); - - const component = new GraphQLComponent({ types, resolvers }); - t.ok(component.schema, 'schema was created'); - }); - - t.test('should handle data sources', async (t) => { - // async tests use t.end() instead of plan - const context = await component.context({}); - t.ok(context.dataSources, 'data sources injected'); - t.end(); - }); - - t.end(); // End parent test -}); -``` - -## Component Testing - -### Basic Component Creation -```typescript -test('component creation', (t) => { - t.test('should create with types and resolvers', (t) => { - t.plan(2); - - const component = new GraphQLComponent({ - types: `type Query { hello: String }`, - resolvers: { - Query: { - hello: () => 'world' - } - } - }); - - t.ok(component.schema, 'schema created'); - t.ok(component.schema.getQueryType(), 'query type exists'); - }); -}); -``` - -### Schema Execution Testing -```typescript -import { graphql } from 'graphql'; - -test('schema execution', async (t) => { - t.plan(1); - - const component = new GraphQLComponent({ - types: `type Query { hello: String }`, - resolvers: { - Query: { - hello: () => 'world' - } - } - }); - - const result = await graphql({ - schema: component.schema, - source: '{ hello }', - contextValue: {} - }); - - t.equal(result.data?.hello, 'world', 'resolver executed correctly'); -}); -``` - -## Data Source Testing - -### Context Injection Testing -```typescript -test('data source context injection', async (t) => { - t.plan(3); - - class TestDataSource { - name = 'test'; - - getData(context, arg) { - t.ok(context, 'context injected'); - t.equal(context.globalValue, 'test', 'context value passed'); - return `${arg}-result`; - } - } - - const component = new GraphQLComponent({ - types: `type Query { test: String }`, - dataSources: [new TestDataSource()] - }); - - const context = await component.context({ globalValue: 'test' }); - const result = context.dataSources.test.getData('input'); - - t.equal(result, 'input-result', 'data source method worked'); -}); -``` - -### Data Source Override Testing -```typescript -test('data source overrides', async (t) => { - t.plan(1); - - class OriginalDataSource { - name = 'test'; - getData() { return 'original'; } - } - - class MockDataSource { - name = 'test'; - getData() { return 'mock'; } - } - - const component = new GraphQLComponent({ - types: `type Query { test: String }`, - dataSources: [new OriginalDataSource()], - dataSourceOverrides: [new MockDataSource()] - }); - - const context = await component.context({}); - const result = context.dataSources.test.getData(); - - t.equal(result, 'mock', 'override replaced original'); -}); -``` - -## Context Testing - -### Middleware Testing -```typescript -test('context middleware', async (t) => { - t.plan(2); - - const component = new GraphQLComponent({ - types: `type Query { test: String }` - }); - - component.context.use('auth', async (ctx) => ({ - ...ctx, - user: { id: '123' } - })); - - const context = await component.context({ req: {} }); - - t.ok(context.user, 'middleware applied'); - t.equal(context.user.id, '123', 'middleware data correct'); -}); -``` - -### Multiple Middleware Order -```typescript -test('middleware execution order', async (t) => { - t.plan(1); - - const component = new GraphQLComponent({ - types: `type Query { test: String }` - }); - - component.context.use('first', async (ctx) => ({ - ...ctx, - value: 1 - })); - - component.context.use('second', async (ctx) => ({ - ...ctx, - value: (ctx.value as number) + 1 - })); - - const context = await component.context({}); - t.equal(context.value, 2, 'middleware executed in order'); -}); -``` - -## Integration Testing - -### Component Composition Testing -```typescript -test('component composition', async (t) => { - t.plan(2); - - const childComponent = new GraphQLComponent({ - types: `type Query { child: String }`, - resolvers: { - Query: { - child: () => 'child result' - } - } - }); - - const parentComponent = new GraphQLComponent({ - types: `type Query { parent: String }`, - resolvers: { - Query: { - parent: () => 'parent result' - } - }, - imports: [childComponent] - }); - - const result = await graphql({ - schema: parentComponent.schema, - source: '{ parent child }' - }); - - t.equal(result.data?.parent, 'parent result', 'parent resolver works'); - t.equal(result.data?.child, 'child result', 'child resolver works'); -}); -``` - -### Federation Testing -```typescript -test('federation schema creation', (t) => { - t.plan(1); - - const component = new GraphQLComponent({ - types: ` - type User @key(fields: "id") { - id: ID! - name: String - } - `, - resolvers: { - User: { - __resolveReference: (ref) => ({ id: ref.id, name: 'Test User' }) - } - }, - federation: true - }); - - t.ok(component.schema, 'federation schema created'); -}); -``` - -## Mock Testing - -### Default Mocks -```typescript -test('default mocks', async (t) => { - t.plan(1); - - const component = new GraphQLComponent({ - types: `type Query { hello: String }`, - mocks: true - }); - - const result = await graphql({ - schema: component.schema, - source: '{ hello }' - }); - - t.ok(result.data?.hello, 'default mock applied'); -}); -``` - -### Custom Mocks -```typescript -test('custom mocks', async (t) => { - t.plan(1); - - const component = new GraphQLComponent({ - types: `type Query { hello: String }`, - mocks: { - Query: () => ({ - hello: 'Custom mock value' - }) - } - }); - - const result = await graphql({ - schema: component.schema, - source: '{ hello }' - }); - - t.equal(result.data?.hello, 'Custom mock value', 'custom mock applied'); -}); -``` - -## Test Organization - -### File Structure -``` -test/ -├── test.ts # Main component tests -├── context.ts # Context-specific tests -├── datasources.ts # Data source tests -├── schema.ts # Schema transformation tests -└── validation.ts # Validation tests -``` - -### Async Test Pattern -- Use `async (t)` for async tests -- Always call `t.end()` for async tests -- Use `t.plan(n)` for sync tests diff --git a/.npmignore b/.npmignore index e314f24..f23fe10 100644 --- a/.npmignore +++ b/.npmignore @@ -15,7 +15,6 @@ coverage/ tsconfig.json eslint.config.mjs .eslintrc* -.prettier* jest.config.* .babelrc* webpack.config.* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6a1589b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,130 @@ +# Agent Instructions + +Authoritative instructions for AI agents working in this repository. Use as guidance; let the model reason rather than follow templates mechanically. + +--- + +## Project + +**graphql-component** -- a library for building modular, composable GraphQL schemas through a component-based architecture. Components encapsulate types, resolvers, data sources, and context. Composition happens via `@graphql-tools/stitch`. Also supports Apollo Federation, mocking, schema transforms, and schema pruning. + +**Author**: Trevor Livingston +**License**: MIT +**Repo**: github.com/ExpediaGroup/graphql-component + +--- + +## Tech Stack + +- **Language**: TypeScript (strict mode expected) +- **Runtime**: Node.js >= 18 +- **GraphQL**: graphql ^16.0.0 (peer dependency) +- **Schema tooling**: @graphql-tools/* (stitch, merge, delegate, schema, mock, utils) +- **Federation**: @apollo/federation ^0.38.1 (deprecated; migration to @apollo/subgraph planned) +- **Test framework**: Tape (TAP output) with Sinon for spies/mocks +- **Coverage**: NYC +- **Linting**: ESLint with @typescript-eslint + +--- + +## Commands + +| Task | Command | +|------|---------| +| Build | `npm run build` (runs `tsc`) | +| Test | `npm test` | +| Lint | `npm run lint` | +| Coverage | `npm run cover` | +| Composition example | `npm run start-composition` | +| Federation example | `npm run start-federation` | + +--- + +## Source Structure + +``` +src/index.ts -- entire library (single file: class, types, helpers) +test/ -- Tape test files (*.ts) +examples/ -- composition, federation, context-middleware demos +dist/ -- compiled output (do not edit) +``` + +The library is a single-file module. All exports come from `src/index.ts`. The main class is `GraphQLComponent`. Helper functions (`memoize`, `bindResolvers`, `createDataSourceContextInjector`) are module-private. + +--- + +## Key Architectural Concepts + +- **GraphQLComponent class**: the core unit. Encapsulates types, resolvers, data sources, context, and imports. +- **Schema composition**: parent components import children; schemas merge via `stitchSchemas()`. Standalone components use `makeExecutableSchema()` or `buildFederatedSchema()`. +- **Data source injection**: uses `Proxy` to transparently inject per-request context as the first argument to data source methods. Dual type system: `DataSourceDefinition` (implementation, includes context param) and `DataSource` (consumption, context stripped). +- **Query memoization**: Query resolvers are wrapped with a WeakMap-based per-request cache keyed by `path_args`. +- **Context flow**: data source injection -> import data source injection -> middleware -> import context resolution (parallel) -> namespace context. +- **Disposal**: `dispose()` nulls all fields; every getter checks `_assertNotDisposed()`. + +--- + +## Editing Expectations + +- Read `src/index.ts` before modifying; the entire library is one file. +- Changes to core behavior require updating or adding tests in `test/`. +- Run `npm test` after any change. All 156 tests must pass. +- Run `npx tsc --noEmit` to verify type safety without building. +- Prefer editing existing code over creating new files. +- Do not add comments explaining what code does; only explain why when non-obvious. + +--- + +## Code Style (TypeScript) + +- 2-space indentation +- Single quotes for strings +- Semicolons always +- `const` for single assignment, `let` when reassigned, never `var` +- `else` / `catch` on new line +- All scopes wrapped in `{ }` +- No `any` unless suppressed by eslint comment with justification +- Use arrow functions when `this` binding is needed; use `function` keyword otherwise +- Early returns to flatten logic; avoid deep nesting + +--- + +## High-Risk Boundaries + +- **Schema building** (`get schema`): cached and lazy. `invalidateSchema()` clears cache. Transforms are also cached in `_transformedSchema`. Modifying caching behavior can break memoization guarantees. +- **Data source proxy** (`createDataSourceContextInjector`): intercepts all property access. Changes here affect every data source method call in every component. +- **Context flow**: the context getter consolidates data source injection, middleware, import processing, and namespace application. Order matters; changes to sequencing break downstream assumptions. +- **Resolver binding** (`bindResolvers`): binds `this` to the component instance. Query resolvers are additionally memoized. Mutations and subscriptions must NOT be memoized. +- **Module exports**: dual export for ESM and CommonJS compatibility (`module.exports = GraphQLComponent; module.exports.default = GraphQLComponent`). Do not remove either. + +--- + +## Testing + +- Framework: Tape (TAP protocol). Tests use `t.test()` nesting, `t.equal`, `t.deepEqual`, `t.ok`, `t.throws`, `t.doesNotThrow`. +- Test files: `test/*.ts` (context, datasources, dispose, error-handling, import-context-async, performance-regression, schema, test, validation). +- When changing behavior, update an existing test or add a new one that would fail without the change. +- Performance regression tests exist (`test/performance-regression.ts`) -- verify parallel processing timing, memoization, proxy isolation. + +--- + +## Contribution Rules + +- Always write tests when introducing new behavior. +- Warn about breaking changes explicitly. +- Avoid introducing new dependencies; ask before adding one. +- Make the minimal change possible to achieve the requested outcome. +- Create examples under `/examples` when demonstrating complex ideas. +- Call out potential performance, security, and resilience issues when you encounter them. + +--- + +## Quality Rules + +- Improve clarity and quality of code you touch. +- Do not preserve poor design solely because it already exists. +- Avoid expanding scope beyond the requested change. +- Architectural changes are allowed when they simplify the system, but must be explicit. +- Duplication is acceptable until a stable abstraction boundary is clear. +- Be strict at system boundaries; internals may rely on types and invariants. +- Fail fast; prefer explicit outcomes over silent failures. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/README.md b/README.md index 280cf9b..22241ad 100644 --- a/README.md +++ b/README.md @@ -2,280 +2,312 @@ ![Build Status](https://github.com/ExpediaGroup/graphql-component/workflows/Build/badge.svg) -A library for building modular and composable GraphQL schemas through a component-based architecture. +A library for building GraphQL schemas through composable, self-contained components. -## Overview +Each component owns its types, resolvers, data sources, and context. Components compose into larger schemas via [`@graphql-tools/stitch`](https://the-guild.dev/graphql/tools/docs/schema-stitching/stitch-combining-schemas), and standalone components can also build [Apollo Federation](https://www.apollographql.com/docs/federation/) subgraphs. -`graphql-component` enables you to build GraphQL schemas progressively through a tree of components. Each component encapsulates its own schema, resolvers, and data sources, making it easier to build and maintain large GraphQL APIs. +Read more about the architecture in the [blog post](https://medium.com/expedia-group-tech/graphql-component-architecture-principles-homeaway-ede8a58d6fde). -Read more about the architecture principles in our [blog post](https://medium.com/expedia-group-tech/graphql-component-architecture-principles-homeaway-ede8a58d6fde). - -## Features - -- 🔧 **Modular Schema Design**: Build schemas through composable components -- 🔄 **Schema Stitching**: Merge multiple component schemas seamlessly -- 🚀 **Apollo Federation Support**: Build federated subgraphs with component architecture -- 📦 **Data Source Management**: Simplified data source injection and overrides ([guide](./DATASOURCES.md)) -- 🛠️ **Flexible Configuration**: Extensive options for schema customization - -## Installation +## Install ```bash npm install graphql-component ``` -## Quick Start +Requires `graphql ^16.0.0` as a peer dependency. -```javascript -// CommonJS -const GraphQLComponent = require('graphql-component'); +## Basic Usage -// ES Modules / TypeScript +```typescript import GraphQLComponent from 'graphql-component'; -const component = new GraphQLComponent({ - types, - resolvers +const component = new GraphQLComponent({ + types: ` + type Query { + property(id: ID!): Property + } + type Property { + id: ID! + name: String + } + `, + resolvers: { + Query: { + property(_, { id }) { + return { id, name: 'Beach House' }; + } + } + } }); const { schema, context } = component; ``` -## Core Concepts +Both CommonJS (`require('graphql-component')`) and ES module imports work. -### Schema Construction +## Composing Components -A `GraphQLComponent` instance creates a GraphQL schema in one of two ways: +Parent components import children. The resulting schema merges all types and resolvers through schema stitching. -1. **With Imports**: Creates a gateway/aggregate schema by combining imported component schemas with local types/resolvers -2. **Without Imports**: Uses `makeExecutableSchema()` to generate a schema from local types/resolvers +```typescript +const property = new GraphQLComponent({ + types: propertyTypes, + resolvers: propertyResolvers, + dataSources: [new PropertyDataSource()] +}); -### Federation Support +const reviews = new GraphQLComponent({ + types: reviewTypes, + resolvers: reviewResolvers, + dataSources: [new ReviewsDataSource()] +}); -To create Apollo Federation subgraphs, set `federation: true` in the component options: +const gateway = new GraphQLComponent({ + types: gatewayTypes, + resolvers: gatewayResolvers, + imports: [property, reviews] +}); -```javascript -const component = new GraphQLComponent({ - types, - resolvers, - federation: true +const server = new ApolloServer({ + schema: gateway.schema, + context: gateway.context }); ``` -This uses `@apollo/federation`'s `buildFederatedSchema()` instead of `makeExecutableSchema()`. - -## API Reference - -### GraphQLComponent Constructor +Import entries can also be configuration objects for advanced stitching scenarios: ```typescript -new GraphQLComponent(options: IGraphQLComponentOptions) +imports: [ + { + component: property, + configuration: { /* SubschemaConfig options */ } + } +] ``` -#### Options +## Data Sources -- `types`: `string | string[]` - GraphQL SDL type definitions -- `resolvers`: `object` - Resolver map for the schema -- `imports`: `Array` - Components to import -- `context`: `{ namespace: string, factory: Function }` - Context configuration -- `mocks`: `boolean | object` - Enable default or custom mocks -- `dataSources`: `Array` - Data source instances -- `dataSourceOverrides`: `Array` - Override default data sources -- `federation`: `boolean` - Enable Apollo Federation support (default: `false`) -- `pruneSchema`: `boolean` - Enable schema pruning (default: `false`) -- `pruneSchemaOptions`: `object` - Schema pruning options -- `transforms`: `Array` - Schema transformation functions using `@graphql-tools/utils` - -### Component Instance Properties +Data sources use a proxy system to inject per-request context automatically. You define methods with context as the first parameter, but callers never pass it directly. ```typescript -interface IGraphQLComponent { - readonly name: string; - readonly schema: GraphQLSchema; - readonly context: IContextWrapper; - readonly types: TypeSource; - readonly resolvers: IResolvers; - readonly imports?: (IGraphQLComponent | IGraphQLComponentConfigObject)[]; - readonly dataSources?: IDataSource[]; - readonly dataSourceOverrides?: IDataSource[]; - federation?: boolean; -} -``` +import GraphQLComponent, { + DataSourceDefinition, + ComponentContext +} from 'graphql-component'; -### Component Instance Methods +class UsersDataSource implements DataSourceDefinition { + name = 'users'; -#### dispose() + async getUserById(context: ComponentContext, id: string) { + const token = context.auth?.token; + return fetchUser(id, token); + } +} -Cleans up internal references and resources. Call this method when you're done with a component instance to help with garbage collection: +const resolvers = { + Query: { + user(_, { id }, context) { + // context is injected by the proxy; just pass the remaining args + return context.dataSources.users.getUserById(id); + } + } +}; -```typescript -component.dispose(); +const component = new GraphQLComponent({ + types, + resolvers, + dataSources: [new UsersDataSource()] +}); ``` -## Migration from v5.x to v6.x +Two type helpers support this pattern: -### delegateToComponent Removal +- `DataSourceDefinition` for the implementation side, where context is the first parameter. +- `DataSource` for the consumption side, where context is stripped. -In v6.0.0, `delegateToComponent` was removed. Use `@graphql-tools/delegate`'s `delegateToSchema` instead: +For the full guide covering both injected and private data source patterns, overrides, testing strategies, and common pitfalls, see [DATASOURCES.md](./DATASOURCES.md). -```javascript -// Before (v5.x - removed) -// return delegateToComponent(targetComponent, { targetRootField: 'fieldName', args, context, info }); +## Context and Middleware -// After (v6.x+) -import { delegateToSchema } from '@graphql-tools/delegate'; +Components support context middleware that runs before the component's own context is built. Middleware receives the accumulated context and returns a transformed version. -return delegateToSchema({ - schema: targetComponent.schema, - fieldName: 'fieldName', - args, - context, - info +```typescript +const component = new GraphQLComponent({ types, resolvers }); + +component.context.use('auth', async (context) => { + const user = await authenticate(context.req?.headers?.authorization); + return { ...context, user }; }); -``` -For more complex delegation scenarios, refer to the [`@graphql-tools/delegate` documentation](https://the-guild.dev/graphql/tools/docs/schema-delegation). +component.context.use('logging', async (context) => { + logger.info('request', { requestId: context.requestId }); + return context; +}); +``` -## Usage Examples +Middleware runs in registration order, and each step receives the output of the previous one. The full context flow is: data source injection, import data source injection, middleware, import context resolution (parallel), then namespace application. -### Component Extension +Components can namespace their context contribution: -```javascript -class PropertyComponent extends GraphQLComponent { - constructor(options) { - super({ - types, - resolvers, - ...options - }); +```typescript +const component = new GraphQLComponent({ + types, + resolvers, + context: { + namespace: 'property', + factory: async (context) => ({ locale: context.locale }) } -} +}); ``` -### Schema Aggregation +## Federation -```javascript -const { schema, context } = new GraphQLComponent({ - imports: [ - new PropertyComponent(), - new ReviewsComponent() - ] -}); +Set `federation: true` to build an Apollo Federation subgraph instead of a standalone schema: -const server = new ApolloServer({ schema, context }); +```typescript +const component = new GraphQLComponent({ + types, + resolvers, + federation: true +}); ``` -### Data Sources +This switches schema construction from `makeExecutableSchema()` to `buildFederatedSchema()`. The federation flag is per-component; a parent does not propagate it to imported children. -Data sources in `graphql-component` provide automatic context injection and type-safe data access. The library uses a proxy system to seamlessly inject context into your data source methods. +## Mocking -```typescript -import GraphQLComponent, { - DataSourceDefinition, - ComponentContext, - IDataSource -} from 'graphql-component'; +Pass `mocks: true` for default mocks, or provide a mock map: -// Define your data source -class UsersDataSource implements DataSourceDefinition, IDataSource { - name = 'users'; - - // Context is automatically injected as first parameter - async getUserById(context: ComponentContext, id: string) { - // Access context for auth, config, etc. - const token = context.auth?.token; - return fetchUser(id, token); +```typescript +const component = new GraphQLComponent({ + types, + resolvers, + mocks: { + Property: () => ({ + id: '1', + name: 'Test Property' + }) } -} +}); +``` -// Use in resolvers - context injection is automatic -const resolvers = { - Query: { - user(_, { id }, context) { - // No need to pass context manually - it's injected automatically - return context.dataSources.users.getUserById(id); - } - } -}; +Mocks are applied via [`@graphql-tools/mock`](https://the-guild.dev/graphql/tools/docs/mocking). + +## Schema Transforms -// Add to component +Transforms are `SchemaMapper` functions from `@graphql-tools/utils`, applied after schema construction: + +```typescript const component = new GraphQLComponent({ types, resolvers, - dataSources: [new UsersDataSource()] + transforms: [ + { + 'MapperKind.OBJECT_FIELD': (fieldConfig) => { + // modify field config + return fieldConfig; + } + } + ] }); ``` -**Key Concepts:** -- **Two Patterns**: Injected data sources (via context) or private data sources (via `this`) -- **Implementation**: Context must be the first parameter in injected data source methods -- **Usage**: Context is automatically injected for injected data sources -- **Resolver Binding**: Resolvers are bound to component instances, enabling `this` access -- **Testing**: Use `dataSourceOverrides` for injected sources, class extension for private sources -- **Type Safety**: TypeScript interfaces ensure correct implementation +## Schema Pruning -For comprehensive documentation including both patterns, advanced usage, testing strategies, and common gotchas, see the **[Data Sources Guide](./DATASOURCES.md)**. - -### Context Middleware - -Components support context middleware that runs before the component's context is built. This is useful for authentication, logging, or transforming context: +Remove unused types from the constructed schema: ```typescript const component = new GraphQLComponent({ types, - resolvers + resolvers, + pruneSchema: true, + pruneSchemaOptions: { /* PruneSchemaOptions */ } }); +``` -// Add authentication middleware -component.context.use('auth', async (context) => { - const user = await authenticate(context.req?.headers?.authorization); - return { ...context, user }; -}); +## API -// Add logging middleware -component.context.use('logging', async (context) => { - console.log('Building context for request', context.requestId); - return context; -}); +### Constructor Options -// Use the context (middleware runs automatically) -const context = await component.context({ req, requestId: '123' }); -// Context now includes user and logs the request -``` +| Option | Type | Description | +|---|---|---| +| `types` | `string \| string[]` | GraphQL SDL type definitions | +| `resolvers` | `object` | Resolver map. Query resolvers are memoized per-request; mutations and subscriptions are not. | +| `imports` | `Array` | Child components to stitch into this schema | +| `context` | `{ namespace, factory }` | Context namespace and factory function | +| `dataSources` | `Array` | Data source instances for automatic context injection | +| `dataSourceOverrides` | `Array` | Replace default data sources (useful in tests) | +| `mocks` | `boolean \| IMocks` | Enable default or custom mocking | +| `federation` | `boolean` | Build as Apollo Federation subgraph (default: `false`) | +| `pruneSchema` | `boolean` | Remove unused types (default: `false`) | +| `pruneSchemaOptions` | `PruneSchemaOptions` | Options for schema pruning | +| `transforms` | `Array` | Schema transformation functions | -Middleware runs in the order it's added and each middleware receives the transformed context from the previous middleware. +### Instance Properties -## Examples +| Property | Type | Description | +|---|---|---| +| `name` | `string` | Component name (derived from class name) | +| `schema` | `GraphQLSchema` | The constructed, cached schema | +| `context` | `IContextWrapper` | Context function with `.use()` for middleware | +| `types` | `TypeSource` | The component's type definitions | +| `resolvers` | `IResolvers` | The component's resolver map | +| `imports` | `Array` | Imported components | +| `dataSources` | `Array` | Registered data sources | +| `dataSourceOverrides` | `Array` | Data source overrides | +| `disposed` | `boolean` | Whether `dispose()` has been called | -The repository includes working example implementations demonstrating different use cases: +### Instance Methods -### Local Schema Composition -```bash -npm run start-composition -``` -This example shows how to compose multiple GraphQL components into a single schema using schema stitching. +**`invalidateSchema()`** — Clears the cached schema so the next access to `.schema` rebuilds it. Call this after changing transforms or configuration at runtime. -### Federation Example -```bash -npm run start-federation +**`dispose()`** — Tears down the component by nulling all internal references. After disposal, accessing any property throws. Check `component.disposed` before accessing a component whose lifecycle you don't control. + +## Extending via Subclass + +```typescript +class PropertyComponent extends GraphQLComponent { + constructor(options) { + super({ + types: propertyTypes, + resolvers: propertyResolvers, + dataSources: [new PropertyDataSource()], + ...options + }); + } +} ``` -This example demonstrates building Apollo Federation subgraphs using GraphQL components. -Both examples are accessible at `http://localhost:4000/graphql` when running. +## Migration from v5 to v6 -You can find the complete example code in the [`examples/`](./examples/) directory. +`delegateToComponent` was removed. Use `@graphql-tools/delegate` directly: -## Repository Structure +```typescript +import { delegateToSchema } from '@graphql-tools/delegate'; -- `src/` - Core library code -- `examples/` - - `composition/` - Schema composition example - - `federation/` - Federation implementation example +// In a resolver: +return delegateToSchema({ + schema: targetComponent.schema, + fieldName: 'fieldName', + args, + context, + info +}); +``` + +See the [`@graphql-tools/delegate` docs](https://the-guild.dev/graphql/tools/docs/schema-delegation) for advanced delegation patterns. + +## Examples + +The repo includes working examples you can run locally: -## Contributing +```bash +npm run start-composition # schema stitching across components +npm run start-federation # Apollo Federation subgraph setup +``` -Please read our contributing guidelines (link) for details on our code of conduct and development process. +Both start a server at `http://localhost:4000/graphql`. Source is in the [`examples/`](./examples/) directory, which also includes a [context middleware example](./examples/context-middleware/). ## License -This project is licensed under the MIT License - see the LICENSE file for details. +MIT diff --git a/eslint.config.mjs b/eslint.config.mjs index ba00bc5..9c7f1a2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -10,7 +10,7 @@ export default [ ...tseslint.configs.recommended, { rules: { - "@typescript-eslint/no-explicit-any": "off" + "@typescript-eslint/no-explicit-any": "warn" } } ]; \ No newline at end of file diff --git a/examples/context-middleware/README.md b/examples/context-middleware/README.md new file mode 100644 index 0000000..88c7130 --- /dev/null +++ b/examples/context-middleware/README.md @@ -0,0 +1,482 @@ +# Context & Middleware Example + +This example demonstrates the powerful context and middleware system in `graphql-component`. It shows how to implement authentication, logging, request tracking, and namespace contexts using the `context.use()` middleware chain. + +## What You'll Learn + +- **Middleware Chaining**: How to chain multiple middleware functions using `context.use()` +- **Authentication Patterns**: JWT validation, role-based authorization, and auth helpers +- **Request Tracking**: Capturing request metadata and generating unique request IDs +- **Namespace Contexts**: Organizing context data using component namespaces +- **Logging Integration**: Structured logging with request correlation +- **Security Patterns**: Protecting resolvers with authentication and authorization +- **Data Source Integration**: How middleware can access data sources for authentication and other operations + +## Architecture Overview + +The example consists of several components working together: + +``` +┌─────────────────────┐ +│ Apollo Server │ +└─────────┬───────────┘ + │ +┌─────────▼───────────┐ +│ Main Component │ ← Imports all sub-components +├─────────────────────┤ +│ • Auth Component │ ← JWT auth, user management +│ • Logging Component │ ← Request/response logging +│ • Namespace Context │ ← User prefs, business data +└─────────┬───────────┘ + │ +┌─────────▼───────────┐ +│ Middleware Chain │ +├─────────────────────┤ +│ 1. Request Tracking │ ← Generate request ID, capture metadata +│ 2. Authentication │ ← Validate JWT, set user context +│ 3. Business Context │ ← Tenant info, feature flags +│ 4. Analytics │ ← Session tracking, user analytics +│ 5. Logging │ ← Log request start, errors +└─────────────────────┘ +``` + +## Components + +### 1. Auth Component (`/auth-component/`) + +Handles user authentication and authorization: + +- **JWT Token Management**: Login, validation, expiration +- **User Storage**: Mock user database with roles +- **Authorization Helpers**: `requireAuth()`, `requireRole()`, `hasRole()` +- **Protected Resolvers**: Queries that require authentication or specific roles + +**Key Files:** +- `datasource.ts` - User management and JWT operations +- `schema.graphql` - Auth mutations and protected queries +- `resolvers.ts` - Login, logout, and protected data resolvers + +### 2. Logging Component (`/logging-component/`) + +Provides structured logging and request tracking: + +- **Log Storage**: In-memory log storage (production would use external service) +- **Request Correlation**: Links logs to specific requests via request ID +- **Log Levels**: Info, warn, error, debug with filtering +- **GraphQL Integration**: Query logs, view request information + +**Key Files:** +- `datasource.ts` - Log storage and querying operations +- `schema.graphql` - Log queries and request info types +- `resolvers.ts` - Protected log access with role checking + +### 3. Namespace Component (`/namespace-component/`) + +Demonstrates namespace context organization: + +- **User Preferences**: Theme, language, timezone settings +- **Business Context**: Tenant info, organization data, feature flags +- **Analytics Context**: Session tracking, device info, geo data +- **Context Factory**: Shows how to build namespace context from global context + +**Key Files:** +- `types.ts` - Context interface definitions and module augmentation +- `schema.graphql` - Queries for accessing namespace data +- `index.ts` - Namespace context factory implementation + +### 4. Main Server (`/server/`) + +Orchestrates the complete middleware chain: + +- **Component Integration**: Imports and combines all sub-components +- **Middleware Chain**: Sets up 5-layer middleware processing +- **Apollo Server**: Complete server setup with context, error handling, plugins +- **Request/Response Logging**: Server-level request lifecycle logging + +## Middleware Chain + +The middleware chain processes each request through 5 layers: + +### 1. Request Tracking Middleware +```typescript +component.context.use('request-tracking', async (context) => { + const requestId = uuidv4(); + const startTime = Date.now(); + + // Capture request metadata + const request = { + requestId, + startTime, + userAgent: context.req?.headers?.['user-agent'], + ip: context.req?.ip || context.req?.connection?.remoteAddress, + operation: context.operationName, + variables: context.variables + }; + + return { ...context, request }; +}); +``` + +### 2. Authentication Middleware +```typescript +component.context.use('auth', async (context) => { + const token = context.req?.headers?.authorization?.replace('Bearer ', ''); + + let user = null; + let isAuthenticated = false; + + if (token && context.dataSources?.auth) { + // Data source methods require context as first parameter + user = await context.dataSources.auth.validateToken(context, token); + isAuthenticated = !!user; + } + + // Create auth helper functions + const authContext = { + token, + user: user || undefined, + isAuthenticated, + hasRole: (role: string) => user?.roles?.includes(role) || false, + requireAuth: () => { + if (!isAuthenticated) throw new Error('Authentication required'); + }, + requireRole: (role: string) => { + if (!isAuthenticated) throw new Error('Authentication required'); + if (!user?.roles?.includes(role)) throw new Error(`Role '${role}' required`); + } + }; + + return { ...context, auth: authContext }; +}); +``` + +### 3. Business Context Middleware +```typescript +component.context.use('business', async (context) => { + const businessContext = { + tenantId: context.req?.headers?.['x-tenant-id'] || 'default-tenant', + organizationId: context.req?.headers?.['x-org-id'] || 'default-org', + environment: process.env.NODE_ENV || 'development', + features: ['feature-a', 'feature-b'], // Would come from feature flags + quotas: { + maxUsers: 100, + maxProjects: 50, + storageLimit: 1024 * 1024 * 1024 // 1GB + } + }; + + return { ...context, business: businessContext }; +}); +``` + +### 4. Analytics Context Middleware +```typescript +component.context.use('analytics', async (context) => { + const analyticsContext = { + sessionId: context.req?.headers?.['x-session-id'] || uuidv4(), + userId: context.auth?.user?.id, + deviceId: context.req?.headers?.['x-device-id'] || 'unknown', + userAgent: context.req?.headers?.['user-agent'] || 'unknown', + country: context.req?.headers?.['cf-ipcountry'], // Cloudflare header + city: context.req?.headers?.['cf-ipcity'], // Cloudflare header + trackingEnabled: context.req?.headers?.['dnt'] !== '1' // Do Not Track + }; + + return { ...context, analytics: analyticsContext }; +}); +``` + +### 5. Logging Middleware +```typescript +component.context.use('logging', async (context) => { + // Log request start + if (context.dataSources?.logging) { + // Data source methods require context as first parameter + await context.dataSources.logging.logEntry(context, { + level: 'info', + message: `GraphQL request started: ${context.request?.operation || 'unknown'}`, + requestId: context.request?.requestId || 'unknown', + timestamp: new Date().toISOString(), + operation: context.request?.operation, + metadata: { + userAgent: context.request?.userAgent, + userId: context.auth?.user?.id, + variables: context.request?.variables + } + }); + } + + return context; +}); +``` + +## Running the Example + +### Start the Server + +```bash +# Install dependencies from project root (if needed) +cd ../../ # Go to project root +npm install + +# Start the middleware example server +cd examples/context-middleware +npx ts-node server/index.ts +``` + +The server will start on `http://localhost:4000` with GraphQL playground available. + +### Example Operations + +#### 1. Login and Get Token +```graphql +mutation { + login(input: { username: "admin", password: "admin123" }) { + token + user { + id + username + roles + } + } +} +``` + +#### 2. Access Protected Data (requires Authorization header) +```graphql +query { + protectedData + me { + username + roles + } +} +``` + +**Headers:** +```json +{ + "Authorization": "Bearer " +} +``` + +#### 3. View Context Information +```graphql +query { + currentRequest { + requestId + operation + duration + userAgent + ip + } + + myPreferences { + theme + language + timezone + notifications { + email + push + sms + } + } + + businessInfo { + tenantId + organizationId + environment + features + quotas { + maxUsers + maxProjects + storageLimit + } + } + + contextSummary +} +``` + +**Headers:** +```json +{ + "X-Tenant-ID": "my-company", + "X-Session-ID": "session-abc123", + "X-Device-ID": "device-xyz789" +} +``` + +#### 4. Admin-Only Operations (requires admin role) +```graphql +query { + adminOnlyData + + recentLogs(limit: 10) { + level + message + timestamp + requestId + userId + operation + metadata + } + + logsByLevel(level: ERROR) { + level + message + timestamp + operation + } +} +``` + +#### 5. View Request Logs +```graphql +query { + logsByRequestId(requestId: "your-request-id") { + level + message + timestamp + duration + metadata + } +} +``` + +### Test Users + +The example includes these test users: + +| Username | Password | Roles | Description | +|----------|----------|-------|-------------| +| `admin` | `admin123` | `admin`, `user` | Full access to all operations | +| `user` | `user123` | `user` | Basic user access, no admin functions | +| `guest` | `guest123` | `guest` | Limited access | + +### Custom Headers + +Test different middleware behaviors with these headers: + +| Header | Purpose | Example | +|--------|---------|---------| +| `Authorization` | JWT authentication | `Bearer ` | +| `X-Tenant-ID` | Multi-tenant context | `acme-corp` | +| `X-Org-ID` | Organization context | `engineering-team` | +| `X-Session-ID` | Session tracking | `session-abc123` | +| `X-Device-ID` | Device tracking | `device-mobile-1` | +| `CF-IPCountry` | Geo location (Cloudflare) | `US` | +| `CF-IPCity` | City location (Cloudflare) | `New York` | +| `DNT` | Do Not Track | `1` (disables tracking) | + +## Testing + +Run the main project test suite which includes middleware tests: + +```bash +cd ../../ # Go to project root +npm test +``` + +The middleware functionality is tested in the main project tests, including: + +- **DataSource Access**: Verifying dataSources are available in middleware +- **Middleware Chaining**: Testing that middleware runs in the correct order +- **Context Building**: Ensuring context is properly constructed through the middleware pipeline +- **Context Tests**: Namespace context access, data composition + +### Test Files + +- `test/auth.test.ts` - Authentication and authorization testing +- `test/logging.test.ts` - Logging functionality testing +- `test/middleware.test.ts` - Integration testing of middleware chain + +## Key Concepts Demonstrated + +### 1. Context Middleware Chaining + +The `context.use()` method allows you to build a middleware chain where each middleware: + +- Receives the context from the previous middleware +- Can add, modify, or transform context data +- Returns the enhanced context for the next middleware +- Has access to data sources and previous context additions + +### 2. Authentication Patterns + +- **JWT Token Validation**: Parsing and validating JWT tokens +- **Helper Functions**: Creating auth helpers attached to context +- **Role-Based Access**: Using roles for fine-grained authorization +- **Protected Resolvers**: Requiring auth/roles in resolver functions + +### 3. Request Correlation + +- **Request IDs**: Unique identifiers for tracking requests +- **Log Correlation**: Linking logs to specific requests +- **Request Metadata**: Capturing user agent, IP, operation details +- **Duration Tracking**: Measuring request processing time + +### 4. Namespace Organization + +- **Context Namespaces**: Organizing related context data +- **Module Augmentation**: Extending TypeScript interfaces +- **Factory Functions**: Building namespace context from global context +- **Separation of Concerns**: Different components managing different context aspects + +### 5. Data Source Integration + +- **Context Injection**: Automatic context injection into data source methods (context as first parameter) +- **Middleware Access**: Data sources available in middleware through `context.dataSources` +- **Cross-Component Data**: Data sources shared across imported components +- **Testing Overrides**: Using `dataSourceOverrides` for testing +- **No Temporary Instances**: Middleware can directly use actual component data sources + +## Production Considerations + +### Security + +- **JWT Secrets**: Use strong, rotating secrets in production +- **HTTPS Only**: Ensure tokens are only sent over HTTPS +- **Token Expiration**: Implement proper token expiration and refresh +- **Rate Limiting**: Add rate limiting middleware +- **Input Validation**: Validate all inputs and headers + +### Performance + +- **Caching**: Cache user data, permissions, and preferences +- **Connection Pooling**: Use connection pooling for databases +- **Async Processing**: Use background jobs for heavy logging operations +- **Monitoring**: Add performance monitoring and alerting + +### Scalability + +- **External Storage**: Use Redis/database for sessions and logs +- **Distributed Tracing**: Implement distributed tracing for microservices +- **Load Balancing**: Configure load balancing for multiple instances +- **Graceful Shutdown**: Handle graceful shutdown of server instances + +### Observability + +- **Structured Logging**: Use structured logging with correlation IDs +- **Metrics**: Collect metrics on authentication, errors, and performance +- **Alerting**: Set up alerts for authentication failures and errors +- **Health Checks**: Implement health check endpoints + +## Next Steps + +After exploring this example, consider: + +1. **Advanced Authentication**: Implement OAuth, SAML, or other auth providers +2. **Permissions System**: Build a more sophisticated permissions/RBAC system +3. **Caching Strategies**: Add Redis for session and data caching +4. **Microservices**: Split components into separate services with federation +5. **Real Monitoring**: Integrate with production monitoring tools +6. **Database Integration**: Replace mock data with real database operations + +## Related Examples + +- **Federation Example**: See how middleware works in a federated architecture +- **Composition Example**: Learn about component composition patterns +- **Data Source Patterns**: Explore advanced data source implementations + +This example provides a solid foundation for building production-ready GraphQL APIs with proper authentication, logging, and context management using `graphql-component`. \ No newline at end of file diff --git a/examples/context-middleware/auth-component/datasource.ts b/examples/context-middleware/auth-component/datasource.ts new file mode 100644 index 0000000..cc48d96 --- /dev/null +++ b/examples/context-middleware/auth-component/datasource.ts @@ -0,0 +1,136 @@ +import { ComponentContext, DataSourceDefinition, IDataSource } from '../../../src'; +import { User, LoginInput, AuthPayload } from './types'; + +// Mock JWT operations (in real app, use proper JWT library) +const JWT_SECRET = 'demo-secret-key'; + +interface JWTPayload { + userId: string; + username: string; + roles: string[]; + iat: number; + exp: number; +} + +export default class AuthDataSource implements DataSourceDefinition, IDataSource { + name = 'auth'; + + // Mock user database + private users: Map = new Map([ + ['1', { + id: '1', + username: 'admin', + email: 'admin@example.com', + roles: ['admin', 'user'], + createdAt: new Date().toISOString(), + password: 'admin123' // In real app, this would be hashed + }], + ['2', { + id: '2', + username: 'user', + email: 'user@example.com', + roles: ['user'], + createdAt: new Date().toISOString(), + password: 'user123' + }], + ['3', { + id: '3', + username: 'guest', + email: 'guest@example.com', + roles: ['guest'], + createdAt: new Date().toISOString(), + password: 'guest123' + }] + ]); + + async login(context: ComponentContext, input: LoginInput): Promise { + const user = Array.from(this.users.values()) + .find(u => u.username === input.username); + + if (!user || user.password !== input.password) { + throw new Error('Invalid credentials'); + } + + const token = this.generateToken({ + userId: user.id, + username: user.username, + roles: user.roles, + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + (60 * 60 * 24) // 24 hours + }); + + const { password, ...publicUser } = user; + return { token, user: publicUser }; + } + + async validateToken(context: ComponentContext, token: string): Promise { + try { + console.log('🔍 AuthDataSource.validateToken called with token:', token?.substring(0, 20) + '...'); + const payload = this.verifyToken(token); + console.log('🔍 JWT payload:', payload); + + const user = this.users.get(payload.userId); + console.log('🔍 Found user:', user ? user.username : 'null'); + + if (!user) { + console.log('❌ User not found for userId:', payload.userId); + return null; + } + + const { password, ...publicUser } = user; + console.log('✅ Token validation successful for user:', publicUser.username); + return publicUser; + } catch (error) { + console.log('❌ Token validation failed:', error.message); + return null; + } + } + + async getUserById(context: ComponentContext, id: string): Promise { + const user = this.users.get(id); + if (!user) { + return null; + } + + const { password, ...publicUser } = user; + return publicUser; + } + + async getAllUsers(context: ComponentContext): Promise { + return Array.from(this.users.values()).map(({ password, ...user }) => user); + } + + // Mock JWT implementation (use real JWT library in production) + private generateToken(payload: JWTPayload): string { + // This is a simplified mock - use proper JWT library like jsonwebtoken + const header = { alg: 'HS256', typ: 'JWT' }; + const encodedHeader = Buffer.from(JSON.stringify(header)).toString('base64'); + const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64'); + const signature = Buffer.from(`${encodedHeader}.${encodedPayload}.${JWT_SECRET}`).toString('base64'); + + return `${encodedHeader}.${encodedPayload}.${signature}`; + } + + private verifyToken(token: string): JWTPayload { + // This is a simplified mock - use proper JWT library like jsonwebtoken + const parts = token.split('.'); + if (parts.length !== 3) { + throw new Error('Invalid token format'); + } + + const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString()); + + // Check expiration + if (payload.exp < Math.floor(Date.now() / 1000)) { + throw new Error('Token expired'); + } + + // Verify signature (simplified) + const expectedSignature = Buffer.from(`${parts[0]}.${parts[1]}.${JWT_SECRET}`).toString('base64'); + if (parts[2] !== expectedSignature) { + throw new Error('Invalid token signature'); + } + + return payload; + } +} \ No newline at end of file diff --git a/examples/context-middleware/auth-component/index.ts b/examples/context-middleware/auth-component/index.ts new file mode 100644 index 0000000..565825e --- /dev/null +++ b/examples/context-middleware/auth-component/index.ts @@ -0,0 +1,21 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import GraphQLComponent from '../../../src'; +import AuthDataSource from './datasource'; +import { resolvers } from './resolvers'; + +const types = readFileSync(join(__dirname, 'schema.graphql'), 'utf-8'); + +export default class AuthComponent extends GraphQLComponent { + constructor(options = {}) { + super({ + types, + resolvers, + dataSources: [new AuthDataSource()], + ...options + }); + } +} + +export { AuthDataSource }; +export * from './types'; \ No newline at end of file diff --git a/examples/context-middleware/auth-component/resolvers.ts b/examples/context-middleware/auth-component/resolvers.ts new file mode 100644 index 0000000..fcd4fe7 --- /dev/null +++ b/examples/context-middleware/auth-component/resolvers.ts @@ -0,0 +1,55 @@ +import { IResolvers } from '@graphql-tools/utils'; +import { ComponentContext } from '../../../src'; +import { AuthContext, LoginInput } from './types'; + +interface ExtendedContext extends ComponentContext { + auth: AuthContext; +} + +export const resolvers: IResolvers = { + Query: { + // Public field - anyone can call this if they have a valid token + me(_: any, __: any, context: ExtendedContext) { + if (!context.auth.isAuthenticated) { + return null; + } + return context.auth.user; + }, + + // Protected field - requires authentication + protectedData(_: any, __: any, context: ExtendedContext) { + // Use the auth helper to require authentication + context.auth.requireAuth(); + + return `This is protected data accessible to user: ${context.auth.user?.username}`; + }, + + // Admin-only field - requires admin role + adminOnlyData(_: any, __: any, context: ExtendedContext) { + // Require both authentication and admin role + context.auth.requireAuth(); + context.auth.requireRole('admin'); + + return 'This is admin-only data that only admins can see'; + }, + + // Protected field that lists all users - requires authentication + async users(_: any, __: any, context: ExtendedContext) { + context.auth.requireAuth(); + return context.dataSources.auth.getAllUsers(); + } + }, + + Mutation: { + async login(_: any, { input }: { input: LoginInput }, context: ComponentContext) { + return context.dataSources.auth.login(input); + }, + + logout(_: any, __: any, context: ExtendedContext) { + // In a real app, you might invalidate the token in a blacklist + // For this example, we just return true + // The client should remove the token from storage + return true; + } + } +}; \ No newline at end of file diff --git a/examples/context-middleware/auth-component/schema.graphql b/examples/context-middleware/auth-component/schema.graphql new file mode 100644 index 0000000..1aff49b --- /dev/null +++ b/examples/context-middleware/auth-component/schema.graphql @@ -0,0 +1,29 @@ +type User { + id: ID! + username: String! + email: String! + roles: [String!]! + createdAt: String! +} + +type AuthPayload { + token: String! + user: User! +} + +input LoginInput { + username: String! + password: String! +} + +type Query { + me: User + users: [User!]! + protectedData: String! + adminOnlyData: String! +} + +type Mutation { + login(input: LoginInput!): AuthPayload! + logout: Boolean! +} \ No newline at end of file diff --git a/examples/context-middleware/auth-component/types.ts b/examples/context-middleware/auth-component/types.ts new file mode 100644 index 0000000..d93cf54 --- /dev/null +++ b/examples/context-middleware/auth-component/types.ts @@ -0,0 +1,26 @@ +export interface User { + id: string; + username: string; + email: string; + roles: string[]; + createdAt: string; +} + +export interface AuthContext { + token?: string; + user?: User; + isAuthenticated: boolean; + hasRole: (role: string) => boolean; + requireAuth: () => void; + requireRole: (role: string) => void; +} + +export interface LoginInput { + username: string; + password: string; +} + +export interface AuthPayload { + token: string; + user: User; +} \ No newline at end of file diff --git a/examples/context-middleware/logging-component/datasource.ts b/examples/context-middleware/logging-component/datasource.ts new file mode 100644 index 0000000..60f68f8 --- /dev/null +++ b/examples/context-middleware/logging-component/datasource.ts @@ -0,0 +1,65 @@ +import { ComponentContext, DataSourceDefinition, IDataSource } from '../../../src'; +import { LogEntry, RequestContext } from './types'; + +export default class LoggingDataSource implements DataSourceDefinition, IDataSource { + name = 'logging'; + + // In a real app, this would write to a file, database, or external service + private logs: LogEntry[] = []; + + async logEntry(context: ComponentContext, entry: LogEntry): Promise { + // Add additional context from the request + const enhancedEntry: LogEntry = { + ...entry, + timestamp: new Date().toISOString(), + // Extract user info from context if available + userId: (context as any).auth?.user?.id, + // Extract request info + requestId: (context as any).request?.requestId || 'unknown', + }; + + this.logs.push(enhancedEntry); + + // In production, you'd write to your logging system + console.log(`[${enhancedEntry.level.toUpperCase()}] ${enhancedEntry.message}`, { + requestId: enhancedEntry.requestId, + userId: enhancedEntry.userId, + timestamp: enhancedEntry.timestamp, + metadata: enhancedEntry.metadata + }); + } + + async getLogsByRequestId(context: ComponentContext, requestId: string): Promise { + return this.logs.filter(log => log.requestId === requestId); + } + + async getRecentLogs(context: ComponentContext, limit: number = 100): Promise { + return this.logs + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) + .slice(0, limit); + } + + async getLogsByUserId(context: ComponentContext, userId: string): Promise { + return this.logs.filter(log => log.userId === userId); + } + + async getLogsByLevel(context: ComponentContext, level: LogEntry['level']): Promise { + return this.logs.filter(log => log.level === level); + } + + // Helper method to calculate request duration + calculateDuration(context: ComponentContext, startTime: number): number { + return Date.now() - startTime; + } + + // In production, you might have methods for log rotation, cleanup, etc. + async cleanup(context: ComponentContext, olderThanDays: number = 30): Promise { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - olderThanDays); + + const initialCount = this.logs.length; + this.logs = this.logs.filter(log => new Date(log.timestamp) > cutoffDate); + + return initialCount - this.logs.length; + } +} \ No newline at end of file diff --git a/examples/context-middleware/logging-component/index.ts b/examples/context-middleware/logging-component/index.ts new file mode 100644 index 0000000..2d31f08 --- /dev/null +++ b/examples/context-middleware/logging-component/index.ts @@ -0,0 +1,21 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import GraphQLComponent from '../../../src'; +import LoggingDataSource from './datasource'; +import { resolvers } from './resolvers'; + +const types = readFileSync(join(__dirname, 'schema.graphql'), 'utf-8'); + +export default class LoggingComponent extends GraphQLComponent { + constructor(options = {}) { + super({ + types, + resolvers, + dataSources: [new LoggingDataSource()], + ...options + }); + } +} + +export { LoggingDataSource }; +export * from './types'; \ No newline at end of file diff --git a/examples/context-middleware/logging-component/resolvers.ts b/examples/context-middleware/logging-component/resolvers.ts new file mode 100644 index 0000000..dd47676 --- /dev/null +++ b/examples/context-middleware/logging-component/resolvers.ts @@ -0,0 +1,80 @@ +import { IResolvers } from '@graphql-tools/utils'; +import { ComponentContext } from '../../../src'; +import { LogEntry } from './types'; + +interface ExtendedContext extends ComponentContext { + auth?: { + isAuthenticated: boolean; + user?: any; + requireAuth: () => void; + requireRole: (role: string) => void; + }; + request?: { + requestId: string; + startTime: number; + userAgent?: string; + ip?: string; + operation?: string; + variables?: any; + }; +} + +export const resolvers: IResolvers = { + Query: { + // Admin-only query to view recent logs + async recentLogs(_: any, { limit = 100 }: { limit?: number }, context: ExtendedContext) { + // Require admin access for viewing logs + if (context.auth) { + context.auth.requireAuth(); + context.auth.requireRole('admin'); + } + + return context.dataSources.logging.getRecentLogs(limit); + }, + + // Get logs for a specific request (users can see their own request logs) + async logsByRequestId(_: any, { requestId }: { requestId: string }, context: ExtendedContext) { + if (context.auth) { + context.auth.requireAuth(); + } + + return context.dataSources.logging.getLogsByRequestId(requestId); + }, + + // Admin-only query to filter logs by level + async logsByLevel(_: any, { level }: { level: string }, context: ExtendedContext) { + if (context.auth) { + context.auth.requireAuth(); + context.auth.requireRole('admin'); + } + + return context.dataSources.logging.getLogsByLevel(level.toLowerCase() as LogEntry['level']); + }, + + // Get current request information + currentRequest(_: any, __: any, context: ExtendedContext) { + if (!context.request) { + return null; + } + + const duration = context.dataSources.logging.calculateDuration(context.request.startTime); + + return { + requestId: context.request.requestId, + startTime: new Date(context.request.startTime).toISOString(), + userAgent: context.request.userAgent, + ip: context.request.ip, + operation: context.request.operation, + variables: context.request.variables ? JSON.stringify(context.request.variables) : null, + duration + }; + } + }, + + LogEntry: { + // Transform metadata object to JSON string + metadata(logEntry: LogEntry) { + return logEntry.metadata ? JSON.stringify(logEntry.metadata) : null; + } + } +}; \ No newline at end of file diff --git a/examples/context-middleware/logging-component/schema.graphql b/examples/context-middleware/logging-component/schema.graphql new file mode 100644 index 0000000..3ca4546 --- /dev/null +++ b/examples/context-middleware/logging-component/schema.graphql @@ -0,0 +1,41 @@ +type LogEntry { + level: LogLevel! + message: String! + requestId: String! + timestamp: String! + duration: Int + userId: String + operation: String + metadata: String # JSON string of metadata +} + +enum LogLevel { + INFO + WARN + ERROR + DEBUG +} + +type RequestInfo { + requestId: String! + startTime: String! + userAgent: String + ip: String + operation: String + variables: String # JSON string of variables + duration: Int +} + +type Query { + # Get recent log entries (requires admin role) + recentLogs(limit: Int = 100): [LogEntry!]! + + # Get logs for a specific request + logsByRequestId(requestId: String!): [LogEntry!]! + + # Get logs by level (requires admin role) + logsByLevel(level: LogLevel!): [LogEntry!]! + + # Get current request info + currentRequest: RequestInfo +} \ No newline at end of file diff --git a/examples/context-middleware/logging-component/types.ts b/examples/context-middleware/logging-component/types.ts new file mode 100644 index 0000000..38dd035 --- /dev/null +++ b/examples/context-middleware/logging-component/types.ts @@ -0,0 +1,32 @@ +export interface RequestContext { + requestId: string; + startTime: number; + userAgent?: string; + ip?: string; + operation?: string; + variables?: any; +} + +export interface LogEntry { + level: 'info' | 'warn' | 'error' | 'debug'; + message: string; + requestId: string; + timestamp: string; + duration?: number; + userId?: string; + operation?: string; + error?: Error; + metadata?: Record; +} + +export interface Logger { + info: (message: string, metadata?: Record) => void; + warn: (message: string, metadata?: Record) => void; + error: (message: string, error?: Error, metadata?: Record) => void; + debug: (message: string, metadata?: Record) => void; +} + +export interface LoggingContext { + logger: Logger; + request: RequestContext; +} \ No newline at end of file diff --git a/examples/context-middleware/namespace-component/index.ts b/examples/context-middleware/namespace-component/index.ts new file mode 100644 index 0000000..b56e277 --- /dev/null +++ b/examples/context-middleware/namespace-component/index.ts @@ -0,0 +1,55 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import GraphQLComponent from '../../../src'; +import { resolvers } from './resolvers'; +import { UserPreferences, BusinessContext, AnalyticsContext } from './types'; + +const types = readFileSync(join(__dirname, 'schema.graphql'), 'utf-8'); + +export default class NamespaceComponent extends GraphQLComponent { + constructor(options = {}) { + super({ + types, + resolvers, + // Demonstrate namespace context configuration + context: { + namespace: 'userPrefs', + factory: async function (globalContext: any): Promise { + // This would typically come from a user preferences service + // based on the authenticated user + const userId = globalContext.auth?.user?.id; + + if (!userId) { + return { + theme: 'light', + language: 'en', + timezone: 'UTC', + notifications: { + email: true, + push: false, + sms: false + } + }; + } + + // Mock user preferences based on user ID + const preferences: UserPreferences = { + theme: userId === '1' ? 'dark' : 'light', + language: userId === '1' ? 'en' : 'es', + timezone: userId === '1' ? 'America/New_York' : 'Europe/Madrid', + notifications: { + email: true, + push: userId === '1', + sms: false + } + }; + + return preferences; + } + }, + ...options + }); + } +} + +export * from './types'; \ No newline at end of file diff --git a/examples/context-middleware/namespace-component/resolvers.ts b/examples/context-middleware/namespace-component/resolvers.ts new file mode 100644 index 0000000..7f69b11 --- /dev/null +++ b/examples/context-middleware/namespace-component/resolvers.ts @@ -0,0 +1,69 @@ +import { IResolvers } from '@graphql-tools/utils'; +import { ComponentContext } from '../../../src'; +import { UserPreferences, BusinessContext, AnalyticsContext } from './types'; + +interface ExtendedContext extends ComponentContext { + userPrefs?: UserPreferences; + business?: BusinessContext; + analytics?: AnalyticsContext; +} + +export const resolvers: IResolvers = { + Query: { + // Access user preferences namespace + myPreferences(_: any, __: any, context: ExtendedContext) { + if (!context.userPrefs) { + return null; + } + + return context.userPrefs; + }, + + // Access business context namespace + businessInfo(_: any, __: any, context: ExtendedContext) { + if (!context.business) { + return null; + } + + return { + tenantId: context.business.tenantId, + organizationId: context.business.organizationId, + environment: context.business.environment.toUpperCase(), + features: context.business.features, + quotas: context.business.quotas + }; + }, + + // Access analytics context namespace + analyticsInfo(_: any, __: any, context: ExtendedContext) { + if (!context.analytics) { + return null; + } + + return context.analytics; + }, + + // Demonstrate using multiple namespace contexts + contextSummary(_: any, __: any, context: ExtendedContext) { + const parts: string[] = []; + + if (context.userPrefs) { + parts.push(`User prefers ${context.userPrefs.theme} theme in ${context.userPrefs.language}`); + } + + if (context.business) { + parts.push(`Organization ${context.business.organizationId} in ${context.business.environment} environment`); + } + + if (context.analytics) { + parts.push(`Session ${context.analytics.sessionId} from ${context.analytics.country || 'unknown location'}`); + } + + if (parts.length === 0) { + return 'No namespace contexts are available'; + } + + return parts.join('. '); + } + } +}; \ No newline at end of file diff --git a/examples/context-middleware/namespace-component/schema.graphql b/examples/context-middleware/namespace-component/schema.graphql new file mode 100644 index 0000000..6fdf891 --- /dev/null +++ b/examples/context-middleware/namespace-component/schema.graphql @@ -0,0 +1,61 @@ +type UserPreferences { + theme: Theme! + language: String! + timezone: String! + notifications: NotificationPreferences! +} + +enum Theme { + LIGHT + DARK +} + +type NotificationPreferences { + email: Boolean! + push: Boolean! + sms: Boolean! +} + +type BusinessInfo { + tenantId: String! + organizationId: String! + environment: Environment! + features: [String!]! + quotas: ResourceQuotas! +} + +enum Environment { + DEVELOPMENT + STAGING + PRODUCTION +} + +type ResourceQuotas { + maxUsers: Int! + maxProjects: Int! + storageLimit: Int! +} + +type AnalyticsInfo { + sessionId: String! + userId: String + deviceId: String! + userAgent: String! + country: String + city: String + trackingEnabled: Boolean! +} + +type Query { + # Access user preferences from namespace context + myPreferences: UserPreferences + + # Access business context information + businessInfo: BusinessInfo + + # Access analytics context + analyticsInfo: AnalyticsInfo + + # Demonstrate using multiple namespace contexts in a single resolver + contextSummary: String! +} \ No newline at end of file diff --git a/examples/context-middleware/namespace-component/types.ts b/examples/context-middleware/namespace-component/types.ts new file mode 100644 index 0000000..1f6d00e --- /dev/null +++ b/examples/context-middleware/namespace-component/types.ts @@ -0,0 +1,42 @@ +export interface UserPreferences { + theme: 'light' | 'dark'; + language: string; + timezone: string; + notifications: { + email: boolean; + push: boolean; + sms: boolean; + }; +} + +export interface BusinessContext { + tenantId: string; + organizationId: string; + environment: 'development' | 'staging' | 'production'; + features: string[]; + quotas: { + maxUsers: number; + maxProjects: number; + storageLimit: number; + }; +} + +export interface AnalyticsContext { + sessionId: string; + userId?: string; + deviceId: string; + userAgent: string; + country?: string; + city?: string; + trackingEnabled: boolean; +} + +// Extending ComponentContext to show how namespaces work +declare module '../../../src' { + interface ComponentContext { + // Namespaced contexts + userPrefs?: UserPreferences; + business?: BusinessContext; + analytics?: AnalyticsContext; + } +} \ No newline at end of file diff --git a/examples/context-middleware/server/index.ts b/examples/context-middleware/server/index.ts new file mode 100644 index 0000000..d8b53d3 --- /dev/null +++ b/examples/context-middleware/server/index.ts @@ -0,0 +1,227 @@ +import { ApolloServer } from 'apollo-server'; +import { v4 as uuidv4 } from 'uuid'; +import GraphQLComponent from '../../../src'; + +// Import our components +import AuthComponent from '../auth-component'; +import LoggingComponent from '../logging-component'; +import NamespaceComponent from '../namespace-component'; + +// Import types for context middleware +import { AuthContext } from '../auth-component/types'; +import { LoggingContext, RequestContext } from '../logging-component/types'; +import { BusinessContext, AnalyticsContext } from '../namespace-component/types'; + +// Create the main component that imports all sub-components +class MainComponent extends GraphQLComponent { + constructor() { + const authComponent = new AuthComponent(); + const loggingComponent = new LoggingComponent(); + const namespaceComponent = new NamespaceComponent(); + + super({ + imports: [authComponent, loggingComponent, namespaceComponent] + }); + + // Add context middleware that runs after data sources are available + this.context.use('auth-context', async (globalContext: any) => { + return await buildContextWithAuth(globalContext); + }); + } +} + +// Context factory that has access to data sources +async function buildContextWithAuth(globalContext: any) { + const requestId = uuidv4(); + const startTime = Date.now(); + + // Extract request information from Apollo Server context + const request: RequestContext = { + requestId, + startTime, + userAgent: globalContext.req?.headers?.['user-agent'], + ip: globalContext.req?.ip || globalContext.req?.connection?.remoteAddress, + operation: globalContext.operationName || undefined, + variables: globalContext.variables + }; + + // Data sources are now available in middleware! + console.log(`🔄 Request ${requestId} started: ${globalContext.operationName || 'unknown operation'}`); + + // Access authentication from the global context + const authHeader = globalContext.req?.headers?.authorization; + + const token = authHeader?.replace('Bearer ', ''); + + let user: any = null; + let isAuthenticated = false; + + if (token && globalContext.dataSources?.auth) { + try { + // Now we can use the actual auth data source! + user = await globalContext.dataSources.auth.validateToken(token); + isAuthenticated = !!user; + } catch (error) { + console.log(`❌ Token validation error: ${error.message}`); + } + } else { + console.log(`🔍 No token provided or auth data source not available`); + } + + // Create auth helper functions + const authContext: AuthContext = { + token, + user: user || undefined, + isAuthenticated, + hasRole: (role: string) => { + return user?.roles?.includes(role) || false; + }, + requireAuth: () => { + if (!isAuthenticated) { + throw new Error('Authentication required'); + } + }, + requireRole: (role: string) => { + if (!isAuthenticated) { + throw new Error('Authentication required'); + } + if (!user?.roles?.includes(role)) { + throw new Error(`Role '${role}' required`); + } + } + }; + + if (isAuthenticated && user) { + console.log(`✅ Authenticated user: ${user.username} (${user.roles.join(', ')})`); + } else { + console.log(`❌ Authentication failed`); + } + + // Build business context + const businessContext: BusinessContext = { + tenantId: globalContext.req?.headers?.['x-tenant-id'] || 'default-tenant', + organizationId: globalContext.req?.headers?.['x-org-id'] || 'default-org', + environment: (process.env.NODE_ENV as any) || 'development', + features: ['feature-a', 'feature-b'], + quotas: { + maxUsers: 100, + maxProjects: 50, + storageLimit: 1024 * 1024 * 1024 + } + }; + + // Build analytics context + const analyticsContext: AnalyticsContext = { + sessionId: globalContext.req?.headers?.['x-session-id'] || uuidv4(), + userId: authContext.user?.id, + deviceId: globalContext.req?.headers?.['x-device-id'] || 'unknown', + userAgent: globalContext.req?.headers?.['user-agent'] || 'unknown', + country: globalContext.req?.headers?.['cf-ipcountry'], + city: globalContext.req?.headers?.['cf-ipcity'], + trackingEnabled: globalContext.req?.headers?.['dnt'] !== '1' + }; + + // Log request start using the actual logging data source + if (globalContext.dataSources?.logging) { + try { + await globalContext.dataSources.logging.logEntry(globalContext, { + level: 'info', + message: `GraphQL request started: ${request.operation || 'unknown'}`, + requestId: request.requestId || 'unknown', + timestamp: new Date().toISOString(), + operation: request.operation, + metadata: { + userAgent: request.userAgent, + userId: authContext.user?.id, + variables: request.variables + } + }); + } catch (error) { + console.log(`⚠️ Logging failed: ${error.message}`); + } + } + + return { + request, + auth: authContext, + business: businessContext, + analytics: analyticsContext + }; +} + +const createServer = (): ApolloServer => { + const component = new MainComponent(); + + const server = new ApolloServer({ + schema: component.schema, + context: component.context, + + // Custom error formatting that uses our logging + formatError: (error) => { + console.error('GraphQL Error:', error); + + // In a real app, you'd log this error using the logging data source + // but since we're in the error handler, we don't have easy access to context + + return { + message: error.message, + // Only include error details in development + ...(process.env.NODE_ENV === 'development' && { + locations: error.locations, + path: error.path, + extensions: error.extensions + }) + }; + }, + + // Add request/response logging + plugins: [ + { + requestDidStart() { + return Promise.resolve({ + async didResolveOperation(requestContext: any) { + console.log(`📝 Operation: ${requestContext.operationName || 'anonymous'}`); + }, + + async willSendResponse(requestContext: any) { + const duration = Date.now() - (requestContext.context.request?.startTime || Date.now()); + console.log(`✨ Request completed in ${duration}ms`); + } + }); + } + } + ] + }); + + return server; +}; + +// Start the server +const startServer = async (): Promise => { + const server = createServer(); + + const { url } = await server.listen({ port: 4000 }); + + console.log('🚀 Context & Middleware Example Server ready!'); + console.log(`📍 Server URL: ${url}`); + console.log('\n📚 Try these operations:'); + console.log('1. Login: mutation { login(input: { username: "admin", password: "admin123" }) { token user { username roles } } }'); + console.log('2. Get protected data: query { protectedData } (requires Authorization header)'); + console.log('3. View context info: query { currentRequest { requestId duration } myPreferences { theme } }'); + console.log('4. Admin logs: query { recentLogs { message timestamp level } } (requires admin role)'); + console.log('\n💡 Headers to try:'); + console.log('- Authorization: Bearer '); + console.log('- X-Tenant-ID: my-tenant'); + console.log('- X-Session-ID: session-123'); +}; + +// Export for testing +export { createServer }; + +// Start server if this file is run directly +if (require.main === module) { + startServer().catch((error) => { + console.error('Failed to start server:', error); + process.exit(1); + }); +} \ No newline at end of file diff --git a/package.json b/package.json index 92711d5..f3b2e9b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graphql-component", - "version": "6.0.3", + "version": "6.1.0", "description": "Build, customize and compose GraphQL schemas in a componentized fashion", "keywords": [ "graphql", @@ -10,16 +10,24 @@ "component" ], "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "require": "./dist/index.js", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, "scripts": { "build": "tsc", - "prepublish": "npm run build", - "test": "tape -r ts-node/register \"test/**/*.ts\"", + "prepublishOnly": "npm run build", + "test": "TS_NODE_PROJECT=tsconfig.test.json tape -r ts-node/register \"test/**/*.ts\"", "start-composition": "ts-node examples/composition/server/index.ts", "start-federation": "ts-node examples/federation/run-federation-example.ts", - "lint": "npx eslint src/index.ts", + "lint": "npx eslint 'src/**/*.ts'", "cover": "nyc npm test", "update-deps": "ncu -u && npm install", - "format": "prettier --write \"src/**/*.ts\"", "precommit": "npm run lint && npm run test", "prepare": "husky install" }, @@ -40,7 +48,6 @@ }, "devDependencies": { "@apollo/gateway": "^2.9.3", - "@types/graphql": "^14.5.0", "@types/node": "^22.9.1", "@typescript-eslint/eslint-plugin": "^8.22.0", "@typescript-eslint/parser": "^8.22.0", @@ -50,15 +57,14 @@ "globals": "^15.14.0", "graphql": "^16.9.0", "graphql-tag": "^2.12.6", + "husky": "^8.0.0", "npm-check-updates": "^17.1.11", "nyc": "^17.1.0", "sinon": "^19.0.2", "tape": "^5.9.0", "ts-node": "^10.9.2", "typescript": "^5.6.3", - "typescript-eslint": "^8.22.0", - "husky": "^8.0.0", - "prettier": "^2.8.8" + "typescript-eslint": "^8.22.0" }, "nyc": { "include": [ diff --git a/src/index.ts b/src/index.ts index 86767e6..baf28ae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,7 @@ export interface ComponentContext extends Record { dataSources: DataSourceMap; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any export type ContextFunction = ((context: Record) => any); export interface IDataSource { @@ -39,7 +40,7 @@ export interface IDataSource { * @example * class MyDataSource { * name = 'MyDataSource'; - * + * * // Context is required as first parameter when implementing * getData(context: ComponentContext, id: string) { * return { id }; @@ -49,7 +50,7 @@ export interface IDataSource { export type DataSourceDefinition = { // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type [P in keyof T]: T[P] extends Function ? (context: ComponentContext, ...args: any[]) => any : T[P]; -} +}; /** * Type for consuming data sources in resolvers @@ -65,7 +66,7 @@ export type DataSourceDefinition = { */ export type DataSource = { [P in keyof T]: T[P] extends (context: ComponentContext, ...p: infer P) => infer R ? (...p: P) => R : T[P]; -} +}; export type DataSourceMap = { [key: string]: IDataSource }; @@ -77,11 +78,11 @@ export interface IContextConfig { } export interface IContextWrapper extends ContextFunction { - use: (name: string | ContextFunction | null, fn?: ContextFunction | string) => void; + use: (name: string | ContextFunction, fn?: ContextFunction) => () => void; } export interface IGraphQLComponentOptions { - types?: TypeSource + types?: TypeSource; resolvers?: IResolvers; mocks?: boolean | IMocks; imports?: (IGraphQLComponent | IGraphQLComponentConfigObject)[]; @@ -89,9 +90,9 @@ export interface IGraphQLComponentOptions { @@ -111,22 +112,25 @@ export interface IGraphQLComponent implements IGraphQLComponent { - _schema: GraphQLSchema; - _types: TypeSource; - _resolvers: IResolvers; - _mocks: boolean | IMocks; - _imports: IGraphQLComponentConfigObject[]; - _context: ContextFunction; - _dataSources: IDataSource[]; - _dataSourceOverrides: IDataSource[]; - _pruneSchema: boolean; - _pruneSchemaOptions: PruneSchemaOptions - _federation: boolean; - _dataSourceContextInject: DataSourceInjectionFunction; - _transforms: SchemaMapper[] - private _transformedSchema: GraphQLSchema; +export default class GraphQLComponent implements IGraphQLComponent { + private _schema: GraphQLSchema | null = null; + private _types: TypeSource; + private _resolvers: IResolvers; + private _mocks: boolean | IMocks; + private _imports: IGraphQLComponentConfigObject[]; + private _contextConfig: IContextConfig | undefined; + private _dataSources: IDataSource[]; + private _dataSourceOverrides: IDataSource[]; + private _pruneSchema: boolean; + private _pruneSchemaOptions: PruneSchemaOptions; + private _federation: boolean; + private _dataSourceContextInject: DataSourceInjectionFunction; + private _transforms: SchemaMapper[]; + private _transformedSchema: GraphQLSchema | null = null; private _middleware: MiddlewareEntry[] = []; + private _disposed = false; + private _contextWrapper: IContextWrapper | null = null; + private _importInjectors: DataSourceInjectionFunction[] | null = null; constructor({ types, @@ -142,7 +146,7 @@ export default class GraphQLComponent): Promise => { - //BREAKING: The context injected into data sources won't have data sources on it - const ctx = { - dataSources: globalContext.dataSources || {} - }; + this._contextConfig = context; - // Add this component's dataSources if not already present or if empty - if (!globalContext.dataSources || Object.keys(globalContext.dataSources).length === 0) { - Object.assign(ctx.dataSources, this._dataSourceContextInject(globalContext)); - } + this.validateConfig({ types, imports, mocks, federation, context, transforms }); - // Only process imports if they exist - if (this._imports.length > 0) { - // Process imports in parallel if they're independent - const importPromises = this._imports.map(async ({ component }) => { - const importContext = await component.context(globalContext); - return importContext; - }); + } - const importResults = await Promise.all(importPromises); - - // Merge results efficiently - for (const { dataSources, ...importedContext } of importResults) { - Object.assign(ctx.dataSources, dataSources); - Object.assign(ctx, importedContext); - } - } + get disposed(): boolean { + return this._disposed; + } - // Handle namespace context if present - if (context) { + private _assertNotDisposed(): void { + if (this._disposed) { + throw new Error(`GraphQLComponent "${this.name}" has been disposed and cannot be used`); + } + } - if (!ctx[context.namespace]) { - ctx[context.namespace] = {}; - } + get context(): IContextWrapper { + this._assertNotDisposed(); - const namespaceContext = await context.factory.call(this, globalContext); - Object.assign(ctx[context.namespace], namespaceContext); - } + if (this._contextWrapper) { + return this._contextWrapper; + } - return ctx as TContextType; - }; + // Cache injectors for imported components so they aren't recreated per request + if (!this._importInjectors && this._imports.length > 0) { + this._importInjectors = this._imports.map(({ component }) => + createDataSourceContextInjector(component.dataSources || [], component.dataSourceOverrides || []) + ); + } + const importInjectors = this._importInjectors; + + const warnedCollisions = new Set(); + + const contextFn = async (incomingContext: Record): Promise => { + this._assertNotDisposed(); + // 1. Inject this component's data sources + const dataSources = this._dataSourceContextInject(incomingContext); + + // 2. Inject imported components' data sources (available to middleware) + if (importInjectors) { + for (const injector of importInjectors) { + const importedDS = injector(incomingContext); + for (const dsName of Object.keys(importedDS)) { + if (dataSources[dsName] && !warnedCollisions.has(dsName)) { + warnedCollisions.add(dsName); + console.warn(`GraphQLComponent "${this.name}": data source "${dsName}" is defined by multiple imports and will be overwritten`); + } + } + Object.assign(dataSources, importedDS); + } + } - this.validateConfig({ types, imports, mocks, federation }); + // 3. Build context with all data sources and run middleware + let ctx: Record = Object.assign({}, incomingContext, { dataSources }); - } + const middleware = [...this._middleware]; + for (const mw of middleware) { + ctx = await mw.fn(ctx); + } - get context(): IContextWrapper { - // Cache middleware array to avoid recreation - const contextFn = async (context: Record): Promise => { - - // Inject dataSources early so middleware can access them - const dataSources = this._dataSourceContextInject(context); - - // Also gather data sources from imported components for middleware - const importedDataSources = {}; + // 4. Process imports in parallel for full context (namespace, non-DS context) if (this._imports.length > 0) { - for (const { component } of this._imports) { - // Use the public dataSources getter and manually inject them - const componentDataSourcesArray = component.dataSources; - const componentDataSourceOverrides = component.dataSourceOverrides; - const componentInjector = createDataSourceContextInjector(componentDataSourcesArray, componentDataSourceOverrides); - const componentDataSources = componentInjector(context); - Object.assign(importedDataSources, componentDataSources); + const importResults = await Promise.all( + this._imports.map(({ component }) => component.context(ctx)) + ); + for (const { dataSources: importDS, ...importedCtx } of importResults) { + Object.assign(ctx.dataSources, importDS); + Object.assign(ctx, importedCtx); } } - - // Combine all data sources for middleware - const allDataSources = Object.assign({}, dataSources, importedDataSources); - let processedContext = Object.assign({}, context, { dataSources: allDataSources }); - - // Apply middleware more efficiently - if (this._middleware.length > 0) { - for (const mw of this._middleware) { - processedContext = await mw.fn(processedContext); + + // 5. Apply this component's namespace context + if (this._contextConfig) { + if (!ctx[this._contextConfig.namespace]) { + ctx[this._contextConfig.namespace] = {}; } + const nsCtx = await this._contextConfig.factory.call(this, ctx); + Object.assign(ctx[this._contextConfig.namespace] as Record, nsCtx); } - const componentContext = await this._context(processedContext); - - // More efficient object composition - return Object.assign({}, processedContext, componentContext); + return ctx as ComponentContext; }; - contextFn.use = (name: string | ContextFunction, fn?: ContextFunction): IContextWrapper => { + contextFn.use = (name: string | ContextFunction, fn?: ContextFunction): (() => void) => { if (typeof name === 'function') { fn = name; - name = 'unknown'; + name = `middleware_${this._middleware.length}`; } + if (typeof fn !== 'function') { + throw new Error(`Middleware "${name}" requires a function argument`); + } + const entry = { name: name as string, fn }; + this._middleware.push(entry); - this._middleware.push({ name: name as string, fn: fn! }); - - return contextFn; + return () => { + const index = this._middleware.indexOf(entry); + if (index > -1) this._middleware.splice(index, 1); + }; }; + this._contextWrapper = contextFn; return contextFn; } @@ -286,16 +296,19 @@ export default class GraphQLComponent GraphQLSchema; if (this._federation) { makeSchema = buildFederatedSchema; - } else { + } + else { makeSchema = makeExecutableSchema; } @@ -315,15 +328,15 @@ export default class GraphQLComponent { + this._assertNotDisposed(); return this._resolvers; } get imports(): IGraphQLComponentConfigObject[] { + this._assertNotDisposed(); return this._imports; } get dataSources(): IDataSource[] { + this._assertNotDisposed(); return this._dataSources; } get dataSourceOverrides(): IDataSource[] { + this._assertNotDisposed(); return this._dataSourceOverrides; } set federation(flag) { this._federation = flag; + this.invalidateSchema(); } get federation(): boolean { @@ -382,12 +399,21 @@ export default class GraphQLComponent unknown)[]> = {}; + const mapping: Record unknown> = {}; for (const transform of transforms) { for (const [key, fn] of Object.entries(transform)) { if (!mapping[key]) { functions[key] = []; - mapping[key] = function (...args) { + mapping[key] = function (...args: unknown[]) { let result; - while (functions[key].length) { - const mapper = functions[key].shift(); + for (const mapper of functions[key]) { result = mapper(...args); - if (!result) { + if (result === undefined) { break; } } return result; - } + }; } functions[key].push(fn); } @@ -422,60 +447,91 @@ export default class GraphQLComponent { - const intercept = (instance: IDataSource, context: any) => { +function createDataSourceContextInjector(dataSources: IDataSource[], dataSourceOverrides: IDataSource[]): DataSourceInjectionFunction { + function intercept(instance: IDataSource, context: Record) { + // Cache wrapped functions per proxy to avoid creating new wrappers on every property access + const wrappedMethods = new Map unknown>(); return new Proxy(instance, { get(target, key) { if (typeof target[key] !== 'function' || key === instance.constructor.name) { return target[key]; } - const original = target[key]; - return function (...args) { - return original.call(instance, context, ...args); - }; + let wrapped = wrappedMethods.get(key); + if (!wrapped) { + const original = target[key]; + wrapped = function (...args: unknown[]) { + return original.call(instance, context, ...args); + }; + wrappedMethods.set(key, wrapped); + } + + return wrapped; } - }) as any as DataSource; - }; + }) as DataSource; + } - return (context: any = {}): DataSourceMap => { - const proxiedDataSources = {}; + return function (context: Record = {}): DataSourceMap { + const proxiedDataSources: DataSourceMap = {}; // Inject data sources for (const dataSource of dataSources) { - proxiedDataSources[dataSource.name || dataSource.constructor.name] = intercept(dataSource, context); + proxiedDataSources[dataSource.name != null && dataSource.name !== '' ? dataSource.name : dataSource.constructor.name] = intercept(dataSource, context); } // Override data sources for (const dataSourceOverride of dataSourceOverrides) { - proxiedDataSources[dataSourceOverride.name || dataSourceOverride.constructor.name] = intercept(dataSourceOverride, context); + proxiedDataSources[dataSourceOverride.name != null && dataSourceOverride.name !== '' ? dataSourceOverride.name : dataSourceOverride.constructor.name] = intercept(dataSourceOverride, context); } return proxiedDataSources; }; -}; +} /** * memoizes resolver functions such that calls of an identical resolver (args/context/path) within the same request context are avoided @@ -489,12 +545,34 @@ const createDataSourceContextInjector = (dataSources: IDataSource[], dataSourceO * whose closure scope contains a WeakMap to achieve memoization of the wrapped * input resolver function */ +const stableStringify = function (args: Record): string { + if (!args || typeof args !== 'object') return String(args); + const keys = Object.keys(args); + if (keys.length === 0) return '{}'; + keys.sort(); + return keys.map(k => { + let v: string; + if (typeof args[k] === 'object') { + try { + v = JSON.stringify(args[k]); + } + catch { + v = '[circular]'; + } + } + else { + v = String(args[k]); + } + return `${k}:${v}`; + }).join(','); +}; + const memoize = function (parentType: string, fieldName: string, resolve: ResolverFunction): ResolverFunction { const _cache = new WeakMap(); return function _memoizedResolver(_, args, context, info) { const path = info && info.path && info.path.key; - const key = `${path}_${JSON.stringify(args)}`; + const key = `${path}_${stableStringify(args)}`; let cached = _cache.get(context); @@ -525,7 +603,7 @@ const memoize = function (parentType: string, fieldName: string, resolve: Resolv * map, except with resolver function bound to the input argument bind */ const bindResolvers = function (bindContext: IGraphQLComponent, resolvers: IResolvers = {}): IResolvers { - const boundResolvers = {}; + const boundResolvers: Record | GraphQLScalarType> = {}; for (const [type, fields] of Object.entries(resolvers)) { // dont bind an object that is an instance of a graphql scalar @@ -538,23 +616,24 @@ const bindResolvers = function (bindContext: IGraphQLComponent, resolvers: IReso boundResolvers[type] = {}; } + const typeResolvers = boundResolvers[type] as Record; + for (const [field, resolver] of Object.entries(fields)) { - if (['Query', 'Mutation'].indexOf(type) > -1) { - boundResolvers[type][field] = memoize(type, field, resolver.bind(bindContext)); - } - else { - // only bind resolvers that are functions - if (typeof resolver === 'function') { - boundResolvers[type][field] = resolver.bind(bindContext); + if (typeof resolver === 'function') { + if (type === 'Query') { + typeResolvers[field] = memoize(type, field, resolver.bind(bindContext)); } else { - boundResolvers[type][field] = resolver; + typeResolvers[field] = resolver.bind(bindContext); } } + else { + typeResolvers[field] = resolver; + } } } - return boundResolvers; + return boundResolvers as IResolvers; }; interface MiddlewareEntry { diff --git a/test/additional-coverage.ts b/test/additional-coverage.ts new file mode 100644 index 0000000..2546e02 --- /dev/null +++ b/test/additional-coverage.ts @@ -0,0 +1,335 @@ +import test from 'tape'; +import GraphQLComponent, { IGraphQLComponent } from '../src/index'; +import { GraphQLSchema, GraphQLObjectType, GraphQLString } from 'graphql'; + +test('Middleware error propagation', (t) => { + t.test('should propagate error thrown mid-chain', async (assert) => { + const component = new GraphQLComponent({ + types: 'type Query { test: String }' + }); + + const contextFn = component.context; + contextFn.use('first', async (ctx) => ({ ...ctx, first: true })); + contextFn.use('failing', async () => { + throw new Error('middleware failure'); + }); + contextFn.use('third', async (ctx) => ({ ...ctx, third: true })); + + try { + await contextFn({}); + assert.fail('should have thrown'); + } + catch (err) { + assert.ok(err instanceof Error, 'error is an Error'); + assert.equal((err as Error).message, 'middleware failure', 'error message preserved'); + } + assert.end(); + }); + + t.test('should stop chain on error and not run subsequent middleware', async (assert) => { + const component = new GraphQLComponent({ + types: 'type Query { test: String }' + }); + + let thirdRan = false; + const contextFn = component.context; + contextFn.use('first', async (ctx) => ({ ...ctx, first: true })); + contextFn.use('failing', async () => { + throw new Error('stop here'); + }); + contextFn.use('third', async (ctx) => { + thirdRan = true; + return { ...ctx, third: true }; + }); + + try { + await contextFn({}); + } + catch { + // expected + } + assert.notOk(thirdRan, 'third middleware did not run after error'); + assert.end(); + }); + + t.end(); +}); + +test('Namespace collision with dataSources', (t) => { + t.test('should throw when namespace is "dataSources"', (assert) => { + assert.throws( + () => new GraphQLComponent({ + types: 'type Query { test: String }', + context: { + namespace: 'dataSources', + factory: () => ({}) + } + }), + /context\.namespace cannot be "dataSources"/, + 'throws on dataSources namespace' + ); + assert.end(); + }); + + t.end(); +}); + +test('Subscription resolver binding', (t) => { + t.test('should bind Subscription resolvers without memoization', async (assert) => { + let callCount = 0; + + const component = new GraphQLComponent({ + types: ` + type Query { test: String } + type Subscription { onTest: String } + `, + resolvers: { + Query: { + test() { + return 'test'; + } + }, + Subscription: { + onTest: { + subscribe() { + callCount++; + return (async function* () { + yield { onTest: `event-${callCount}` }; + })(); + } + } + } + } + }); + + const resolvers = component.resolvers as any; + assert.ok(resolvers.Subscription, 'Subscription resolvers exist'); + assert.ok(resolvers.Subscription.onTest, 'onTest resolver exists'); + assert.ok(resolvers.Subscription.onTest.subscribe, 'subscribe function exists'); + + // Call subscribe twice -- should NOT be memoized + const iter1 = resolvers.Subscription.onTest.subscribe(); + const iter2 = resolvers.Subscription.onTest.subscribe(); + assert.equal(callCount, 2, 'subscribe called twice (not memoized)'); + assert.notEqual(iter1, iter2, 'each call returns a new iterator'); + assert.end(); + }); + + t.end(); +}); + +test('Middleware unsubscribe function', (t) => { + t.test('should remove middleware when unsubscribe is called', async (assert) => { + const component = new GraphQLComponent({ + types: 'type Query { test: String }' + }); + + const contextFn = component.context; + const unsub = contextFn.use('removable', async (ctx) => ({ + ...ctx, + removable: true + })); + + // Verify middleware runs + const ctx1 = await contextFn({}); + assert.ok(ctx1.removable, 'middleware applied before unsub'); + + // Unsubscribe + unsub(); + + // Verify middleware no longer runs + const ctx2 = await contextFn({}); + assert.notOk(ctx2.removable, 'middleware not applied after unsub'); + assert.end(); + }); + + t.test('calling unsubscribe twice should be safe', (assert) => { + const component = new GraphQLComponent({ + types: 'type Query { test: String }' + }); + + const contextFn = component.context; + const unsub = contextFn.use('removable', async (ctx) => ctx); + + unsub(); + assert.doesNotThrow(() => unsub(), 'second unsub does not throw'); + assert.end(); + }); + + t.end(); +}); + +test('Concurrent context() calls', (t) => { + t.test('should handle concurrent context calls without corruption', async (assert) => { + class TestDS { + name = 'TestDS'; + getData(context: any) { + return { caller: context.caller }; + } + } + + const component = new GraphQLComponent({ + types: 'type Query { test: String }', + dataSources: [new TestDS()], + context: { + namespace: 'ns', + factory: async (ctx: any) => { + // Simulate async work + await new Promise(resolve => setTimeout(resolve, 5)); + return { callerNs: ctx.caller }; + } + } + }); + + // Fire 10 concurrent context calls + const results = await Promise.all( + Array.from({ length: 10 }, (_, i) => component.context({ caller: `req-${i}` })) + ); + + for (let i = 0; i < 10; i++) { + const ns = (results[i] as any).ns; + assert.equal(ns.callerNs, `req-${i}`, `request ${i} has correct namespace context`); + } + assert.end(); + }); + + t.end(); +}); + +test('Import injector with custom IGraphQLComponent without dataSources', (t) => { + t.test('should not crash when imported component has no dataSources', async (assert) => { + const customComponent: IGraphQLComponent = { + get name() { return 'NoDSComponent'; }, + get schema() { + return new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { custom: { type: GraphQLString, resolve: () => 'val' } } + }) + }); + }, + get context() { + const fn = async (ctx: Record) => ({ ...ctx, dataSources: {} as any }); + fn.use = () => () => {}; + return fn; + }, + get types() { return ['type Query { custom: String }']; }, + get resolvers() { return { Query: { custom: () => 'val' } }; } + // dataSources and dataSourceOverrides intentionally omitted + }; + + const parent = new GraphQLComponent({ + types: 'type Query { parent: String }', + imports: [customComponent] + }); + + const ctx = await parent.context({}); + assert.ok(ctx.dataSources, 'dataSources exists in context'); + assert.end(); + }); + + t.end(); +}); + +test('Transform loop handles falsy return values', (t) => { + t.test('should not break on null transform result', (assert) => { + const component = new GraphQLComponent({ + types: `type Query { test: String }`, + resolvers: { + Query: { + test() { + return 'hello'; + } + } + }, + transforms: [ + { + ['{graphql-tools}.MapperKind.OBJECT_TYPE' as any]: () => null + } + ] + }); + + assert.doesNotThrow(() => component.schema, 'schema with null-returning transform does not throw'); + assert.end(); + }); + + t.end(); +}); + +test('Federation setter invalidates schema', (t) => { + t.test('should invalidate cached schema when federation flag changes', (assert) => { + const component = new GraphQLComponent({ + types: `type Query { test: String }`, + resolvers: { + Query: { + test() { + return 'hello'; + } + } + } + }); + + // Access schema to cache it + const schema1 = component.schema; + assert.ok(schema1, 'initial schema created'); + + // Toggle federation off->off shouldn't matter, but toggle off->true + // Setting federation = true invalidates cached schema + component.federation = true; + + // Re-set to false so schema build succeeds with makeExecutableSchema + component.federation = false; + + const schema2 = component.schema; + assert.ok(schema2, 'schema rebuilt after federation change'); + assert.notEqual(schema1, schema2, 'schema is a new instance after invalidation'); + assert.end(); + }); + + t.end(); +}); + +test('Circular import detection', (t) => { + t.test('should allow importing a different component', (assert) => { + const component = new GraphQLComponent({ + types: 'type Query { test: String }' + }); + + assert.doesNotThrow( + () => new GraphQLComponent({ + types: 'type Query { parent: String }', + imports: [component] + }), + 'importing a different component does not throw' + ); + assert.end(); + }); + + t.end(); +}); + +test('stableStringify handles circular references', (t) => { + t.test('should not throw on circular object args via graphql query', async (assert) => { + const component = new GraphQLComponent({ + types: `type Query { test(input: String): String }`, + resolvers: { + Query: { + test(_: any, args: any, context: any) { + return 'result'; + } + } + } + }); + + const schema = component.schema; + const { graphql } = await import('graphql'); + + // Execute query with a context object (needed for WeakMap memoization) + const result = await graphql({ schema, source: '{ test(input: "hello") }', contextValue: {} }); + assert.notOk(result.errors, 'query succeeds without errors'); + assert.equal(result.data?.test, 'result', 'returns correct result'); + assert.end(); + }); + + t.end(); +}); diff --git a/test/datasources.ts b/test/datasources.ts index 0ec21e7..91f12dc 100644 --- a/test/datasources.ts +++ b/test/datasources.ts @@ -7,7 +7,7 @@ test('GraphQLComponent DataSource Tests', (t) => { t.test('should inject context into data source methods', async (assert) => { class TestDataSource { name = 'test'; - getData(context, arg) { + getData(context: Record, arg: string) { return `${context.value}-${arg}`; } } @@ -56,7 +56,7 @@ test('GraphQLComponent DataSource Tests', (t) => { class TestDataSource { name = 'test'; staticValue = 'static value'; - getData(context) { + getData(context: Record) { return this.staticValue; } } diff --git a/test/dispose.ts b/test/dispose.ts new file mode 100644 index 0000000..ec383ea --- /dev/null +++ b/test/dispose.ts @@ -0,0 +1,130 @@ +import test from 'tape'; +import GraphQLComponent from '../src/index'; + +test('GraphQLComponent dispose() Tests', (t) => { + + t.test('disposed returns false before dispose', (assert) => { + const component = new GraphQLComponent({ + types: 'type Query { hello: String }' + }); + + assert.equal(component.disposed, false, 'not disposed initially'); + assert.end(); + }); + + t.test('disposed returns true after dispose', (assert) => { + const component = new GraphQLComponent({ + types: 'type Query { hello: String }' + }); + + component.dispose(); + assert.equal(component.disposed, true, 'disposed after calling dispose()'); + assert.end(); + }); + + t.test('accessing schema after dispose throws', (assert) => { + const component = new GraphQLComponent({ + types: 'type Query { hello: String }', + resolvers: { Query: { hello: () => 'world' } } + }); + + // Access schema before dispose to ensure it works + assert.ok(component.schema, 'schema accessible before dispose'); + + component.dispose(); + + assert.throws( + () => component.schema, + /has been disposed/, + 'accessing schema after dispose throws' + ); + assert.end(); + }); + + t.test('accessing context after dispose throws', (assert) => { + const component = new GraphQLComponent({ + types: 'type Query { hello: String }' + }); + + component.dispose(); + + assert.throws( + () => component.context, + /has been disposed/, + 'accessing context after dispose throws' + ); + assert.end(); + }); + + t.test('accessing types after dispose throws', (assert) => { + const component = new GraphQLComponent({ + types: 'type Query { hello: String }' + }); + + component.dispose(); + + assert.throws( + () => component.types, + /has been disposed/, + 'accessing types after dispose throws' + ); + assert.end(); + }); + + t.test('accessing resolvers after dispose throws', (assert) => { + const component = new GraphQLComponent({ + types: 'type Query { hello: String }' + }); + + component.dispose(); + + assert.throws( + () => component.resolvers, + /has been disposed/, + 'accessing resolvers after dispose throws' + ); + assert.end(); + }); + + t.test('accessing imports after dispose throws', (assert) => { + const component = new GraphQLComponent({ + types: 'type Query { hello: String }' + }); + + component.dispose(); + + assert.throws( + () => component.imports, + /has been disposed/, + 'accessing imports after dispose throws' + ); + assert.end(); + }); + + t.test('accessing dataSources after dispose throws', (assert) => { + const component = new GraphQLComponent({ + types: 'type Query { hello: String }' + }); + + component.dispose(); + + assert.throws( + () => component.dataSources, + /has been disposed/, + 'accessing dataSources after dispose throws' + ); + assert.end(); + }); + + t.test('calling dispose twice does not throw', (assert) => { + const component = new GraphQLComponent({ + types: 'type Query { hello: String }' + }); + + component.dispose(); + assert.doesNotThrow(() => component.dispose(), 'second dispose does not throw'); + assert.end(); + }); + + t.end(); +}); diff --git a/test/error-handling.ts b/test/error-handling.ts new file mode 100644 index 0000000..d1771b4 --- /dev/null +++ b/test/error-handling.ts @@ -0,0 +1,92 @@ +import test from 'tape'; +import GraphQLComponent from '../src/index'; + +test('Error Handling Tests', (t) => { + + t.test('schema creation error preserves cause', (assert) => { + const component = new GraphQLComponent({ + types: 'invalid schema definition %%%', + }); + + try { + component.schema; + assert.fail('should have thrown'); + } catch (err) { + assert.ok(err instanceof Error, 'throws an Error'); + assert.ok(err.message.includes('Failed to create schema'), 'has descriptive message'); + assert.ok(err.cause, 'preserves original error as cause'); + assert.end(); + } + }); + + t.test('null mocks should not throw validation error', (assert) => { + assert.doesNotThrow(() => { + new GraphQLComponent({ + types: 'type Query { hello: String }', + mocks: null + }); + }, 'null mocks are accepted'); + assert.end(); + }); + + t.test('context.namespace must be a non-empty string', (assert) => { + assert.throws( + () => new GraphQLComponent({ + types: 'type Query { hello: String }', + context: { namespace: '', factory: () => ({}) } + }), + /context\.namespace must be a non-empty string/, + 'empty namespace throws' + ); + assert.end(); + }); + + t.test('context.factory must be a function', (assert) => { + assert.throws( + () => new GraphQLComponent({ + types: 'type Query { hello: String }', + context: { namespace: 'test', factory: 'not a function' as any } + }), + /context\.factory must be a function/, + 'non-function factory throws' + ); + assert.end(); + }); + + t.test('transforms must be an array', (assert) => { + assert.throws( + () => new GraphQLComponent({ + types: 'type Query { hello: String }', + transforms: {} as any + }), + /transforms must be an array/, + 'non-array transforms throws' + ); + assert.end(); + }); + + t.test('middleware use() without function throws', (assert) => { + const component = new GraphQLComponent({ + types: 'type Query { hello: String }' + }); + + assert.throws( + () => component.context.use('test'), + /Middleware "test" requires a function argument/, + 'use() without function throws' + ); + assert.end(); + }); + + t.test('middleware use() returns unsubscribe function', (assert) => { + const component = new GraphQLComponent({ + types: 'type Query { hello: String }' + }); + + const unsubscribe = component.context.use('test', (ctx) => ctx); + assert.equal(typeof unsubscribe, 'function', 'use() returns a function'); + assert.end(); + }); + + t.end(); +}); diff --git a/test/schema.ts b/test/schema.ts index e14928a..03aee7c 100644 --- a/test/schema.ts +++ b/test/schema.ts @@ -1,6 +1,7 @@ import test from 'tape'; import GraphQLComponent from '../src/index'; import { MapperKind } from '@graphql-tools/utils'; +import { GraphQLFieldConfig } from 'graphql'; test('GraphQLComponent Schema Tests', (t) => { t.test('should create basic schema', (assert) => { @@ -29,7 +30,7 @@ test('GraphQLComponent Schema Tests', (t) => { `; const transforms = [{ - [MapperKind.OBJECT_FIELD]: (fieldConfig, fieldName) => { + [MapperKind.OBJECT_FIELD]: (fieldConfig: GraphQLFieldConfig, fieldName: string) => { if (fieldName === 'hello') { return { ...fieldConfig, @@ -49,5 +50,42 @@ test('GraphQLComponent Schema Tests', (t) => { assert.end(); }); + t.test('should apply transforms correctly after schema invalidation', (assert) => { + const types = ` + type Query { + hello: String + } + `; + + const transforms = [{ + [MapperKind.OBJECT_FIELD]: (fieldConfig: GraphQLFieldConfig, fieldName: string) => { + if (fieldName === 'hello') { + return { + ...fieldConfig, + description: 'Transformed field' + }; + } + return fieldConfig; + } + }]; + + const component = new GraphQLComponent({ types, transforms, resolvers: { + Query: { + hello: () => 'world' + } + } }); + + // First access - transforms should apply + const schema1 = component.schema; + assert.ok(schema1.getQueryType()?.getFields().hello.description === 'Transformed field', 'transform applied on first access'); + + // Invalidate and re-access - transforms should still apply + component.invalidateSchema(); + const schema2 = component.schema; + assert.ok(schema2.getQueryType()?.getFields().hello.description === 'Transformed field', 'transform applied after invalidation'); + assert.notEqual(schema1, schema2, 'new schema instance after invalidation'); + assert.end(); + }); + t.end(); }); \ No newline at end of file diff --git a/test/test.ts b/test/test.ts index 03bdd7c..c66bce8 100644 --- a/test/test.ts +++ b/test/test.ts @@ -192,7 +192,17 @@ test('federation', (t) => { t.test('imported federated components will merge correctly', (t) => { - t.plan(1); + t.plan(2); + + const innerComponent = new GraphQLComponent({ + types: ` + type Inner { + id: ID! + } + `, + federation: true, + pruneSchema: false + }); const component = new GraphQLComponent({ types: ` @@ -202,19 +212,11 @@ test('federation', (t) => { `, federation: true, pruneSchema: false, - imports: [ - new GraphQLComponent({ - types: ` - type Inner { - id: ID! - } - `, - pruneSchema: false - }) - ] + imports: [innerComponent] }); - t.ok(component.imports[0].component.federation, 'imported federated component types are merged'); + t.ok(innerComponent.federation, 'imported component has its own federation flag set'); + t.notOk(component.imports[0].component.federation !== innerComponent.federation, 'federation flag is not mutated by parent'); }); @@ -630,4 +632,43 @@ test('mocks', async (t) => { t.equal(result.data?.hello, 'Custom hello world!', 'custom mocks are used'); }); +}); + +test('mutation resolvers are not memoized', async (t) => { + let callCount = 0; + + const component = new GraphQLComponent({ + types: ` + type Query { + hello: String + } + type Mutation { + increment: Int + } + `, + resolvers: { + Query: { + hello: () => 'world' + }, + Mutation: { + increment: () => { + callCount++; + return callCount; + } + } + } + }); + + const schema = component.schema; + const contextValue = { dataSources: {} }; + + const mutation = `mutation { increment }`; + + const result1 = await graphql({ schema, source: mutation, contextValue }); + const result2 = await graphql({ schema, source: mutation, contextValue }); + + t.equal(result1.data?.increment, 1, 'first mutation call returns 1'); + t.equal(result2.data?.increment, 2, 'second mutation call returns 2 (not memoized)'); + t.equal(callCount, 2, 'mutation resolver was called twice'); + t.end(); }); \ No newline at end of file diff --git a/test/validation.ts b/test/validation.ts index 354e830..0672afa 100644 --- a/test/validation.ts +++ b/test/validation.ts @@ -84,7 +84,7 @@ test('GraphQLComponent Import Handling', (t) => { }, get context() { const fn = async (ctx: Record) => ctx; - fn.use = () => fn; + fn.use = () => () => {}; return fn; }, get types() { @@ -147,84 +147,45 @@ test('GraphQLComponent Import Handling', (t) => { assert.end(); }); - t.test('should set federation flag on imported components when parent has federation', (assert) => { + t.test('should not mutate federation flag on imported components when parent has federation', (assert) => { const childComponent = new GraphQLComponent({ types: ['type Query { child: String }'] }); - + assert.notOk(childComponent.federation, 'child component federation is false initially'); - + const parentComponent = new GraphQLComponent({ types: ['type Query { parent: String }'], imports: [childComponent], federation: true }); - assert.ok(childComponent.federation, 'child component federation is set to true'); + assert.notOk(childComponent.federation, 'child component federation is not mutated by parent'); + assert.ok(parentComponent.federation, 'parent federation is true'); assert.end(); }); - t.test('should set federation flag on custom IGraphQLComponent when parent has federation', (assert) => { - let federationFlag = false; - - const customComponent: IGraphQLComponent = { - get name() { - return 'CustomComponent'; - }, - get schema() { - return new GraphQLSchema({ - query: new GraphQLObjectType({ - name: 'Query', - fields: { - custom: { - type: GraphQLString, - resolve: () => 'custom value' - } - } - }) - }); - }, - get context() { - const fn = async (ctx: Record) => ctx; - fn.use = () => fn; - return fn; - }, - get types() { - return ['type Query { custom: String }']; - }, - get resolvers() { - return { - Query: { - custom: () => 'custom value' - } - }; - }, - get imports() { - return undefined; - }, - get dataSources() { - return []; - }, - get dataSourceOverrides() { - return []; - }, - get federation() { - return federationFlag; - }, - set federation(value: boolean) { - federationFlag = value; - } - }; - - assert.notOk(customComponent.federation, 'custom component federation is false initially'); - - const parentComponent = new GraphQLComponent({ - types: ['type Query { parent: String }'], - imports: [customComponent], + t.test('should not mutate federation flag on shared component imported by multiple parents', (assert) => { + const sharedComponent = new GraphQLComponent({ + types: ['type Query { shared: String }'] + }); + + assert.notOk(sharedComponent.federation, 'shared component federation is false initially'); + + const federatedParent = new GraphQLComponent({ + types: ['type Query { fedParent: String }'], + imports: [sharedComponent], federation: true }); - assert.ok(customComponent.federation, 'custom component federation is set to true'); + const nonFederatedParent = new GraphQLComponent({ + types: ['type Query { nonFedParent: String }'], + imports: [sharedComponent] + }); + + assert.notOk(sharedComponent.federation, 'shared component federation is still false'); + assert.ok(federatedParent.federation, 'federated parent is true'); + assert.notOk(nonFederatedParent.federation, 'non-federated parent is false'); assert.end(); }); diff --git a/tsconfig.json b/tsconfig.json index 97f5e07..a09ec82 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,7 @@ "module": "commonjs", "target": "esnext", "lib": ["esnext"], - "noImplicitAny": false, + "noImplicitAny": true, "moduleResolution": "node", "emitDecoratorMetadata": true, "inlineSourceMap": true, diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..eead3fb --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noImplicitAny": false, + "rootDir": ".", + "skipLibCheck": true + }, + "include": [ + "./src", + "./test" + ] +}