19 Commits

Author SHA1 Message Date
Samuele Lorefice
b9c4f65892 Added descriptions for all tests
All checks were successful
Nuget Pkg Build / build (push) Successful in 1m43s
2026-06-15 00:39:55 +02:00
Samuele Lorefice
fda62e30bc removed stub file 2026-06-15 00:38:39 +02:00
Samuele Lorefice
5dfee34bf5 Added unit testing 2026-06-15 00:37:40 +02:00
Samuele Lorefice
8fc2fbc7f1 Removed SandboxProject 2026-06-15 00:37:30 +02:00
Samuele Lorefice
f8f814dd16 Removed targeting for .Net 9 2026-06-15 00:27:32 +02:00
Samuele Lorefice
568ab548e5 fix: rewrite ServiceContainer resolution logic and add AGENTS.md
- Replace broken reflection-based GetService(Type, args) with
  direct ResolveService method
- Fix positional argument matching (type-based FirstOrDefault)
- Fix greedy constructor scoring (no double-count)
- Narrow TryGetService exception handling to InvalidOperationException
- Throw on duplicate registrations
- Add IDisposable support
- Add thread-safe singleton creation
- Create AGENTS.md with repo guide
2026-06-15 00:24:44 +02:00
Samuele Lorefice
65f624a355 Exposes GetService(Type, obiect[]? args) and it's safer variant TryGetService() to enable consumers to request a service without needing to do reflection work themselves.
All checks were successful
Nuget Pkg Build / build (push) Successful in 1m11s
bumps version to 0.0.1.8-alpha
2025-10-01 19:03:53 +02:00
Samuele Lorefice
66e7fcc798 Fixes #5 makes copy of the args list instead of stripping it away from the descriptor (preventing catastrophic problems). Bumps version
All checks were successful
Nuget Pkg Build / build (push) Successful in 1m7s
2025-09-24 19:53:29 +02:00
Samuele Lorefice
c888da8045 Fixes #4, renames ServiceLifetime.Lifetime to Singleton, adds support for arguments in constructors, version bumps.
All checks were successful
Nuget Pkg Build / build (push) Successful in 49s
2025-09-24 18:06:57 +02:00
Samuele Lorefice
b9fbb4b851 Version bump with new fixes
All checks were successful
Nuget Pkg Build / build (push) Successful in 1m0s
2025-09-24 04:08:53 +02:00
Samuele Lorefice
3df2f50765 Fixes #2 by providing a proper implementation of GetServices that actually returns instances of those services instead of just a list of types. 2025-09-24 04:07:24 +02:00
Samuele Lorefice
d20788de33 Rename method GetServices to GetServiceTypes for clarity (relevant for #2) 2025-09-24 03:55:01 +02:00
Samuele Lorefice
b8f2ddad5a Fixes #1 by searching also inside the implementation types list 2025-09-24 03:51:39 +02:00
86513ec6c6 Update .gitea/workflows/nuget-pkg-build.yml 2025-09-22 03:38:32 +02:00
Samuele Lorefice
debedc837e Added shorthands methods for no-interface service types
All checks were successful
Nuget Pkg Build / build (push) Successful in 42s
2025-09-22 03:15:51 +02:00
Samuele Lorefice
d0ccdbfa0f Updated workflow, widened support, now including Net 8, 9 and 10
Some checks failed
Nuget Pkg Build / build (push) Failing after 58s
2025-09-22 02:26:07 +02:00
Samuele Lorefice
16d0142967 enables CI flag 2025-09-21 23:04:36 +02:00
Samuele Lorefice
b06e886cf2 CI/CD Fixes 2025-09-21 21:54:22 +02:00
Samuele Lorefice
043cba4b3f Added support for multiple constructors and best match greedy resolution.
Some checks failed
Nuget Pkg Build / build (push) Failing after 25s
2025-09-21 21:30:41 +02:00
19 changed files with 846 additions and 173 deletions

View File

@@ -11,16 +11,21 @@ jobs:
timeout-minutes: 5 timeout-minutes: 5
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Setup .NET SDK - name: Setup .NET SDK
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v5
with: with:
dotnet-version: 9.x dotnet-version: |
10.x
9.x
8.x
- name: Build - name: Build
run: dotnet build Syrette -c Release run: dotnet build Syrette -c Release
#- name: Test #- name: Test
# run: dotnet test -c Release --no-build # run: dotnet test -c Release --no-build
- name: Pack nugets - name: Pack nugets
run: dotnet pack Syrette -c Release --no-build --output . run: dotnet pack Syrette -c Release --no-build --output . --include-symbols --include-source -p:SymbolPackageFormat=snupkg
- name: Push to NuGet - name: Push to NuGet
run: dotnet nuget push "*.nupkg" --api-key ${{secrets.NUGETAPIKEY}} --source https://api.nuget.org/v3/index.json run: dotnet nuget push "*.nupkg" --api-key ${{secrets.NUGETAPIKEY}} --skip-duplicate --source https://api.nuget.org/v3/index.json
- name: Push to Gitea
run: dotnet nuget push "*.nupkg" --api-key ${{secrets.NUGETGITEA}} --skip-duplicate --source https://git.r3d.codes/api/packages/REDCODE/nuget/index.json

14
.idea/.idea.Syrette/.idea/discord.xml generated Normal file
View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="PROJECT_FILES" />
<option name="description" value="" />
<option name="applicationTheme" value="default" />
<option name="iconsTheme" value="default" />
<option name="button1Title" value="" />
<option name="button1Url" value="" />
<option name="button2Title" value="" />
<option name="button2Url" value="" />
<option name="customApplicationId" value="" />
</component>
</project>

38
AGENTS.md Normal file
View File

@@ -0,0 +1,38 @@
# Syrette — agent guide
Minimal C# DI library targeting net8.0;net9.0;net10.0.
## Build commands
```bash
dotnet build Syrette # library only
dotnet build # everything (includes DISandbox console app)
dotnet pack Syrette -c Release --output .
```
SDK: .NET 10.0+ (see `global.json``rollForward: latestMinor`, `allowPrerelease: true`).
## Project structure
| Path | Purpose |
|---|---|
| `Syrette/` | Library (class lib). Entrypoints: `ServiceContainer.cs`, `ServiceDescriptor.cs`, `ServiceLifetime.cs` |
| `DISandbox/` | Unofficial manual-test sandbox. Console app referencing `Syrette`. Not part of the shipped package. |
No test project exists. CI has the test step commented out (`nuget-pkg-build.yml:24`).
## NuGet publishing
- CI (Gitea Actions) triggers on `v*` tags or `workflow_dispatch`.
- Pushes to both nuget.org and a Gitea package feed.
- Version is `0.0.1.8-alpha` per `Syrette.csproj`.
## Library API
- `ServiceContainer` exposes only `AddSingleton` / `AddTransient` (multiple overloads) and `GetService<T>()`, `GetService(Type, args)`, `TryGetService(Type, args)`, `GetServices<T>()`, `GetServiceTypes<T>()`.
- All registration methods return `ServiceContainer` for fluent chaining.
- Constructor selection is **greedy**: picks the constructor with the most parameters satisfiable by registered services or explicit args.
- Only Singleton and Transient lifetimes; no scoped, no property/method injection.
- `ServiceContainer : IDisposable` — disposes singleton instances that implement `IDisposable`.
- Duplicate registrations (same `(ServiceType, ImplementationType)`) throw `InvalidOperationException`.
- Resolution-time args (`GetService(Type, args)`) override registration-time args of the same type (type-based merge).

View File

@@ -1,14 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Syrette\Syrette.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,44 +0,0 @@
using Syrette;
namespace DISandbox;
interface IService {
public void Log(string message);
}
class Service : IService {
public void Log(string message) {
Console.WriteLine($"[Service] {message}");
}
}
interface IOtherService {
public Guid Id { get; }
public void ShowId() => Console.WriteLine($"[OtherService] ID: {Id}");
}
class GuidService : IOtherService {
public Guid Id { get; } = Guid.NewGuid();
}
class GuidDependantService(IService logService, IOtherService guidService) {
public void LogWithId(string message) {
logService.Log($"[GuidDependantService] {message} (ID: {guidService.Id})");
}
}
static class Program {
static void Main(string[] args) {
var container = new ServiceContainer()
.AddSingleton<IService, Service>()
.AddTransient<IOtherService, GuidService>()
.AddTransient<GuidDependantService, GuidDependantService>();
var service = container.GetService<IService>();
service.Log("Hello, Dependency Injection!");
container.GetService<IOtherService>().ShowId();
container.GetService<GuidDependantService>().LogWithId("Hello, sent from the dependency.");
container.GetService<IService>().Log("Goodbye, Dependency Injection!");
}
}

View File

@@ -10,11 +10,11 @@ It is designed to be used in minimalistic applications, such as console applicat
- **Lightweight**: Minimal codebase with no external dependencies. 1 class, the service container. You don't need anything else. - **Lightweight**: Minimal codebase with no external dependencies. 1 class, the service container. You don't need anything else.
- **Simple API**: You register services using two methods (one for singletons, one for transients) and resolve them with one. - **Simple API**: You register services using two methods (one for singletons, one for transients) and resolve them with one.
- **Supports Singleton and Transient lifetimes**: Choose between singleton (one instance per container) and transient (new instance per resolution) lifetimes for your services. - **Supports Singleton and Transient lifetimes**: Choose between singleton (one instance per container) and transient (new instance per resolution) lifetimes for your services.
- **Greedy matching constructor selection**: When resolving a service, the constructor with the most parameters that can be satisfied by the container is chosen.
## Limitations ## Limitations
- **No support for scoped lifetimes** - **No support for scoped lifetimes**
- **No support for property injection or method injection.** - **No support for property injection or method injection.**
- **Every service should have only 1 constructor.** (planned to be lifted in future versions)
## Usage ## Usage
````csharp ````csharp

View File

@@ -0,0 +1,127 @@
namespace Syrette.Tests;
public class ServiceContainerConstructorSelectionTests
{
public class CtorWithOptional
{
public ITestService? A { get; }
public string? Name { get; }
public CtorWithOptional(ITestService a, string? name = "default")
{
A = a;
Name = name;
}
}
public class CtorWithExactMatch
{
public int Value { get; }
public CtorWithExactMatch(int value) { Value = value; }
public CtorWithExactMatch(int value, string label) { Value = value; }
}
public class NoSatisfiableCtor
{
public NoSatisfiableCtor(int value) { }
}
[Fact(DisplayName = "Greedy constructor picks the ctor with the most parameters satisfiable by registered services")]
public void Greedy_picks_most_satisfiable_constructor()
{
var container = new ServiceContainer();
container.AddSingleton<ITestService, TestServiceImpl>();
container.AddSingleton<ITestServiceB, TestServiceBImpl>();
container.AddSingleton<TestMultiCtor>();
var instance = container.GetService<TestMultiCtor>();
Assert.NotNull(instance.A);
Assert.NotNull(instance.B);
Assert.Null(instance.C);
}
[Fact(DisplayName = "Greedy constructor picks the ctor with all parameters satisfiable over a partial match")]
public void Greedy_picks_all_satisfied_over_partial()
{
var container = new ServiceContainer();
container.AddSingleton<ITestService, TestServiceImpl>();
container.AddSingleton<ITestServiceB, TestServiceBImpl>();
container.AddSingleton<ITestServiceC, TestServiceCImpl>();
container.AddSingleton<TestMultiCtor>();
var instance = container.GetService<TestMultiCtor>();
Assert.NotNull(instance.A);
Assert.NotNull(instance.B);
Assert.NotNull(instance.C);
}
[Fact(DisplayName = "Greedy constructor falls back to 1-param ctor when only 1 service is available")]
public void Greedy_falls_back_to_1_param_when_only_1_satisfiable()
{
var container = new ServiceContainer();
container.AddSingleton<ITestService, TestServiceImpl>();
container.AddSingleton<TestMultiCtor>();
var instance = container.GetService<TestMultiCtor>();
Assert.NotNull(instance.A);
Assert.Null(instance.B);
Assert.Null(instance.C);
}
[Fact(DisplayName = "Optional parameter uses its default value when the parameter type is not registered")]
public void Optional_parameter_used_when_not_registered()
{
var container = new ServiceContainer();
container.AddSingleton<ITestService, TestServiceImpl>();
container.AddSingleton<CtorWithOptional>();
var instance = container.GetService<CtorWithOptional>();
Assert.NotNull(instance.A);
Assert.Equal("default", instance.Name);
}
[Fact(DisplayName = "Registration-time args satisfy a constructor parameter by type matching")]
public void Registration_args_satisfy_constructor()
{
var container = new ServiceContainer();
container.AddSingleton<CtorWithExactMatch, CtorWithExactMatch>(42);
var instance = container.GetService<CtorWithExactMatch>();
Assert.Equal(42, instance.Value);
}
[Fact(DisplayName = "Resolution-time args satisfy a constructor parameter when no registration-time args exist")]
public void Resolution_args_satisfy_constructor()
{
var container = new ServiceContainer();
container.AddSingleton<CtorWithExactMatch, CtorWithExactMatch>();
var instance = (CtorWithExactMatch)container.GetService(
typeof(CtorWithExactMatch), new object[] { 99 });
Assert.Equal(99, instance.Value);
}
[Fact(DisplayName = "Resolution-time args override registration-time args of the same type in constructor selection")]
public void Resolution_args_override_registration_args()
{
var container = new ServiceContainer();
container.AddSingleton<CtorWithExactMatch, CtorWithExactMatch>(10);
var instance = (CtorWithExactMatch)container.GetService(
typeof(CtorWithExactMatch), new object[] { 20 });
Assert.Equal(20, instance.Value);
}
[Fact(DisplayName = "Throws InvalidOperationException when no constructor has all required parameters satisfiable")]
public void No_suitable_constructor_throws()
{
var container = new ServiceContainer();
container.AddSingleton<NoSatisfiableCtor>();
Assert.Throws<InvalidOperationException>(() =>
container.GetService<NoSatisfiableCtor>());
}
}

View File

@@ -0,0 +1,40 @@
namespace Syrette.Tests;
public class ServiceContainerDisposalTests
{
[Fact(DisplayName = "Dispose calls Dispose on singleton instances that implement IDisposable")]
public void Dispose_disposes_singletons_implementing_IDisposable()
{
var container = new ServiceContainer();
container.AddSingleton<TestDisposableService>();
var instance = container.GetService<TestDisposableService>();
container.Dispose();
Assert.True(instance.IsDisposed);
}
[Fact(DisplayName = "Dispose clears the singleton cache so subsequent resolution throws")]
public void Dispose_clears_singleton_cache()
{
var container = new ServiceContainer();
container.AddSingleton<TestDisposableService>();
container.GetService<TestDisposableService>();
container.Dispose();
Assert.Throws<InvalidOperationException>(() =>
container.GetService<TestDisposableService>());
}
[Fact(DisplayName = "Calling Dispose multiple times does not throw")]
public void Multiple_dispose_is_safe()
{
var container = new ServiceContainer();
container.AddSingleton<TestDisposableService>();
container.GetService<TestDisposableService>();
container.Dispose();
container.Dispose();
Assert.True(true);
}
}

View File

@@ -0,0 +1,100 @@
namespace Syrette.Tests;
public class ServiceContainerRegistrationTests
{
[Fact(DisplayName = "AddSingleton<TInterface, TImplementation> registers a service that can be resolved")]
public void AddSingleton_TInterface_TImplementation_registers_successfully()
{
var container = new ServiceContainer();
container.AddSingleton<ITestService, TestServiceImpl>();
Assert.NotNull(container.GetService<ITestService>());
}
[Fact(DisplayName = "AddSingleton<TInterface, TImplementation> with constructor args registers and resolves")]
public void AddSingleton_TInterface_TImplementation_with_args_registers_successfully()
{
var container = new ServiceContainer();
container.AddSingleton<ITestService, TestDeepService>(Guid.NewGuid());
Assert.NotNull(container.GetService<ITestService>());
}
[Fact(DisplayName = "AddSingleton<TClass> (self-registration) registers and resolves")]
public void AddSingleton_TClass_registers_successfully()
{
var container = new ServiceContainer();
container.AddSingleton<TestServiceImpl>();
Assert.NotNull(container.GetService<TestServiceImpl>());
}
[Fact(DisplayName = "AddSingleton<TClass> with constructor args registers and resolves")]
public void AddSingleton_TClass_with_args_registers_successfully()
{
var container = new ServiceContainer();
container.AddSingleton<TestDeepService>(Guid.NewGuid());
Assert.NotNull(container.GetService<TestDeepService>());
}
[Fact(DisplayName = "AddTransient<TInterface, TImplementation> registers a service that can be resolved")]
public void AddTransient_TInterface_TImplementation_registers_successfully()
{
var container = new ServiceContainer();
container.AddTransient<ITestService, TestServiceImpl>();
Assert.NotNull(container.GetService<ITestService>());
}
[Fact(DisplayName = "AddTransient<TInterface, TImplementation> with constructor args registers and resolves")]
public void AddTransient_TInterface_TImplementation_with_args_registers_successfully()
{
var container = new ServiceContainer();
container.AddTransient<ITestService, TestDeepService>(Guid.NewGuid());
Assert.NotNull(container.GetService<ITestService>());
}
[Fact(DisplayName = "AddTransient<TClass> (self-registration) registers and resolves")]
public void AddTransient_TClass_registers_successfully()
{
var container = new ServiceContainer();
container.AddTransient<TestServiceImpl>();
Assert.NotNull(container.GetService<TestServiceImpl>());
}
[Fact(DisplayName = "AddTransient<TClass> with constructor args registers and resolves")]
public void AddTransient_TClass_with_args_registers_successfully()
{
var container = new ServiceContainer();
container.AddTransient<TestDeepService>(Guid.NewGuid());
Assert.NotNull(container.GetService<TestDeepService>());
}
[Fact(DisplayName = "Fluent chaining returns the same ServiceContainer instance")]
public void Fluent_chaining_returns_same_container()
{
var container = new ServiceContainer();
var result = container
.AddSingleton<ITestService, TestServiceImpl>()
.AddTransient<TestServiceImpl>();
Assert.Same(container, result);
}
[Fact(DisplayName = "Registering the same (ServiceType, ImplementationType) pair twice throws InvalidOperationException")]
public void Duplicate_registration_same_pair_throws()
{
var container = new ServiceContainer();
container.AddSingleton<ITestService, TestServiceImpl>();
Assert.Throws<InvalidOperationException>(() =>
container.AddSingleton<ITestService, TestServiceImpl>());
}
[Fact(DisplayName = "Registering two different implementations for the same service type is allowed and both are resolvable")]
public void Duplicate_registration_different_impl_for_same_service_allowed()
{
var container = new ServiceContainer();
container.AddSingleton<ITestService, TestServiceImpl>();
container.AddSingleton<ITestService, TestServiceAlt>();
var services = container.GetServiceTypes<ITestService>();
Assert.Equal(2, services.Count);
Assert.Contains(typeof(TestServiceImpl), services);
Assert.Contains(typeof(TestServiceAlt), services);
}
}

View File

@@ -0,0 +1,141 @@
namespace Syrette.Tests;
public class ServiceContainerResolutionTests
{
[Fact(DisplayName = "GetService<T> resolves a singleton registration")]
public void GetService_T_resolves_singleton()
{
var container = new ServiceContainer();
container.AddSingleton<ITestService, TestServiceImpl>();
var instance = container.GetService<ITestService>();
Assert.NotNull(instance);
Assert.IsType<TestServiceImpl>(instance);
}
[Fact(DisplayName = "GetService<T> resolves a transient registration")]
public void GetService_T_resolves_transient()
{
var container = new ServiceContainer();
container.AddTransient<ITestServiceB, TestServiceBImpl>();
var instance = container.GetService<ITestServiceB>();
Assert.NotNull(instance);
Assert.IsType<TestServiceBImpl>(instance);
}
[Fact(DisplayName = "Multiple GetService<T> calls for a singleton return the same instance")]
public void Singleton_returns_same_instance()
{
var container = new ServiceContainer();
container.AddSingleton<ITestService, TestServiceImpl>();
var a = container.GetService<ITestService>();
var b = container.GetService<ITestService>();
Assert.Same(a, b);
}
[Fact(DisplayName = "Multiple GetService<T> calls for a transient return different instances")]
public void Transient_returns_new_instance()
{
var container = new ServiceContainer();
container.AddTransient<ITestServiceB, TestServiceBImpl>();
var a = container.GetService<ITestServiceB>();
var b = container.GetService<ITestServiceB>();
Assert.NotSame(a, b);
}
[Fact(DisplayName = "GetService(Type) non-generic overload resolves a registered service")]
public void GetService_Type_non_generic_resolves()
{
var container = new ServiceContainer();
container.AddSingleton<ITestService, TestServiceImpl>();
var instance = container.GetService(typeof(ITestService));
Assert.NotNull(instance);
Assert.IsType<TestServiceImpl>(instance);
}
[Fact(DisplayName = "GetService(Type, object[]) passes resolution-time args to the constructor")]
public void GetService_Type_with_args_resolves()
{
var container = new ServiceContainer();
container.AddTransient<ITestService, TestDeepService>();
var instance = container.GetService(typeof(ITestService), new object[] { Guid.NewGuid() });
Assert.NotNull(instance);
}
[Fact(DisplayName = "GetService<T> throws InvalidOperationException when type is not registered")]
public void GetService_T_throws_for_unregistered()
{
var container = new ServiceContainer();
Assert.Throws<InvalidOperationException>(() =>
container.GetService<ITestService>());
}
[Fact(DisplayName = "GetService(Type) throws InvalidOperationException when type is not registered")]
public void GetService_Type_throws_for_unregistered()
{
var container = new ServiceContainer();
Assert.Throws<InvalidOperationException>(() =>
container.GetService(typeof(ITestService)));
}
[Fact(DisplayName = "TryGetService(Type) returns null when type is not registered")]
public void TryGetService_returns_null_for_unregistered()
{
var container = new ServiceContainer();
var result = container.TryGetService(typeof(ITestService));
Assert.Null(result);
}
[Fact(DisplayName = "TryGetService(Type) returns an instance when the type is registered")]
public void TryGetService_returns_instance_when_registered()
{
var container = new ServiceContainer();
container.AddSingleton<ITestService, TestServiceImpl>();
var result = container.TryGetService(typeof(ITestService));
Assert.NotNull(result);
}
[Fact(DisplayName = "Resolving by implementation type works when only the interface was registered")]
public void Resolve_by_implementation_type_when_not_registered_directly()
{
var container = new ServiceContainer();
container.AddSingleton<ITestService, TestServiceImpl>();
var instance = container.GetService(typeof(TestServiceImpl));
Assert.NotNull(instance);
}
[Fact(DisplayName = "Resolution-time args override registration-time args of the same type")]
public void Resolution_time_args_override_registration_time_args()
{
var container = new ServiceContainer();
container.AddSingleton<ITestService, TestDeepService>(Guid.Empty);
var resolved = container.GetService(typeof(ITestService), new object[] { Guid.NewGuid() });
Assert.NotNull(resolved);
var deep = (TestDeepService)resolved;
Assert.NotEqual(Guid.Empty, deep.Id);
}
[Fact(DisplayName = "GetServices<T> returns instances of all registered implementations for a service type")]
public void GetServices_returns_all_registered_implementations()
{
var container = new ServiceContainer();
container.AddSingleton<ITestService, TestServiceImpl>();
container.AddSingleton<ITestService, TestServiceAlt>();
var services = container.GetServices<ITestService>();
Assert.Equal(2, services.Count);
var types = services.Select(s => s.GetType()).ToList();
Assert.Contains(typeof(TestServiceImpl), types);
Assert.Contains(typeof(TestServiceAlt), types);
}
[Fact(DisplayName = "GetServiceTypes<T> returns the implementation types of all registered services")]
public void GetServiceTypes_returns_all_implementation_types()
{
var container = new ServiceContainer();
container.AddSingleton<ITestService, TestServiceImpl>();
container.AddSingleton<ITestService, TestServiceAlt>();
var types = container.GetServiceTypes<ITestService>();
Assert.Equal(2, types.Count);
Assert.Contains(typeof(TestServiceImpl), types);
Assert.Contains(typeof(TestServiceAlt), types);
}
}

View File

@@ -0,0 +1,61 @@
namespace Syrette.Tests;
public class ServiceContainerThreadingTests
{
[Fact(DisplayName = "Concurrent singleton resolution from multiple threads returns the same instance")]
public void Concurrent_singleton_resolution_returns_same_instance()
{
var container = new ServiceContainer();
container.AddSingleton<ITestService, TestServiceImpl>();
var results = new ITestService[10];
Parallel.For(0, 10, i =>
{
results[i] = container.GetService<ITestService>();
});
for (int i = 1; i < results.Length; i++)
{
Assert.Same(results[0], results[i]);
}
}
[Fact(DisplayName = "Concurrent transient resolution from multiple threads returns unique instances")]
public void Concurrent_transient_resolution_returns_unique_instances()
{
var container = new ServiceContainer();
container.AddTransient<ITestService, TestServiceImpl>();
var results = new ITestService[10];
Parallel.For(0, 10, i =>
{
results[i] = container.GetService<ITestService>();
});
for (int i = 0; i < results.Length; i++)
{
for (int j = i + 1; j < results.Length; j++)
{
Assert.NotSame(results[i], results[j]);
}
}
}
[Fact(DisplayName = "Concurrent registration and resolution does not crash or deadlock")]
public void Concurrent_registration_and_resolution_does_not_crash()
{
var container = new ServiceContainer();
Parallel.Invoke(
() => { try { container.AddSingleton<ITestService, TestServiceImpl>(); } catch { } },
() => { try { container.AddSingleton<ITestService, TestServiceImpl>(); } catch { } },
() => { try { container.GetService<ITestService>(); } catch { } },
() => { try { container.AddSingleton<ITestService, TestServiceImpl>(); } catch { } },
() => { try { container.GetService<ITestService>(); } catch { } }
);
Assert.NotNull(container);
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<TargetFrameworks>net10.0;net8.0</TargetFrameworks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Syrette\Syrette.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,35 @@
namespace Syrette.Tests;
public interface ITestService { }
public class TestServiceImpl : ITestService { }
public class TestServiceAlt : ITestService { }
public interface ITestServiceB { }
public class TestServiceBImpl : ITestServiceB { }
public interface ITestServiceC { }
public class TestServiceCImpl : ITestServiceC { }
public class TestMultiCtor
{
public ITestService? A { get; }
public ITestServiceB? B { get; }
public ITestServiceC? C { get; }
public TestMultiCtor(ITestService a) { A = a; }
public TestMultiCtor(ITestService a, ITestServiceB b) { A = a; B = b; }
public TestMultiCtor(ITestService a, ITestServiceB b, ITestServiceC c) { A = a; B = b; C = c; }
}
public class TestDeepService : ITestService
{
public Guid Id { get; }
public TestDeepService(Guid id) { Id = id; }
}
public class TestDisposableService : IDisposable
{
public bool IsDisposed { get; private set; }
public void Dispose() => IsDisposed = true;
}

View File

@@ -2,21 +2,44 @@
Microsoft Visual Studio Solution File, Format Version 12.00 Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Syrette", "Syrette\Syrette.csproj", "{4730ABA2-3979-4B74-A3FF-042F6C3C47D6}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Syrette", "Syrette\Syrette.csproj", "{4730ABA2-3979-4B74-A3FF-042F6C3C47D6}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DISandbox", "DISandbox\DISandbox.csproj", "{536F5490-926D-4B2A-8F07-9A7D1F8B9381}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Syrette.Tests", "Syrette.Tests\Syrette.Tests.csproj", "{CE07A0AF-1EAB-4AFF-B04F-16E0A8A5FC24}"
EndProject EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{4730ABA2-3979-4B74-A3FF-042F6C3C47D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4730ABA2-3979-4B74-A3FF-042F6C3C47D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4730ABA2-3979-4B74-A3FF-042F6C3C47D6}.Debug|Any CPU.Build.0 = Debug|Any CPU {4730ABA2-3979-4B74-A3FF-042F6C3C47D6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4730ABA2-3979-4B74-A3FF-042F6C3C47D6}.Debug|x64.ActiveCfg = Debug|Any CPU
{4730ABA2-3979-4B74-A3FF-042F6C3C47D6}.Debug|x64.Build.0 = Debug|Any CPU
{4730ABA2-3979-4B74-A3FF-042F6C3C47D6}.Debug|x86.ActiveCfg = Debug|Any CPU
{4730ABA2-3979-4B74-A3FF-042F6C3C47D6}.Debug|x86.Build.0 = Debug|Any CPU
{4730ABA2-3979-4B74-A3FF-042F6C3C47D6}.Release|Any CPU.ActiveCfg = Release|Any CPU {4730ABA2-3979-4B74-A3FF-042F6C3C47D6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4730ABA2-3979-4B74-A3FF-042F6C3C47D6}.Release|Any CPU.Build.0 = Release|Any CPU {4730ABA2-3979-4B74-A3FF-042F6C3C47D6}.Release|Any CPU.Build.0 = Release|Any CPU
{536F5490-926D-4B2A-8F07-9A7D1F8B9381}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4730ABA2-3979-4B74-A3FF-042F6C3C47D6}.Release|x64.ActiveCfg = Release|Any CPU
{536F5490-926D-4B2A-8F07-9A7D1F8B9381}.Debug|Any CPU.Build.0 = Debug|Any CPU {4730ABA2-3979-4B74-A3FF-042F6C3C47D6}.Release|x64.Build.0 = Release|Any CPU
{536F5490-926D-4B2A-8F07-9A7D1F8B9381}.Release|Any CPU.ActiveCfg = Release|Any CPU {4730ABA2-3979-4B74-A3FF-042F6C3C47D6}.Release|x86.ActiveCfg = Release|Any CPU
{536F5490-926D-4B2A-8F07-9A7D1F8B9381}.Release|Any CPU.Build.0 = Release|Any CPU {4730ABA2-3979-4B74-A3FF-042F6C3C47D6}.Release|x86.Build.0 = Release|Any CPU
{CE07A0AF-1EAB-4AFF-B04F-16E0A8A5FC24}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CE07A0AF-1EAB-4AFF-B04F-16E0A8A5FC24}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CE07A0AF-1EAB-4AFF-B04F-16E0A8A5FC24}.Debug|x64.ActiveCfg = Debug|Any CPU
{CE07A0AF-1EAB-4AFF-B04F-16E0A8A5FC24}.Debug|x64.Build.0 = Debug|Any CPU
{CE07A0AF-1EAB-4AFF-B04F-16E0A8A5FC24}.Debug|x86.ActiveCfg = Debug|Any CPU
{CE07A0AF-1EAB-4AFF-B04F-16E0A8A5FC24}.Debug|x86.Build.0 = Debug|Any CPU
{CE07A0AF-1EAB-4AFF-B04F-16E0A8A5FC24}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CE07A0AF-1EAB-4AFF-B04F-16E0A8A5FC24}.Release|Any CPU.Build.0 = Release|Any CPU
{CE07A0AF-1EAB-4AFF-B04F-16E0A8A5FC24}.Release|x64.ActiveCfg = Release|Any CPU
{CE07A0AF-1EAB-4AFF-B04F-16E0A8A5FC24}.Release|x64.Build.0 = Release|Any CPU
{CE07A0AF-1EAB-4AFF-B04F-16E0A8A5FC24}.Release|x86.ActiveCfg = Release|Any CPU
{CE07A0AF-1EAB-4AFF-B04F-16E0A8A5FC24}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

