Skip to content

Commit deb6ca5

Browse files
authored
Merge pull request #37 from oproto/main
Merge main back into develop
2 parents 2425b17 + b71a71c commit deb6ca5

15 files changed

Lines changed: 967 additions & 38 deletions

File tree

.kiro/specs/dictionary-schema-support/tasks.md

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,87 +6,87 @@ This implementation adds proper dictionary type handling to the Oproto.Lambda.Op
66

77
## Tasks
88

9-
- [ ] 1. Implement dictionary type detection
10-
- [ ] 1.1 Add `IsDictionaryType` method to `OpenApiSpecGenerator_Types.cs`
9+
- [x] 1. Implement dictionary type detection
10+
- [x] 1.1 Add `IsDictionaryType` method to `OpenApiSpecGenerator_Types.cs`
1111
- Implement detection for `Dictionary<K,V>`, `IDictionary<K,V>`, `IReadOnlyDictionary<K,V>`
1212
- Extract key and value type symbols from type arguments
1313
- Check for interface implementation for custom dictionary types
1414
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5_
1515

16-
- [ ] 2. Implement dictionary schema generation
17-
- [ ] 2.1 Add `TryCreateDictionarySchema` method to `OpenApiSpecGenerator_Schema.cs`
16+
- [x] 2. Implement dictionary schema generation
17+
- [x] 2.1 Add `TryCreateDictionarySchema` method to `OpenApiSpecGenerator_Schema.cs`
1818
- Create schema with `type: "object"` and `additionalProperties`
1919
- Recursively call `CreateSchema` for the value type
2020
- Apply `[OpenApiSchema]` attributes (Description, Example) to dictionary schema
2121
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 3.1, 3.2, 3.3, 6.1, 6.2_
2222

23-
- [ ] 2.2 Integrate dictionary detection into `CreateSchema` pipeline
23+
- [x] 2.2 Integrate dictionary detection into `CreateSchema` pipeline
2424
- Add `TryCreateDictionarySchema` call after `TryCreateCollectionSchema` and before complex type handling
2525
- Ensure dictionaries don't fall through to `CreateComplexTypeSchema`
2626
- _Requirements: 5.1, 5.2, 5.3_
2727

28-
- [ ] 2.3 Handle nullable dictionary types
28+
- [x] 2.3 Handle nullable dictionary types
2929
- Ensure nullable dictionaries (`Dictionary<K,V>?`) produce `nullable: true` in schema
3030
- Handle nullable reference type annotations on dictionary properties
3131
- _Requirements: 4.1, 4.2_
3232

33-
- [ ] 3. Checkpoint - Verify implementation compiles
33+
- [x] 3. Checkpoint - Verify implementation compiles
3434
- Run `dotnet build` on the source generator project
3535
- Ensure no compiler warnings or errors
3636
- Ensure all tests pass, ask the user if questions arise
3737

38-
- [ ] 4. Add unit tests for dictionary schema generation
39-
- [ ] 4.1 Add basic dictionary type tests to `OpenApiGeneratorTests.cs`
38+
- [x] 4. Add unit tests for dictionary schema generation
39+
- [x] 4.1 Add basic dictionary type tests to `OpenApiGeneratorTests.cs`
4040
- Test `Dictionary<string, string>` produces correct schema
4141
- Test `Dictionary<string, int>` produces integer additionalProperties
4242
- Test `Dictionary<string, bool>` produces boolean additionalProperties
4343
- Test `Dictionary<string, decimal>` produces number additionalProperties
4444
- Test `Dictionary<string, DateTime>` produces string with date-time format
4545
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5_
4646

47-
- [ ] 4.2 Add complex value type tests
47+
- [x] 4.2 Add complex value type tests
4848
- Test `Dictionary<string, ComplexType>` produces $ref in additionalProperties
4949
- Test `Dictionary<string, List<string>>` produces nested array schema
5050
- Test `Dictionary<string, Dictionary<string, int>>` produces nested dictionary schema
5151
- _Requirements: 3.1, 3.2, 3.3_
5252

53-
- [ ] 4.3 Add nullable dictionary tests
53+
- [x] 4.3 Add nullable dictionary tests
5454
- Test nullable dictionary property produces `nullable: true`
5555
- Test `Dictionary<string, T>?` produces `nullable: true`
5656
- _Requirements: 4.1, 4.2_
5757

