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/KiotaBuilder.cs b/src/Kiota.Builder/KiotaBuilder.cs index 1045c042e0..296dae2810 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,38 @@ 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); 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 +1816,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 @@ -1973,7 +1982,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 +2005,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,7 +2035,7 @@ 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() && @@ -2053,23 +2063,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); } } @@ -2087,29 +2111,87 @@ 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 static string? TryGetDynamicBindingSuffix(IOpenApiSchema schema, OpenApiUrlTreeNode currentNode, OpenApiOperation? operation = default, IOpenApiResponse? response = default, bool isRequestBody = false, string suffixForInlineSchema = "") + { + if (schema.Definitions is null || schema.Definitions.Count == 0) + return ContainsDynamicReference(schema) ? GetActiveDynamicBindingSuffix() : 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 ContainsDynamicReference(schema) ? GetActiveDynamicBindingSuffix() : 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 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.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 typeNameForInlineSchema, bool isRequestBody) { - 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 shouldPush = schema.Definitions?.Values.Any(static d => !string.IsNullOrEmpty(d.DynamicAnchor)) == true; + if (shouldPush) _dynamicScope.Value!.Push(new(schema, null)); + 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..ec6e0fd0b5 100644 --- a/tests/Kiota.Builder.IntegrationTests/GenerateSample.cs +++ b/tests/Kiota.Builder.IntegrationTests/GenerateSample.cs @@ -436,4 +436,408 @@ 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); + } } diff --git a/tests/Kiota.Builder.IntegrationTests/Kiota.Builder.IntegrationTests.csproj b/tests/Kiota.Builder.IntegrationTests/Kiota.Builder.IntegrationTests.csproj index 896f584824..7239fbd268 100644 --- a/tests/Kiota.Builder.IntegrationTests/Kiota.Builder.IntegrationTests.csproj +++ b/tests/Kiota.Builder.IntegrationTests/Kiota.Builder.IntegrationTests.csproj @@ -69,9 +69,54 @@ 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-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/KiotaBuilderDynamicRefTests.cs b/tests/Kiota.Builder.Tests/KiotaBuilderDynamicRefTests.cs new file mode 100644 index 0000000000..e2649ab7ff --- /dev/null +++ b/tests/Kiota.Builder.Tests/KiotaBuilderDynamicRefTests.cs @@ -0,0 +1,17 @@ +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)); + } +}