View File

@@ -1,116 +1,228 @@
namespace Syrette; using System.Reflection;
/// <summary> namespace Syrette;
/// Container for managing service registrations and resolutions.
/// </summary> public class ServiceContainer : IDisposable {
public class ServiceContainer {
private readonly List<ServiceDescriptor> descriptors = new(); private readonly List<ServiceDescriptor> descriptors = new();
private readonly Dictionary<Type, object> singletons = new(); private readonly Dictionary<Type, object> singletons = new();
private readonly object singletonLock = new();
/// <summary> public List<Type> GetServiceTypes<TServices>() =>
/// Get all registered implementation types for a given service type.
/// </summary>
/// <typeparam name="TServices"></typeparam>
/// <returns></returns>
public List<Type> GetServices<TServices>() =>
descriptors.Where(d => d.ServiceType == typeof(TServices)) descriptors.Where(d => d.ServiceType == typeof(TServices))
.Select(d => d.ImplementationType).ToList(); .Select(d => d.ImplementationType).ToList();
/// <summary> public List<TService> GetServices<TService>() where TService : class =>
/// Registers a singleton service with its implementation. descriptors.Where(d => d.ServiceType == typeof(TService))
/// </summary> .Select(d => (TService)ResolveService(d.ImplementationType, null)).ToList();
/// <typeparam name="TInterface">Interface the service is implementing</typeparam>
/// <typeparam name="TImplementation">Implementation type of the service</typeparam>
public ServiceContainer AddSingleton<TInterface, TImplementation>() public ServiceContainer AddSingleton<TInterface, TImplementation>()
where TInterface : class
where TImplementation : class, TInterface { where TImplementation : class, TInterface {
descriptors.Add(new ServiceDescriptor { AddDescriptor(typeof(TInterface), typeof(TImplementation), ServiceLifetime.Singleton, null);
ServiceType = typeof(TInterface), return this;
ImplementationType = typeof(TImplementation), }
Lifetime = ServiceLifetime.Lifetime,
RequiredTypes = typeof(TImplementation).GetConstructors().Single() public ServiceContainer AddSingleton<TInterface, TImplementation>(params object[] args)
.GetParameters().Select(p => p.ParameterType).ToList() where TInterface : class
}); where TImplementation : class, TInterface {
AddDescriptor(typeof(TInterface), typeof(TImplementation), ServiceLifetime.Singleton, args);
return this;
}
public ServiceContainer AddSingleton<TClass>()
where TClass : class {
AddDescriptor(typeof(TClass), typeof(TClass), ServiceLifetime.Singleton, null);
return this;
}
public ServiceContainer AddSingleton<TImplementation>(params object[] args)
where TImplementation : class {
AddDescriptor(typeof(TImplementation), typeof(TImplementation), ServiceLifetime.Singleton, args);
return this; return this;
} }
/// <summary>
/// Registers a transient service with its implementation.
/// </summary>
/// <typeparam name="TInterface">Interface the service is implementing</typeparam>
/// <typeparam name="TImplementation">Implementation type of the service</typeparam>
public ServiceContainer AddTransient<TInterface, TImplementation>() public ServiceContainer AddTransient<TInterface, TImplementation>()
where TInterface : class
where TImplementation : class, TInterface { where TImplementation : class, TInterface {
descriptors.Add(new ServiceDescriptor { AddDescriptor(typeof(TInterface), typeof(TImplementation), ServiceLifetime.Transient, null);
ServiceType = typeof(TInterface),
ImplementationType = typeof(TImplementation),
Lifetime = ServiceLifetime.Transient,
RequiredTypes = typeof(TImplementation).GetConstructors().Single()
.GetParameters().Select(p => p.ParameterType).ToList()
});
return this; return this;
} }
// you can't call generic methods with an unknown type at compile time public ServiceContainer AddTransient<TInterface, TImplementation>(params object[] args)
// so we use reflection to call the generic GetService<T> method with the provided type where TInterface : class
// Basically we build the method GetService<serviceType>() at runtime and then call it. where TImplementation : class, TInterface {
// "Classic black magic sorcery" in reflection. AddDescriptor(typeof(TInterface), typeof(TImplementation), ServiceLifetime.Transient, args);
private object GetService(Type serviceType) { return this;
var method = typeof(ServiceContainer)
.GetMethod(nameof(GetService))!
.MakeGenericMethod(serviceType);
return method.Invoke(this, null)!;
} }
/// <summary> public ServiceContainer AddTransient<TClass>()
/// Resolves and returns an instance of the requested service type. where TClass : class {
/// </summary> AddDescriptor(typeof(TClass), typeof(TClass), ServiceLifetime.Transient, null);
/// <typeparam name="TInterface">Interface type of the service being requested</typeparam> return this;
/// <returns>Resolved service instance</returns>
public TInterface GetService<TInterface>() {
var descriptor = descriptors.FirstOrDefault(d => d.ServiceType == typeof(TInterface));
if (descriptor == null) throw new Exception($"Service of type {typeof(TInterface)} not registered.");
// Ensure all required dependencies are registered
//TODO: some services might be asking for specific implementations, not interfaces. We should check for that too.
var missing = descriptor.RequiredTypes
//filter all required types that are not in the registered descriptors
.Where(t => descriptors.All(d => d.ServiceType != t))
.Select(t => t.Name)
.ToList();
if (missing.Any())
throw new Exception($"Cannot create service of type {typeof(TInterface)}. Missing dependencies: {string.Join(", ", missing)}");
// Transient: create a new instance each time
if (descriptor.Lifetime != ServiceLifetime.Lifetime) {
var service = Instantiate<TInterface>(descriptor);
return service;
} }
// Singleton: return existing instance public ServiceContainer AddTransient<TClass>(params object[] args)
if (singletons.TryGetValue(descriptor.ServiceType, out object? singleton)) return (TInterface)singleton; where TClass : class {
AddDescriptor(typeof(TClass), typeof(TClass), ServiceLifetime.Transient, args);
// or create a new one if not yet created. return this;
var newSingleton = Instantiate<TInterface>(descriptor);
singletons[descriptor.ServiceType] = newSingleton!;
return newSingleton;
} }
private TInterface Instantiate<TInterface>(ServiceDescriptor descriptor) { private void AddDescriptor(Type serviceType, Type implementationType, ServiceLifetime lifetime, object[]? args) {
var par = descriptor.ImplementationType if (descriptors.Any(d => d.ServiceType == serviceType && d.ImplementationType == implementationType)) {
.GetConstructors().Single() throw new InvalidOperationException(
.GetParameters() $"A registration for '{implementationType.Name}' as '{serviceType.Name}' already exists.");
.Select(p => p.ParameterType) }
.ToList();
object[] parameters = new object[par.Count]; descriptors.Add(new ServiceDescriptor {
ServiceType = serviceType,
ImplementationType = implementationType,
Lifetime = lifetime,
Arguments = args?.ToList()
});
}
for (int i = 0; i < par.Count; i++) public object GetService(Type serviceType, object[]? args = null) {
parameters[i] = GetService(par[i]); return ResolveService(serviceType, args);
}
var service = (TInterface?)Activator.CreateInstance(descriptor.ImplementationType, parameters); public TService GetService<TService>() {
return (TService)ResolveService(typeof(TService), null);
}
return service ?? throw new Exception($"Could not create instance of type {descriptor.ImplementationType}"); public object? TryGetService(Type serviceType, object[]? args = null) {
try {
return ResolveService(serviceType, args);
}
catch (InvalidOperationException) {
return null;
}
}
private object ResolveService(Type serviceType, object[]? resolutionArgs) {
var descriptor = descriptors.FirstOrDefault(d =>
d.ServiceType == serviceType || d.ImplementationType == serviceType);
if (descriptor == null) {
throw new InvalidOperationException(
$"Service of type '{serviceType.Name}' not registered.");
}
var mergedArgs = descriptor.Arguments != null
? new List<object>(descriptor.Arguments)
: new List<object>();
if (resolutionArgs != null) {
foreach (var arg in resolutionArgs) {
var argType = arg.GetType();
var index = mergedArgs.FindIndex(a => a.GetType() == argType);
if (index >= 0) {
mergedArgs[index] = arg;
} else {
mergedArgs.Add(arg);
}
}
}
var ctors = descriptor.ImplementationType.GetConstructors();
ConstructorInfo? bestCtor = null;
int max = -1;
foreach (var ctor in ctors) {
var parameters = ctor.GetParameters();
if (parameters.Any(p =>
descriptors.All(d => d.ServiceType != p.ParameterType) &&
mergedArgs.All(a => !p.ParameterType.IsAssignableFrom(a.GetType())) &&
!p.IsOptional)) {
continue;
}
int satisfied = parameters.Count(p =>
descriptors.Any(d => d.ServiceType == p.ParameterType));
int argSatisfied = 0;
foreach (var param in parameters) {
if (!descriptors.Any(d => d.ServiceType == param.ParameterType) &&
mergedArgs.Any(a => param.ParameterType.IsAssignableFrom(a.GetType()))) {
argSatisfied++;
}
}
satisfied += argSatisfied;
if (satisfied > max) {
max = satisfied;
bestCtor = ctor;
}
}
if (bestCtor == null) {
throw new InvalidOperationException(
$"Cannot create service of type '{serviceType.Name}'. No suitable constructor found.");
}
if (descriptor.Lifetime == ServiceLifetime.Singleton) {
lock (singletonLock) {
if (singletons.TryGetValue(descriptor.ImplementationType, out var singleton)) {
return singleton;
}
var instance = Instantiate(descriptor.ImplementationType, bestCtor, mergedArgs);
singletons[descriptor.ImplementationType] = instance;
return instance;
}
}
return Instantiate(descriptor.ImplementationType, bestCtor, mergedArgs);
}
private object Instantiate(Type implementationType, ConstructorInfo ctor, List<object> args) {
var parameters = ctor.GetParameters();
var resolvedParams = new object?[parameters.Length];
var usedArgs = new List<object>();
for (var i = 0; i < parameters.Length; i++) {
var paramType = parameters[i].ParameterType;
var arg = args.FirstOrDefault(a =>
!usedArgs.Contains(a) && paramType.IsAssignableFrom(a.GetType()));
if (arg != null) {
resolvedParams[i] = arg;
usedArgs.Add(arg);
continue;
}
var ctorArg = TryGetService(paramType);
if (ctorArg != null) {
resolvedParams[i] = ctorArg;
continue;
}
if (parameters[i].IsOptional) {
resolvedParams[i] = parameters[i].DefaultValue;
continue;
}
throw new InvalidOperationException(
$"Cannot resolve parameter '{parameters[i].Name}' of type '{paramType.Name}' for service '{implementationType.Name}'.");
}
var instance = Activator.CreateInstance(implementationType, resolvedParams);
return instance ??
throw new InvalidOperationException(
$"Could not create instance of type '{implementationType.Name}'.");
}
public void Dispose() {
foreach (var disposable in singletons.Values.OfType<IDisposable>()) {
disposable.Dispose();
}
singletons.Clear();
descriptors.Clear();
} }
} }