58-
- [ ] 4.4 Add dictionary interface tests
58+
- [x] 4.4 Add dictionary interface tests
5959
- Test `IDictionary<string, T>` is detected as dictionary
6060
- Test `IReadOnlyDictionary<string, T>` is detected as dictionary
6161
- _Requirements: 1.2, 1.3_
6262

63-
- [ ] 4.5 Add attribute support tests
63+
- [x] 4.5 Add attribute support tests
6464
- Test `[OpenApiSchema(Description = "...")]` applies to dictionary schema
6565
- Test `[OpenApiSchema(Example = "...")]` applies to dictionary schema
6666
- _Requirements: 6.1, 6.2_
6767

68-
- [ ] 5. Add property-based tests for dictionary handling
69-
- [ ] 5.1 Write property test for dictionary type detection
68+
- [x] 5. Add property-based tests for dictionary handling
69+
- [x] 5.1 Write property test for dictionary type detection
7070
- **Property 1: Dictionary Type Detection**
7171
- **Validates: Requirements 1.1, 1.2, 1.3, 1.4, 1.5**
7272

73-
- [ ] 5.2 Write property test for dictionary schema structure
73+
- [x] 5.2 Write property test for dictionary schema structure
7474
- **Property 2: Dictionary Schema Structure**
7575
- **Validates: Requirements 5.2**
7676

77-
- [ ] 5.3 Write property test for simple value type mapping
77+
- [x] 5.3 Write property test for simple value type mapping
7878
- **Property 3: Simple Value Type Schema**
7979
- **Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5**
8080

81-
- [ ] 5.4 Write property test for complex value type references
81+
- [x] 5.4 Write property test for complex value type references
8282
- **Property 4: Complex Value Type Reference**
8383
- **Validates: Requirements 3.1**
8484

85-
- [ ] 5.5 Write property test for nullable dictionary handling
85+
- [x] 5.5 Write property test for nullable dictionary handling
8686
- **Property 5: Nullable Dictionary Handling**
8787
- **Validates: Requirements 4.1, 4.2**
8888

89-
- [ ] 6. Final checkpoint - Ensure all tests pass
89+
- [x] 6. Final checkpoint - Ensure all tests pass
9090
- Run full test suite with `dotnet test`
9191
- Verify no regressions in existing functionality
9292
- Ensure all tests pass, ask the user if questions arise

Oproto.Lambda.OpenApi.Build/ExtractOpenApiSpecTask.cs

Lines changed: 115 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -248,22 +248,55 @@ private string TryExtractViaReflection()
248248
var assemblyDir = Path.GetDirectoryName(AssemblyPath);
249249
Log.LogMessage(MessageImportance.High, $"Trying reflection extraction from: {assemblyDir}");
250250

251-
// Collect all DLLs in the output directory for the resolver
252-
var assemblyPaths = new List<string> { AssemblyPath };
251+
// Collect assemblies for the resolver, avoiding duplicates that cause
252+
// "assembly has already been loaded" errors in MetadataLoadContext.
253+
// This can happen when:
254+
// 1. Same assembly exists in multiple directories (output + runtime)
255+
// 2. Different SDK version (e.g., .NET 10) builds a different target (e.g., .NET 8)
256+
var assemblyPathsByName = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
257+
258+
// Add the target assembly first
259+
assemblyPathsByName[Path.GetFileName(AssemblyPath)] = AssemblyPath;
260+
261+
// Add assemblies from output directory - these should match the target framework
253262
if (assemblyDir != null)
254263
{
255-
assemblyPaths.AddRange(Directory.GetFiles(assemblyDir, "*.dll"));
264+
foreach (var dll in Directory.GetFiles(assemblyDir, "*.dll"))
265+
{
266+
var fileName = Path.GetFileName(dll);
267+
if (!assemblyPathsByName.ContainsKey(fileName))
268+
{
269+
assemblyPathsByName[fileName] = dll;
270+
}
271+
}
256272
}
257273

