Skip to content

Commit 6df2d6f

Browse files
authored
feat: support dotted schema namespaces (#198)
1 parent 698879e commit 6df2d6f

27 files changed

+716
-79
lines changed

src/libs/AutoSDK.CLI/Commands/GenerateCommand.cs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,36 @@ internal sealed class GenerateCommand : Command
162162
Description = "Namespace to use for type references instead of the main namespace. Used for cross-namespace schema referencing where models live in a different namespace.",
163163
};
164164

165+
private Option<string> NamespaceDelimiter { get; } = new(
166+
name: "--namespace-delimiter")
167+
{
168+
DefaultValueFactory = _ => string.Empty,
169+
Description = "Optional single-character delimiter for splitting component schema ids into namespaces (for example '.' turns PetStore.Pet into namespace PetStore and class Pet).",
170+
};
171+
172+
private Option<string[]> IncludeModels { get; } = new(
173+
name: "--include-models")
174+
{
175+
DefaultValueFactory = _ => Array.Empty<string>(),
176+
Description = "Only include these component model ids. Repeatable or pass multiple values.",
177+
AllowMultipleArgumentsPerToken = true,
178+
};
179+
180+
private Option<string[]> ExcludeModels { get; } = new(
181+
name: "--exclude-models")
182+
{
183+
DefaultValueFactory = _ => Array.Empty<string>(),
184+
Description = "Exclude these component model ids. Repeatable or pass multiple values.",
185+
AllowMultipleArgumentsPerToken = true,
186+
};
187+
188+
private Option<ExcludedModelNamespaceMode> ExcludedModelNamespaceMode { get; } = new(
189+
name: "--excluded-model-namespace-mode")
190+
{
191+
DefaultValueFactory = _ => AutoSDK.Models.ExcludedModelNamespaceMode.External,
192+
Description = "How filtered-out dotted models are referenced when --namespace-delimiter is enabled: External or SdkRoot.",
193+
};
194+
165195
private Option<bool> GenerateModels { get; } = new(
166196
name: "--generate-models")
167197
{
@@ -198,6 +228,10 @@ internal sealed class GenerateCommand : Command
198228
Options.Add(WebSocketClientClassName);
199229
Options.Add(JsonSerializerContextName);
200230
Options.Add(TypesNamespace);
231+
Options.Add(NamespaceDelimiter);
232+
Options.Add(IncludeModels);
233+
Options.Add(ExcludeModels);
234+
Options.Add(ExcludedModelNamespaceMode);
201235
Options.Add(GenerateModels);
202236
Options.Add(Language);
203237

@@ -219,6 +253,12 @@ private async Task HandleAsync(ParseResult parseResult)
219253

220254
var generateModels = parseResult.GetRequiredValue(GenerateModels);
221255
var typesNamespaceValue = parseResult.GetRequiredValue(TypesNamespace);
256+
var namespaceDelimiterValue = parseResult.GetRequiredValue(NamespaceDelimiter);
257+
258+
if (!string.IsNullOrEmpty(namespaceDelimiterValue) && namespaceDelimiterValue.Length != 1)
259+
{
260+
throw new ArgumentException("--namespace-delimiter must be empty or a single character.");
261+
}
222262

223263
if (!generateModels && string.IsNullOrWhiteSpace(typesNamespaceValue))
224264
{
@@ -236,6 +276,10 @@ private async Task HandleAsync(ParseResult parseResult)
236276
JsonSerializerContext = $"{namespaceValue}.{contextClassName}",
237277
GenerateJsonSerializerContextTypes = true,
238278
GenerateModels = generateModels,
279+
IncludeModels = parseResult.GetRequiredValue(IncludeModels).ToImmutableArray(),
280+
ExcludeModels = parseResult.GetRequiredValue(ExcludeModels).ToImmutableArray(),
281+
NamespaceDelimiter = namespaceDelimiterValue,
282+
ExcludedModelNamespaceMode = parseResult.GetRequiredValue(ExcludedModelNamespaceMode),
239283
ComputeDiscriminators = parseResult.GetRequiredValue(ComputeDiscriminators),
240284
GenerateModelValidationMethods = parseResult.GetRequiredValue(GenerateModelValidationMethods),
241285
IgnoreOpenApiErrors = parseResult.GetRequiredValue(IgnoreOpenApiErrors),

src/libs/AutoSDK.CSharp/Enrichment/CSharpModelDataFactory.cs

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using AutoSDK.Extensions;
33
using AutoSDK.Helpers;
44
using AutoSDK.Models;
5+
using AutoSDK.Naming.Models;
56
using AutoSDK.Naming.Parameters;
67

78
namespace AutoSDK.Enrichment;
@@ -50,13 +51,28 @@ public static ModelData CreateModelData(SchemaContext context)
5051
}
5152

5253
var mapping = context.Schema.Discriminator?.Mapping;
53-
EquatableArray<(string ClassName, string Discriminator)> derivedTypes = default;
54+
EquatableArray<(string GlobalClassName, string Discriminator)> derivedTypes = default;
5455
if (mapping is { Count: > 0 })
5556
{
56-
var builder = ImmutableArray.CreateBuilder<(string ClassName, string Discriminator)>(mapping.Count);
57+
var builder = ImmutableArray.CreateBuilder<(string GlobalClassName, string Discriminator)>(mapping.Count);
5758
foreach (var kvp in mapping.OrderBy(x => x.Key, StringComparer.Ordinal))
5859
{
59-
builder.Add((ClassName: kvp.Value.Reference?.Id ?? string.Empty, Discriminator: kvp.Key));
60+
var referenceId = kvp.Value.Reference?.Id ?? string.Empty;
61+
var globalClassName = referenceId;
62+
if (context.ComponentSchemas?.TryGetValue(referenceId, out var derivedContext) == true)
63+
{
64+
globalClassName = derivedContext.GetGlobalClassName();
65+
}
66+
else if (CSharpNamespacedTypeNameResolver.TryResolve(referenceId, context.Settings, out var resolved))
67+
{
68+
globalClassName = $"global::{resolved!.GeneratedQualifiedName}";
69+
}
70+
else if (!string.IsNullOrWhiteSpace(referenceId))
71+
{
72+
globalClassName = $"global::{context.Settings.Namespace}.{CSharpNamespacedTypeNameResolver.GetComponentClassName(referenceId, context.Settings.ToSchemaNamingSettings())}";
73+
}
74+
75+
builder.Add((GlobalClassName: globalClassName, Discriminator: kvp.Key));
6076
}
6177

6278
derivedTypes = builder.MoveToImmutable().AsEquatableArray();
@@ -65,6 +81,7 @@ public static ModelData CreateModelData(SchemaContext context)
6581
var hasDeprecatedBaseClass = context.IsDerivedClass &&
6682
context.BaseClassContext.Schema.IsDeprecated();
6783
var inheritedPropertyNames = GetInheritedPropertyNames(context);
84+
var modelNamespace = context.GetGeneratedNamespace();
6885

6986
var className = context.Id;
7087
var externalClassName = context.Settings.NamingConvention switch
@@ -80,7 +97,7 @@ public static ModelData CreateModelData(SchemaContext context)
8097
SchemaContext: context,
8198
Id: context.Id,
8299
Parents: boxedParents,
83-
Namespace: context.Settings.Namespace,
100+
Namespace: modelNamespace,
84101
Settings: context.Settings.ToEmitterSettings(),
85102
Style: context.Schema.IsEnum() ? ModelStyle.Enumeration : context.Settings.ModelStyle,
86103
Properties: GetVisibleProperties(context),
@@ -92,7 +109,7 @@ public static ModelData CreateModelData(SchemaContext context)
92109
IsDeprecated: context.Schema.IsDeprecated(),
93110
DeprecationMessage: context.Schema.GetDeprecationMessage(),
94111
BaseClass: context.IsDerivedClass
95-
? context.BaseClassContext.Id
112+
? context.BaseClassContext.GetGlobalClassName()
96113
: string.Empty,
97114
HasDeprecatedBaseClass: hasDeprecatedBaseClass,
98115
IsBaseClass: context.IsBaseClass,
@@ -101,9 +118,9 @@ public static ModelData CreateModelData(SchemaContext context)
101118
DiscriminatorPropertyName: context.Schema.Discriminator?.PropertyName ?? string.Empty,
102119
DerivedTypes: derivedTypes,
103120
ClassName: className,
104-
GlobalClassName: $"global::{context.Settings.Namespace}.{className}",
121+
GlobalClassName: $"global::{modelNamespace}.{className}",
105122
ExternalClassName: externalClassName,
106-
FileNameWithoutExtension: $"{context.Settings.Namespace}.Models.{externalClassName}");
123+
FileNameWithoutExtension: $"{modelNamespace}.Models.{externalClassName}");
107124
}
108125