View File

@@ -8,20 +8,28 @@ public class ServiceDescriptor
/// <summary> /// <summary>
/// Gets or sets the type of the service to be provided. /// Gets or sets the type of the service to be provided.
/// </summary> /// </summary>
public required Type ServiceType { get; set; } public required Type ServiceType { get; init; }
/// <summary> /// <summary>
/// Gets or sets the concrete type that implements the service. /// Gets or sets the concrete type that implements the service.
/// </summary> /// </summary>
public required Type ImplementationType { get; set; } public required Type ImplementationType { get; init; }
/// <summary> /// <summary>
/// Gets or sets the lifetime of the service (e.g., Singleton or Transient). /// Gets or sets the lifetime of the service (e.g., Singleton or Transient).
/// </summary> /// </summary>
public required ServiceLifetime Lifetime { get; set; } public required ServiceLifetime Lifetime { get; init; }
/// <summary> /// <summary>
/// Gets or sets the list of types required by the implementation (dependencies). /// Arguments to be passed to the constructor of the implementation type.
/// </summary> /// </summary>
public List<Type> RequiredTypes { get; set; } = new(); public List<object>? Arguments { get; init; }
/// <summary>
/// Returns a string with the specific type of service, its implementation, and its lifetime.
/// </summary>
/// <returns>{implementation Name} as {Service Name} ({Lifetime})</returns>
public override string ToString() {
return $"{ImplementationType.Name} as {ServiceType.Name} ({Lifetime})";
}
} }