258-
// Add core library path
259-
var coreAssemblyPath = typeof(object).Assembly.Location;
260-
var coreDir = Path.GetDirectoryName(coreAssemblyPath);
261-
if (coreDir != null)
274+
// Try to find the target framework's reference assemblies from the SDK packs
275+
// This is more reliable than using the running runtime when SDK version differs from target
276+
var referenceAssembliesAdded = TryAddReferenceAssemblies(assemblyPathsByName);
277+
278+
// Only fall back to running runtime assemblies if we couldn't find reference assemblies
279+
// and the output directory doesn't have core assemblies (e.g., System.Private.CoreLib)
280+
if (!referenceAssembliesAdded && !assemblyPathsByName.ContainsKey("System.Private.CoreLib.dll"))
262281
{
263-
assemblyPaths.AddRange(Directory.GetFiles(coreDir, "*.dll"));
282+
var coreAssemblyPath = typeof(object).Assembly.Location;
283+
var coreDir = Path.GetDirectoryName(coreAssemblyPath);
284+
if (coreDir != null)
285+
{
286+
Log.LogMessage(MessageImportance.Low,
287+
$"Adding runtime assemblies from: {coreDir}");
288+
foreach (var dll in Directory.GetFiles(coreDir, "*.dll"))
289+
{
290+
var fileName = Path.GetFileName(dll);
291+
if (!assemblyPathsByName.ContainsKey(fileName))
292+
{
293+
assemblyPathsByName[fileName] = dll;
294+
}
295+
}
296+
}
264297
}
265298

266-
var resolver = new PathAssemblyResolver(assemblyPaths.Distinct());
299+
var resolver = new PathAssemblyResolver(assemblyPathsByName.Values);
267300
using var mlc = new MetadataLoadContext(resolver);
268301

269302
var assembly = mlc.LoadFromAssemblyPath(AssemblyPath);
@@ -301,4 +334,77 @@ private string TryExtractViaReflection()
301334
return null;
302335
}
303336
}
337+
338+
/// <summary>
339+
/// Attempts to add reference assemblies from the .NET SDK packs directory.
340+
/// This ensures we use assemblies matching the target framework, not the running SDK.
341+
/// </summary>
342+
private bool TryAddReferenceAssemblies(Dictionary<string, string> assemblyPathsByName)
343+
{
344+
try
345+
{
346+
// Try to determine target framework from the assembly path (e.g., bin/Debug/net8.0/)
347+
var assemblyDir = Path.GetDirectoryName(AssemblyPath);
348+
if (assemblyDir == null) return false;
349+
350+
var tfmDir = Path.GetFileName(assemblyDir); // e.g., "net8.0"
351+
if (string.IsNullOrEmpty(tfmDir) || !tfmDir.StartsWith("net")) return false;
352+
353+
// Look for reference assemblies in the SDK packs directory
354+
// Typical path: ~/.dotnet/packs/Microsoft.NETCore.App.Ref/{version}/ref/{tfm}/
355+
var dotnetRoot = Environment.GetEnvironmentVariable("DOTNET_ROOT");
356+
if (string.IsNullOrEmpty(dotnetRoot))
357+
{
358+
// Try common locations
359+
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
360+
var possibleRoots = new[]
361+
{
362+
Path.Combine(home, ".dotnet"),
363+
"/usr/share/dotnet",
364+
"/usr/local/share/dotnet",
365+
@"C:\Program Files\dotnet"
366+
};
367+
dotnetRoot = possibleRoots.FirstOrDefault(Directory.Exists);
368+
}
369+
370+
if (string.IsNullOrEmpty(dotnetRoot) || !Directory.Exists(dotnetRoot)) return false;
371+
372+
var packsDir = Path.Combine(dotnetRoot, "packs", "Microsoft.NETCore.App.Ref");
373+
if (!Directory.Exists(packsDir)) return false;
374+
375+
// Find the appropriate version directory
376+
var versionDirs = Directory.GetDirectories(packsDir)
377+
.Select(d => new { Path = d, Name = Path.GetFileName(d) })
378+
.Where(d => d.Name.StartsWith(tfmDir.Replace("net", ""))) // e.g., "8.0" for "net8.0"
379+
.OrderByDescending(d => d.Name)
380+
.ToList();
381+
382+
foreach (var versionDir in versionDirs)
383+
{
384+
var refDir = Path.Combine(versionDir.Path, "ref", tfmDir);
385+
if (Directory.Exists(refDir))
386+
{
387+
Log.LogMessage(MessageImportance.Low,
388+
$"Adding reference assemblies from: {refDir}");
389+
foreach (var dll in Directory.GetFiles(refDir, "*.dll"))
390+
{
391+
var fileName = Path.GetFileName(dll);
392+
if (!assemblyPathsByName.ContainsKey(fileName))
393+
{
394+
assemblyPathsByName[fileName] = dll;
395+
}
396+
}
397+
return true;
398+
}
399+
}
400+
401+
return false;
402+
}
403+
catch (Exception ex)
404+
{
405+
Log.LogMessage(MessageImportance.Low,
406+
$"Could not locate reference assemblies: {ex.Message}");
407+
return false;
408+
}
409+
}
304410
}

