From 498559c46a0ce2d023dac39b390b7fe049be6fdd Mon Sep 17 00:00:00 2001 From: Matthias Nistl Date: Fri, 14 Nov 2025 23:27:26 +0100 Subject: [PATCH 1/8] Add support for enum indexers --- src/Kiota.Builder/KiotaBuilder.cs | 51 ++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/src/Kiota.Builder/KiotaBuilder.cs b/src/Kiota.Builder/KiotaBuilder.cs index 94a77921f2..50ea581050 100644 --- a/src/Kiota.Builder/KiotaBuilder.cs +++ b/src/Kiota.Builder/KiotaBuilder.cs @@ -1107,8 +1107,8 @@ private CodeParameter GetIndexerParameter(OpenApiUrlTreeNode currentNode) var type = parameter switch { null => DefaultIndexerParameterType, - _ => GetPrimitiveType(parameter.Schema), - } ?? DefaultIndexerParameterType; + _ => GetEnumType(currentNode, parameter) ?? GetPrimitiveType(parameter.Schema) ?? DefaultIndexerParameterType, + }; type.IsNullable = false; var segment = currentNode.DeduplicatedSegment(); var result = new CodeParameter @@ -1123,6 +1123,35 @@ private CodeParameter GetIndexerParameter(OpenApiUrlTreeNode currentNode) }; return result; } + + private CodeType? GetEnumType(OpenApiUrlTreeNode currentNode, IOpenApiParameter parameter) + { + IOpenApiSchema? enumCandidateSchema = parameter.Schema; + // many specs wrap refs under allOf: [ { $ref: ... } ] + if (enumCandidateSchema?.AllOf is { Count: 1 } && enumCandidateSchema.AllOf.FirstOrDefault() is IOpenApiSchema singleAllOf) + enumCandidateSchema = singleAllOf; + + if (enumCandidateSchema is null || modelsNamespace is null) + { + return default; + } + + var targetNamespace = GetShortestNamespace(modelsNamespace, enumCandidateSchema); + var declarationName = enumCandidateSchema.GetSchemaName()?.CleanupSymbolName(); + if (string.IsNullOrEmpty(declarationName)) + { + return default; + } + + var enumDeclaration = AddEnumDeclarationIfDoesntExist(currentNode, enumCandidateSchema, declarationName!, targetNamespace); + if (enumDeclaration is not null) + { + return new CodeType { Name = enumDeclaration.Name, TypeDefinition = enumDeclaration }; + } + + return default; + } + private static IDictionary GetPathItems(OpenApiUrlTreeNode currentNode, bool validateIsParameterNode = true) { if ((!validateIsParameterNode || currentNode.IsParameter) && currentNode.PathItems.Count != 0) @@ -2262,11 +2291,21 @@ private IEnumerable GetAllNamespaces(CodeNamespace currentNamespa } private IEnumerable GetTypeDefinitionsInNamespace(CodeNamespace currentNamespace) { - var requestExecutors = GetAllNamespaces(currentNamespace) - .SelectMany(static x => x.Classes) - .Where(static x => x.IsOfKind(CodeClassKind.RequestBuilder)) + var requestBuilders = GetAllNamespaces(currentNamespace) + .SelectMany(static x => x.Classes) + .Where(static x => x.IsOfKind(CodeClassKind.RequestBuilder)) + .ToArray(); + + var requestExecutors = requestBuilders .SelectMany(static x => x.Methods) .Where(static x => x.IsOfKind(CodeMethodKind.RequestExecutor)); + + var indexerParameterTypes = requestBuilders + .Select(static rb => rb.Indexer?.IndexParameter.Type) + .OfType() + .SelectMany(static t => t.AllTypes) + .ToArray(); + return requestExecutors.SelectMany(static x => x.ReturnType.AllTypes) .Union(requestExecutors .SelectMany(static x => x.Parameters) @@ -2278,6 +2317,8 @@ private IEnumerable GetTypeDefinitionsInNamespace(CodeNamespace cur .OfType() .Select(static x => x.Properties.FirstOrDefault(static y => y.Kind is CodePropertyKind.QueryParameters)?.Type) .OfType()) + // include the indexer parameter types so enums used there are not pruned as unused + .Union(indexerParameterTypes) .Union(requestExecutors.SelectMany(static x => x.ErrorMappings.SelectMany(static y => y.Value.AllTypes))) .Where(static x => x.TypeDefinition != null) .Select(static x => x.TypeDefinition!) From 53c6b34afa7308f28d2b2e13384728378c317139 Mon Sep 17 00:00:00 2001 From: Matthias Nistl Date: Fri, 14 Nov 2025 23:41:45 +0100 Subject: [PATCH 2/8] Add tests --- .../Kiota.Builder.Tests/KiotaBuilderTests.cs | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/tests/Kiota.Builder.Tests/KiotaBuilderTests.cs b/tests/Kiota.Builder.Tests/KiotaBuilderTests.cs index 50740a6275..555110d2b1 100644 --- a/tests/Kiota.Builder.Tests/KiotaBuilderTests.cs +++ b/tests/Kiota.Builder.Tests/KiotaBuilderTests.cs @@ -10110,6 +10110,140 @@ public void CleansUpOperationIdChangesOperationId() Assert.Equal("PostAdministrativeUnits_With201_response", operations[1].Value.OperationId); Assert.Equal("directory_adminstativeunits_item_get", operations[2].Value.OperationId); } + + [Fact] + public async Task GeneratesEnumTypeForIndexerParameterAndCreatesEnumModelAsync() + { + var tempFilePath = Path.GetTempFileName(); + await File.WriteAllTextAsync(tempFilePath, @$"openapi: 3.0.1 +info: + title: Test API + version: 1.0.0 +servers: + - url: https://api.contoso.test +paths: + /tenants/{{tenant}}/resources: + get: + parameters: + - name: tenant + in: path + required: true + schema: + $ref: '#/components/schemas/Tenant' + responses: + '200': + description: OK +components: + schemas: + Tenant: + type: string + enum: [A, B] +"); + + await using var fs = new FileStream(tempFilePath, FileMode.Open); + var mockLogger = new Mock>(); + var builder = new KiotaBuilder(mockLogger.Object, new GenerationConfiguration + { + ClientClassName = "ApiSdk", + OpenAPIFilePath = tempFilePath, + Language = GenerationLanguage.CSharp + }, _httpClient); + + var document = await builder.CreateOpenApiDocumentAsync(fs); + var node = builder.CreateUriSpace(document!); + builder.SetApiRootUrl(); + var codeModel = builder.CreateSourceModel(node); + + var collectionRequestBuilderNamespace = codeModel.FindNamespaceByName("ApiSdk.tenants"); + Assert.NotNull(collectionRequestBuilderNamespace); + var collectionRequestBuilder = collectionRequestBuilderNamespace.FindChildByName("tenantsRequestBuilder"); + Assert.NotNull(collectionRequestBuilder); + + var indexer = collectionRequestBuilder.Indexer; + Assert.NotNull(indexer); + Assert.NotNull(indexer.IndexParameter); + Assert.NotNull(indexer.IndexParameter.Type); + Assert.False(indexer.IndexParameter.Type.IsNullable); + + // verify the type is an enum definition named Tenant + var indexParamTypeDef = indexer.IndexParameter.Type.AllTypes.First().TypeDefinition; + Assert.IsType(indexParamTypeDef); + var enumType = (CodeEnum)indexParamTypeDef!; + Assert.Equal("Tenant", enumType.Name); + + // verify the enum model exists in the Models namespace + var modelsNS = codeModel.FindNamespaceByName("ApiSdk.Models"); + Assert.NotNull(modelsNS); + var tenantEnumInModels = modelsNS.FindChildByName("Tenant", false); + Assert.NotNull(tenantEnumInModels); + } + + [Fact] + public async Task GeneratesEnumTypeForIndexerParameterFromAllOfWrapperAsync() + { + var tempFilePath = Path.GetTempFileName(); + await File.WriteAllTextAsync(tempFilePath, @$"openapi: 3.0.1 +info: + title: Test API + version: 1.0.0 +servers: + - url: https://api.contoso.test +paths: + /tenants/{{tenant}}/resources: + get: + parameters: + - name: tenant + in: path + required: true + schema: + allOf: + - $ref: '#/components/schemas/Tenant' + responses: + '200': + description: OK +components: + schemas: + Tenant: + type: string + enum: [A, B] +"); + + await using var fs = new FileStream(tempFilePath, FileMode.Open); + var mockLogger = new Mock>(); + var builder = new KiotaBuilder(mockLogger.Object, new GenerationConfiguration + { + ClientClassName = "ApiSdk", + OpenAPIFilePath = tempFilePath, + Language = GenerationLanguage.CSharp + }, _httpClient); + + var document = await builder.CreateOpenApiDocumentAsync(fs); + var node = builder.CreateUriSpace(document!); + builder.SetApiRootUrl(); + var codeModel = builder.CreateSourceModel(node); + + var collectionRequestBuilderNamespace = codeModel.FindNamespaceByName("ApiSdk.tenants"); + Assert.NotNull(collectionRequestBuilderNamespace); + var collectionRequestBuilder = collectionRequestBuilderNamespace.FindChildByName("tenantsRequestBuilder"); + Assert.NotNull(collectionRequestBuilder); + + var indexer = collectionRequestBuilder.Indexer; + Assert.NotNull(indexer); + Assert.NotNull(indexer.IndexParameter); + Assert.NotNull(indexer.IndexParameter.Type); + Assert.False(indexer.IndexParameter.Type.IsNullable); + + var indexParamTypeDef = indexer.IndexParameter.Type.AllTypes.First().TypeDefinition; + Assert.IsType(indexParamTypeDef); + var enumType = (CodeEnum)indexParamTypeDef!; + Assert.Equal("Tenant", enumType.Name); + + var modelsNS = codeModel.FindNamespaceByName("ApiSdk.Models"); + Assert.NotNull(modelsNS); + var tenantEnumInModels = modelsNS.FindChildByName("Tenant", false); + Assert.NotNull(tenantEnumInModels); + } + [GeneratedRegex(@"^[a-zA-Z0-9_]*$", RegexOptions.IgnoreCase | RegexOptions.Singleline, 2000)] private static partial Regex OperationIdValidationRegex(); } From 1faa423f3784aced0131020a5b2b355f29310d47 Mon Sep 17 00:00:00 2001 From: Matthias Nistl Date: Fri, 14 Nov 2025 23:51:18 +0100 Subject: [PATCH 3/8] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe3d0ebe85..3fc4ba64c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added support for OpenAPI 3.2.0 +- Added support for enum path parameters ### Changed From 16228096531c44a9bcddb534a51837135aa79970 Mon Sep 17 00:00:00 2001 From: Matthias Nistl Date: Mon, 17 Nov 2025 18:58:48 +0100 Subject: [PATCH 4/8] Use FlattenSchemaIfRequired extension to check AllOf --- src/Kiota.Builder/KiotaBuilder.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Kiota.Builder/KiotaBuilder.cs b/src/Kiota.Builder/KiotaBuilder.cs index 50ea581050..c5e24b335a 100644 --- a/src/Kiota.Builder/KiotaBuilder.cs +++ b/src/Kiota.Builder/KiotaBuilder.cs @@ -1127,9 +1127,10 @@ private CodeParameter GetIndexerParameter(OpenApiUrlTreeNode currentNode) private CodeType? GetEnumType(OpenApiUrlTreeNode currentNode, IOpenApiParameter parameter) { IOpenApiSchema? enumCandidateSchema = parameter.Schema; - // many specs wrap refs under allOf: [ { $ref: ... } ] - if (enumCandidateSchema?.AllOf is { Count: 1 } && enumCandidateSchema.AllOf.FirstOrDefault() is IOpenApiSchema singleAllOf) - enumCandidateSchema = singleAllOf; + // many specs wrap refs under allOf or nested empty entries: [ { $ref: ... } ] + enumCandidateSchema = enumCandidateSchema?.AllOf? + .FlattenSchemaIfRequired(static x => x.AllOf) + .FirstOrDefault() ?? enumCandidateSchema; if (enumCandidateSchema is null || modelsNamespace is null) { From 7e991d62f473412a72e99f3e57c27229599e0156 Mon Sep 17 00:00:00 2001 From: Matthias Nistl Date: Mon, 17 Nov 2025 18:59:44 +0100 Subject: [PATCH 5/8] Refactor switch statement Co-authored-by: Vincent Biret --- src/Kiota.Builder/KiotaBuilder.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Kiota.Builder/KiotaBuilder.cs b/src/Kiota.Builder/KiotaBuilder.cs index c5e24b335a..747949e609 100644 --- a/src/Kiota.Builder/KiotaBuilder.cs +++ b/src/Kiota.Builder/KiotaBuilder.cs @@ -1107,7 +1107,9 @@ private CodeParameter GetIndexerParameter(OpenApiUrlTreeNode currentNode) var type = parameter switch { null => DefaultIndexerParameterType, - _ => GetEnumType(currentNode, parameter) ?? GetPrimitiveType(parameter.Schema) ?? DefaultIndexerParameterType, + not null when GetEnumType(currentNode, parameter) is {} enumType => enumType, + not null when GetPrimitiveType(parameter.Schema) is {} primitiveType => primitiveType, + _ => DefaultIndexerParameterType, }; type.IsNullable = false; var segment = currentNode.DeduplicatedSegment(); From 58b3d7320646d926d8da912d40bbeb93770afeb7 Mon Sep 17 00:00:00 2001 From: Matthias Nistl Date: Mon, 17 Nov 2025 20:36:56 +0100 Subject: [PATCH 6/8] Remove unnecessary switch case Co-authored-by: Vincent Biret --- src/Kiota.Builder/KiotaBuilder.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Kiota.Builder/KiotaBuilder.cs b/src/Kiota.Builder/KiotaBuilder.cs index 747949e609..df7aa4a971 100644 --- a/src/Kiota.Builder/KiotaBuilder.cs +++ b/src/Kiota.Builder/KiotaBuilder.cs @@ -1106,7 +1106,6 @@ private CodeParameter GetIndexerParameter(OpenApiUrlTreeNode currentNode) default; var type = parameter switch { - null => DefaultIndexerParameterType, not null when GetEnumType(currentNode, parameter) is {} enumType => enumType, not null when GetPrimitiveType(parameter.Schema) is {} primitiveType => primitiveType, _ => DefaultIndexerParameterType, From 1527470d49f8e62b1e0fe0aa5337b54bec5eb09d Mon Sep 17 00:00:00 2001 From: Matthias Nistl Date: Mon, 17 Nov 2025 22:12:16 +0100 Subject: [PATCH 7/8] Check for AnyOf and OneOf, too --- .../Extensions/OpenApiSchemaExtensions.cs | 14 ++++++++++---- src/Kiota.Builder/KiotaBuilder.cs | 16 ++++++++++++---- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/Kiota.Builder/Extensions/OpenApiSchemaExtensions.cs b/src/Kiota.Builder/Extensions/OpenApiSchemaExtensions.cs index 8d8f93bf25..0ab534e0ea 100644 --- a/src/Kiota.Builder/Extensions/OpenApiSchemaExtensions.cs +++ b/src/Kiota.Builder/Extensions/OpenApiSchemaExtensions.cs @@ -10,7 +10,7 @@ namespace Kiota.Builder.Extensions; public static class OpenApiSchemaExtensions { - private static readonly Func> classNamesFlattener = x => + private static readonly Func> subsequentSchemaGetter = x => (x.AnyOf ?? Enumerable.Empty()).Union(x.AllOf ?? []).Union(x.OneOf ?? []).ToList(); public static IEnumerable GetSchemaNames(this IOpenApiSchema schema, bool directOnly = false) { @@ -21,13 +21,19 @@ public static IEnumerable GetSchemaNames(this IOpenApiSchema schema, boo if (schema.GetReferenceId() is string refId && !string.IsNullOrEmpty(refId)) return [refId.Split('/')[^1].Split('.')[^1]]; if (!directOnly && schema.AnyOf is { Count: > 0 }) - return schema.AnyOf.FlattenIfRequired(classNamesFlattener); + return schema.AnyOf.FlattenIfRequired(subsequentSchemaGetter); if (!directOnly && schema.AllOf is { Count: > 0 }) - return schema.AllOf.FlattenIfRequired(classNamesFlattener); + return schema.AllOf.FlattenIfRequired(subsequentSchemaGetter); if (!directOnly && schema.OneOf is { Count: > 0 }) - return schema.OneOf.FlattenIfRequired(classNamesFlattener); + return schema.OneOf.FlattenIfRequired(subsequentSchemaGetter); return []; } + + public static IList GetSubsequentSchemas(this IOpenApiSchema schema) + { + return subsequentSchemaGetter(schema); + } + internal static string? GetReferenceId(this IOpenApiSchema schema) { return schema switch diff --git a/src/Kiota.Builder/KiotaBuilder.cs b/src/Kiota.Builder/KiotaBuilder.cs index df7aa4a971..daefe04ed5 100644 --- a/src/Kiota.Builder/KiotaBuilder.cs +++ b/src/Kiota.Builder/KiotaBuilder.cs @@ -1128,10 +1128,18 @@ not null when GetPrimitiveType(parameter.Schema) is {} primitiveType => primitiv private CodeType? GetEnumType(OpenApiUrlTreeNode currentNode, IOpenApiParameter parameter) { IOpenApiSchema? enumCandidateSchema = parameter.Schema; - // many specs wrap refs under allOf or nested empty entries: [ { $ref: ... } ] - enumCandidateSchema = enumCandidateSchema?.AllOf? - .FlattenSchemaIfRequired(static x => x.AllOf) - .FirstOrDefault() ?? enumCandidateSchema; + // Many specs wrap enum refs under allOf/anyOf/oneOf or nested empty entries: [ { $ref: ... } ] + if (enumCandidateSchema is not null) + { + var subsequentSchemas = enumCandidateSchema.GetSubsequentSchemas(); + var flattened = subsequentSchemas + .FlattenSchemaIfRequired(x => x.GetSubsequentSchemas()) + .ToList(); + var candidates = flattened.Count != 0 ? flattened : [enumCandidateSchema]; + + // Prefer the actual enum-bearing subschema + enumCandidateSchema = candidates.FirstOrDefault(x => x.IsEnum()) ?? enumCandidateSchema; + } if (enumCandidateSchema is null || modelsNamespace is null) { From db622fd5dbbf150a3db67132be608ebc93057f4c Mon Sep 17 00:00:00 2001 From: Matthias Nistl Date: Fri, 19 Dec 2025 22:09:02 +0100 Subject: [PATCH 8/8] Simplify early return --- src/Kiota.Builder/KiotaBuilder.cs | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/Kiota.Builder/KiotaBuilder.cs b/src/Kiota.Builder/KiotaBuilder.cs index b82d169186..88d7a2bc0a 100644 --- a/src/Kiota.Builder/KiotaBuilder.cs +++ b/src/Kiota.Builder/KiotaBuilder.cs @@ -1132,24 +1132,21 @@ not null when GetPrimitiveType(parameter.Schema) is {} primitiveType => primitiv private CodeType? GetEnumType(OpenApiUrlTreeNode currentNode, IOpenApiParameter parameter) { IOpenApiSchema? enumCandidateSchema = parameter.Schema; - // Many specs wrap enum refs under allOf/anyOf/oneOf or nested empty entries: [ { $ref: ... } ] - if (enumCandidateSchema is not null) - { - var subsequentSchemas = enumCandidateSchema.GetSubsequentSchemas(); - var flattened = subsequentSchemas - .FlattenSchemaIfRequired(x => x.GetSubsequentSchemas()) - .ToList(); - var candidates = flattened.Count != 0 ? flattened : [enumCandidateSchema]; - - // Prefer the actual enum-bearing subschema - enumCandidateSchema = candidates.FirstOrDefault(x => x.IsEnum()) ?? enumCandidateSchema; - } - if (enumCandidateSchema is null || modelsNamespace is null) { return default; } + // Many specs wrap enum refs under allOf/anyOf/oneOf or nested empty entries: [ { $ref: ... } ] + var subsequentSchemas = enumCandidateSchema.GetSubsequentSchemas(); + var flattened = subsequentSchemas + .FlattenSchemaIfRequired(static x => x.GetSubsequentSchemas()) + .ToList(); + var candidates = flattened.Count != 0 ? flattened : [enumCandidateSchema]; + + // Prefer the actual enum-bearing subschema + enumCandidateSchema = candidates.FirstOrDefault(static x => x.IsEnum()) ?? enumCandidateSchema; + var targetNamespace = GetShortestNamespace(modelsNamespace, enumCandidateSchema); var declarationName = enumCandidateSchema.GetSchemaName()?.CleanupSymbolName(); if (string.IsNullOrEmpty(declarationName))