109126
private static ImmutableArray<string> GetInheritedPropertyNames(SchemaContext context)

src/libs/AutoSDK.CSharp/Enrichment/CSharpSchemaDataFactory.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using AutoSDK.Helpers;
44
using AutoSDK.Models;
55
using AutoSDK.Naming.AnyOfs;
6+
using AutoSDK.Naming.Models;
67
using AutoSDK.Naming.Parameters;
78
using AutoSDK.Naming.Properties;
89
using AutoSDK.Serialization.Json;
@@ -245,7 +246,7 @@ public static AnyOfData CreateAnyOfData(SchemaContext context)
245246
Type = (TypeData.Default with
246247
{
247248
CSharpTypeRaw = $"T{i}",
248-
GeneratedNamespace = context.Settings.Namespace,
249+
GeneratedNamespace = context.GetGeneratedNamespace(),
249250
}).WithCSharpComputedValues(),
250251
}).WithCSharpParameterName());
251252
}
@@ -268,7 +269,7 @@ public static AnyOfData CreateAnyOfData(SchemaContext context)
268269
DiscriminatorType: discriminatorType,
269270
DiscriminatorPropertyName: discriminatorPropertyName,
270271
IsTrimming: context.Settings.UsesSystemTextJsonContext(),
271-
Namespace: context.Settings.Namespace,
272+
Namespace: context.GetGeneratedNamespace(),
272273
Name: context.IsNamedAnyOfLike
273274
? context.Id
274275
: string.Empty,
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
using AutoSDK.Extensions;
2+
using AutoSDK.Models;
3+
using AutoSDK.Naming.Properties;
4+
5+
namespace AutoSDK.Naming.Models;
6+
7+
public sealed class CSharpNamespacedTypeName
8+
{
9+
public CSharpNamespacedTypeName(
10+
string className,
11+
string namespaceSuffix,
12+
string generatedNamespace,
13+
string generatedQualifiedName,
14+
string externalQualifiedName)
15+
{
16+
ClassName = className;
17+
NamespaceSuffix = namespaceSuffix;
18+
GeneratedNamespace = generatedNamespace;
19+
GeneratedQualifiedName = generatedQualifiedName;
20+
ExternalQualifiedName = externalQualifiedName;
21+
}
22+
23+
public string ClassName { get; }
24+
25+
public string NamespaceSuffix { get; }
26+
27+
public string GeneratedNamespace { get; }
28+
29+
public string GeneratedQualifiedName { get; }
30+
31+
public string ExternalQualifiedName { get; }
32+
33+
public string GetQualifiedName(ExcludedModelNamespaceMode mode)
34+
{
35+
return mode switch
36+
{
37+
ExcludedModelNamespaceMode.SdkRoot => GeneratedQualifiedName,
38+
_ => ExternalQualifiedName,
39+
};
40+
}
41+
}
42+
43+
public static class CSharpNamespacedTypeNameResolver
44+
{
45+
public static string GetGlobalClassName(this SchemaContext context)
46+
{
47+
context = context ?? throw new ArgumentNullException(nameof(context));
48+
return $"global::{context.GetGeneratedNamespace()}.{context.Id}";
49+
}
50+
51+
public static string GetComponentClassName(string rawId, SchemaNamingSettings settings)
52+
{
53+
return TryResolve(rawId, settings, rootNamespace: string.Empty, out var resolved)
54+
? resolved!.ClassName
55+
: rawId.ToCSharpName(settings, parent: null).ToClassName();
56+
}
57+
58+
public static string GetGeneratedNamespace(this SchemaContext context)
59+
{
60+
context = context ?? throw new ArgumentNullException(nameof(context));
61+
62+
var componentId = FindOwningComponentId(context);
63+
return componentId != null &&
64+
TryResolve(componentId, context.Settings.ToSchemaNamingSettings(), context.Settings.Namespace, out var resolved)
65+
? resolved!.GeneratedNamespace
66+
: context.Settings.Namespace;
67+
}
68+
69+
public static bool TryResolve(
70+
string rawId,
71+
SchemaContextSettings settings,
72+
out CSharpNamespacedTypeName? resolved)
73+
{
74+
return TryResolve(rawId, settings.ToSchemaNamingSettings(), settings.Namespace, out resolved);
75+
}
76+
77+
public static bool TryResolve(
78+
string rawId,
79+
SchemaNamingSettings settings,
80+
string rootNamespace,
81+
out CSharpNamespacedTypeName? resolved)
82+
{
83+
rawId = rawId ?? throw new ArgumentNullException(nameof(rawId));
84+
85+
var delimiter = settings.NamespaceDelimiter;
86+
if (string.IsNullOrEmpty(delimiter))
87+
{
88+
resolved = default;
89+
return false;
90+
}
91+
92+
if (delimiter.Length != 1)
93+
{
94+
throw new ArgumentException("NamespaceDelimiter must be empty or a single character.", nameof(settings));
95+
}
96+
97+
var delimiterChar = delimiter[0];
98+
if (rawId.IndexOf(delimiterChar) < 0)
99+
{
100+
resolved = default;
101+
return false;
102+
}
103+
104+
var segments = rawId
105+
.Split([delimiterChar], StringSplitOptions.RemoveEmptyEntries)
106+
.Select(segment => SanitizeSegment(segment.Trim(), settings))
107+
.Where(static segment => !string.IsNullOrWhiteSpace(segment))
108+
.ToArray();
109+
110+
if (segments.Length < 2)
111+
{
112+
resolved = default;
113+
return false;
114+
}
115+
116+
var className = segments[segments.Length - 1];
117+
var namespaceSuffix = string.Join(".", segments, 0, segments.Length - 1);
118+
var generatedNamespace = string.IsNullOrWhiteSpace(rootNamespace)
119+
? namespaceSuffix
120+
: $"{rootNamespace}.{namespaceSuffix}";
121+
var generatedQualifiedName = $"{generatedNamespace}.{className}";
122+
var externalQualifiedName = $"{namespaceSuffix}.{className}";
123+
124+
resolved = new CSharpNamespacedTypeName(
125+
className: className,
126+
namespaceSuffix: namespaceSuffix,
127+
generatedNamespace: generatedNamespace,
128+
generatedQualifiedName: generatedQualifiedName,
129+
externalQualifiedName: externalQualifiedName);
130+
return true;
131+
}
132+
133+
private static string? FindOwningComponentId(SchemaContext? context)
134+
{
135+
while (context != null)
136+
{
137+
if (!string.IsNullOrWhiteSpace(context.ComponentId))
138+
{
139+
return context.ComponentId;
140+
}
141+
142+
context = context.Parent;
143+
}
144+
145+
return null;
146+
}
147+
148+
private static string SanitizeSegment(string segment, SchemaNamingSettings settings)
149+
{
150+
var name = segment.ToPropertyName();
151+
name = CSharpPropertyNameGenerator.HandleWordSeparators(name);
152+
return CSharpPropertyNameGenerator.SanitizeName(name, settings.ClsCompliantEnumPrefix, skipHandlingWordSeparators: true);
153+
}
154+
}