Oproto.Lambda.OpenApi.Examples/Oproto.Lambda.OpenApi.Examples.csproj

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
4-
<TargetFramework>net8.0</TargetFramework>
4+
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
55
<ImplicitUsings>enable</ImplicitUsings>
66
<Nullable>enable</Nullable>
77
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
@@ -42,7 +42,8 @@
4242
<UsingTask TaskName="Oproto.Lambda.OpenApi.Build.ExtractOpenApiSpecTask"
4343
AssemblyFile="..\Oproto.Lambda.OpenApi.Build\bin\$(Configuration)\netstandard2.0\Oproto.Lambda.OpenApi.Build.dll"/>
4444

45-
<Target Name="GenerateOpenApi" AfterTargets="Build">
45+
<!-- Only run OpenAPI generation during inner builds (when TargetFramework is set) -->
46+
<Target Name="GenerateOpenApi" AfterTargets="Build" Condition="'$(TargetFramework)' != ''">
4647
<Message Text="Starting OpenAPI Generation" Importance="high"/>
4748
<ExtractOpenApiSpecTask
4849
AssemblyPath="$(TargetDir)$(TargetFileName)"

Oproto.Lambda.OpenApi.Merge.Cdk/Oproto.Lambda.OpenApi.Merge.Cdk.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
4-
<TargetFramework>net8.0</TargetFramework>
4+
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
55
<ImplicitUsings>enable</ImplicitUsings>
66
<Nullable>enable</Nullable>
77
<LangVersion>12</LangVersion>

Oproto.Lambda.OpenApi.Merge.Lambda.Tests/Oproto.Lambda.OpenApi.Merge.Lambda.Tests.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
4-
<TargetFramework>net8.0</TargetFramework>
4+
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
55
<ImplicitUsings>enable</ImplicitUsings>
66
<Nullable>enable</Nullable>
77
<LangVersion>12</LangVersion>

Oproto.Lambda.OpenApi.Merge.Lambda/Oproto.Lambda.OpenApi.Merge.Lambda.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
4+
<!-- Lambda runtime target - set to net10.0 for .NET 10 Lambda runtime -->
45
<TargetFramework>net8.0</TargetFramework>
56
<ImplicitUsings>enable</ImplicitUsings>
67
<Nullable>enable</Nullable>

Oproto.Lambda.OpenApi.Merge.Tests/Oproto.Lambda.OpenApi.Merge.Tests.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
4-
<TargetFramework>net8.0</TargetFramework>
4+
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
55
<ImplicitUsings>enable</ImplicitUsings>
66
<Nullable>enable</Nullable>
77
<LangVersion>12</LangVersion>

Oproto.Lambda.OpenApi.Merge.Tool/Oproto.Lambda.OpenApi.Merge.Tool.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
<PropertyGroup>
44
<OutputType>Exe</OutputType>
5-
<TargetFramework>net8.0</TargetFramework>
5+
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
66
<LangVersion>12</LangVersion>
77
<Nullable>enable</Nullable>
88
<ImplicitUsings>enable</ImplicitUsings>

Oproto.Lambda.OpenApi.Merge/Oproto.Lambda.OpenApi.Merge.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
4-
<TargetFramework>netstandard2.0</TargetFramework>
4+
<TargetFrameworks>netstandard2.0;net8.0;net10.0</TargetFrameworks>
55
<LangVersion>12</LangVersion>
66
<Nullable>enable</Nullable>
77
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>

Oproto.Lambda.OpenApi.SourceGenerator/OpenApiSpecGenerator.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1762,7 +1762,8 @@ private OpenApiSchema CreateComplexTypeSchema(ITypeSymbol typeSymbol)
17621762
a.AttributeClass?.Name is "OpenApiIgnore" or "OpenApiIgnoreAttribute"))
17631763
continue;
17641764

1765-
var propertySchema = CreateSchema(member.Type);
1765+
// Pass the member symbol to CreateSchema so nullable annotations can be detected
1766+
var propertySchema = CreateSchema(member.Type, member);
17661767
if (propertySchema != null)
17671768
{
17681769
// Get attributes from both the current property and its base if it's an override

0 commit comments

Comments
 (0)