diff --git a/CHANGELOG.md b/CHANGELOG.md index a24e5cc50e..812ab68f29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added model generation for schemas used by OpenAPI 3.1 webhooks. [#6394](https://github.com/microsoft/kiota/issues/6394) - Adds support for resolving JSON Schema 2020-12 `$dynamicRef` against schemas declaring `$dynamicAnchor`, so recursive types (e.g. `LocalizedCategory.children: LocalizedCategory[]`) generate correctly instead of degrading to `UntypedNode`. Phase 1 of [#7815](https://github.com/microsoft/kiota/issues/7815). +- Added binding-aware `$dynamicRef` resolution for `$defs` / `$dynamicAnchor` contexts, generating distinct concrete models for each active binding and preserving typed request bodies, responses, and error responses. Phase 2 of [#7815](https://github.com/microsoft/kiota/issues/7815). ### Changed diff --git a/src/Kiota.Builder/CodeDOM/CodeTypeParameter.cs b/src/Kiota.Builder/CodeDOM/CodeTypeParameter.cs new file mode 100644 index 0000000000..efd6d92f1c --- /dev/null +++ b/src/Kiota.Builder/CodeDOM/CodeTypeParameter.cs @@ -0,0 +1,5 @@ +namespace Kiota.Builder.CodeDOM; + +public class CodeTypeParameter : CodeTerminal +{ +} diff --git a/src/Kiota.Builder/CodeDOM/ProprietableBlock.cs b/src/Kiota.Builder/CodeDOM/ProprietableBlock.cs index 35a833febc..1fefa13d43 100644 --- a/src/Kiota.Builder/CodeDOM/ProprietableBlock.cs +++ b/src/Kiota.Builder/CodeDOM/ProprietableBlock.cs @@ -71,6 +71,8 @@ public bool IsOfKind(params TBlockKind[] kinds) public IEnumerable Methods => InnerChildElements.Values.OfType().OrderBy(static x => x.Name, StringComparer.OrdinalIgnoreCase); public IEnumerable UnorderedMethods => InnerChildElements.Values.OfType(); public IEnumerable InnerClasses => InnerChildElements.Values.OfType().OrderBy(static x => x.Name, StringComparer.OrdinalIgnoreCase); + public IReadOnlyList TypeParameters => StartBlock.TypeParameters; + public bool IsGeneric => StartBlock.IsGeneric; public bool ContainsMember(string name) { return InnerChildElements.ContainsKey(name); @@ -121,4 +123,16 @@ public void RemoveImplements(params CodeType[] types) implements.TryRemove(type.Name, out var _); } public IEnumerable Implements => implements.Values.OrderBy(static x => x.Name, StringComparer.OrdinalIgnoreCase); + + private readonly ConcurrentDictionary typeParameters = new(StringComparer.OrdinalIgnoreCase); + public void AddTypeParameter(params CodeTypeParameter[] parameters) + { + if (parameters == null || parameters.Any(static x => x == null)) + throw new ArgumentNullException(nameof(parameters)); + EnsureElementsAreChildren(parameters); + foreach (var parameter in parameters) + typeParameters.TryAdd(parameter.Name, parameter); + } + public IReadOnlyList TypeParameters => typeParameters.Values.OrderBy(static x => x.Name, StringComparer.OrdinalIgnoreCase).ToList(); + public bool IsGeneric => !typeParameters.IsEmpty; } diff --git a/src/Kiota.Builder/KiotaBuilder.cs b/src/Kiota.Builder/KiotaBuilder.cs index 1045c042e0..e95399c435 100644 --- a/src/Kiota.Builder/KiotaBuilder.cs +++ b/src/Kiota.Builder/KiotaBuilder.cs @@ -53,7 +53,8 @@ public partial class KiotaBuilder /// /// Tracks dynamic scopes for unresolved $dynamicRef; thread-local because generation is parallel. /// - private static readonly ThreadLocal> _dynamicScope = new(() => new(), trackAllValues: false); + private sealed record DynamicScopeFrame(IOpenApiSchema Schema, string? BindingSuffix); + private static readonly ThreadLocal> _dynamicScope = new(() => new(), trackAllValues: false); internal void SetOpenApiDocument(OpenApiDocument document) => openApiDocument = document ?? throw new ArgumentNullException(nameof(document)); public KiotaBuilder(ILogger logger, GenerationConfiguration config, HttpClient client, bool useKiotaConfig = false, ISettingsManagementService? settingsManagementService = null) @@ -732,6 +733,8 @@ public async Task CreateLanguageSourceFilesAsync(GenerationLanguage language, Co private const string ConstructorMethodName = "constructor"; internal const string UntypedNodeName = "UntypedNode"; internal const string TrailingSlashPlaceholder = "EmptyPathSegment"; + private const string RequestBodySuffix = "RequestBody"; + private const string ResponseSuffix = "Response"; /// /// Create a CodeClass instance that is a request builder class for the OpenApiUrlTreeNode /// @@ -1761,34 +1764,40 @@ private string GetModelsNamespaceNameFromReferenceId(string? referenceId) var namespaceSuffix = lastDotIndex != -1 ? $".{referenceId[..lastDotIndex]}" : string.Empty; return $"{modelsNamespace?.Name}{string.Join(NsNameSeparator, namespaceSuffix.Split(NsNameSeparator).Select(static x => x.CleanupSymbolName()))}"; } - private CodeType CreateModelDeclarationAndType(OpenApiUrlTreeNode currentNode, IOpenApiSchema schema, OpenApiOperation? operation, CodeNamespace codeNamespace, string classNameSuffix = "", IOpenApiResponse? response = default, string typeNameForInlineSchema = "", bool isRequestBody = false) + private CodeType CreateModelDeclarationAndType(OpenApiUrlTreeNode currentNode, IOpenApiSchema schema, OpenApiOperation? operation, CodeNamespace codeNamespace, string classNameSuffix = "", IOpenApiResponse? response = default, string typeNameForInlineSchema = "", bool isRequestBody = false, string dynamicBindingSuffixContext = "") { - _dynamicScope.Value!.Push(schema); + var bindingSuffix = TryGetDynamicBindingSuffix(schema, currentNode, operation, response, isRequestBody, dynamicBindingSuffixContext); + _dynamicScope.Value!.Push(new(schema, bindingSuffix)); try { var className = string.IsNullOrEmpty(typeNameForInlineSchema) ? currentNode.GetClassName(config.StructuredMimeTypes, operation: operation, suffix: classNameSuffix, response: response, schema: schema, requestBody: isRequestBody).CleanupSymbolName() : typeNameForInlineSchema; + if (bindingSuffix is not null) + className = $"{className}{bindingSuffix}"; var codeDeclaration = AddModelDeclarationIfDoesntExist(currentNode, operation, schema, className, codeNamespace); + if (bindingSuffix is not null) + AddDynamicBindingTypeParameters(schema, codeDeclaration); return new CodeType { TypeDefinition = codeDeclaration }; } finally { _dynamicScope.Value!.Pop(); } } - private CodeType CreateInheritedModelDeclarationAndType(OpenApiUrlTreeNode currentNode, IOpenApiSchema schema, OpenApiOperation? operation, string classNameSuffix, CodeNamespace codeNamespace, bool isRequestBody, string typeNameForInlineSchema, bool isViaDiscriminator = false) + private CodeType CreateInheritedModelDeclarationAndType(OpenApiUrlTreeNode currentNode, IOpenApiSchema schema, OpenApiOperation? operation, string classNameSuffix, CodeNamespace codeNamespace, bool isRequestBody, string typeNameForInlineSchema, bool isViaDiscriminator = false, IOpenApiResponse? response = default, string dynamicBindingSuffixContext = "") { return new CodeType { - TypeDefinition = CreateInheritedModelDeclaration(currentNode, schema, operation, classNameSuffix, codeNamespace, isRequestBody, typeNameForInlineSchema, isViaDiscriminator), + TypeDefinition = CreateInheritedModelDeclaration(currentNode, schema, operation, classNameSuffix, codeNamespace, isRequestBody, typeNameForInlineSchema, isViaDiscriminator, response, dynamicBindingSuffixContext), }; } - private CodeClass CreateInheritedModelDeclaration(OpenApiUrlTreeNode currentNode, IOpenApiSchema schema, OpenApiOperation? operation, string classNameSuffix, CodeNamespace codeNamespace, bool isRequestBody, string typeNameForInlineSchema, bool isViaDiscriminator = false) + private CodeClass CreateInheritedModelDeclaration(OpenApiUrlTreeNode currentNode, IOpenApiSchema schema, OpenApiOperation? operation, string classNameSuffix, CodeNamespace codeNamespace, bool isRequestBody, string typeNameForInlineSchema, bool isViaDiscriminator = false, IOpenApiResponse? response = default, string dynamicBindingSuffixContext = "") { - _dynamicScope.Value!.Push(schema); + var bindingSuffix = TryGetDynamicBindingSuffix(schema, currentNode, operation, response, isRequestBody, dynamicBindingSuffixContext); + _dynamicScope.Value!.Push(new(schema, bindingSuffix)); try { - return CreateInheritedModelDeclarationCore(currentNode, schema, operation, classNameSuffix, codeNamespace, isRequestBody, typeNameForInlineSchema, isViaDiscriminator); + return CreateInheritedModelDeclarationCore(currentNode, schema, operation, classNameSuffix, codeNamespace, isRequestBody, typeNameForInlineSchema, isViaDiscriminator, response, dynamicBindingSuffixContext, bindingSuffix); } finally { _dynamicScope.Value!.Pop(); } } - private CodeClass CreateInheritedModelDeclarationCore(OpenApiUrlTreeNode currentNode, IOpenApiSchema schema, OpenApiOperation? operation, string classNameSuffix, CodeNamespace codeNamespace, bool isRequestBody, string typeNameForInlineSchema, bool isViaDiscriminator = false) + private CodeClass CreateInheritedModelDeclarationCore(OpenApiUrlTreeNode currentNode, IOpenApiSchema schema, OpenApiOperation? operation, string classNameSuffix, CodeNamespace codeNamespace, bool isRequestBody, string typeNameForInlineSchema, bool isViaDiscriminator = false, IOpenApiResponse? response = default, string dynamicBindingSuffixContext = "", string? bindingSuffix = null) { var flattenedAllOfs = schema.AllOf.FlattenSchemaIfRequired(static x => x.AllOf).ToArray(); var referenceId = schema.GetReferenceId(); @@ -1809,6 +1818,8 @@ private CodeClass CreateInheritedModelDeclarationCore(OpenApiUrlTreeNode current typeNameForInlineSchema : currentNode.GetClassName(config.StructuredMimeTypes, operation: operation, suffix: classNameSuffix, schema: schema, requestBody: isRequestBody))) .CleanupSymbolName(); + if (bindingSuffix is not null) + className = $"{className}{bindingSuffix}"; var codeDeclaration = (rootSchemaHasProperties, inlineSchemas, referencedSchemas, isViaDiscriminator) switch { // greatest parent type @@ -1863,6 +1874,8 @@ private CodeClass CreateInheritedModelDeclarationCore(OpenApiUrlTreeNode current .FirstOrDefault(static x => !string.IsNullOrEmpty(x)) is string description) currentClass.Documentation.DescriptionTemplate = description.CleanupDescription(); // the last allof entry often is not a reference and doesn't have a description. + if (bindingSuffix is not null) + AddDynamicBindingTypeParameters(schema, currentClass); return currentClass; } private CodeTypeBase CreateComposedModelDeclaration(OpenApiUrlTreeNode currentNode, IOpenApiSchema schema, OpenApiOperation? operation, string suffixForInlineSchema, CodeNamespace codeNamespace, bool isRequestBody, string typeNameForInlineSchema) @@ -1973,7 +1986,8 @@ private CodeTypeBase CreateModelDeclarations(OpenApiUrlTreeNode currentNode, IOp // If typeNameForInlineSchema is not null and the schema is referenced, we have most likely unwrapped a referenced schema(most likely from an AllOf/OneOf/AnyOf). // Therefore the current type/schema is not really inlined, so invalidate the typeNameForInlineSchema and just work with the information from the schema reference. - if (schema.IsReferencedSchema() && !string.IsNullOrEmpty(typeNameForInlineSchema)) + // Schemas with $dynamicRef are the exception: binding resolution can produce an inline schema, so keep the caller-provided inline name. + if (schema.IsReferencedSchema() && string.IsNullOrEmpty(schema.DynamicRef) && !string.IsNullOrEmpty(typeNameForInlineSchema)) { typeNameForInlineSchema = string.Empty; } @@ -1995,13 +2009,13 @@ private CodeTypeBase CreateModelDeclarations(OpenApiUrlTreeNode currentNode, IOp if (schema.IsInherited()) { // Pass isViaDiscriminator so that we can handle the special case where this model was referenced by a discriminator and we always want to generate a base class. - return CreateInheritedModelDeclarationAndType(currentNode, schema, operation, suffix, codeNamespace, isRequestBody, typeNameForInlineSchema, isViaDiscriminator); + return CreateInheritedModelDeclarationAndType(currentNode, schema, operation, suffix, codeNamespace, isRequestBody, typeNameForInlineSchema, isViaDiscriminator, responseValue, suffixForInlineSchema); } if (schema.IsIntersection() && schema.MergeIntersectionSchemaEntries() is IOpenApiSchema mergedSchema) { // multiple allOf entries that do not translate to inheritance - return CreateModelDeclarationAndType(currentNode, mergedSchema, operation, codeNamespace, suffix, response: responseValue, typeNameForInlineSchema: typeNameForInlineSchema, isRequestBody); + return CreateModelDeclarationAndType(currentNode, mergedSchema, operation, codeNamespace, suffix, response: responseValue, typeNameForInlineSchema: typeNameForInlineSchema, isRequestBody, dynamicBindingSuffixContext: suffixForInlineSchema); } if ((schema.IsInclusiveUnion() || schema.IsExclusiveUnion()) && string.IsNullOrEmpty(schema.Format) @@ -2025,14 +2039,14 @@ private CodeTypeBase CreateModelDeclarations(OpenApiUrlTreeNode currentNode, IOp if (schema.IsObjectType() || schema.HasAnyProperty() || schema.IsEnum() || schema.AdditionalProperties?.Type is not null) { // no inheritance or union type, often empty definitions with only additional properties are used as property bags. - return CreateModelDeclarationAndType(currentNode, schema, operation, codeNamespace, suffix, response: responseValue, typeNameForInlineSchema: typeNameForInlineSchema, isRequestBody); + return CreateModelDeclarationAndType(currentNode, schema, operation, codeNamespace, suffix, response: responseValue, typeNameForInlineSchema: typeNameForInlineSchema, isRequestBody, dynamicBindingSuffixContext: suffixForInlineSchema); } if (schema.IsArray() && !schema.Items.IsArray()) // Only handle collections of primitives and complex types. Otherwise, multi-dimensional arrays would be recursively unwrapped undesirably to lead to incorrect serialization types. { // collections at root - return CreateCollectionModelDeclaration(currentNode, schema, operation, codeNamespace, typeNameForInlineSchema, isRequestBody); + return CreateCollectionModelDeclaration(currentNode, schema, operation, codeNamespace, suffixForInlineSchema, response, typeNameForInlineSchema, isRequestBody); } if (schema.Type is not null && (schema.Type & ~JsonSchemaType.Null) != 0 || !string.IsNullOrEmpty(schema.Format)) @@ -2053,23 +2067,37 @@ private CodeTypeBase CreateModelDeclarations(OpenApiUrlTreeNode currentNode, IOp // Stack enumerates innermost-first (top to bottom); reverse to walk outermost-first. foreach (var frame in scope.Reverse()) { - // Unwrap references to their target: ResolveDynamicAnchorInContext checks a reference's - // own sibling $dynamicAnchor / $defs, but the anchor is usually declared on the target. - var context = frame is OpenApiSchemaReference { Target: { } t } ? t : frame; - if (OpenApiWorkspace.ResolveDynamicAnchorInContext(context, anchorName) is { } resolved) + // Try frame as-is (binding $defs), then unwrapped target. + if (TryResolveAnchor(frame.Schema, false) is { } result) + return result; + if (frame.Schema is OpenApiSchemaReference { Target: { } target } + && TryResolveAnchor(target, true) is { } result2) + return result2; + + CodeTypeBase? TryResolveAnchor(IOpenApiSchema context, bool allowFrameReferenceFallback) { - // Use the resolved schema's reference ID first, fall back to the resolving frame's. - var refId = resolved.GetReferenceId() ?? frame.GetReferenceId(); - if (!string.IsNullOrEmpty(refId)) + if (OpenApiWorkspace.ResolveDynamicAnchorInContext(context, anchorName) is not { } resolved) + return null; + // Only target resolution can borrow the frame ref id; inline binding $defs must stay inline. + var refId = resolved.GetReferenceId() ?? (allowFrameReferenceFallback ? frame.Schema.GetReferenceId() : null); + if (string.IsNullOrEmpty(refId)) + return CreateModelDeclarations(currentNode, resolved, operation, parentElement, suffixForInlineSchema, response, typeNameForInlineSchema, isRequestBody); + var className = refId.Split('/').Last().Split('.').Last().CleanupSymbolName(); + var nsName = GetModelsNamespaceNameFromReferenceId(refId); + var searchNs = rootNamespace?.FindOrAddNamespace(nsName) ?? codeNamespace; + // Recursive: forward-reference with enclosing suffix (combined recursive + generic). + // Must run before GetExistingDeclaration so a bare class from a non-bound reference + // doesn't bypass the binding suffix. + if (scope.Any(f => f.Schema.GetReferenceId() == refId)) { - var className = refId.Split('/').Last().Split('.').Last().CleanupSymbolName(); - var nsName = GetModelsNamespaceNameFromReferenceId(refId); - var searchNs = rootNamespace?.FindOrAddNamespace(nsName) ?? codeNamespace; - if (GetExistingDeclaration(searchNs, currentNode, className) is CodeClass existing) - return new CodeType { TypeDefinition = existing }; - // Forward reference — the class will be created by the enclosing materialization. - return new CodeType { Name = className }; + var enclosingSuffix = scope.Reverse().Select(static f => f.BindingSuffix).FirstOrDefault(static s => !string.IsNullOrEmpty(s)); + var suffixedName = enclosingSuffix is null ? className : $"{className}{enclosingSuffix}"; + if (GetExistingDeclaration(searchNs, currentNode, suffixedName) is CodeClass existingSuffixed) + return new CodeType { TypeDefinition = existingSuffixed }; + return new CodeType { Name = suffixedName }; } + if (GetExistingDeclaration(searchNs, currentNode, className) is CodeClass existing) + return new CodeType { TypeDefinition = existing }; return CreateModelDeclarations(currentNode, resolved, operation, parentElement, suffixForInlineSchema, response, typeNameForInlineSchema, isRequestBody); } } @@ -2077,6 +2105,39 @@ private CodeTypeBase CreateModelDeclarations(OpenApiUrlTreeNode currentNode, IOp } if (!string.IsNullOrEmpty(schema.DynamicRef)) { + var anchorName = ExtractAnchorName(schema.DynamicRef); + if (!string.IsNullOrEmpty(anchorName)) + { + var candidates = openApiDocument?.Components?.Schemas + ?.Where(kv => !string.IsNullOrEmpty(kv.Value.DynamicAnchor) && ExtractAnchorName(kv.Value.DynamicAnchor) == anchorName) + .Select(kv => (Name: kv.Key, Schema: kv.Value)) + .OrderBy(static x => x.Name, StringComparer.Ordinal) + .ToList(); + if (candidates is { Count: > 0 }) + { + if (candidates.Count == 1) + { + var c = candidates[0]; + var className = c.Name.Split('/').Last().Split('.').Last().CleanupSymbolName(); + var nsName = GetModelsNamespaceNameFromReferenceId(c.Name); + var ns = rootNamespace?.FindOrAddNamespace(nsName) ?? codeNamespace; + return new CodeType { TypeDefinition = AddModelDeclarationIfDoesntExist(currentNode, operation, c.Schema, className, ns) }; + } + var unionName = string.IsNullOrEmpty(typeNameForInlineSchema) ? + currentNode.GetClassName(config.StructuredMimeTypes, operation: operation, suffix: suffixForInlineSchema, schema: schema).CleanupSymbolName() : + typeNameForInlineSchema; + var unionType = new CodeUnionType { Name = unionName }; + foreach (var c in candidates) + { + var className = c.Name.Split('/').Last().Split('.').Last().CleanupSymbolName(); + var nsName = GetModelsNamespaceNameFromReferenceId(c.Name); + var ns = rootNamespace?.FindOrAddNamespace(nsName) ?? codeNamespace; + var decl = AddModelDeclarationIfDoesntExist(currentNode, operation, c.Schema, className, ns); + unionType.AddType(new CodeType { TypeDefinition = decl }); + } + return unionType; + } + } var schemaRefId = schema.GetReferenceId(); var location = !string.IsNullOrEmpty(schemaRefId) ? schemaRefId : currentNode.Path; logger.LogWarning("Schema at {Location} uses $dynamicRef '{DynamicRef}' which could not be resolved. Generating UntypedNode.", location, schema.DynamicRef); @@ -2087,29 +2148,100 @@ private CodeTypeBase CreateModelDeclarations(OpenApiUrlTreeNode currentNode, IOp /// Extracts the bare anchor name from a $dynamicRef value (e.g. #categorycategory, /// https://example.com/schema#itemTypeitemType). Per JSON Schema 2020-12 §7.3.3. /// - private static string? ExtractAnchorName(string? dynamicRef) + internal static string? ExtractAnchorName(string? dynamicRef) { if (string.IsNullOrEmpty(dynamicRef)) return null; var hashIndex = dynamicRef.LastIndexOf('#'); return hashIndex >= 0 ? dynamicRef[(hashIndex + 1)..] : dynamicRef; } - private CodeTypeBase CreateCollectionModelDeclaration(OpenApiUrlTreeNode currentNode, IOpenApiSchema schema, OpenApiOperation? operation, CodeNamespace codeNamespace, string typeNameForInlineSchema, bool isRequestBody) + private static string? TryGetDynamicBindingSuffix(IOpenApiSchema schema, OpenApiUrlTreeNode currentNode, OpenApiOperation? operation = default, IOpenApiResponse? response = default, bool isRequestBody = false, string suffixForInlineSchema = "") { - CodeTypeBase? type = GetPrimitiveType(schema.Items); - var isEnumOrComposedCollectionType = schema.Items.IsEnum() //the collection could be an enum type so override with strong type instead of string type. - || schema.Items.IsComposedEnum() && string.IsNullOrEmpty(schema.Items?.Format);//the collection could be a composed type with an enum type so override with strong type instead of string type. - if ((string.IsNullOrEmpty(type?.Name) - || isEnumOrComposedCollectionType) - && schema.Items != null) + var activeBindingSuffix = GetActiveDynamicBindingSuffix(); + if (schema.Definitions is null || schema.Definitions.Count == 0) + return activeBindingSuffix is not null && ContainsDynamicReference(schema) ? activeBindingSuffix : null; + var anchorSuffix = string.Empty; + var hasInline = false; + foreach (var def in schema.Definitions.OrderBy(static kv => kv.Key, StringComparer.Ordinal).Select(static kv => kv.Value)) + { + if (string.IsNullOrEmpty(def.DynamicAnchor)) + continue; + var refId = def.GetReferenceId(); + if (!string.IsNullOrEmpty(refId)) + anchorSuffix += refId.CleanupSymbolName().ToFirstCharacterUpperCase(); + else + hasInline = true; + } + if (!hasInline && anchorSuffix.Length == 0) + return activeBindingSuffix is not null && ContainsDynamicReference(schema) ? activeBindingSuffix : null; + var suffix = anchorSuffix; + if (hasInline) + { + var pathSuffix = string.Join(string.Empty, currentNode.Path + .Split('\\', StringSplitOptions.RemoveEmptyEntries) + .Select(static segment => segment.CleanupSymbolName().ToFirstCharacterUpperCase())); + if (!string.IsNullOrEmpty(pathSuffix)) + suffix += pathSuffix; + if (!string.IsNullOrEmpty(operation?.OperationId)) + suffix += operation.OperationId.CleanupSymbolName().ToFirstCharacterUpperCase(); + if (isRequestBody) + suffix += string.IsNullOrEmpty(operation?.OperationId) && !string.IsNullOrEmpty(suffixForInlineSchema) ? + suffixForInlineSchema.CleanupSymbolName().ToFirstCharacterUpperCase() : + RequestBodySuffix; + else if (response is not null) + suffix += string.IsNullOrEmpty(suffixForInlineSchema) ? ResponseSuffix : suffixForInlineSchema.CleanupSymbolName().ToFirstCharacterUpperCase(); + } + return suffix.Length > 0 ? suffix : null; + } + private static string? GetActiveDynamicBindingSuffix() => _dynamicScope.Value?.Reverse().Select(static f => f.BindingSuffix).FirstOrDefault(static s => !string.IsNullOrEmpty(s)); + private static void AddDynamicBindingTypeParameters(IOpenApiSchema schema, CodeElement? declaration) + { + if (schema?.Definitions is null || declaration is not CodeClass codeClass) return; + foreach (var def in schema.Definitions.Values) + { + if (string.IsNullOrEmpty(def.DynamicAnchor)) continue; + var paramName = $"T{def.DynamicAnchor.CleanupSymbolName().ToFirstCharacterUpperCase()}"; + codeClass.StartBlock.AddTypeParameter(new CodeTypeParameter { Name = paramName }); + } + } + private static bool ContainsDynamicReference(IOpenApiSchema schema, HashSet? visited = null) + { + visited ??= new(ReferenceEqualityComparer.Instance); + if (!visited.Add(schema)) + return false; + return !string.IsNullOrEmpty(schema.DynamicRef) || + schema is OpenApiSchemaReference { Target: { } target } && ContainsDynamicReference(target, visited) || + schema.Items is not null && ContainsDynamicReference(schema.Items, visited) || + schema.Properties?.Values.Any(x => ContainsDynamicReference(x, visited)) == true || + schema.AdditionalProperties is not null && ContainsDynamicReference(schema.AdditionalProperties, visited) || + schema.Definitions?.Values.Any(x => ContainsDynamicReference(x, visited)) == true || + schema.AllOf?.Any(x => ContainsDynamicReference(x, visited)) == true || + schema.AnyOf?.Any(x => ContainsDynamicReference(x, visited)) == true || + schema.OneOf?.Any(x => ContainsDynamicReference(x, visited)) == true; + } + private CodeTypeBase CreateCollectionModelDeclaration(OpenApiUrlTreeNode currentNode, IOpenApiSchema schema, OpenApiOperation? operation, CodeNamespace codeNamespace, string suffixForInlineSchema, IOpenApiResponse? response, string typeNameForInlineSchema, bool isRequestBody) + { + var shouldPush = schema.Definitions?.Values.Any(static d => !string.IsNullOrEmpty(d.DynamicAnchor)) == true; + var bindingSuffix = shouldPush ? TryGetDynamicBindingSuffix(schema, currentNode, operation, response, isRequestBody, suffixForInlineSchema) : null; + if (shouldPush) _dynamicScope.Value!.Push(new(schema, bindingSuffix)); + try { - var targetNamespace = GetShortestNamespace(codeNamespace, schema.Items); - type = CreateModelDeclarations(currentNode, schema.Items, operation, targetNamespace, string.Empty, typeNameForInlineSchema: typeNameForInlineSchema, isRequestBody: isRequestBody); + CodeTypeBase? type = GetPrimitiveType(schema.Items); + var isEnumOrComposedCollectionType = schema.Items.IsEnum() + || schema.Items.IsComposedEnum() && string.IsNullOrEmpty(schema.Items?.Format); + if ((string.IsNullOrEmpty(type?.Name) + || isEnumOrComposedCollectionType) + && schema.Items != null) + { + var targetNamespace = GetShortestNamespace(codeNamespace, schema.Items); + type = CreateModelDeclarations(currentNode, schema.Items, operation, targetNamespace, string.Empty, typeNameForInlineSchema: typeNameForInlineSchema, isRequestBody: isRequestBody); + } + if (type is null) + return new CodeType { Name = UntypedNodeName, IsExternal = true }; + type.CollectionKind = CodeTypeBase.CodeTypeCollectionKind.Complex; + return type; } - if (type is null) - return new CodeType { Name = UntypedNodeName, IsExternal = true }; - type.CollectionKind = CodeTypeBase.CodeTypeCollectionKind.Complex; - return type; + finally { if (shouldPush) _dynamicScope.Value!.Pop(); } } private CodeElement? GetExistingDeclaration(CodeNamespace currentNamespace, OpenApiUrlTreeNode currentNode, string declarationName) { diff --git a/tests/Kiota.Builder.IntegrationTests/GenerateSample.cs b/tests/Kiota.Builder.IntegrationTests/GenerateSample.cs index b8e7fc6fde..c6556ff29b 100644 --- a/tests/Kiota.Builder.IntegrationTests/GenerateSample.cs +++ b/tests/Kiota.Builder.IntegrationTests/GenerateSample.cs @@ -436,4 +436,456 @@ public async Task ResolvesNestedDynamicScopeAsync(GenerationLanguage language) } Assert.DoesNotContain("UntypedNode", allModelText, StringComparison.Ordinal); } + + [InlineData(GenerationLanguage.CSharp)] + [InlineData(GenerationLanguage.Java)] + [InlineData(GenerationLanguage.TypeScript)] + [InlineData(GenerationLanguage.Go)] + [InlineData(GenerationLanguage.Python)] + [Theory] + public async Task ResolvesGenericBindingDynamicRefAsync(GenerationLanguage language) + { + var logger = LoggerFactory.Create(builder => { }).CreateLogger(); + var configuration = new GenerationConfiguration + { + Language = language, + OpenAPIFilePath = GetAbsolutePath("generic-binding.yaml"), + OutputPath = Path.Combine(".", "Generated", "GenericBinding", language.ToString()), + CleanOutput = true, + }; + await new KiotaBuilder(logger, configuration, _httpClient).GenerateClientAsync(new()); + + var allModelText = ReadGeneratedModelText(Path.Combine(Directory.GetCurrentDirectory(), "Generated", "GenericBinding", language.ToString())); + Assert.DoesNotContain("UntypedNode", allModelText, StringComparison.Ordinal); + Assert.Contains("PaginatedTemplateUser", allModelText, StringComparison.Ordinal); + Assert.Contains("PaginatedTemplateGroup", allModelText, StringComparison.Ordinal); + Assert.Contains("PaginatedTemplateUserProfile", allModelText, StringComparison.Ordinal); + Assert.DoesNotContain("PaginatedTemplateUser-profile", allModelText, StringComparison.Ordinal); + // The bare template class must NOT exist — each binding context gets its own suffixed class. + Assert.DoesNotContain("class PaginatedTemplate\n", allModelText, StringComparison.Ordinal); + Assert.DoesNotContain("class PaginatedTemplate ", allModelText, StringComparison.Ordinal); + Assert.DoesNotContain("class PaginatedTemplate:", allModelText, StringComparison.Ordinal); + Assert.DoesNotContain("type PaginatedTemplate struct", allModelText, StringComparison.Ordinal); + switch (language) + { + case GenerationLanguage.CSharp: + Assert.Contains("GetCollectionOfObjectValues", allModelText, StringComparison.Ordinal); + Assert.Contains("GetCollectionOfObjectValues", allModelText, StringComparison.Ordinal); + break; + case GenerationLanguage.Java: + Assert.Contains("getCollectionOfObjectValues(User::createFromDiscriminatorValue)", allModelText, StringComparison.Ordinal); + Assert.Contains("getCollectionOfObjectValues(Group::createFromDiscriminatorValue)", allModelText, StringComparison.Ordinal); + break; + case GenerationLanguage.TypeScript: + Assert.Contains("getCollectionOfObjectValues(createUserFromDiscriminatorValue)", allModelText, StringComparison.Ordinal); + Assert.Contains("getCollectionOfObjectValues(createGroupFromDiscriminatorValue)", allModelText, StringComparison.Ordinal); + break; + case GenerationLanguage.Go: + Assert.Contains("GetCollectionOfObjectValues(CreateUserFromDiscriminatorValue)", allModelText, StringComparison.Ordinal); + Assert.Contains("GetCollectionOfObjectValues(CreateGroupFromDiscriminatorValue)", allModelText, StringComparison.Ordinal); + break; + case GenerationLanguage.Python: + Assert.Contains("n.get_collection_of_object_values(User)", allModelText, StringComparison.Ordinal); + Assert.Contains("n.get_collection_of_object_values(Group)", allModelText, StringComparison.Ordinal); + break; + default: + throw new Exception($"Please implement a test-case for {language}"); + } + } + + [InlineData(GenerationLanguage.CSharp)] + [InlineData(GenerationLanguage.Java)] + [InlineData(GenerationLanguage.TypeScript)] + [InlineData(GenerationLanguage.Go)] + [InlineData(GenerationLanguage.Python)] + [Theory] + public async Task ResolvesInlineBindingDynamicRefAsync(GenerationLanguage language) + { + var logger = LoggerFactory.Create(builder => { }).CreateLogger(); + var configuration = new GenerationConfiguration + { + Language = language, + OpenAPIFilePath = GetAbsolutePath("inline-binding.yaml"), + OutputPath = Path.Combine(".", "Generated", "InlineBinding", language.ToString()), + CleanOutput = true, + }; + await new KiotaBuilder(logger, configuration, _httpClient).GenerateClientAsync(new()); + + var allModelText = ReadGeneratedModelText(Path.Combine(Directory.GetCurrentDirectory(), "Generated", "InlineBinding", language.ToString())); + Assert.DoesNotContain("UntypedNode", allModelText, StringComparison.Ordinal); + // Inline bindings (no $ref) use the route path as suffix so repeated terminal segments do not collide. + Assert.Contains("PaginatedTemplateUsers", allModelText, StringComparison.Ordinal); + Assert.Contains("PaginatedTemplateGroups", allModelText, StringComparison.Ordinal); + Assert.Contains("PaginatedTemplateOrgsUsers", allModelText, StringComparison.Ordinal); + Assert.Contains("PaginatedTemplateTeamsUsers", allModelText, StringComparison.Ordinal); + if (language is GenerationLanguage.CSharp) + Assert.Contains("GetCollectionOfObjectValues", allModelText, StringComparison.Ordinal); + } + + [InlineData(GenerationLanguage.CSharp)] + [InlineData(GenerationLanguage.Java)] + [InlineData(GenerationLanguage.TypeScript)] + [InlineData(GenerationLanguage.Go)] + [InlineData(GenerationLanguage.Python)] + [Theory] + public async Task ResolvesRecursiveGenericBindingDynamicRefAsync(GenerationLanguage language) + { + var logger = LoggerFactory.Create(builder => { }).CreateLogger(); + var configuration = new GenerationConfiguration + { + Language = language, + OpenAPIFilePath = GetAbsolutePath("recursive-generic-binding.yaml"), + OutputPath = Path.Combine(".", "Generated", "RecursiveGenericBinding", language.ToString()), + CleanOutput = true, + }; + await new KiotaBuilder(logger, configuration, _httpClient).GenerateClientAsync(new()); + + var allModelText = ReadGeneratedModelText(Path.Combine(Directory.GetCurrentDirectory(), "Generated", "RecursiveGenericBinding", language.ToString())); + Assert.DoesNotContain("UntypedNode", allModelText, StringComparison.Ordinal); + // Generic binding: distinct classes per bound type. + Assert.Contains("TreeTemplateBranch", allModelText, StringComparison.Ordinal); + Assert.Contains("TreeTemplateLeaf", allModelText, StringComparison.Ordinal); + // Recursive forward reference must use the suffixed name, not the bare template name. + Assert.DoesNotContain("class TreeTemplate\n", allModelText, StringComparison.Ordinal); + Assert.DoesNotContain("class TreeTemplate ", allModelText, StringComparison.Ordinal); + Assert.DoesNotContain("class TreeTemplate:", allModelText, StringComparison.Ordinal); + Assert.DoesNotContain("type TreeTemplate struct", allModelText, StringComparison.Ordinal); + } + + [InlineData(GenerationLanguage.CSharp)] + [InlineData(GenerationLanguage.Java)] + [InlineData(GenerationLanguage.TypeScript)] + [InlineData(GenerationLanguage.Go)] + [InlineData(GenerationLanguage.Python)] + [Theory] + public async Task ResolvesRequestBodyGenericBindingDynamicRefAsync(GenerationLanguage language) + { + var logger = LoggerFactory.Create(builder => { }).CreateLogger(); + var configuration = new GenerationConfiguration + { + Language = language, + OpenAPIFilePath = GetAbsolutePath("request-body-generic-binding.yaml"), + OutputPath = Path.Combine(".", "Generated", "RequestBodyGenericBinding", language.ToString()), + CleanOutput = true, + }; + await new KiotaBuilder(logger, configuration, _httpClient).GenerateClientAsync(new()); + + var allModelText = ReadGeneratedModelText(Path.Combine(Directory.GetCurrentDirectory(), "Generated", "RequestBodyGenericBinding", language.ToString())); + Assert.DoesNotContain("UntypedNode", allModelText, StringComparison.Ordinal); + Assert.Contains("SearchTemplateUser", allModelText, StringComparison.Ordinal); + Assert.Contains("SearchTemplateGroup", allModelText, StringComparison.Ordinal); + Assert.DoesNotContain("class SearchTemplate\n", allModelText, StringComparison.Ordinal); + Assert.DoesNotContain("class SearchTemplate ", allModelText, StringComparison.Ordinal); + Assert.DoesNotContain("class SearchTemplate:", allModelText, StringComparison.Ordinal); + Assert.DoesNotContain("type SearchTemplate struct", allModelText, StringComparison.Ordinal); + } + + [Fact] + public async Task ResolvesInheritedGenericBindingDynamicRefAsync() + { + var logger = LoggerFactory.Create(builder => { }).CreateLogger(); + var configuration = new GenerationConfiguration + { + Language = GenerationLanguage.CSharp, + OpenAPIFilePath = GetAbsolutePath("inherited-generic-binding.yaml"), + OutputPath = Path.Combine(".", "Generated", "InheritedGenericBinding", "CSharp"), + CleanOutput = true, + }; + await new KiotaBuilder(logger, configuration, _httpClient).GenerateClientAsync(new()); + + var allModelText = ReadGeneratedModelText(Path.Combine(Directory.GetCurrentDirectory(), "Generated", "InheritedGenericBinding", "CSharp")); + Assert.DoesNotContain("UntypedNode", allModelText, StringComparison.Ordinal); + Assert.Contains("class InheritedTemplateUser", allModelText, StringComparison.Ordinal); + Assert.Contains("class InheritedTemplateGroup", allModelText, StringComparison.Ordinal); + Assert.Contains("GetCollectionOfObjectValues", allModelText, StringComparison.Ordinal); + Assert.Contains("GetCollectionOfObjectValues", allModelText, StringComparison.Ordinal); + } + + [Fact] + public async Task DisambiguatesRequestResponseInlineBindingDynamicRefAsync() + { + var logger = LoggerFactory.Create(builder => { }).CreateLogger(); + var configuration = new GenerationConfiguration + { + Language = GenerationLanguage.CSharp, + OpenAPIFilePath = GetAbsolutePath("request-response-inline-binding.yaml"), + OutputPath = Path.Combine(".", "Generated", "RequestResponseInlineBinding", "CSharp"), + CleanOutput = true, + }; + await new KiotaBuilder(logger, configuration, _httpClient).GenerateClientAsync(new()); + + var allModelText = ReadGeneratedModelText(Path.Combine(Directory.GetCurrentDirectory(), "Generated", "RequestResponseInlineBinding", "CSharp")); + Assert.DoesNotContain("UntypedNode", allModelText, StringComparison.Ordinal); + Assert.Contains("partial class SharedTemplateSharedSubmitSharedRequestBody :", allModelText, StringComparison.Ordinal); + Assert.Contains("partial class SharedTemplateSharedSubmitShared :", allModelText, StringComparison.Ordinal); + Assert.Contains("GetCollectionOfObjectValues", allModelText, StringComparison.Ordinal); + Assert.Contains("GetCollectionOfObjectValues", allModelText, StringComparison.Ordinal); + } + + [Fact] + public async Task DisambiguatesMultipleErrorInlineBindingDynamicRefAsync() + { + var logger = LoggerFactory.Create(builder => { }).CreateLogger(); + var configuration = new GenerationConfiguration + { + Language = GenerationLanguage.CSharp, + OpenAPIFilePath = GetAbsolutePath("multi-error-inline-binding.yaml"), + OutputPath = Path.Combine(".", "Generated", "MultiErrorInlineBinding", "CSharp"), + CleanOutput = true, + }; + await new KiotaBuilder(logger, configuration, _httpClient).GenerateClientAsync(new()); + + var allModelText = ReadGeneratedModelText(Path.Combine(Directory.GetCurrentDirectory(), "Generated", "MultiErrorInlineBinding", "CSharp")); + Assert.DoesNotContain("UntypedNode", allModelText, StringComparison.Ordinal); + Assert.Contains("partial class ErrorTemplateSharedErrorsGetSharedErrorsFourZeroZeroError :", allModelText, StringComparison.Ordinal); + Assert.Contains("partial class ErrorTemplateSharedErrorsGetSharedErrorsFourZeroFourError :", allModelText, StringComparison.Ordinal); + Assert.Contains("GetCollectionOfObjectValues", allModelText, StringComparison.Ordinal); + Assert.Contains("GetCollectionOfObjectValues", allModelText, StringComparison.Ordinal); + } + + [InlineData(GenerationLanguage.CSharp)] + [InlineData(GenerationLanguage.Java)] + [InlineData(GenerationLanguage.TypeScript)] + [InlineData(GenerationLanguage.Go)] + [InlineData(GenerationLanguage.Python)] + [Theory] + public async Task ResolvesMultiAnchorGenericBindingDynamicRefAsync(GenerationLanguage language) + { + var logger = LoggerFactory.Create(builder => { }).CreateLogger(); + var configuration = new GenerationConfiguration + { + Language = language, + OpenAPIFilePath = GetAbsolutePath("multi-anchor-generic-binding.yaml"), + OutputPath = Path.Combine(".", "Generated", "MultiAnchorGenericBinding", language.ToString()), + CleanOutput = true, + }; + await new KiotaBuilder(logger, configuration, _httpClient).GenerateClientAsync(new()); + + var allModelText = ReadGeneratedModelText(Path.Combine(Directory.GetCurrentDirectory(), "Generated", "MultiAnchorGenericBinding", language.ToString())); + Assert.DoesNotContain("UntypedNode", allModelText, StringComparison.Ordinal); + // Two anchors bound per route: dataType + errorType → suffix includes both bound type names. + Assert.Contains("EnvelopeTemplateUserProblemDetails", allModelText, StringComparison.Ordinal); + Assert.Contains("EnvelopeTemplateGroupProblemDetails", allModelText, StringComparison.Ordinal); + Assert.DoesNotContain("class EnvelopeTemplate\n", allModelText, StringComparison.Ordinal); + Assert.DoesNotContain("class EnvelopeTemplate ", allModelText, StringComparison.Ordinal); + Assert.DoesNotContain("class EnvelopeTemplate:", allModelText, StringComparison.Ordinal); + Assert.DoesNotContain("type EnvelopeTemplate struct", allModelText, StringComparison.Ordinal); + } + + [Fact] + public async Task GeneratesUntypedNodeForUnresolvedDynamicRefAsync() + { + var logger = LoggerFactory.Create(builder => { }).CreateLogger(); + var configuration = new GenerationConfiguration + { + Language = GenerationLanguage.CSharp, + OpenAPIFilePath = GetAbsolutePath("unresolved-dynamicref.yaml"), + OutputPath = Path.Combine(".", "Generated", "UnresolvedDynamicRef", "CSharp"), + CleanOutput = true, + }; + await new KiotaBuilder(logger, configuration, _httpClient).GenerateClientAsync(new()); + + var allModelText = ReadGeneratedModelText(Path.Combine(Directory.GetCurrentDirectory(), "Generated", "UnresolvedDynamicRef", "CSharp")); + // Unresolved $dynamicRef must degrade to UntypedNode, not crash. + Assert.Contains("UntypedNode", allModelText, StringComparison.Ordinal); + } + + [InlineData(GenerationLanguage.CSharp)] + [InlineData(GenerationLanguage.Java)] + [InlineData(GenerationLanguage.TypeScript)] + [InlineData(GenerationLanguage.Go)] + [InlineData(GenerationLanguage.Python)] + [Theory] + public async Task ResolvesArrayRootDynamicRefAsync(GenerationLanguage language) + { + var logger = LoggerFactory.Create(builder => { }).CreateLogger(); + var configuration = new GenerationConfiguration + { + Language = language, + OpenAPIFilePath = GetAbsolutePath("array-root-dynamicref.yaml"), + OutputPath = Path.Combine(".", "Generated", "ArrayRootDynamicRef", language.ToString()), + CleanOutput = true, + }; + await new KiotaBuilder(logger, configuration, _httpClient).GenerateClientAsync(new()); + + var allText = ReadGeneratedModelText(Path.Combine(Directory.GetCurrentDirectory(), "Generated", "ArrayRootDynamicRef", language.ToString())); + Assert.DoesNotContain("UntypedNode", allText, StringComparison.Ordinal); + switch (language) + { + case GenerationLanguage.CSharp: + Assert.Contains("SendCollectionAsync", allText, StringComparison.Ordinal); + Assert.Contains("SendCollectionAsync", allText, StringComparison.Ordinal); + break; + case GenerationLanguage.Java: + Assert.Contains("User::createFromDiscriminatorValue", allText, StringComparison.Ordinal); + Assert.Contains("Group::createFromDiscriminatorValue", allText, StringComparison.Ordinal); + break; + case GenerationLanguage.TypeScript: + Assert.Contains("createUserFromDiscriminatorValue", allText, StringComparison.Ordinal); + Assert.Contains("createGroupFromDiscriminatorValue", allText, StringComparison.Ordinal); + break; + case GenerationLanguage.Go: + Assert.Contains("CreateUserFromDiscriminatorValue", allText, StringComparison.Ordinal); + Assert.Contains("CreateGroupFromDiscriminatorValue", allText, StringComparison.Ordinal); + break; + case GenerationLanguage.Python: + Assert.Contains("list[User]", allText, StringComparison.Ordinal); + Assert.Contains("list[Group]", allText, StringComparison.Ordinal); + break; + default: + throw new Exception($"Please implement a test-case for {language}"); + } + } + + [Fact] + public async Task ResolvesMultiInlineBindingDynamicRefAsync() + { + var logger = LoggerFactory.Create(builder => { }).CreateLogger(); + var configuration = new GenerationConfiguration + { + Language = GenerationLanguage.CSharp, + OpenAPIFilePath = GetAbsolutePath("multi-inline-binding.yaml"), + OutputPath = Path.Combine(".", "Generated", "MultiInlineBinding", "CSharp"), + CleanOutput = true, + }; + await new KiotaBuilder(logger, configuration, _httpClient).GenerateClientAsync(new()); + + var allModelText = ReadGeneratedModelText(Path.Combine(Directory.GetCurrentDirectory(), "Generated", "MultiInlineBinding", "CSharp")); + Assert.DoesNotContain("UntypedNode", allModelText, StringComparison.Ordinal); + // Context suffix (route + operation) must appear exactly once, not duplicated per inline anchor. + var classDecl = "class CompositeTemplateCompositeGetComposite"; + Assert.Contains(classDecl + " ", allModelText, StringComparison.Ordinal); + // If duplicated, the class name would contain the context twice — verify it doesn't. + Assert.DoesNotContain(classDecl + "Composite", allModelText, StringComparison.Ordinal); + } + + [Fact] + public async Task ResolvesMixedAnchorBindingDynamicRefAsync() + { + var logger = LoggerFactory.Create(builder => { }).CreateLogger(); + var configuration = new GenerationConfiguration + { + Language = GenerationLanguage.CSharp, + OpenAPIFilePath = GetAbsolutePath("mixed-anchor-binding.yaml"), + OutputPath = Path.Combine(".", "Generated", "MixedAnchorBinding", "CSharp"), + CleanOutput = true, + }; + await new KiotaBuilder(logger, configuration, _httpClient).GenerateClientAsync(new()); + + var allModelText = ReadGeneratedModelText(Path.Combine(Directory.GetCurrentDirectory(), "Generated", "MixedAnchorBinding", "CSharp")); + Assert.DoesNotContain("UntypedNode", allModelText, StringComparison.Ordinal); + // Mixed anchors: one $ref (aRef → User) + one inline (zInline). + // Ref suffixes come first, context appended once after. + Assert.Contains("MixedTemplateUserMixedGetMixed", allModelText, StringComparison.Ordinal); + Assert.DoesNotContain("class MixedTemplate\n", allModelText, StringComparison.Ordinal); + Assert.DoesNotContain("class MixedTemplate ", allModelText, StringComparison.Ordinal); + Assert.DoesNotContain("class MixedTemplate:", allModelText, StringComparison.Ordinal); + } + + [Fact] + public async Task SpecializesInheritedComponentDynamicRefBindingsAsync() + { + var logger = LoggerFactory.Create(builder => { }).CreateLogger(); + var configuration = new GenerationConfiguration + { + Language = GenerationLanguage.CSharp, + OpenAPIFilePath = GetAbsolutePath("inherited-component-binding.yaml"), + OutputPath = Path.Combine(".", "Generated", "InheritedComponentBinding", "CSharp"), + CleanOutput = true, + }; + await new KiotaBuilder(logger, configuration, _httpClient).GenerateClientAsync(new()); + + var allModelText = ReadGeneratedModelText(Path.Combine(Directory.GetCurrentDirectory(), "Generated", "InheritedComponentBinding", "CSharp")); + Assert.DoesNotContain("UntypedNode", allModelText, StringComparison.Ordinal); + Assert.Contains("class BasePageUser", allModelText, StringComparison.Ordinal); + Assert.Contains("class BasePageGroup", allModelText, StringComparison.Ordinal); + Assert.Contains("GetCollectionOfObjectValues", allModelText, StringComparison.Ordinal); + Assert.Contains("GetCollectionOfObjectValues", allModelText, StringComparison.Ordinal); + } + + [Fact] + public async Task DisambiguatesInlineBindingsWithoutOperationIdsAsync() + { + var logger = LoggerFactory.Create(builder => { }).CreateLogger(); + var configuration = new GenerationConfiguration + { + Language = GenerationLanguage.CSharp, + OpenAPIFilePath = GetAbsolutePath("no-operation-id-inline-binding.yaml"), + OutputPath = Path.Combine(".", "Generated", "NoOperationIdInlineBinding", "CSharp"), + CleanOutput = true, + }; + await new KiotaBuilder(logger, configuration, _httpClient).GenerateClientAsync(new()); + + var allModelText = ReadGeneratedModelText(Path.Combine(Directory.GetCurrentDirectory(), "Generated", "NoOperationIdInlineBinding", "CSharp")); + Assert.DoesNotContain("UntypedNode", allModelText, StringComparison.Ordinal); + Assert.Contains("class SharedTemplateSharedGetRequestBody", allModelText, StringComparison.Ordinal); + Assert.Contains("class SharedTemplateSharedPostRequestBody", allModelText, StringComparison.Ordinal); + } + + [Fact] + public async Task DisambiguatesNamespacedDynamicRefBindingsAsync() + { + var logger = LoggerFactory.Create(builder => { }).CreateLogger(); + var configuration = new GenerationConfiguration + { + Language = GenerationLanguage.CSharp, + OpenAPIFilePath = GetAbsolutePath("namespaced-binding.yaml"), + OutputPath = Path.Combine(".", "Generated", "NamespacedBinding", "CSharp"), + CleanOutput = true, + }; + await new KiotaBuilder(logger, configuration, _httpClient).GenerateClientAsync(new()); + + var allModelText = ReadGeneratedModelText(Path.Combine(Directory.GetCurrentDirectory(), "Generated", "NamespacedBinding", "CSharp")); + Assert.DoesNotContain("UntypedNode", allModelText, StringComparison.Ordinal); + Assert.Contains("class PageTemplateV1User", allModelText, StringComparison.Ordinal); + Assert.Contains("class PageTemplateV2User", allModelText, StringComparison.Ordinal); + } + + [InlineData(GenerationLanguage.CSharp)] + [InlineData(GenerationLanguage.Java)] + [InlineData(GenerationLanguage.TypeScript)] + [InlineData(GenerationLanguage.Go)] + [InlineData(GenerationLanguage.Python)] + [Theory] + public async Task ResolvesMultiCandidateWithoutBindingDynamicRefAsync(GenerationLanguage language) + { + var logger = LoggerFactory.Create(builder => { }).CreateLogger(); + var configuration = new GenerationConfiguration + { + Language = language, + OpenAPIFilePath = GetAbsolutePath("multi-candidate-no-binding.yaml"), + OutputPath = Path.Combine(".", "Generated", "MultiCandidateNoBinding", language.ToString()), + CleanOutput = true, + }; + await new KiotaBuilder(logger, configuration, _httpClient).GenerateClientAsync(new()); + + var allModelText = ReadGeneratedModelText(Path.Combine(Directory.GetCurrentDirectory(), "Generated", "MultiCandidateNoBinding", language.ToString())); + // Multi-candidate $dynamicRef with no binding context should produce a union of candidates, + // not UntypedNode. + Assert.DoesNotContain("UntypedNode", allModelText, StringComparison.Ordinal); + Assert.Contains("TreeNode", allModelText, StringComparison.Ordinal); + Assert.Contains("LeafA", allModelText, StringComparison.Ordinal); + Assert.Contains("LeafB", allModelText, StringComparison.Ordinal); + // Verify the property is actually a union type, not just three standalone classes. + switch (language) + { + case GenerationLanguage.CSharp: + Assert.Contains("IComposedTypeWrapper", allModelText, StringComparison.Ordinal); + break; + case GenerationLanguage.Java: + Assert.Contains("ComposedTypeWrapper", allModelText, StringComparison.Ordinal); + break; + case GenerationLanguage.TypeScript: + Assert.Contains("LeafA | LeafB | TreeNode", allModelText, StringComparison.Ordinal); + break; + case GenerationLanguage.Go: + Assert.Contains("LeafAable", allModelText, StringComparison.Ordinal); + break; + case GenerationLanguage.Python: + Assert.Contains("ComposedTypeWrapper", allModelText, StringComparison.Ordinal); + break; + default: + throw new Exception($"Please implement a test-case for {language}"); + } + } } diff --git a/tests/Kiota.Builder.IntegrationTests/Kiota.Builder.IntegrationTests.csproj b/tests/Kiota.Builder.IntegrationTests/Kiota.Builder.IntegrationTests.csproj index 896f584824..625b64c47b 100644 --- a/tests/Kiota.Builder.IntegrationTests/Kiota.Builder.IntegrationTests.csproj +++ b/tests/Kiota.Builder.IntegrationTests/Kiota.Builder.IntegrationTests.csproj @@ -69,9 +69,57 @@ PreserveNewest + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + PreserveNewest + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + diff --git a/tests/Kiota.Builder.IntegrationTests/array-root-dynamicref.yaml b/tests/Kiota.Builder.IntegrationTests/array-root-dynamicref.yaml new file mode 100644 index 0000000000..cfb2822322 --- /dev/null +++ b/tests/Kiota.Builder.IntegrationTests/array-root-dynamicref.yaml @@ -0,0 +1,59 @@ +openapi: 3.1.0 +info: + title: DynamicRef Array Root Generic Binding API + version: 0.1.0 +paths: + /users/list: + get: + operationId: listUsersArray + responses: + '200': + description: Array of users via dynamic ref + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/User' + type: array + items: + $dynamicRef: '#itemType' + default: + description: Error + /groups/list: + get: + operationId: listGroupsArray + responses: + '200': + description: Array of groups via dynamic ref + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/Group' + type: array + items: + $dynamicRef: '#itemType' + default: + description: Error +components: + schemas: + User: + type: object + required: [id, name] + properties: + id: + type: string + name: + type: string + Group: + type: object + required: [id, label] + properties: + id: + type: string + label: + type: string diff --git a/tests/Kiota.Builder.IntegrationTests/generic-binding.yaml b/tests/Kiota.Builder.IntegrationTests/generic-binding.yaml new file mode 100644 index 0000000000..2099c56099 --- /dev/null +++ b/tests/Kiota.Builder.IntegrationTests/generic-binding.yaml @@ -0,0 +1,89 @@ +openapi: 3.1.0 +info: + title: DynamicRef Generic Binding API + version: 0.1.0 +paths: + /users: + get: + operationId: listUsers + responses: + '200': + description: User page + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/User' + $ref: '#/components/schemas/PaginatedTemplate' + default: + description: Error + /groups: + get: + operationId: listGroups + responses: + '200': + description: Group page + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/Group' + $ref: '#/components/schemas/PaginatedTemplate' + default: + description: Error + /profiles: + get: + operationId: listProfiles + responses: + '200': + description: Profile page + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/user-profile' + $ref: '#/components/schemas/PaginatedTemplate' + default: + description: Error +components: + schemas: + User: + type: object + required: [id, name] + properties: + id: + type: string + name: + type: string + Group: + type: object + required: [id, label] + properties: + id: + type: string + label: + type: string + user-profile: + type: object + required: [id, displayName] + properties: + id: + type: string + displayName: + type: string + PaginatedTemplate: + $id: https://example.com/schemas/PaginatedTemplate + $dynamicAnchor: itemType + type: object + required: [items] + properties: + items: + type: array + items: + $dynamicRef: '#itemType' diff --git a/tests/Kiota.Builder.IntegrationTests/inherited-component-binding.yaml b/tests/Kiota.Builder.IntegrationTests/inherited-component-binding.yaml new file mode 100644 index 0000000000..9684b768ae --- /dev/null +++ b/tests/Kiota.Builder.IntegrationTests/inherited-component-binding.yaml @@ -0,0 +1,60 @@ +openapi: 3.1.0 +info: + title: DynamicRef Inherited Component Binding API + version: 0.1.0 +paths: + /pages/users: + get: + operationId: listUserPages + responses: + '200': + description: User page + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/User' + $ref: '#/components/schemas/DerivedPage' + /pages/groups: + get: + operationId: listGroupPages + responses: + '200': + description: Group page + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/Group' + $ref: '#/components/schemas/DerivedPage' +components: + schemas: + User: + type: object + properties: + id: + type: string + Group: + type: object + properties: + id: + type: string + BasePage: + $dynamicAnchor: itemType + type: object + properties: + items: + type: array + items: + $dynamicRef: '#itemType' + DerivedPage: + allOf: + - $ref: '#/components/schemas/BasePage' + - type: object + properties: + page: + type: integer diff --git a/tests/Kiota.Builder.IntegrationTests/inherited-generic-binding.yaml b/tests/Kiota.Builder.IntegrationTests/inherited-generic-binding.yaml new file mode 100644 index 0000000000..a93ee87b28 --- /dev/null +++ b/tests/Kiota.Builder.IntegrationTests/inherited-generic-binding.yaml @@ -0,0 +1,72 @@ +openapi: 3.1.0 +info: + title: DynamicRef Inherited Generic Binding API + version: 0.1.0 +paths: + /inherited/users: + get: + operationId: listInheritedUsers + responses: + '200': + description: User page + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/User' + $ref: '#/components/schemas/InheritedTemplate' + default: + description: Error + /inherited/groups: + get: + operationId: listInheritedGroups + responses: + '200': + description: Group page + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/Group' + $ref: '#/components/schemas/InheritedTemplate' + default: + description: Error +components: + schemas: + User: + type: object + required: [id, name] + properties: + id: + type: string + name: + type: string + Group: + type: object + required: [id, label] + properties: + id: + type: string + label: + type: string + PageBase: + type: object + properties: + nextLink: + type: string + InheritedTemplate: + $id: https://example.com/schemas/InheritedTemplate + $dynamicAnchor: itemType + allOf: + - $ref: '#/components/schemas/PageBase' + - type: object + required: [items] + properties: + items: + type: array + items: + $dynamicRef: '#itemType' diff --git a/tests/Kiota.Builder.IntegrationTests/inline-binding.yaml b/tests/Kiota.Builder.IntegrationTests/inline-binding.yaml new file mode 100644 index 0000000000..14d9b5abde --- /dev/null +++ b/tests/Kiota.Builder.IntegrationTests/inline-binding.yaml @@ -0,0 +1,105 @@ +openapi: 3.1.0 +info: + title: DynamicRef Inline Binding API + version: 0.1.0 +paths: + /users: + get: + operationId: listUsers + responses: + '200': + description: User page + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + type: object + required: [id, name] + properties: + id: + type: string + name: + type: string + $ref: '#/components/schemas/PaginatedTemplate' + default: + description: Error + /groups: + get: + operationId: listGroups + responses: + '200': + description: Group page + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + type: object + required: [id, label] + properties: + id: + type: string + label: + type: string + $ref: '#/components/schemas/PaginatedTemplate' + default: + description: Error + /orgs/users: + get: + operationId: listOrgUsers + responses: + '200': + description: Organization user page + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + type: object + required: [id, orgRole] + properties: + id: + type: string + orgRole: + type: string + $ref: '#/components/schemas/PaginatedTemplate' + default: + description: Error + /teams/users: + get: + operationId: listTeamUsers + responses: + '200': + description: Team user page + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + type: object + required: [id, teamRole] + properties: + id: + type: string + teamRole: + type: string + $ref: '#/components/schemas/PaginatedTemplate' + default: + description: Error +components: + schemas: + PaginatedTemplate: + $id: https://example.com/schemas/PaginatedTemplate + $dynamicAnchor: itemType + type: object + required: [items] + properties: + items: + type: array + items: + $dynamicRef: '#itemType' diff --git a/tests/Kiota.Builder.IntegrationTests/mixed-anchor-binding.yaml b/tests/Kiota.Builder.IntegrationTests/mixed-anchor-binding.yaml new file mode 100644 index 0000000000..7e5cb0bb9a --- /dev/null +++ b/tests/Kiota.Builder.IntegrationTests/mixed-anchor-binding.yaml @@ -0,0 +1,47 @@ +openapi: 3.1.0 +info: + title: DynamicRef Mixed Anchor Binding API + version: 0.1.0 +paths: + /mixed: + get: + operationId: getMixed + responses: + '200': + description: Mixed ref + inline anchors + content: + application/json: + schema: + $defs: + aRef: + $dynamicAnchor: aRef + $ref: '#/components/schemas/User' + zInline: + $dynamicAnchor: zInline + type: object + required: [code] + properties: + code: + type: integer + $ref: '#/components/schemas/MixedTemplate' + default: + description: Error +components: + schemas: + User: + type: object + required: [id, name] + properties: + id: + type: string + name: + type: string + MixedTemplate: + $id: https://example.com/schemas/MixedTemplate + type: object + required: [inline, ref] + properties: + inline: + $dynamicRef: '#zInline' + ref: + $dynamicRef: '#aRef' diff --git a/tests/Kiota.Builder.IntegrationTests/multi-anchor-generic-binding.yaml b/tests/Kiota.Builder.IntegrationTests/multi-anchor-generic-binding.yaml new file mode 100644 index 0000000000..68a8fa650f --- /dev/null +++ b/tests/Kiota.Builder.IntegrationTests/multi-anchor-generic-binding.yaml @@ -0,0 +1,81 @@ +openapi: 3.1.0 +info: + title: DynamicRef Multi-Anchor Generic Binding API + version: 0.1.0 +paths: + /user-messages: + get: + operationId: listUserMessages + responses: + '200': + description: User message envelope + content: + application/json: + schema: + $defs: + dataType: + $dynamicAnchor: dataType + $ref: '#/components/schemas/User' + errorType: + $dynamicAnchor: errorType + $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/EnvelopeTemplate' + default: + description: Error + /group-messages: + get: + operationId: listGroupMessages + responses: + '200': + description: Group message envelope + content: + application/json: + schema: + $defs: + dataType: + $dynamicAnchor: dataType + $ref: '#/components/schemas/Group' + errorType: + $dynamicAnchor: errorType + $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/EnvelopeTemplate' + default: + description: Error +components: + schemas: + User: + type: object + required: [id, name] + properties: + id: + type: string + name: + type: string + Group: + type: object + required: [id, label] + properties: + id: + type: string + label: + type: string + ProblemDetails: + type: object + required: [code, detail] + properties: + code: + type: integer + detail: + type: string + EnvelopeTemplate: + $id: https://example.com/schemas/EnvelopeTemplate + $dynamicAnchor: dataType + type: object + required: [data, errors] + properties: + data: + $dynamicRef: '#dataType' + errors: + type: array + items: + $dynamicRef: '#errorType' diff --git a/tests/Kiota.Builder.IntegrationTests/multi-candidate-no-binding.yaml b/tests/Kiota.Builder.IntegrationTests/multi-candidate-no-binding.yaml new file mode 100644 index 0000000000..6a6e8bc956 --- /dev/null +++ b/tests/Kiota.Builder.IntegrationTests/multi-candidate-no-binding.yaml @@ -0,0 +1,53 @@ +openapi: 3.1.0 +info: + title: DynamicRef Multi-Candidate No Binding API + version: 0.1.0 +paths: + /container: + get: + operationId: getContainer + responses: + '200': + description: Container with an unbound dynamic ref + content: + application/json: + schema: + $ref: '#/components/schemas/Container' + default: + description: Error +components: + schemas: + Container: + type: object + required: [item] + properties: + item: + $dynamicRef: '#node' + TreeNode: + $id: https://example.com/schemas/TreeNode + $dynamicAnchor: node + type: object + required: [id] + properties: + id: + type: string + LeafA: + $id: https://example.com/schemas/LeafA + $dynamicAnchor: node + type: object + required: [id, value] + properties: + id: + type: string + value: + type: string + LeafB: + $id: https://example.com/schemas/LeafB + $dynamicAnchor: node + type: object + required: [id, count] + properties: + id: + type: string + count: + type: integer diff --git a/tests/Kiota.Builder.IntegrationTests/multi-error-inline-binding.yaml b/tests/Kiota.Builder.IntegrationTests/multi-error-inline-binding.yaml new file mode 100644 index 0000000000..a20b2532c6 --- /dev/null +++ b/tests/Kiota.Builder.IntegrationTests/multi-error-inline-binding.yaml @@ -0,0 +1,55 @@ +openapi: 3.1.0 +info: + title: DynamicRef Multi Error Inline Binding API + version: 0.1.0 +paths: + /shared/errors: + get: + operationId: getSharedErrors + responses: + '204': + description: No content + '400': + description: User error + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + type: object + required: [id, name] + properties: + id: + type: string + name: + type: string + $ref: '#/components/schemas/ErrorTemplate' + '404': + description: Group error + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + type: object + required: [id, label] + properties: + id: + type: string + label: + type: string + $ref: '#/components/schemas/ErrorTemplate' +components: + schemas: + ErrorTemplate: + $id: https://example.com/schemas/ErrorTemplate + $dynamicAnchor: itemType + type: object + required: [items] + properties: + items: + type: array + items: + $dynamicRef: '#itemType' diff --git a/tests/Kiota.Builder.IntegrationTests/multi-inline-binding.yaml b/tests/Kiota.Builder.IntegrationTests/multi-inline-binding.yaml new file mode 100644 index 0000000000..1e34eccf5b --- /dev/null +++ b/tests/Kiota.Builder.IntegrationTests/multi-inline-binding.yaml @@ -0,0 +1,50 @@ +openapi: 3.1.0 +info: + title: DynamicRef Multi Inline Anchor Binding API + version: 0.1.0 +paths: + /composite: + get: + operationId: getComposite + responses: + '200': + description: Composite with two inline dynamic anchors + content: + application/json: + schema: + $defs: + dataType: + $dynamicAnchor: dataType + type: object + required: [id, name] + properties: + id: + type: string + name: + type: string + errorType: + $dynamicAnchor: errorType + type: object + required: [code, detail] + properties: + code: + type: integer + detail: + type: string + $ref: '#/components/schemas/CompositeTemplate' + default: + description: Error +components: + schemas: + CompositeTemplate: + $id: https://example.com/schemas/CompositeTemplate + $dynamicAnchor: dataType + type: object + required: [data, errors] + properties: + data: + $dynamicRef: '#dataType' + errors: + type: array + items: + $dynamicRef: '#errorType' diff --git a/tests/Kiota.Builder.IntegrationTests/namespaced-binding.yaml b/tests/Kiota.Builder.IntegrationTests/namespaced-binding.yaml new file mode 100644 index 0000000000..457039253d --- /dev/null +++ b/tests/Kiota.Builder.IntegrationTests/namespaced-binding.yaml @@ -0,0 +1,53 @@ +openapi: 3.1.0 +info: + title: DynamicRef Namespaced Binding API + version: 0.1.0 +paths: + /v1/users: + get: + operationId: listV1Users + responses: + '200': + description: V1 user page + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/v1.User' + $ref: '#/components/schemas/PageTemplate' + /v2/users: + get: + operationId: listV2Users + responses: + '200': + description: V2 user page + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/v2.User' + $ref: '#/components/schemas/PageTemplate' +components: + schemas: + v1.User: + type: object + properties: + legacyId: + type: string + v2.User: + type: object + properties: + id: + type: integer + PageTemplate: + $dynamicAnchor: itemType + type: object + properties: + items: + type: array + items: + $dynamicRef: '#itemType' diff --git a/tests/Kiota.Builder.IntegrationTests/no-operation-id-inline-binding.yaml b/tests/Kiota.Builder.IntegrationTests/no-operation-id-inline-binding.yaml new file mode 100644 index 0000000000..4f9ce31a26 --- /dev/null +++ b/tests/Kiota.Builder.IntegrationTests/no-operation-id-inline-binding.yaml @@ -0,0 +1,50 @@ +openapi: 3.1.0 +info: + title: DynamicRef No Operation ID Inline Binding API + version: 0.1.0 +paths: + /shared: + get: + requestBody: + required: true + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + type: object + properties: + name: + type: string + $ref: '#/components/schemas/SharedTemplate' + responses: + '204': + description: No content + post: + requestBody: + required: true + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + type: object + properties: + count: + type: integer + $ref: '#/components/schemas/SharedTemplate' + responses: + '204': + description: No content +components: + schemas: + SharedTemplate: + $dynamicAnchor: itemType + type: object + properties: + items: + type: array + items: + $dynamicRef: '#itemType' diff --git a/tests/Kiota.Builder.IntegrationTests/recursive-generic-binding.yaml b/tests/Kiota.Builder.IntegrationTests/recursive-generic-binding.yaml new file mode 100644 index 0000000000..2cfed1a300 --- /dev/null +++ b/tests/Kiota.Builder.IntegrationTests/recursive-generic-binding.yaml @@ -0,0 +1,67 @@ +openapi: 3.1.0 +info: + title: DynamicRef Recursive + Generic Binding API + version: 0.1.0 +paths: + /trees: + get: + operationId: listTrees + responses: + '200': + description: Tree page + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/Branch' + $ref: '#/components/schemas/TreeTemplate' + default: + description: Error + /leaves: + get: + operationId: listLeaves + responses: + '200': + description: Leaf page + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/Leaf' + $ref: '#/components/schemas/TreeTemplate' + default: + description: Error +components: + schemas: + Branch: + type: object + required: [id, name] + properties: + id: + type: string + name: + type: string + Leaf: + type: object + required: [id, color] + properties: + id: + type: string + color: + type: string + TreeTemplate: + $id: https://example.com/schemas/TreeTemplate + $dynamicAnchor: self + type: object + required: [items, parent] + properties: + items: + type: array + items: + $dynamicRef: '#itemType' + parent: + $dynamicRef: '#self' diff --git a/tests/Kiota.Builder.IntegrationTests/request-body-generic-binding.yaml b/tests/Kiota.Builder.IntegrationTests/request-body-generic-binding.yaml new file mode 100644 index 0000000000..78f7dad2a8 --- /dev/null +++ b/tests/Kiota.Builder.IntegrationTests/request-body-generic-binding.yaml @@ -0,0 +1,71 @@ +openapi: 3.1.0 +info: + title: DynamicRef Request Body Generic Binding API + version: 0.1.0 +paths: + /users/search: + post: + operationId: searchUsers + requestBody: + required: true + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/User' + $ref: '#/components/schemas/SearchTemplate' + responses: + '200': + description: Search results + default: + description: Error + /groups/search: + post: + operationId: searchGroups + requestBody: + required: true + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/Group' + $ref: '#/components/schemas/SearchTemplate' + responses: + '200': + description: Search results + default: + description: Error +components: + schemas: + User: + type: object + required: [id, name] + properties: + id: + type: string + name: + type: string + Group: + type: object + required: [id, label] + properties: + id: + type: string + label: + type: string + SearchTemplate: + $id: https://example.com/schemas/SearchTemplate + $dynamicAnchor: itemType + type: object + required: [query, filters] + properties: + query: + type: string + filters: + type: array + items: + $dynamicRef: '#itemType' diff --git a/tests/Kiota.Builder.IntegrationTests/request-response-inline-binding.yaml b/tests/Kiota.Builder.IntegrationTests/request-response-inline-binding.yaml new file mode 100644 index 0000000000..388365e276 --- /dev/null +++ b/tests/Kiota.Builder.IntegrationTests/request-response-inline-binding.yaml @@ -0,0 +1,55 @@ +openapi: 3.1.0 +info: + title: DynamicRef Request Response Inline Binding API + version: 0.1.0 +paths: + /shared: + post: + operationId: submitShared + requestBody: + required: true + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + type: object + required: [id, name] + properties: + id: + type: string + name: + type: string + $ref: '#/components/schemas/SharedTemplate' + responses: + '200': + description: Shared response + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + type: object + required: [id, label] + properties: + id: + type: string + label: + type: string + $ref: '#/components/schemas/SharedTemplate' + default: + description: Error +components: + schemas: + SharedTemplate: + $id: https://example.com/schemas/SharedTemplate + $dynamicAnchor: itemType + type: object + required: [items] + properties: + items: + type: array + items: + $dynamicRef: '#itemType' diff --git a/tests/Kiota.Builder.IntegrationTests/unresolved-dynamicref.yaml b/tests/Kiota.Builder.IntegrationTests/unresolved-dynamicref.yaml new file mode 100644 index 0000000000..02e7bc6e15 --- /dev/null +++ b/tests/Kiota.Builder.IntegrationTests/unresolved-dynamicref.yaml @@ -0,0 +1,25 @@ +openapi: 3.1.0 +info: + title: DynamicRef Unresolved Anchor API + version: 0.1.0 +paths: + /items: + get: + operationId: listItems + responses: + '200': + description: Items with unresolved dynamic ref + content: + application/json: + schema: + $ref: '#/components/schemas/ItemContainer' + default: + description: Error +components: + schemas: + ItemContainer: + type: object + required: [value] + properties: + value: + $dynamicRef: '#nonexistent' diff --git a/tests/Kiota.Builder.Tests/CodeDOM/CodeTypeParameterTests.cs b/tests/Kiota.Builder.Tests/CodeDOM/CodeTypeParameterTests.cs new file mode 100644 index 0000000000..b97f6e9a97 --- /dev/null +++ b/tests/Kiota.Builder.Tests/CodeDOM/CodeTypeParameterTests.cs @@ -0,0 +1,49 @@ +using System; +using System.Linq; + +using Kiota.Builder.CodeDOM; + +using Xunit; + +namespace Kiota.Builder.Tests.CodeDOM; + +public class CodeTypeParameterTests +{ + [Fact] + public void AddTypeParameterMakesClassGeneric() + { + var root = CodeNamespace.InitRootNamespace(); + var codeClass = root.AddClass(new CodeClass { Name = "PaginatedTemplate" }).First(); + + Assert.False(codeClass.IsGeneric); + Assert.Empty(codeClass.TypeParameters); + + codeClass.StartBlock.AddTypeParameter(new CodeTypeParameter { Name = "TItemType" }); + + Assert.True(codeClass.IsGeneric); + var parameter = Assert.Single(codeClass.TypeParameters); + Assert.Equal("TItemType", parameter.Name); + } + + [Fact] + public void AddTypeParameterIsIdempotentByName() + { + var root = CodeNamespace.InitRootNamespace(); + var codeClass = root.AddClass(new CodeClass { Name = "PaginatedTemplate" }).First(); + + codeClass.StartBlock.AddTypeParameter(new CodeTypeParameter { Name = "T" }); + codeClass.StartBlock.AddTypeParameter(new CodeTypeParameter { Name = "T" }); + + Assert.Single(codeClass.TypeParameters); + } + + [Fact] + public void AddTypeParameterRejectsNullEntries() + { + var root = CodeNamespace.InitRootNamespace(); + var codeClass = root.AddClass(new CodeClass { Name = "PaginatedTemplate" }).First(); + + Assert.Throws(() => codeClass.StartBlock.AddTypeParameter(null!)); + Assert.Throws(() => codeClass.StartBlock.AddTypeParameter(new CodeTypeParameter { Name = "T" }, null!)); + } +} diff --git a/tests/Kiota.Builder.Tests/KiotaBuilderDynamicRefTests.cs b/tests/Kiota.Builder.Tests/KiotaBuilderDynamicRefTests.cs new file mode 100644 index 0000000000..e6d26a89cf --- /dev/null +++ b/tests/Kiota.Builder.Tests/KiotaBuilderDynamicRefTests.cs @@ -0,0 +1,307 @@ +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +using Kiota.Builder.CodeDOM; +using Kiota.Builder.Configuration; + +using Microsoft.Extensions.Logging; + +using Moq; + +using Xunit; + +namespace Kiota.Builder.Tests; + +public sealed partial class KiotaBuilderTests +{ + [Theory] + [InlineData("#category", "category")] + [InlineData("https://example.com/schema#itemType", "itemType")] + [InlineData("itemType", "itemType")] + [InlineData("", null)] + [InlineData(null, null)] + public void ExtractAnchorNameReturnsExpectedValue(string dynamicRef, string expected) + { + Assert.Equal(expected, KiotaBuilder.ExtractAnchorName(dynamicRef)); + } + + [Fact] + public async Task DynamicBindingPopulatesTypeParametersOnTemplateAsync() + { + var tempFilePath = Path.GetTempFileName(); + _tempFiles.Add(tempFilePath); + await File.WriteAllTextAsync(tempFilePath, """ +openapi: 3.1.0 +info: + title: T + version: 0.1.0 +servers: + - url: https://localhost +paths: + /users: + get: + operationId: listUsers + responses: + '200': + description: ok + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/User' + $ref: '#/components/schemas/PaginatedTemplate' + default: + description: err +components: + schemas: + User: + type: object + properties: + id: + type: string + PaginatedTemplate: + $id: https://example.com/schemas/PaginatedTemplate + $dynamicAnchor: itemType + type: object + properties: + items: + type: array + items: + $dynamicRef: '#itemType' +""", cancellationToken: TestContext.Current.CancellationToken); + var mockLogger = new Mock>(); + var builder = new KiotaBuilder(mockLogger.Object, new GenerationConfiguration { ClientClassName = "ApiSdk", OpenAPIFilePath = tempFilePath }, _httpClient); + await using var fs = new FileStream(tempFilePath, FileMode.Open); + var document = await builder.CreateOpenApiDocumentAsync(fs, cancellationToken: TestContext.Current.CancellationToken); + Assert.NotNull(document); + var node = builder.CreateUriSpace(document!); + builder.SetApiRootUrl(); + var codeModel = builder.CreateSourceModel(node); + + var modelsNS = codeModel.FindNamespaceByName("ApiSdk.models"); + Assert.NotNull(modelsNS); + var templateClass = modelsNS!.FindChildByName("PaginatedTemplateUser", true); + Assert.NotNull(templateClass); + Assert.True(templateClass!.IsGeneric); + var parameter = Assert.Single(templateClass.TypeParameters); + Assert.Equal("TItemType", parameter.Name); + } + + [Fact] + public async Task UsesDistinctNamesForUnboundDynamicRefUnionsAsync() + { + var tempFilePath = Path.GetTempFileName(); + _tempFiles.Add(tempFilePath); + await File.WriteAllTextAsync(tempFilePath, """ +openapi: 3.1.0 +info: + title: T + version: 0.1.0 +paths: + /envelope: + get: + operationId: getEnvelope + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope' +components: + schemas: + Envelope: + type: object + properties: + data: + $dynamicRef: '#dataType' + error: + $dynamicRef: '#errorType' + v1.StringModel: + $dynamicAnchor: dataType + type: object + v1.NumberModel: + $dynamicAnchor: dataType + type: object + ErrorA: + $dynamicAnchor: errorType + type: object + ErrorB: + $dynamicAnchor: errorType + type: object +""", cancellationToken: TestContext.Current.CancellationToken); + var mockLogger = new Mock>(); + var builder = new KiotaBuilder(mockLogger.Object, new GenerationConfiguration { ClientClassName = "ApiSdk", OpenAPIFilePath = tempFilePath }, _httpClient); + await using var fs = new FileStream(tempFilePath, FileMode.Open); + var document = await builder.CreateOpenApiDocumentAsync(fs, cancellationToken: TestContext.Current.CancellationToken); + var codeModel = builder.CreateSourceModel(builder.CreateUriSpace(document!)); + + var envelope = codeModel.FindNamespaceByName("ApiSdk.models")!.FindChildByName("Envelope", true)!; + var dataType = Assert.IsType(envelope.Properties.Single(x => x.Name == "data").Type); + var errorType = Assert.IsType(envelope.Properties.Single(x => x.Name == "error").Type); + + Assert.Equal("Envelope_data", dataType.Name); + Assert.Equal("Envelope_error", errorType.Name); + Assert.Equal(["NumberModel", "StringModel"], dataType.Types.Select(x => x.TypeDefinition!.Name).OrderBy(x => x)); + } + + [Fact] + public async Task InheritsBindingSuffixForDynamicRefsInAdditionalPropertiesAsync() + { + var tempFilePath = Path.GetTempFileName(); + _tempFiles.Add(tempFilePath); + await File.WriteAllTextAsync(tempFilePath, """ +openapi: 3.1.0 +info: + title: T + version: 0.1.0 +paths: + /users: + get: + operationId: listUsers + responses: + '200': + description: ok + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/User' + $ref: '#/components/schemas/PaginatedTemplate' +components: + schemas: + User: + type: object + PaginatedTemplate: + $dynamicAnchor: itemType + type: object + properties: + entries: + type: object + additionalProperties: + $dynamicRef: '#itemType' +""", cancellationToken: TestContext.Current.CancellationToken); + var mockLogger = new Mock>(); + var builder = new KiotaBuilder(mockLogger.Object, new GenerationConfiguration { ClientClassName = "ApiSdk", OpenAPIFilePath = tempFilePath }, _httpClient); + await using var fs = new FileStream(tempFilePath, FileMode.Open); + var document = await builder.CreateOpenApiDocumentAsync(fs, cancellationToken: TestContext.Current.CancellationToken); + var codeModel = builder.CreateSourceModel(builder.CreateUriSpace(document!)); + + var template = codeModel.FindNamespaceByName("ApiSdk.models")!.FindChildByName("PaginatedTemplateUser", true)!; + var entries = Assert.IsType(template.Properties.Single(x => x.Name == "entries").Type); + + Assert.Equal("PaginatedTemplateUser_entriesUser", entries.TypeDefinition!.Name); + } + + [Fact] + public async Task MultiCandidateDottedKeysLandInDistinctNamespacesAsync() + { + var tempFilePath = Path.GetTempFileName(); + _tempFiles.Add(tempFilePath); + await File.WriteAllTextAsync(tempFilePath, """ +openapi: 3.1.0 +info: + title: T + version: 0.1.0 +paths: + /container: + get: + operationId: getContainer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Container' +components: + schemas: + Container: + type: object + properties: + item: + $dynamicRef: '#node' + v1.UserModel: + $dynamicAnchor: node + type: object + properties: + version: { type: string } + v2.AdminModel: + $dynamicAnchor: node + type: object + properties: + version: { type: integer } +""", cancellationToken: TestContext.Current.CancellationToken); + var mockLogger = new Mock>(); + var builder = new KiotaBuilder(mockLogger.Object, new GenerationConfiguration { ClientClassName = "ApiSdk", OpenAPIFilePath = tempFilePath }, _httpClient); + await using var fs = new FileStream(tempFilePath, FileMode.Open); + var document = await builder.CreateOpenApiDocumentAsync(fs, cancellationToken: TestContext.Current.CancellationToken); + var codeModel = builder.CreateSourceModel(builder.CreateUriSpace(document!)); + + var v1Ns = codeModel.FindNamespaceByName("ApiSdk.models.v1"); + var v2Ns = codeModel.FindNamespaceByName("ApiSdk.models.v2"); + Assert.NotNull(v1Ns); + Assert.NotNull(v2Ns); + Assert.NotNull(v1Ns.FindChildByName("UserModel", true)); + Assert.NotNull(v2Ns.FindChildByName("AdminModel", true)); + + var container = codeModel.FindNamespaceByName("ApiSdk.models")!.FindChildByName("Container", true)!; + var itemType = Assert.IsType(container.Properties.Single(x => x.Name == "item").Type); + Assert.Equal(["ApiSdk.models.v1.UserModel", "ApiSdk.models.v2.AdminModel"], itemType.Types.Select(x => $"{((CodeNamespace)x.TypeDefinition!.Parent!).Name}.{x.TypeDefinition.Name}").OrderBy(x => x)); + } + + [Fact] + public async Task ArrayRootBindingSuffixAppliesToTemplateItemsAsync() + { + var tempFilePath = Path.GetTempFileName(); + _tempFiles.Add(tempFilePath); + await File.WriteAllTextAsync(tempFilePath, """ +openapi: 3.1.0 +info: + title: T + version: 0.1.0 +paths: + /users: + get: + operationId: listUsers + responses: + '200': + description: ok + content: + application/json: + schema: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/User' + type: array + items: + $ref: '#/components/schemas/PaginatedTemplate' +components: + schemas: + User: + type: object + PaginatedTemplate: + type: object + properties: + items: + type: array + items: + $dynamicRef: '#itemType' +""", cancellationToken: TestContext.Current.CancellationToken); + var mockLogger = new Mock>(); + var builder = new KiotaBuilder(mockLogger.Object, new GenerationConfiguration { ClientClassName = "ApiSdk", OpenAPIFilePath = tempFilePath }, _httpClient); + await using var fs = new FileStream(tempFilePath, FileMode.Open); + var document = await builder.CreateOpenApiDocumentAsync(fs, cancellationToken: TestContext.Current.CancellationToken); + var codeModel = builder.CreateSourceModel(builder.CreateUriSpace(document!)); + + var modelsNamespace = codeModel.FindNamespaceByName("ApiSdk.models")!; + Assert.NotNull(modelsNamespace.FindChildByName("PaginatedTemplateUser", true)); + Assert.Null(modelsNamespace.FindChildByName("PaginatedTemplate", true)); + } +}