diff --git a/docs/prd/prd-04-deepen-interface-generator.md b/docs/prd/prd-04-deepen-interface-generator.md new file mode 100644 index 000000000..c0eaca33c --- /dev/null +++ b/docs/prd/prd-04-deepen-interface-generator.md @@ -0,0 +1,100 @@ +# PRD: Deepen InterfaceGenerator + +## Problem Statement + +The InterfaceGenerator module is 450 lines handling interface declaration, method generation (with all the header/attribute logic), return type derivation, dynamic querystring parameter class generation, obsolete attribute handling, and parameter aggregation. The `IInterfacePartitioning` interface already exists and is a good seam for multi-interface mode, but `InterfaceGenerator` itself is shallow — it's nearly as complex as the code it generates. Method generation has many conditional paths (headers, multipart, auth, dynamic querystring, cancellation, Apizr options) that are all entangled in the `GenerateMethod` method. + +## Solution + +Extract `IMethodGenerator` as the seam between interface assembly and method generation. Each method generation concern (signature, attributes, return type) becomes its own adapter. The generator delegates to these adapters. + +The deepened module structure: + +``` +IMethodGenerator (new seam) + ├── GenerateMethodSignature(operation, interfaceName, partitioning) → string + ├── GenerateMethodAttributes(operation, interfaceName, partitioning) → string[] + └── GenerateReturnType(operation) → string + +InterfaceGenerator (existing type, deepened) + ├── Generate(partitioning, operations) → GeneratedCode + └── Delegates to IMethodGenerator for method-level concerns + +IMethodSignatureGenerator (new module) + ├── Generate(operation, interfaceName, partitioning, knownIdentifiers) → (methodName, parameters) + └── Handles: method name deduplication, parameter aggregation, optional parameter reordering + +IMethodAttributeGenerator (new module) + ├── GenerateHeaders(operation, document, settings) → string[] + ├── GenerateMultipartAttribute(operationModel) → string? + ├── GenerateObsoleteAttribute(operation) → string? + └── Handles: Accept headers, Content-Type headers, Authorization headers, [Multipart], [Obsolete] + +IReturnTypeGenerator (new module) + ├── Generate(operation, settings) → string + ├── GenerateForApiResponse(operation) → string? + ├── GenerateForFileStream(operation) → string? + └── Handles: return type derivation, IApiResponse wrapping, Task/IObservable selection +``` + +The existing `InterfaceGenerator.Generate()` method becomes a composition of method generators: + +``` +InterfaceGenerator.Generate(partitioning) + → for each operation group: + → for each operation: + → signature = methodSignatureGenerator.Generate(...) + → attributes = methodAttributeGenerator.Generate(...) + → returnType = returnTypeGenerator.Generate(...) + → emit interface with signature, attributes, return type +``` + +## User Stories + +1. As a maintainer fixing return type derivation, I want return type logic isolated in IReturnTypeGenerator, so that I don't need to understand method signature generation to fix it. +2. As a tester, I want to test return type derivation independently of method signature generation, so that I can verify return types for all operation combinations. +3. As a developer, I want the IMethodGenerator interface to be small and stable, so that new method generation concerns can be added without changing existing code. +4. As a maintainer fixing header generation, I want header logic isolated in IMethodAttributeGenerator, so that I don't risk breaking parameter aggregation. +5. As a tester, I want to test header generation for all header combinations (Accept, Content-Type, Authorization), so that I can verify all header paths. +6. As a developer, I want the IReturnTypeGenerator interface to expose return type derivation clearly, so that I can understand what determines a method's return type. +7. As a tester, I want to test dynamic querystring parameter generation independently, so that I can verify the parameter class generation. +8. As a maintainer, I want the deletion test to pass for each generator, so that I can be confident each generator concentrates its complexity. +9. As a developer, I want the InterfaceGenerator public API to remain unchanged, so that GeneratorPipeline consumers are unaffected. +10. As a tester, I want each generator to have its own test class, so that I can verify behavior in isolation. + +## Implementation Decisions + +- **InterfaceGenerator remains the public type:** The class name and public API surface remain unchanged. The deepening is internal. +- **IMethodGenerator interface:** A small interface that generates method-level code. The interface has three methods: GenerateMethodSignature, GenerateMethodAttributes, and GenerateReturnType. +- **IMethodSignatureGenerator:** Handles method name deduplication (using IdentifierUtils.Counted), parameter aggregation (delegating to ParameterAggregator), and optional parameter reordering (delegating to OptionalParameterReorderer). +- **IMethodAttributeGenerator:** Handles all method-level attributes: Accept headers, Content-Type headers, Authorization headers, [Multipart], [Obsolete]. Each attribute type is a separate method. +- **IReturnTypeGenerator:** Handles return type derivation: operation response type, IApiResponse wrapping, Task/IObservable selection, file stream detection, response type override. +- **Dynamic querystring parameter generation:** The existing DynamicQuerystringParameterBuilder logic is preserved. It is called by IMethodSignatureGenerator when dynamic querystring mode is enabled. +- **No new dependencies:** All deepening uses existing types. No new libraries introduced. +- **Partitioning interface preserved:** The existing `IInterfacePartitioning` interface is the correct seam for multi-interface mode. It is not changed. + +## Testing Decisions + +- **What makes a good test:** Only test external behavior (generated method code), not internal generator structure. Each generator's test surface is defined by its interface. +- **Return type tests:** Tests verify return type derivation for all operation combinations: success codes, file streams, IApiResponse wrapping, Task/IObservable selection, response type override. +- **Header generation tests:** Tests verify Accept header generation, Content-Type header generation, Authorization header generation, and header combinations. Tests that ignored headers are excluded. +- **Method signature tests:** Tests verify method name deduplication, parameter aggregation, and optional parameter reordering. Tests that dynamic querystring parameter generation produces correct output. +- **Attribute generation tests:** Tests verify [Multipart] attribute generation for multipart/form-data operations. Tests that [Obsolete] attribute is generated for deprecated operations. +- **Integration tests:** Existing scenario tests under `Refitter.Tests.Scenarios` remain valid. Tests that use `RefitGenerator.CreateAsync(...).Generate()` continue to work. The generated output must remain identical. +- **Build verification:** `dotnet build -c Release src/Refitter.slnx` and `dotnet test --solution src/Refitter.slnx -c Release` must pass before commit. +- **Prior art:** Existing tests for `InterfaceGenerator` follow the pattern: OpenAPI spec → generate → assert string patterns → `BuildHelper.BuildCSharp()`. These tests remain valid after deepening. + +## Out of Scope + +- **Interface naming logic:** The interface naming logic (using IInterfacePartitioning) is preserved in InterfaceGenerator. It is not changed. +- **Interface documentation:** The interface documentation logic (delegating to XmlDocumentationGenerator) is preserved in InterfaceGenerator. It is not changed. +- **Parameter extraction:** The existing IParameterExtractor and IParameterTypeExtractor interfaces are preserved. They are not changed. +- **Adding new method attributes:** This PRD deepens existing attribute generation only. Adding new attributes is a separate effort. +- **Performance optimization:** This PRD does not address generation performance. + +## Further Notes + +- **Risk:** Low. The public API is preserved. The deepening is internal refactoring. +- **Leverage:** 450 lines → 4 modules (1 interface + 3 generators). Each generator is independently testable. +- **Deletion test:** Delete IReturnTypeGenerator → return type derivation vanishes. Delete IMethodAttributeGenerator → attribute generation vanishes. Each generator concentrates complexity that would otherwise reappear across callers. +- **The interface is the test surface:** After deepening, each generator's interface defines what tests must cover. A deep interface means fewer tests know about implementation details. diff --git a/src/Refitter.Core/IMethodAttributeGenerator.cs b/src/Refitter.Core/IMethodAttributeGenerator.cs new file mode 100644 index 000000000..02bbc2843 --- /dev/null +++ b/src/Refitter.Core/IMethodAttributeGenerator.cs @@ -0,0 +1,9 @@ +using NSwag; +using NSwag.CodeGeneration.CSharp.Models; + +namespace Refitter.Core; + +internal interface IMethodAttributeGenerator +{ + string[] Generate(OpenApiOperation operation, CSharpOperationModel operationModel); +} diff --git a/src/Refitter.Core/IMethodGenerator.cs b/src/Refitter.Core/IMethodGenerator.cs new file mode 100644 index 000000000..cc7ef9278 --- /dev/null +++ b/src/Refitter.Core/IMethodGenerator.cs @@ -0,0 +1,18 @@ +using NSwag; +using NSwag.CodeGeneration.CSharp.Models; + +namespace Refitter.Core; + +internal interface IMethodGenerator +{ + string GenerateReturnType(OpenApiOperation operation); + + bool IsApiResponseType(string typeName); + + string[] GenerateMethodAttributes(OpenApiOperation operation, CSharpOperationModel operationModel); + + (string ParametersString, IReadOnlyList Parameters, string? DynamicQuerystringParameters) GenerateMethodSignature( + CSharpOperationModel operationModel, + OpenApiOperation operation, + string dynamicQuerystringParameterType); +} diff --git a/src/Refitter.Core/IMethodSignatureGenerator.cs b/src/Refitter.Core/IMethodSignatureGenerator.cs new file mode 100644 index 000000000..d34f8581f --- /dev/null +++ b/src/Refitter.Core/IMethodSignatureGenerator.cs @@ -0,0 +1,12 @@ +using NSwag; +using NSwag.CodeGeneration.CSharp.Models; + +namespace Refitter.Core; + +internal interface IMethodSignatureGenerator +{ + (string ParametersString, IReadOnlyList Parameters, string? DynamicQuerystringParameters) Generate( + CSharpOperationModel operationModel, + OpenApiOperation operation, + string dynamicQuerystringParameterType); +} diff --git a/src/Refitter.Core/IReturnTypeGenerator.cs b/src/Refitter.Core/IReturnTypeGenerator.cs new file mode 100644 index 000000000..6db70cc83 --- /dev/null +++ b/src/Refitter.Core/IReturnTypeGenerator.cs @@ -0,0 +1,12 @@ +using NSwag; + +namespace Refitter.Core; + +internal interface IReturnTypeGenerator +{ + string Generate(OpenApiOperation operation); + + bool IsApiResponseType(string typeName); + + bool IsFileStreamResponse(OpenApiOperation operation); +} diff --git a/src/Refitter.Core/InterfaceGenerator.cs b/src/Refitter.Core/InterfaceGenerator.cs index 155291736..e5516e68d 100644 --- a/src/Refitter.Core/InterfaceGenerator.cs +++ b/src/Refitter.Core/InterfaceGenerator.cs @@ -1,5 +1,4 @@ using System.Text; -using System.Text.RegularExpressions; using NSwag; using NSwag.CodeGeneration.CSharp.Models; @@ -8,14 +7,12 @@ namespace Refitter.Core; internal class InterfaceGenerator { private const string Separator = " "; - private static readonly Regex HttpResponseMessageTypeRegex = new("(Task|IObservable)", RegexOptions.Compiled, TimeSpan.FromSeconds(1)); - private static readonly Regex ApiResponseTypeRegex = new("(Task|IObservable)<(I)?ApiResponse(<[\\w<>]+>)?>", RegexOptions.Compiled, TimeSpan.FromSeconds(1)); private readonly RefitGeneratorSettings settings; private readonly OpenApiDocument document; private readonly CustomCSharpClientGenerator generator; private readonly XmlDocumentationGenerator docGenerator; - private readonly IParameterExtractor parameterExtractor; + private readonly IMethodGenerator methodGenerator; internal InterfaceGenerator( RefitGeneratorSettings settings, @@ -23,12 +20,26 @@ internal InterfaceGenerator( CustomCSharpClientGenerator generator, XmlDocumentationGenerator docGenerator, IParameterExtractor? parameterExtractor = null) + : this(settings, document, generator, docGenerator, + new MethodGenerator( + new ReturnTypeGenerator(settings, generator), + new MethodAttributeGenerator(settings, document), + new MethodSignatureGenerator(settings, parameterExtractor ?? new ParameterAggregator()))) + { + } + + internal InterfaceGenerator( + RefitGeneratorSettings settings, + OpenApiDocument document, + CustomCSharpClientGenerator generator, + XmlDocumentationGenerator docGenerator, + IMethodGenerator methodGenerator) { this.settings = settings; this.document = document; this.generator = generator; this.docGenerator = docGenerator; - this.parameterExtractor = parameterExtractor ?? new ParameterAggregator(); + this.methodGenerator = methodGenerator; generator.BaseSettings.OperationNameGenerator = new OperationNameGenerator(document, settings); } @@ -158,7 +169,7 @@ private IEnumerable GenerateMultipleInterface( return (string.Empty, string.Empty); } - var returnType = GetTypeName(operation); + var returnType = methodGenerator.GenerateReturnType(operation); var verb = op.Verb.CapitalizeFirstCharacter(); var baseOperationName = GetBaseOperationName(op); var rawMethodName = partitioning.GetMethodName(op, interfaceName, baseOperationName); @@ -167,22 +178,24 @@ private IEnumerable GenerateMultipleInterface( var dynamicQuerystringParameterType = partitioning.GetDynamicQuerystringParameterType(interfaceName, methodName); var operationModel = generator.CreateOperationModel(operation); - var parameters = parameterExtractor.ExtractParameters(operationModel, operation, settings, dynamicQuerystringParameterType, out var operationDynamicQuerystringParameters).ToList(); + + var (parametersString, parameters, operationDynamicQuerystringParameters) = + methodGenerator.GenerateMethodSignature(operationModel, operation, dynamicQuerystringParameterType); var hasDynamicQuerystringParameter = !string.IsNullOrWhiteSpace(operationDynamicQuerystringParameters); - var parametersString = string.Join(", ", parameters); var hasApizrRequestOptionsParameter = settings.ApizrSettings?.WithRequestOptions == true; var hasCancellationToken = settings.UseCancellationTokens && !hasApizrRequestOptionsParameter; - var isApiResponseType = IsApiResponseType(returnType); + var isApiResponseType = methodGenerator.IsApiResponseType(returnType); if (settings.GenerateXmlDocCodeComments) { docGenerator.AppendMethodDocumentation(operationModel, isApiResponseType, hasDynamicQuerystringParameter, hasApizrRequestOptionsParameter, hasCancellationToken, code); } - GenerateObsoleteAttribute(operation, code); - GenerateForMultipartFormData(operationModel, code); - GenerateHeaders(operation, operationModel, code); + foreach (var attribute in methodGenerator.GenerateMethodAttributes(operation, operationModel)) + { + code.AppendLine($"{Separator}{Separator}{attribute}"); + } code.AppendLine($"{Separator}{Separator}[{verb}(\"{op.Path}\")]") .AppendLine($"{Separator}{Separator}{returnType} {methodName}({parametersString});") @@ -195,14 +208,17 @@ private IEnumerable GenerateMultipleInterface( docGenerator.AppendMethodDocumentation(operationModel, isApiResponseType, false, hasApizrRequestOptionsParameter, hasCancellationToken, code); } - GenerateObsoleteAttribute(operation, code); - GenerateForMultipartFormData(operationModel, code); - GenerateHeaders(operation, operationModel, code); + foreach (var attribute in methodGenerator.GenerateMethodAttributes(operation, operationModel)) + { + code.AppendLine($"{Separator}{Separator}{attribute}"); + } - parametersString = string.Join(", ", parameters.Where(parameter => !parameter.Contains("?"))); + var nonOptionalParametersString = string.Join( + ", ", + parameters.Where(parameter => !parameter.Contains("?"))); code.AppendLine($"{Separator}{Separator}[{verb}(\"{op.Path}\")]") - .AppendLine($"{Separator}{Separator}{returnType} {methodName}({parametersString});") + .AppendLine($"{Separator}{Separator}{returnType} {methodName}({nonOptionalParametersString});") .AppendLine(); } @@ -217,219 +233,6 @@ private string GetBaseOperationName(OpenApiOperationInfo op) .GetOperationName(document, op.Path, op.Verb, op.Operation); } - private static string TrimImportedNamespaces(string returnTypeParameter) => - returnTypeParameter.StartsWith("System.Collections.Generic.", StringComparison.OrdinalIgnoreCase) - ? returnTypeParameter.Replace("System.Collections.Generic.", string.Empty) - : returnTypeParameter; - - private string GetTypeName(OpenApiOperation operation) - { - if (settings.ResponseTypeOverride.TryGetValue(operation.OperationId, out var type)) - { - return type is null or "void" - ? GetAsyncOperationType(true) - : $"{GetAsyncOperationType(false)}<{TrimImportedNamespaces(type)}>"; - } - - if (IsFileStreamResponse(operation)) - { - return $"{GetAsyncOperationType(false)}"; - } - - var successCodes = new[] { "200", "201", "203", "206" }; - var returnTypeParameter = successCodes - .Where(operation.Responses.ContainsKey) - .Select(code => GetTypeName(code, operation)) - .FirstOrDefault(); - - if (returnTypeParameter == null && operation.Responses.ContainsKey("2XX")) - { - returnTypeParameter = GetTypeName("2XX", operation); - } - - if (returnTypeParameter == null && operation.Responses.ContainsKey("default")) - { - returnTypeParameter = GetTypeName("default", operation); - } - - return GetReturnType(returnTypeParameter); - } - - private static bool IsFileStreamResponse(OpenApiOperation operation) - { - var successCodes = new[] { "200", "201", "203", "206", "2XX" }; - - foreach (var code in successCodes) - { - if (!operation.Responses.TryGetValue(code, out var apiResponse)) - continue; - - var response = apiResponse.ActualResponse; - - if (response.Content?.Any() != true) - continue; - - foreach (var contentEntry in response.Content) - { - if (IsFileContentType(contentEntry.Key)) - { - var schema = contentEntry.Value?.Schema; - if (schema?.Format == "binary" || schema?.Type == NJsonSchema.JsonObjectType.File) - return true; - } - } - } - - return false; - } - - private static bool IsFileContentType(string contentType) - { - return - contentType.StartsWith("application/octet-stream", StringComparison.OrdinalIgnoreCase) || - contentType.StartsWith("application/pdf", StringComparison.OrdinalIgnoreCase) || - contentType.StartsWith("application/vnd", StringComparison.OrdinalIgnoreCase) || - contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase) || - contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase) || - contentType.StartsWith("audio/", StringComparison.OrdinalIgnoreCase) || - contentType.StartsWith("application/zip", StringComparison.OrdinalIgnoreCase) || - contentType.StartsWith("application/gzip", StringComparison.OrdinalIgnoreCase) || - (contentType.StartsWith("application/x-", StringComparison.OrdinalIgnoreCase) && - !contentType.StartsWith("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase)); - } - - private string GetTypeName(string code, OpenApiOperation operation) - { - var schema = operation.Responses[code].ActualResponse.Schema; - var typeName = generator.GetTypeName(schema, false, null); - - if (!string.IsNullOrWhiteSpace(settings.CodeGeneratorSettings?.ArrayType) && - schema?.Type == NJsonSchema.JsonObjectType.Array) - { - typeName = typeName - .Replace("ICollection", settings.CodeGeneratorSettings!.ArrayType) - .Replace("IEnumerable", settings.CodeGeneratorSettings!.ArrayType); - } - - return typeName; - } - - private string GetReturnType(string? returnTypeParameter) - { - return returnTypeParameter is null or "void" - ? GetDefaultReturnType() - : GetConfiguredReturnType(returnTypeParameter); - } - - private string GetDefaultReturnType() - { - var asyncType = GetAsyncOperationType(true); - return settings.ReturnIApiResponse - ? $"{asyncType}" - : asyncType; - } - - private string GetConfiguredReturnType(string returnTypeParameter) - { - var asyncType = GetAsyncOperationType(false); - return settings.ReturnIApiResponse - ? $"{asyncType}>" - : $"{asyncType}<{TrimImportedNamespaces(returnTypeParameter)}>"; - } - - private string GetAsyncOperationType(bool withVoidReturnType) - { - var type = withVoidReturnType ? "" : string.Empty; - return settings.ReturnIObservable - ? "IObservable" + type - : "Task"; - } - - private static bool IsApiResponseType(string typeName) - { - return HttpResponseMessageTypeRegex.IsMatch(typeName) || ApiResponseTypeRegex.IsMatch(typeName); - } - - private static void GenerateObsoleteAttribute(OpenApiOperation operation, StringBuilder code) - { - if (operation.IsDeprecated) - { - code.AppendLine($"{Separator}{Separator}[System.Obsolete]"); - } - } - - private static void GenerateForMultipartFormData(CSharpOperationModel operationModel, StringBuilder code) - { - if (operationModel.Consumes.Contains("multipart/form-data")) - { - code.AppendLine($"{Separator}{Separator}[Multipart]"); - } - } - - private void GenerateHeaders( - OpenApiOperation operation, - CSharpOperationModel operationModel, - StringBuilder code) - { - var headers = new List(); - - if (settings.AddAcceptHeaders && document.SchemaType is >= NJsonSchema.SchemaType.OpenApi3) - { - var uniqueContentTypes = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var response in operation.Responses.Values) - { - if (response.Content == null) - continue; - - foreach (var contentType in response.Content.Keys) - { - uniqueContentTypes.Add(contentType); - } - } - - if (uniqueContentTypes.Any()) - { - headers.Add($"\"Accept: {string.Join(", ", uniqueContentTypes)}\""); - } - } - - if (settings.AddContentTypeHeaders && document.SchemaType is >= NJsonSchema.SchemaType.OpenApi3) - { - var uniqueContentTypes = operation.RequestBody?.Content.Keys ?? Array.Empty(); - var contentType = - uniqueContentTypes.FirstOrDefault(c => c.Equals("application/json", StringComparison.OrdinalIgnoreCase)) ?? - uniqueContentTypes.FirstOrDefault(); - - if (!string.IsNullOrWhiteSpace(contentType) && !operationModel.Consumes.Contains("multipart/form-data")) - { - headers.Add($"\"Content-Type: {contentType}\""); - } - } - - if (settings.AuthenticationHeaderStyle == AuthenticationHeaderStyle.Method) - { - foreach (var securitySchemeName in operationModel.Security.SelectMany(x => x.Keys)) - { - if ((settings.SecurityScheme != null && securitySchemeName != settings.SecurityScheme) || - !document.SecurityDefinitions.TryGetValue(securitySchemeName, out var securityScheme)) - { - continue; - } - - if (securityScheme is { Type: OpenApiSecuritySchemeType.Http, Scheme: var scheme } - && string.Equals(scheme, "bearer", StringComparison.OrdinalIgnoreCase)) - { - headers.Add("\"Authorization: Bearer\""); - } - } - } - - if (headers.Any()) - { - code.AppendLine($"{Separator}{Separator}[Headers({string.Join(", ", headers)})]"); - } - } - private string GenerateInterfaceDeclaration(string interfaceName, bool isSingleInterface) { var inheritance = isSingleInterface && settings.GenerateDisposableClients diff --git a/src/Refitter.Core/MethodAttributeGenerator.cs b/src/Refitter.Core/MethodAttributeGenerator.cs new file mode 100644 index 000000000..7469f7d90 --- /dev/null +++ b/src/Refitter.Core/MethodAttributeGenerator.cs @@ -0,0 +1,93 @@ +using NSwag; +using NSwag.CodeGeneration.CSharp.Models; + +namespace Refitter.Core; + +internal class MethodAttributeGenerator : IMethodAttributeGenerator +{ + private readonly RefitGeneratorSettings settings; + private readonly OpenApiDocument document; + + public MethodAttributeGenerator( + RefitGeneratorSettings settings, + OpenApiDocument document) + { + this.settings = settings; + this.document = document; + } + + public string[] Generate(OpenApiOperation operation, CSharpOperationModel operationModel) + { + var attributes = new List(); + + if (operation.IsDeprecated) + { + attributes.Add("[System.Obsolete]"); + } + + if (operationModel.Consumes.Contains("multipart/form-data")) + { + attributes.Add("[Multipart]"); + } + + var headers = new List(); + + if (settings.AddAcceptHeaders && document.SchemaType is >= NJsonSchema.SchemaType.OpenApi3) + { + var uniqueContentTypes = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var response in operation.Responses.Values) + { + if (response.Content == null) + continue; + + foreach (var contentType in response.Content.Keys) + { + uniqueContentTypes.Add(contentType); + } + } + + if (uniqueContentTypes.Any()) + { + headers.Add($"\"Accept: {string.Join(", ", uniqueContentTypes)}\""); + } + } + + if (settings.AddContentTypeHeaders && document.SchemaType is >= NJsonSchema.SchemaType.OpenApi3) + { + var uniqueContentTypes = operation.RequestBody?.Content.Keys ?? Array.Empty(); + var contentType = + uniqueContentTypes.FirstOrDefault(c => c.Equals("application/json", StringComparison.OrdinalIgnoreCase)) ?? + uniqueContentTypes.FirstOrDefault(); + + if (!string.IsNullOrWhiteSpace(contentType) && !operationModel.Consumes.Contains("multipart/form-data")) + { + headers.Add($"\"Content-Type: {contentType}\""); + } + } + + if (settings.AuthenticationHeaderStyle == AuthenticationHeaderStyle.Method) + { + foreach (var securitySchemeName in operationModel.Security.SelectMany(x => x.Keys)) + { + if ((settings.SecurityScheme != null && securitySchemeName != settings.SecurityScheme) || + !document.SecurityDefinitions.TryGetValue(securitySchemeName, out var securityScheme)) + { + continue; + } + + if (securityScheme is { Type: OpenApiSecuritySchemeType.Http, Scheme: var scheme } + && string.Equals(scheme, "bearer", StringComparison.OrdinalIgnoreCase)) + { + headers.Add("\"Authorization: Bearer\""); + } + } + } + + if (headers.Any()) + { + attributes.Add($"[Headers({string.Join(", ", headers)})]"); + } + + return attributes.ToArray(); + } +} diff --git a/src/Refitter.Core/MethodGenerator.cs b/src/Refitter.Core/MethodGenerator.cs new file mode 100644 index 000000000..076635009 --- /dev/null +++ b/src/Refitter.Core/MethodGenerator.cs @@ -0,0 +1,36 @@ +using NSwag; +using NSwag.CodeGeneration.CSharp.Models; + +namespace Refitter.Core; + +internal class MethodGenerator : IMethodGenerator +{ + private readonly IReturnTypeGenerator returnTypeGenerator; + private readonly IMethodAttributeGenerator methodAttributeGenerator; + private readonly IMethodSignatureGenerator methodSignatureGenerator; + + public MethodGenerator( + IReturnTypeGenerator returnTypeGenerator, + IMethodAttributeGenerator methodAttributeGenerator, + IMethodSignatureGenerator methodSignatureGenerator) + { + this.returnTypeGenerator = returnTypeGenerator; + this.methodAttributeGenerator = methodAttributeGenerator; + this.methodSignatureGenerator = methodSignatureGenerator; + } + + public string GenerateReturnType(OpenApiOperation operation) => + returnTypeGenerator.Generate(operation); + + public bool IsApiResponseType(string typeName) => + returnTypeGenerator.IsApiResponseType(typeName); + + public string[] GenerateMethodAttributes(OpenApiOperation operation, CSharpOperationModel operationModel) => + methodAttributeGenerator.Generate(operation, operationModel); + + public (string ParametersString, IReadOnlyList Parameters, string? DynamicQuerystringParameters) GenerateMethodSignature( + CSharpOperationModel operationModel, + OpenApiOperation operation, + string dynamicQuerystringParameterType) => + methodSignatureGenerator.Generate(operationModel, operation, dynamicQuerystringParameterType); +} diff --git a/src/Refitter.Core/MethodSignatureGenerator.cs b/src/Refitter.Core/MethodSignatureGenerator.cs new file mode 100644 index 000000000..70235c82f --- /dev/null +++ b/src/Refitter.Core/MethodSignatureGenerator.cs @@ -0,0 +1,35 @@ +using NSwag; +using NSwag.CodeGeneration.CSharp.Models; + +namespace Refitter.Core; + +internal class MethodSignatureGenerator : IMethodSignatureGenerator +{ + private readonly RefitGeneratorSettings settings; + private readonly IParameterExtractor parameterExtractor; + + public MethodSignatureGenerator( + RefitGeneratorSettings settings, + IParameterExtractor? parameterExtractor = null) + { + this.settings = settings; + this.parameterExtractor = parameterExtractor ?? new ParameterAggregator(); + } + + public (string ParametersString, IReadOnlyList Parameters, string? DynamicQuerystringParameters) Generate( + CSharpOperationModel operationModel, + OpenApiOperation operation, + string dynamicQuerystringParameterType) + { + var parameters = parameterExtractor.ExtractParameters( + operationModel, + operation, + settings, + dynamicQuerystringParameterType, + out var operationDynamicQuerystringParameters) + .ToList(); + + var parametersString = string.Join(", ", parameters); + return (parametersString, parameters, operationDynamicQuerystringParameters); + } +} diff --git a/src/Refitter.Core/ReturnTypeGenerator.cs b/src/Refitter.Core/ReturnTypeGenerator.cs new file mode 100644 index 000000000..fb44640e5 --- /dev/null +++ b/src/Refitter.Core/ReturnTypeGenerator.cs @@ -0,0 +1,162 @@ +using System.Text.RegularExpressions; +using NSwag; + +namespace Refitter.Core; + +internal class ReturnTypeGenerator : IReturnTypeGenerator +{ + private static readonly Regex HttpResponseMessageTypeRegex = new( + "(Task|IObservable)", + RegexOptions.Compiled, + TimeSpan.FromSeconds(1)); + + private static readonly Regex ApiResponseTypeRegex = new( + "(Task|IObservable)<(I)?ApiResponse(<[\\w<>]+>)?>", + RegexOptions.Compiled, + TimeSpan.FromSeconds(1)); + + private readonly RefitGeneratorSettings settings; + private readonly CustomCSharpClientGenerator generator; + + public ReturnTypeGenerator( + RefitGeneratorSettings settings, + CustomCSharpClientGenerator generator) + { + this.settings = settings; + this.generator = generator; + } + + public string Generate(OpenApiOperation operation) + { + if (settings.ResponseTypeOverride.TryGetValue(operation.OperationId, out var type)) + { + return type is null or "void" + ? GetAsyncOperationType(true) + : $"{GetAsyncOperationType(false)}<{TrimImportedNamespaces(type)}>"; + } + + if (IsFileStreamResponse(operation)) + { + return $"{GetAsyncOperationType(false)}"; + } + + var successCodes = new[] { "200", "201", "203", "206" }; + var returnTypeParameter = successCodes + .Where(operation.Responses.ContainsKey) + .Select(code => GetTypeName(code, operation)) + .FirstOrDefault(); + + if (returnTypeParameter == null && operation.Responses.ContainsKey("2XX")) + { + returnTypeParameter = GetTypeName("2XX", operation); + } + + if (returnTypeParameter == null && operation.Responses.ContainsKey("default")) + { + returnTypeParameter = GetTypeName("default", operation); + } + + return GetReturnType(returnTypeParameter); + } + + public bool IsApiResponseType(string typeName) + { + return HttpResponseMessageTypeRegex.IsMatch(typeName) || + ApiResponseTypeRegex.IsMatch(typeName); + } + + public bool IsFileStreamResponse(OpenApiOperation operation) + { + var successCodes = new[] { "200", "201", "203", "206", "2XX" }; + + foreach (var code in successCodes) + { + if (!operation.Responses.TryGetValue(code, out var apiResponse)) + continue; + + var response = apiResponse.ActualResponse; + + if (response.Content?.Any() != true) + continue; + + foreach (var contentEntry in response.Content) + { + if (IsFileContentType(contentEntry.Key)) + { + var schema = contentEntry.Value?.Schema; + if (schema?.Format == "binary" || schema?.Type == NJsonSchema.JsonObjectType.File) + return true; + } + } + } + + return false; + } + + private static bool IsFileContentType(string contentType) + { + return + contentType.StartsWith("application/octet-stream", StringComparison.OrdinalIgnoreCase) || + contentType.StartsWith("application/pdf", StringComparison.OrdinalIgnoreCase) || + contentType.StartsWith("application/vnd", StringComparison.OrdinalIgnoreCase) || + contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase) || + contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase) || + contentType.StartsWith("audio/", StringComparison.OrdinalIgnoreCase) || + contentType.StartsWith("application/zip", StringComparison.OrdinalIgnoreCase) || + contentType.StartsWith("application/gzip", StringComparison.OrdinalIgnoreCase) || + (contentType.StartsWith("application/x-", StringComparison.OrdinalIgnoreCase) && + !contentType.StartsWith("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase)); + } + + private string GetTypeName(string code, OpenApiOperation operation) + { + var schema = operation.Responses[code].ActualResponse.Schema; + var typeName = generator.GetTypeName(schema, false, null); + + if (!string.IsNullOrWhiteSpace(settings.CodeGeneratorSettings?.ArrayType) && + schema?.Type == NJsonSchema.JsonObjectType.Array) + { + typeName = typeName + .Replace("ICollection", settings.CodeGeneratorSettings!.ArrayType) + .Replace("IEnumerable", settings.CodeGeneratorSettings!.ArrayType); + } + + return typeName; + } + + private string GetReturnType(string? returnTypeParameter) + { + return returnTypeParameter is null or "void" + ? GetDefaultReturnType() + : GetConfiguredReturnType(returnTypeParameter); + } + + private string GetDefaultReturnType() + { + var asyncType = GetAsyncOperationType(true); + return settings.ReturnIApiResponse + ? $"{asyncType}" + : asyncType; + } + + private string GetConfiguredReturnType(string returnTypeParameter) + { + var asyncType = GetAsyncOperationType(false); + return settings.ReturnIApiResponse + ? $"{asyncType}>" + : $"{asyncType}<{TrimImportedNamespaces(returnTypeParameter)}>"; + } + + private string GetAsyncOperationType(bool withVoidReturnType) + { + var type = withVoidReturnType ? "" : string.Empty; + return settings.ReturnIObservable + ? "IObservable" + type + : "Task"; + } + + private static string TrimImportedNamespaces(string returnTypeParameter) => + returnTypeParameter.StartsWith("System.Collections.Generic.", StringComparison.OrdinalIgnoreCase) + ? returnTypeParameter.Replace("System.Collections.Generic.", string.Empty) + : returnTypeParameter; +} diff --git a/src/Refitter.Tests/MethodAttributeGeneratorTests.cs b/src/Refitter.Tests/MethodAttributeGeneratorTests.cs new file mode 100644 index 000000000..1d263ffcf --- /dev/null +++ b/src/Refitter.Tests/MethodAttributeGeneratorTests.cs @@ -0,0 +1,297 @@ +using FluentAssertions; +using NSwag; +using Refitter.Core; +using TUnit.Core; + +namespace Refitter.Tests; + +public class MethodAttributeGeneratorTests +{ + [Test] + public async Task Generate_Includes_Obsolete_When_Deprecated() + { + var spec = """ + openapi: 3.0.0 + info: + title: Test + version: "1.0" + paths: + /test: + get: + operationId: getTest + deprecated: true + responses: + '200': + description: Success + """; + + var document = await OpenApiYamlDocument.FromYamlAsync(spec); + var settings = new RefitGeneratorSettings(); + var generator = new CSharpClientGeneratorFactory(settings, document).Create(); + var sut = new MethodAttributeGenerator(settings, document); + + var operation = document.Paths["/test"]["get"]; + var operationModel = generator.CreateOperationModel(operation); + var attributes = sut.Generate(operation, operationModel); + + attributes.Should().Contain("[System.Obsolete]"); + } + + [Test] + public async Task Generate_Includes_Multipart_For_MultipartFormData() + { + var spec = """ + openapi: 3.0.0 + info: + title: Test + version: "1.0" + paths: + /test: + post: + operationId: uploadFile + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + responses: + '200': + description: Success + """; + + var document = await OpenApiYamlDocument.FromYamlAsync(spec); + var settings = new RefitGeneratorSettings(); + var generator = new CSharpClientGeneratorFactory(settings, document).Create(); + var sut = new MethodAttributeGenerator(settings, document); + + var operation = document.Paths["/test"]["post"]; + var operationModel = generator.CreateOperationModel(operation); + var attributes = sut.Generate(operation, operationModel); + + attributes.Should().Contain("[Multipart]"); + } + + [Test] + public async Task Generate_Includes_Accept_Header_When_Enabled() + { + var spec = """ + openapi: 3.0.0 + info: + title: Test + version: "1.0" + paths: + /test: + get: + operationId: getTest + responses: + '200': + description: Success + content: + application/json: + schema: + type: string + """; + + var document = await OpenApiYamlDocument.FromYamlAsync(spec); + var settings = new RefitGeneratorSettings { AddAcceptHeaders = true }; + var generator = new CSharpClientGeneratorFactory(settings, document).Create(); + var sut = new MethodAttributeGenerator(settings, document); + + var operation = document.Paths["/test"]["get"]; + var operationModel = generator.CreateOperationModel(operation); + var attributes = sut.Generate(operation, operationModel); + + attributes.Should().Contain(a => a.Contains("Accept")); + } + + [Test] + public async Task Generate_Includes_ContentType_Header_When_Enabled() + { + var spec = """ + openapi: 3.0.0 + info: + title: Test + version: "1.0" + paths: + /test: + post: + operationId: createTest + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + responses: + '201': + description: Created + """; + + var document = await OpenApiYamlDocument.FromYamlAsync(spec); + var settings = new RefitGeneratorSettings { AddContentTypeHeaders = true }; + var generator = new CSharpClientGeneratorFactory(settings, document).Create(); + var sut = new MethodAttributeGenerator(settings, document); + + var operation = document.Paths["/test"]["post"]; + var operationModel = generator.CreateOperationModel(operation); + var attributes = sut.Generate(operation, operationModel); + + attributes.Should().Contain(a => a.Contains("Content-Type")); + } + + [Test] + public async Task Generate_Omits_Accept_Header_When_Disabled() + { + var spec = """ + openapi: 3.0.0 + info: + title: Test + version: "1.0" + paths: + /test: + get: + operationId: getTest + responses: + '200': + description: Success + content: + application/json: + schema: + type: string + """; + + var document = await OpenApiYamlDocument.FromYamlAsync(spec); + var settings = new RefitGeneratorSettings { AddAcceptHeaders = false }; + var generator = new CSharpClientGeneratorFactory(settings, document).Create(); + var sut = new MethodAttributeGenerator(settings, document); + + var operation = document.Paths["/test"]["get"]; + var operationModel = generator.CreateOperationModel(operation); + var attributes = sut.Generate(operation, operationModel); + + attributes.Should().NotContain(a => a.Contains("Accept")); + } + + [Test] + public async Task Generate_Includes_Authorization_Header_For_Bearer_Auth() + { + var spec = """ + openapi: 3.0.0 + info: + title: Test + version: "1.0" + paths: + /test: + get: + operationId: getTest + security: + - bearerAuth: [] + responses: + '200': + description: Success + components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + """; + + var document = await OpenApiYamlDocument.FromYamlAsync(spec); + var settings = new RefitGeneratorSettings + { + AuthenticationHeaderStyle = AuthenticationHeaderStyle.Method + }; + var generator = new CSharpClientGeneratorFactory(settings, document).Create(); + var sut = new MethodAttributeGenerator(settings, document); + + var operation = document.Paths["/test"]["get"]; + var operationModel = generator.CreateOperationModel(operation); + var attributes = sut.Generate(operation, operationModel); + + attributes.Should().Contain(a => a.Contains("Authorization: Bearer")); + } + + [Test] + public async Task Generate_Omits_ContentType_For_Multipart() + { + var spec = """ + openapi: 3.0.0 + info: + title: Test + version: "1.0" + paths: + /test: + post: + operationId: uploadFile + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + responses: + '200': + description: Success + """; + + var document = await OpenApiYamlDocument.FromYamlAsync(spec); + var settings = new RefitGeneratorSettings { AddContentTypeHeaders = true }; + var generator = new CSharpClientGeneratorFactory(settings, document).Create(); + var sut = new MethodAttributeGenerator(settings, document); + + var operation = document.Paths["/test"]["post"]; + var operationModel = generator.CreateOperationModel(operation); + var attributes = sut.Generate(operation, operationModel); + + attributes.Should().NotContain(a => a.Contains("Content-Type")); + } + + [Test] + public async Task Generate_Returns_Empty_When_No_Attributes() + { + var spec = """ + openapi: 3.0.0 + info: + title: Test + version: "1.0" + paths: + /test: + get: + operationId: getTest + responses: + '200': + description: Success + content: + application/json: + schema: + type: string + """; + + var document = await OpenApiYamlDocument.FromYamlAsync(spec); + var settings = new RefitGeneratorSettings + { + AddAcceptHeaders = false, + AddContentTypeHeaders = false + }; + var generator = new CSharpClientGeneratorFactory(settings, document).Create(); + var sut = new MethodAttributeGenerator(settings, document); + + var operation = document.Paths["/test"]["get"]; + var operationModel = generator.CreateOperationModel(operation); + var attributes = sut.Generate(operation, operationModel); + + attributes.Should().BeEmpty(); + } +} diff --git a/src/Refitter.Tests/MethodSignatureGeneratorTests.cs b/src/Refitter.Tests/MethodSignatureGeneratorTests.cs new file mode 100644 index 000000000..35f16ee09 --- /dev/null +++ b/src/Refitter.Tests/MethodSignatureGeneratorTests.cs @@ -0,0 +1,250 @@ +using FluentAssertions; +using NSwag; +using Refitter.Core; +using TUnit.Core; + +namespace Refitter.Tests; + +public class MethodSignatureGeneratorTests +{ + [Test] + public async Task Generate_Returns_Route_Parameter() + { + var spec = """ + openapi: 3.0.0 + info: + title: Test + version: "1.0" + paths: + /test/{id}: + get: + operationId: getTest + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: Success + """; + + var document = await OpenApiYamlDocument.FromYamlAsync(spec); + var settings = new RefitGeneratorSettings(); + var generator = new CSharpClientGeneratorFactory(settings, document).Create(); + var sut = new MethodSignatureGenerator(settings); + + var operation = document.Paths["/test/{id}"]["get"]; + var operationModel = generator.CreateOperationModel(operation); + var (parametersString, _, _) = sut.Generate(operationModel, operation, string.Empty); + + parametersString.Should().Contain("int id"); + } + + [Test] + public async Task Generate_Returns_Query_Parameter() + { + var spec = """ + openapi: 3.0.0 + info: + title: Test + version: "1.0" + paths: + /test: + get: + operationId: getTest + parameters: + - name: search + in: query + schema: + type: string + responses: + '200': + description: Success + """; + + var document = await OpenApiYamlDocument.FromYamlAsync(spec); + var settings = new RefitGeneratorSettings(); + var generator = new CSharpClientGeneratorFactory(settings, document).Create(); + var sut = new MethodSignatureGenerator(settings); + + var operation = document.Paths["/test"]["get"]; + var operationModel = generator.CreateOperationModel(operation); + var (parametersString, _, _) = sut.Generate(operationModel, operation, string.Empty); + + parametersString.Should().Contain("string search"); + } + + [Test] + public async Task Generate_Includes_Body_Parameter() + { + var spec = """ + openapi: 3.0.0 + info: + title: Test + version: "1.0" + paths: + /test: + post: + operationId: createTest + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + responses: + '201': + description: Created + """; + + var document = await OpenApiYamlDocument.FromYamlAsync(spec); + var settings = new RefitGeneratorSettings(); + var generator = new CSharpClientGeneratorFactory(settings, document).Create(); + var sut = new MethodSignatureGenerator(settings); + + var operation = document.Paths["/test"]["post"]; + var operationModel = generator.CreateOperationModel(operation); + var (parametersString, _, _) = sut.Generate(operationModel, operation, string.Empty); + + parametersString.Should().Contain("body"); + } + + [Test] + public async Task Generate_With_CancellationToken_Includes_CancellationToken() + { + var spec = """ + openapi: 3.0.0 + info: + title: Test + version: "1.0" + paths: + /test: + get: + operationId: getTest + responses: + '200': + description: Success + """; + + var document = await OpenApiYamlDocument.FromYamlAsync(spec); + var settings = new RefitGeneratorSettings { UseCancellationTokens = true }; + var generator = new CSharpClientGeneratorFactory(settings, document).Create(); + var sut = new MethodSignatureGenerator(settings); + + var operation = document.Paths["/test"]["get"]; + var operationModel = generator.CreateOperationModel(operation); + var (parametersString, _, _) = sut.Generate(operationModel, operation, string.Empty); + + parametersString.Should().Contain("CancellationToken"); + } + + [Test] + public async Task Generate_With_DynamicQuerystring_Returns_Generated_Code() + { + var spec = """ + openapi: 3.0.0 + info: + title: Test + version: "1.0" + paths: + /test: + get: + operationId: getTest + parameters: + - name: page + in: query + schema: + type: integer + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: Success + """; + + var document = await OpenApiYamlDocument.FromYamlAsync(spec); + var settings = new RefitGeneratorSettings(); + var generator = new CSharpClientGeneratorFactory(settings, document).Create(); + var sut = new MethodSignatureGenerator(settings); + + var operation = document.Paths["/test"]["get"]; + var operationModel = generator.CreateOperationModel(operation); + var (_, _, dynamicQs) = sut.Generate(operationModel, operation, "TestQueryParams"); + + dynamicQs.Should().NotBeNull(); + } + + [Test] + public async Task Generate_With_Apizr_RequestOptions_Includes_Options_Parameter() + { + var spec = """ + openapi: 3.0.0 + info: + title: Test + version: "1.0" + paths: + /test: + get: + operationId: getTest + responses: + '200': + description: Success + """; + + var document = await OpenApiYamlDocument.FromYamlAsync(spec); + var settings = new RefitGeneratorSettings + { + ApizrSettings = new ApizrSettings { WithRequestOptions = true } + }; + var generator = new CSharpClientGeneratorFactory(settings, document).Create(); + var sut = new MethodSignatureGenerator(settings); + + var operation = document.Paths["/test"]["get"]; + var operationModel = generator.CreateOperationModel(operation); + var (parametersString, _, _) = sut.Generate(operationModel, operation, string.Empty); + + parametersString.Should().Contain("IApizrRequestOptions"); + } + + [Test] + public async Task Generate_Returns_Parameters_List_For_Apizr_Overload_Filtering() + { + var spec = """ + openapi: 3.0.0 + info: + title: Test + version: "1.0" + paths: + /test: + get: + operationId: getTest + parameters: + - name: search + in: query + required: false + schema: + type: string + responses: + '200': + description: Success + """; + + var document = await OpenApiYamlDocument.FromYamlAsync(spec); + var settings = new RefitGeneratorSettings { OptionalParameters = true }; + var generator = new CSharpClientGeneratorFactory(settings, document).Create(); + var sut = new MethodSignatureGenerator(settings); + + var operation = document.Paths["/test"]["get"]; + var operationModel = generator.CreateOperationModel(operation); + var (_, parameters, _) = sut.Generate(operationModel, operation, string.Empty); + + parameters.Should().NotBeEmpty(); + } +} diff --git a/src/Refitter.Tests/ReturnTypeGeneratorTests.cs b/src/Refitter.Tests/ReturnTypeGeneratorTests.cs new file mode 100644 index 000000000..3ff85d6c5 --- /dev/null +++ b/src/Refitter.Tests/ReturnTypeGeneratorTests.cs @@ -0,0 +1,379 @@ +using FluentAssertions; +using NSwag; +using Refitter.Core; +using TUnit.Core; + +namespace Refitter.Tests; + +public class ReturnTypeGeneratorTests +{ + [Test] + public async Task Generate_Returns_Task_For_Void_Response() + { + var spec = """ + { + "openapi": "3.0.0", + "info": { "title": "Test", "version": "1.0" }, + "paths": { + "/test": { + "get": { + "operationId": "getTest", + "responses": { + "204": { "description": "No Content" } + } + } + } + } + } + """; + + var document = await OpenApiDocument.FromJsonAsync(spec); + var settings = new RefitGeneratorSettings(); + var generator = new CSharpClientGeneratorFactory(settings, document).Create(); + var sut = new ReturnTypeGenerator(settings, generator); + + var operation = document.Paths["/test"]["get"]; + var result = sut.Generate(operation); + + result.Should().Be("Task"); + } + + [Test] + public async Task Generate_Returns_Task_Of_Type_For_Success_Response() + { + var spec = """ + { + "openapi": "3.0.0", + "info": { "title": "Test", "version": "1.0" }, + "paths": { + "/test": { + "get": { + "operationId": "getTest", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + } + } + """; + + var document = await OpenApiDocument.FromJsonAsync(spec); + var settings = new RefitGeneratorSettings(); + var generator = new CSharpClientGeneratorFactory(settings, document).Create(); + var sut = new ReturnTypeGenerator(settings, generator); + + var operation = document.Paths["/test"]["get"]; + var result = sut.Generate(operation); + + result.Should().Be("Task"); + } + + [Test] + public async Task Generate_Returns_IApiResponse_When_Wrapping_Enabled() + { + var spec = """ + { + "openapi": "3.0.0", + "info": { "title": "Test", "version": "1.0" }, + "paths": { + "/test": { + "get": { + "operationId": "getTest", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + } + } + """; + + var document = await OpenApiDocument.FromJsonAsync(spec); + var settings = new RefitGeneratorSettings { ReturnIApiResponse = true }; + var generator = new CSharpClientGeneratorFactory(settings, document).Create(); + var sut = new ReturnTypeGenerator(settings, generator); + + var operation = document.Paths["/test"]["get"]; + var result = sut.Generate(operation); + + result.Should().Be("Task>"); + } + + [Test] + public async Task Generate_Returns_IObservable_When_Observable_Enabled() + { + var spec = """ + { + "openapi": "3.0.0", + "info": { "title": "Test", "version": "1.0" }, + "paths": { + "/test": { + "get": { + "operationId": "getTest", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + } + } + """; + + var document = await OpenApiDocument.FromJsonAsync(spec); + var settings = new RefitGeneratorSettings { ReturnIObservable = true }; + var generator = new CSharpClientGeneratorFactory(settings, document).Create(); + var sut = new ReturnTypeGenerator(settings, generator); + + var operation = document.Paths["/test"]["get"]; + var result = sut.Generate(operation); + + result.Should().Be("IObservable"); + } + + [Test] + public async Task IsFileStreamResponse_Returns_True_For_Binary_Content() + { + var spec = """ + { + "openapi": "3.0.0", + "info": { "title": "Test", "version": "1.0" }, + "paths": { + "/test": { + "get": { + "operationId": "getFile", + "responses": { + "200": { + "description": "File", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + } + } + } + """; + + var document = await OpenApiDocument.FromJsonAsync(spec); + var settings = new RefitGeneratorSettings(); + var generator = new CSharpClientGeneratorFactory(settings, document).Create(); + var sut = new ReturnTypeGenerator(settings, generator); + + var operation = document.Paths["/test"]["get"]; + var result = sut.IsFileStreamResponse(operation); + + result.Should().BeTrue(); + } + + [Test] + public async Task IsFileStreamResponse_Returns_False_For_Json_Content() + { + var spec = """ + { + "openapi": "3.0.0", + "info": { "title": "Test", "version": "1.0" }, + "paths": { + "/test": { + "get": { + "operationId": "getTest", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + } + } + """; + + var document = await OpenApiDocument.FromJsonAsync(spec); + var settings = new RefitGeneratorSettings(); + var generator = new CSharpClientGeneratorFactory(settings, document).Create(); + var sut = new ReturnTypeGenerator(settings, generator); + + var operation = document.Paths["/test"]["get"]; + var result = sut.IsFileStreamResponse(operation); + + result.Should().BeFalse(); + } + + [Test] + public async Task Generate_Returns_FileStream_Response_Type() + { + var spec = """ + { + "openapi": "3.0.0", + "info": { "title": "Test", "version": "1.0" }, + "paths": { + "/test": { + "get": { + "operationId": "getFile", + "responses": { + "200": { + "description": "File", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + } + } + } + """; + + var document = await OpenApiDocument.FromJsonAsync(spec); + var settings = new RefitGeneratorSettings(); + var generator = new CSharpClientGeneratorFactory(settings, document).Create(); + var sut = new ReturnTypeGenerator(settings, generator); + + var operation = document.Paths["/test"]["get"]; + var result = sut.Generate(operation); + + result.Should().Be("Task"); + } + + [Test] + public async Task IsApiResponseType_Detects_Task_Of_HttpResponseMessage() + { + var settings = new RefitGeneratorSettings(); + var generator = new CSharpClientGeneratorFactory( + settings, + await OpenApiDocument.FromJsonAsync(""" + { "openapi": "3.0.0", "info": { "title": "Test", "version": "1.0" }, "paths": {} } + """)) + .Create(); + var sut = new ReturnTypeGenerator(settings, generator); + + sut.IsApiResponseType("Task").Should().BeTrue(); + sut.IsApiResponseType("IObservable").Should().BeTrue(); + sut.IsApiResponseType("Task").Should().BeFalse(); + sut.IsApiResponseType("Task").Should().BeTrue(); + sut.IsApiResponseType("Task>").Should().BeTrue(); + } + + [Test] + public async Task Generate_With_ResponseTypeOverride_Uses_Custom_Type() + { + var spec = """ + { + "openapi": "3.0.0", + "info": { "title": "Test", "version": "1.0" }, + "paths": { + "/test": { + "get": { + "operationId": "customOp", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { "type": "string" } + } + } + } + } + } + } + } + } + """; + + var document = await OpenApiDocument.FromJsonAsync(spec); + var settings = new RefitGeneratorSettings(); + settings.ResponseTypeOverride["customOp"] = "MyCustomType"; + var generator = new CSharpClientGeneratorFactory(settings, document).Create(); + var sut = new ReturnTypeGenerator(settings, generator); + + var operation = document.Paths["/test"]["get"]; + var result = sut.Generate(operation); + + result.Should().Be("Task"); + } + + [Test] + public async Task Generate_With_ResponseTypeOverride_Void_Returns_Task() + { + var spec = """ + { + "openapi": "3.0.0", + "info": { "title": "Test", "version": "1.0" }, + "paths": { + "/test": { + "get": { + "operationId": "customOp", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { "type": "string" } + } + } + } + } + } + } + } + } + """; + + var document = await OpenApiDocument.FromJsonAsync(spec); + var settings = new RefitGeneratorSettings(); + settings.ResponseTypeOverride["customOp"] = "void"; + var generator = new CSharpClientGeneratorFactory(settings, document).Create(); + var sut = new ReturnTypeGenerator(settings, generator); + + var operation = document.Paths["/test"]["get"]; + var result = sut.Generate(operation); + + result.Should().Be("Task"); + } +}