src/libs/AutoSDK.CSharp/Naming/Models/CSharpSchemaNamingFactory.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ public static class CSharpSchemaNamingFactory
77
{
88
public static string CreateReferenceId(ReferenceNameContext context)
99
{
10-
return context.ReferenceId.ToCSharpName(context.Settings, context.Parent);
10+
return CSharpNamespacedTypeNameResolver.GetComponentClassName(context.ReferenceId, context.Settings);
1111
}
1212

1313
public static string CreateSchemaId(SchemaNameContext context)

src/libs/AutoSDK.CSharp/Naming/Models/ModelNameGenerator.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public static string ComputeId(
2828
}
2929
if (componentId != null)
3030
{
31-
return componentId.ToCSharpName(settings, parent);
31+
return CSharpNamespacedTypeNameResolver.GetComponentClassName(componentId, settings);
3232
}
3333

3434
var helper = hint switch
@@ -152,7 +152,7 @@ public static string ComputeClassName(this SchemaContext context)
152152

153153
if (context.ComponentId != null)
154154
{
155-
className = context.ComponentId.ToCSharpName(context.Settings, context.Parent).ToClassName();
155+
className = CSharpNamespacedTypeNameResolver.GetComponentClassName(context.ComponentId, context.Settings.ToSchemaNamingSettings());
156156
context.CachedComputedClassName = className;
157157
return className;
158158
}
@@ -213,10 +213,11 @@ public static void ResolveCollisions(IReadOnlyCollection<SchemaContext> contexts
213213
{
214214
continue;
215215
}
216-
if (!groups.TryGetValue(context.Id, out var list))
216+
var groupKey = $"{context.GetGeneratedNamespace()}|{context.Id}";
217+
if (!groups.TryGetValue(groupKey, out var list))
217218
{
218219
list = [context];
219-
groups[context.Id] = list;
220+
groups[groupKey] = list;
220221
}
221222
else
222223
{

src/libs/AutoSDK.CSharp/Pipeline/AsyncApiData.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1389,21 +1389,21 @@ private static ImmutableArray<string> BuildConverters(
13891389
!x.Settings.UsesNewtonsoftJson())
13901390
.SelectMany(x => new[]
13911391
{
1392-
$"global::{settings.Namespace}.JsonConverters.{x.ClassName}JsonConverter",
1393-
$"global::{settings.Namespace}.JsonConverters.{x.ClassName}NullableJsonConverter"
1392+
$"global::{x.Namespace}.JsonConverters.{x.ClassName}JsonConverter",
1393+
$"global::{x.Namespace}.JsonConverters.{x.ClassName}NullableJsonConverter"
13941394
})
13951395
.Concat(anyOfDatas
13961396
.Where(x =>
13971397
x.Settings.UsesSystemTextJson() &&
13981398
!string.IsNullOrWhiteSpace(x.Name))
1399-
.Select(x => $"global::{settings.Namespace}.JsonConverters.{x.Name}JsonConverter"))
1399+
.Select(x => $"global::{x.Namespace}.JsonConverters.{x.Name}JsonConverter"))
14001400
.Concat(filteredSchemas
14011401
.Where(x =>
14021402
x.Settings.UsesSystemTextJson() &&
14031403
x.AnyOfData.HasValue &&
14041404
string.IsNullOrWhiteSpace(x.AnyOfData.Value.Name))
14051405
.Select(x =>
1406-
$"global::{settings.Namespace}.JsonConverters.{x.AnyOfData?.SubType}JsonConverter<{string.Join(", ", x.Children
1406+
$"global::{x.AnyOfData?.Namespace}.JsonConverters.{x.AnyOfData?.SubType}JsonConverter<{string.Join(", ", x.Children
14071407
.Where(y => y.Hint == (x.IsAnyOf ? Hint.AnyOf : x.IsOneOf ? Hint.OneOf : Hint.AllOf))
14081408
.Select(y => y.TypeData.CSharpTypeWithNullabilityForValueTypes))}>"))
14091409
.ToImmutableArray();

0 commit comments

Comments
 (0)