View File

@@ -7,7 +7,7 @@ public enum ServiceLifetime {
/// <summary> /// <summary>
/// Defines a singleton service, which is created once and shared throughout the application's lifetime. /// Defines a singleton service, which is created once and shared throughout the application's lifetime.
/// </summary> /// </summary>
Lifetime, Singleton,
/// <summary> /// <summary>
/// Defines a transient service, which is created anew each time it is requested. /// Defines a transient service, which is created anew each time it is requested.
/// </summary> /// </summary>

View File

@@ -6,11 +6,10 @@
</Content> </Content>
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<Version>0.0.1.1-alpha</Version> <Version>0.0.1.8-alpha</Version>
<Title>Syrette </Title> <Title>Syrette </Title>
<Authors>Lorefice Samuele</Authors> <Authors>Lorefice Samuele</Authors>
<Description>Syrette is a minimalistic dependency injection library for C#. It aims to provide a simple and efficient way to achieve dependency injections in your applications without the overhead of larger frameworks.</Description> <Description>Syrette is a minimalistic dependency injection library for C#. It aims to provide a simple and efficient way to achieve dependency injections in your applications without the overhead of larger frameworks.</Description>
@@ -24,6 +23,9 @@
<Company>Samuele Lorefice</Company> <Company>Samuele Lorefice</Company>
<Deterministic>true</Deterministic> <Deterministic>true</Deterministic>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild> <GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<TargetFrameworks>net10.0;net8.0</TargetFrameworks>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' "> <PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<DebugSymbols>true</DebugSymbols> <DebugSymbols>true</DebugSymbols>

View File

@@ -1,7 +1,7 @@
{ {
"sdk": { "sdk": {
"version": "9.0.0", "version": "10.0.0",
"rollForward": "latestMinor", "rollForward": "latestMinor",
"allowPrerelease": false "allowPrerelease": true
} }
} }