diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4a16dce..01cabe8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,7 +27,7 @@ jobs: - name: Pack solution [Release] run: dotnet pack --no-restore --no-build -c Release -p:VersionSuffix=$GITHUB_RUN_NUMBER -o out - name: Upload artifacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: Nuget packages path: | diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7fc3752..966a64e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -37,7 +37,7 @@ jobs: - name: Pack solution [Release] run: dotnet pack --no-restore --no-build -c Release -p:Version=$version -o out - name: Upload artifacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: Nuget packages path: | @@ -45,7 +45,7 @@ jobs: - name: Publish Nuget packages to Nuget registry run: dotnet nuget push "out/*" -k ${{secrets.NUGET_AUTH_TOKEN}} - name: Upload nuget packages as release artifacts - uses: actions/github-script@v2 + uses: actions/github-script@v7 with: github-token: ${{secrets.GITHUB_TOKEN}} script: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0aa1866..578dfb8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,9 +2,6 @@ name: Run code tests on: pull_request: - branches: - - master - - develop push: branches: - master @@ -54,9 +51,9 @@ jobs: reporttypes: 'Html' tag: 'test_${{ github.run_number }}' - name: Upload artifacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: - name: Code coverage artifacts + name: Code coverage artifacts for ${{ matrix.os }} path: | ${{ matrix.os }}.lcov.info Clover.xml diff --git a/Directory.Build.props b/Directory.Build.props index a98330b..3e4a590 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -24,6 +24,7 @@ Recommended 8.0.0 6.0.0 + direct diff --git a/src/GraphQL.DI/DIObjectGraphBase.cs b/src/GraphQL.DI/DIObjectGraphBase.cs index 67e2db3..8ce896b 100644 --- a/src/GraphQL.DI/DIObjectGraphBase.cs +++ b/src/GraphQL.DI/DIObjectGraphBase.cs @@ -9,6 +9,12 @@ namespace GraphQL.DI; /// /// This is a required base type of all DI-created graph types. may be /// used if the type is . +/// +/// If the derived class implements , the class must be registered within the DI container. +/// +/// +/// When registered within the DI container, the service lifetime must be Transient. +/// /// public abstract class DIObjectGraphBase : IDIObjectGraphBase, IResolveFieldContext { diff --git a/src/GraphQL.DI/DIObjectGraphType.cs b/src/GraphQL.DI/DIObjectGraphType.cs index 2d0acc3..a156165 100644 --- a/src/GraphQL.DI/DIObjectGraphType.cs +++ b/src/GraphQL.DI/DIObjectGraphType.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Linq.Expressions; using System.Reflection; using GraphQL.Types; @@ -60,13 +61,36 @@ protected override IEnumerable GetRegisteredMembers() // each field resolver will build a new instance of DIObject /// protected override LambdaExpression BuildMemberInstanceExpression(MemberInfo memberInfo) - => (Expression>)((IResolveFieldContext context) => MemberInstanceFunc(context)); + { + // use an explicit type here rather than simply LambdaExpression + Expression> func; + if (typeof(IDisposable).IsAssignableFrom(typeof(TDIGraph))) { + func = (IResolveFieldContext context) => MemberInstanceDisposableFunc(context); + } else { + func = (IResolveFieldContext context) => MemberInstanceFunc(context); + } + return func; + } /// private static TDIGraph MemberInstanceFunc(IResolveFieldContext context) { // create a new instance of DIObject, filling in any constructor arguments from DI - var graph = ActivatorUtilities.GetServiceOrCreateInstance(context.RequestServices ?? throw new MissingRequestServicesException()); + var graph = ActivatorUtilities.GetServiceOrCreateInstance(context.RequestServices ?? ThrowMissingRequestServicesException()); + // set the context + graph.Context = context; + // return the object + return graph; + + static IServiceProvider ThrowMissingRequestServicesException() => throw new MissingRequestServicesException(); + } + + /// + private static TDIGraph MemberInstanceDisposableFunc(IResolveFieldContext context) + { + // pull DIObject from dependency injection, as it is disposable and must be managed by DI + var graph = (context.RequestServices ?? throw new MissingRequestServicesException()).GetService() + ?? throw new InvalidOperationException($"Could not resolve an instance of {typeof(TDIGraph).Name} from the service provider. DI graph types that implement IDisposable must be registered in the service provider."); // set the context graph.Context = context; // return the object diff --git a/src/GraphQL.DI/GraphQLBuilderExtensions.cs b/src/GraphQL.DI/GraphQLBuilderExtensions.cs index 9c694a1..29f44f8 100644 --- a/src/GraphQL.DI/GraphQLBuilderExtensions.cs +++ b/src/GraphQL.DI/GraphQLBuilderExtensions.cs @@ -18,6 +18,26 @@ public static IGraphQLBuilder AddDIGraphTypes(this IGraphQLBuilder builder) return builder; } + /// + /// Scans the calling assembly for classes that implement + /// and registers them as transients within the DI container. + /// + public static IGraphQLBuilder AddDIGraphBases(this IGraphQLBuilder builder) + => AddDIGraphBases(builder, Assembly.GetCallingAssembly()); + + /// + /// Scans the specified assembly for classes that implement + /// and registers them as transients within the DI container. + /// + public static IGraphQLBuilder AddDIGraphBases(this IGraphQLBuilder builder, Assembly assembly) + { + foreach (var type in assembly.GetTypes() + .Where(x => x.IsClass && !x.IsAbstract && typeof(IDIObjectGraphBase).IsAssignableFrom(x))) { + builder.Services.TryRegister(type, type, ServiceLifetime.Transient); + } + return builder; + } + /// /// Scans the calling assembly for classes that implement and /// registers clr type mappings on the schema between that diff --git a/src/GraphQL.DI/GraphQLDIBuilderExtensions.cs b/src/GraphQL.DI/GraphQLDIBuilderExtensions.cs new file mode 100644 index 0000000..9cd5110 --- /dev/null +++ b/src/GraphQL.DI/GraphQLDIBuilderExtensions.cs @@ -0,0 +1,63 @@ +using System.Reflection; +using GraphQL.DI; + +namespace GraphQL; + +/// +/// Provides extension methods to configure GraphQL.NET services within a dependency injection framework. +/// +public static class GraphQLDIBuilderExtensions +{ + /// + /// Performs the following: + /// + /// + /// Registers and + /// as generic types. + /// + /// + /// Scans the calling assembly for classes that implement + /// and registers them as transients within the DI container. + /// + /// + /// Scans the calling assembly for classes that implement and + /// registers clr type mappings on the schema between that + /// (constructed from that class and its source type), and the source type. + /// Skips classes where the source type is , or where the class is marked with + /// the , or where another graph type would be automatically mapped + /// to the specified type, or where a graph type has already been registered to the specified clr type. + /// + /// + /// + public static IGraphQLBuilder AddDI(this IGraphQLBuilder builder) + => AddDI(builder, Assembly.GetCallingAssembly()); + + /// + /// Performs the following: + /// + /// + /// Registers and + /// as generic types. + /// + /// + /// Scans the specified assembly for classes that implement + /// and registers them as transients within the DI container. + /// + /// + /// Scans the specified assembly for classes that implement and + /// registers clr type mappings on the schema between that + /// (constructed from that class and its source type), and the source type. + /// Skips classes where the source type is , or where the class is marked with + /// the , or where another graph type would be automatically mapped + /// to the specified type, or where a graph type has already been registered to the specified clr type. + /// + /// + /// + public static IGraphQLBuilder AddDI(this IGraphQLBuilder builder, Assembly assembly) + { + return builder + .AddDIGraphTypes() + .AddDIGraphBases(assembly) + .AddDIClrTypeMappings(assembly); + } +} diff --git a/src/Tests/DIObjectGraphTypeTests/DIObjectGraphTypeTestBase.cs b/src/Tests/DIObjectGraphTypeTests/DIObjectGraphTypeTestBase.cs index 49feaa7..1e959cc 100644 --- a/src/Tests/DIObjectGraphTypeTests/DIObjectGraphTypeTestBase.cs +++ b/src/Tests/DIObjectGraphTypeTests/DIObjectGraphTypeTestBase.cs @@ -31,13 +31,17 @@ public DIObjectGraphTypeTestBase() : base() _contextMock.SetupGet(x => x.Schema).Returns((ISchema)null!); } - protected IComplexGraphType Configure(bool instance = false, bool scoped = false) where T : DIObjectGraphBase, new() + protected IComplexGraphType Configure(bool instance = false, bool scoped = false, bool registered = true) where T : DIObjectGraphBase, new() { if (instance) { - if (scoped) { - _scopedServiceProviderMock.Setup(x => x.GetService(typeof(T))).Returns(() => new T()).Verifiable(); + if (registered) { + if (scoped) { + _scopedServiceProviderMock.Setup(x => x.GetService(typeof(T))).Returns(() => new T()).Verifiable(); + } else { + _serviceProviderMock.Setup(x => x.GetService(typeof(T))).Returns(() => new T()).Verifiable(); + } } else { - _serviceProviderMock.Setup(x => x.GetService(typeof(T))).Returns(() => new T()).Verifiable(); + _serviceProviderMock.Setup(x => x.GetService(typeof(T))).Returns(() => null).Verifiable(); } } _graphType = new DIObjectGraphType(); diff --git a/src/Tests/DIObjectGraphTypeTests/Field.cs b/src/Tests/DIObjectGraphTypeTests/Field.cs index ba6c0c5..a7bcb0a 100644 --- a/src/Tests/DIObjectGraphTypeTests/Field.cs +++ b/src/Tests/DIObjectGraphTypeTests/Field.cs @@ -19,10 +19,12 @@ public class CStaticMethod : DIObjectGraphBase public static string? Field1() => "hello"; } - [Fact] - public void InstanceMethod() + [Theory] + [InlineData(true)] + [InlineData(false)] + public void InstanceMethod(bool registered) { - Configure(true); + Configure(true, registered: registered); VerifyField("Field1", nullable: true, concurrent: false, returnValue: "hello"); Verify(false); } @@ -443,6 +445,31 @@ public class CIgnore : DIObjectGraphBase public static string Field2() => "hello"; } + [Fact] + public void DisposableRegistered() + { + Configure(true); + VerifyField("FieldTest", true, false, "hello"); + Verify(false); + } + + [Fact] + public async Task DisposableUnRegistered() + { + Configure(true, registered: false); + var err = await Should.ThrowAsync(() => VerifyFieldAsync("FieldTest", true, false, "hello")); + err.Message.ShouldBe("Could not resolve an instance of CDisposable from the service provider. DI graph types that implement IDisposable must be registered in the service provider."); + Verify(false); + } + + public class CDisposable : DIObjectGraphBase, IDisposable + { + [Name("FieldTest")] + public string? Field1() => "hello"; + + void IDisposable.Dispose() => GC.SuppressFinalize(this); + } + [Fact] public void AddsMetadata() { diff --git a/src/Tests/Execution/GraphQLBuilderTests.cs b/src/Tests/Execution/GraphQLBuilderTests.cs index 10ee129..284e671 100644 --- a/src/Tests/Execution/GraphQLBuilderTests.cs +++ b/src/Tests/Execution/GraphQLBuilderTests.cs @@ -20,6 +20,36 @@ public void AddDIGraphTypes() _mockServiceRegister.Setup(x => x.TryRegister(typeof(DIObjectGraphType<,>), typeof(DIObjectGraphType<,>), ServiceLifetime.Transient, RegistrationCompareMode.ServiceType)).Returns(_mockServiceRegister.Object).Verifiable(); _graphQLBuilder.AddDIGraphTypes().ShouldBe(_graphQLBuilder); _mockGraphQLBuilder.Verify(); + _mockServiceRegister.Verify(); + } + + [Fact] + public void AddDIGraphBases() + { + var registeredTypes = new List(); + _mockServiceRegister.Setup(x => x.TryRegister(It.IsAny(), It.IsAny(), ServiceLifetime.Transient, RegistrationCompareMode.ServiceType)) + .Returns((serviceType, implementationType, _, _) => { + // Verify the type meets the criteria (class, not abstract, implements IDIObjectGraphBase) + if (!implementationType.IsClass || implementationType.IsAbstract || !typeof(IDIObjectGraphBase).IsAssignableFrom(implementationType)) { + throw new InvalidOperationException($"Invalid type registration: {implementationType}"); + } + // Service type should match implementation type + if (serviceType != implementationType) { + throw new InvalidOperationException($"Service type {serviceType} does not match implementation type {implementationType}"); + } + registeredTypes.Add(implementationType); + return _mockServiceRegister.Object; + }) + .Verifiable(); + + // Call with specific assembly to test the overload and avoid assembly scanning issues + _graphQLBuilder.AddDIGraphBases().ShouldBe(_graphQLBuilder); + _mockGraphQLBuilder.Verify(); + _mockServiceRegister.Verify(); + + // Verify Base1 and Base2 were registered + registeredTypes.ShouldContain(typeof(Base1)); + registeredTypes.ShouldContain(typeof(Base2)); } [Fact] @@ -43,6 +73,61 @@ public void AddDIClrTypeMappings() mapper.GetGraphTypeFromClrType(typeof(Class3), false, typeof(Class4)).ShouldBe(typeof(Class4)); } + [Fact] + public void AddDI() + { + // Setup expectations for AddDIGraphBases + var requiredTypes = new List { + typeof(DIObjectGraphType<>), + typeof(DIObjectGraphType<,>), + typeof(Base1), + typeof(Base2) + }; + var registeredTypes = new List(); + _mockServiceRegister.Setup(x => x.TryRegister(It.IsAny(), It.IsAny(), ServiceLifetime.Transient, RegistrationCompareMode.ServiceType)) + .Returns((serviceType, implementationType, _, _) => { + if (serviceType == implementationType && requiredTypes.Contains(serviceType)) { + registeredTypes.Add(serviceType); + return _mockServiceRegister.Object; + } + if (!implementationType.IsClass || implementationType.IsAbstract || !typeof(IDIObjectGraphBase).IsAssignableFrom(implementationType)) { + throw new InvalidOperationException($"Invalid type registration: {implementationType}"); + } + if (serviceType != implementationType) { + throw new InvalidOperationException($"Service type {serviceType} does not match implementation type {implementationType}"); + } + registeredTypes.Add(implementationType); + return _mockServiceRegister.Object; + }) + .Verifiable(); + + // Setup expectations for AddDIClrTypeMappings + IGraphTypeMappingProvider? mapper = null; + _mockServiceRegister.Setup(x => x.Register(typeof(IGraphTypeMappingProvider), It.IsAny(), false)) + .Returns((_, m, _) => { + mapper = m; + return _mockServiceRegister.Object; + }); + + // Call AddDI with specific assembly + _graphQLBuilder.AddDI().ShouldBe(_graphQLBuilder); + + // Verify all expectations were met + _mockGraphQLBuilder.Verify(); + _mockServiceRegister.Verify(); + + // Verify required types were registered by AddDI + foreach (var type in requiredTypes) { + registeredTypes.ShouldContain(type); + } + + // Verify mapper was created and works correctly + mapper.ShouldNotBeNull(); + mapper.GetGraphTypeFromClrType(typeof(Class1), false, null).ShouldBe(typeof(DIObjectGraphType)); + mapper.GetGraphTypeFromClrType(typeof(Class2), false, null).ShouldBe(typeof(DIObjectGraphType)); + mapper.GetGraphTypeFromClrType(typeof(Class3), false, null).ShouldBeNull(); + } + private class Class1 { } private class Class2 { } private class Class3 { }