Repository: microsoft/kiota (csharp)

Issue:
Kiota throws ApiException when server returns 304
Our server returns [HTTP 304 (Not Modified)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/304) without a response body when the incoming fingerprint in the [`If-None-Match`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match) header matches the stored version. This enables clients to poll for changes without receiving a response body, unless it has changed since it was last fetched.

Status 304 is described in the OAS document, but Kiota throws an `ApiException` saying the error isn't mapped:
```
Microsoft.Kiota.Abstractions.ApiException: The server returned an unexpected status code and no error factory is registered for this code: 304
```

OAS fragment:
```json
{
  "paths": {
    "/api/people": {
      "get": {
        "tags": [
          "people"
        ],
        "summary": "Retrieves a collection of people.",
        "operationId": "getPersonCollection",
        "responses": {
          "200": {
            "description": "Successfully returns the found people, or an empty array if none were found.",
            "headers": {
              "ETag": {
                "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.",
                "required": true,
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/vnd.api+json": {
                "schema": {
                  "$ref": "#/components/schemas/personCollectionResponseDocument"
                }
              }
            }
          },
          "400": {
            "description": "The query string is invalid.",
            "content": {
              "application/vnd.api+json": {
                "schema": {
                  "$ref": "#/components/schemas/errorResponseDocument"
                }
              }
            }
          },
          "304": {
            "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If_None_Match header.",
            "headers": {
              "ETag": {
                "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.",
                "required": true,
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    }
  }
}
```

Generated C# code:
```c#
namespace GeneratedClient.Api.People {
    public class PeopleRequestBuilder : BaseRequestBuilder {
        /// <summary>
        /// Retrieves a collection of people.
        /// </summary>
        /// <param name="cancellationToken">Cancellation token to use when cancelling requests</param>
        /// <param name="requestConfiguration">Configuration for the request such as headers, query parameters, and middleware options.</param>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
        public async Task<PersonCollectionResponseDocument?> GetAsync(Action<RequestConfiguration<PeopleRequestBuilderGetQueryParameters>>? requestConfiguration = default, CancellationToken cancellationToken = default) {
#nullable restore
#else
        public async Task<PersonCollectionResponseDocument> GetAsync(Action<RequestConfiguration<PeopleRequestBuilderGetQueryParameters>> requestConfiguration = default, CancellationToken cancellationToken = default) {
#endif
            var requestInfo = ToGetRequestInformation(requestConfiguration);
            var errorMapping = new Dictionary<string, ParsableFactory<IParsable>> {
                {"400", ErrorResponseDocument.CreateFromDiscriminatorValue},
            };
            return await RequestAdapter.SendAsync<PersonCollectionResponseDocument>(requestInfo, PersonCollectionResponseDocument.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false);
        }
    }
}
```
To work around this, we need to catch an exception to detect HTTP 304 is being returned:
```c#
var headerInspector = new HeadersInspectionHandlerOption
{
    InspectResponseHeaders = true
};

PersonCollectionResponseDocument? getResponse1 = await client.Api.People.GetAsync(
    configuration => configuration.Options.Add(headerInspector));

string eTag = headerInspector.ResponseHeaders["ETag"].Single();

try
{
    PersonCollectionResponseDocument? getResponse2 = await client.Api.People.GetAsync(
        configuration => configuration.Headers.Add("If-None-Match", eTag));
}
catch (ApiException exception) when (exception.ResponseStatusCode == (int)HttpStatusCode.NotModified)
{
    Console.WriteLine("The HTTP response hasn't changed, so no response body was returned.");
}
```

This looks like a bug to me. In `KiotaBuilder.cs`, I found the following line:
```c#
    private static readonly HashSet<string> noContentStatusCodes = new(StringComparer.OrdinalIgnoreCase) { "201", "202", "204", "205" };
```

Should that just be changed to include 304?

Pull request description:
fixes #4190

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 47407350b6..2be0f1ee40 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - PREVIEW: Renamed the config to workspace file. [#4310](https://github.com/microsoft/kiota/issues/4310)
 - Fixed a bug where TypeScript generation would consider boolean argument as empty when false. [#4103](https://github.com/microsoft/kiota/issues/4103)
 - Changed Csharp code generation to put braces on new lines (where it makes sense). [#4347](https://github.com/microsoft/kiota/issues/4347)
+- Fixed a bug where some no-content status codes would be considered structured (301, 302, 303, 307) when described. [#4190](https://github.com/microsoft/kiota/issues/4190)
 - TypeScript is now a preview language!
 
 ## [1.12.0] - 2024-03-06
diff --git a/src/Kiota.Builder/KiotaBuilder.cs b/src/Kiota.Builder/KiotaBuilder.cs
index b9ae9bf50d..5a39f14fe6 100644
--- a/src/Kiota.Builder/KiotaBuilder.cs
+++ b/src/Kiota.Builder/KiotaBuilder.cs
@@ -1083,7 +1083,7 @@ openApiExtension is OpenApiPrimaryErrorMessageExtension primaryErrorMessageExten
         };
     }
     private const string RequestBodyPlainTextContentType = "text/plain";
-    private static readonly HashSet<string> noContentStatusCodes = new(StringComparer.OrdinalIgnoreCase) { "201", "202", "204", "205" };
+    private static readonly HashSet<string> noContentStatusCodes = new(StringComparer.OrdinalIgnoreCase) { "201", "202", "204", "205", "301", "302", "303", "304", "307" };
     private static readonly HashSet<string> errorStatusCodes = new(Enumerable.Range(400, 599).Select(static x => x.ToString(CultureInfo.InvariantCulture))
                                                                                  .Concat([CodeMethod.ErrorMappingClientRange, CodeMethod.ErrorMappingServerRange]), StringComparer.OrdinalIgnoreCase);
     private void AddErrorMappingsForExecutorMethod(OpenApiUrlTreeNode currentNode, OpenApiOperation operation, CodeMethod executorMethod)


Test patch:
diff --git a/tests/Kiota.Builder.Tests/KiotaBuilderTests.cs b/tests/Kiota.Builder.Tests/KiotaBuilderTests.cs
index 69d1eb4ca7..18c042e104 100644
--- a/tests/Kiota.Builder.Tests/KiotaBuilderTests.cs
+++ b/tests/Kiota.Builder.Tests/KiotaBuilderTests.cs
@@ -5411,8 +5411,14 @@ public void Considers204WithNoSchemaOver206WithNoSchema()
         Assert.NotNull(executor);
         Assert.Equal("void", executor.ReturnType.Name);
     }
-    [Fact]
-    public void DoesntGenerateVoidExecutorOnMixed204()
+    [InlineData(204)]
+    [InlineData(301)]
+    [InlineData(302)]
+    [InlineData(303)]
+    [InlineData(304)]
+    [InlineData(307)]
+    [Theory]
+    public void DoesntGenerateVoidExecutorOnMixedNoContent(int statusCode)
     {
         var myObjectSchema = new OpenApiSchema
         {
@@ -5449,7 +5455,7 @@ public void DoesntGenerateVoidExecutorOnMixed204()
                                         }
                                     }
                                 },
-                                ["204"] = new OpenApiResponse(),
+                                [statusCode.ToString()] = new OpenApiResponse(),
                             }
                         }
                     }
@@ -5477,6 +5483,77 @@ public void DoesntGenerateVoidExecutorOnMixed204()
         Assert.NotNull(executor);
         Assert.NotEqual("void", executor.ReturnType.Name);
     }
+    [InlineData(204)]
+    [InlineData(301)]
+    [InlineData(302)]
+    [InlineData(303)]
+    [InlineData(304)]
+    [InlineData(307)]
+    [Theory]
+    public void GeneratesVoidReturnTypeForNoContent(int statusCode)
+    {
+        var myObjectSchema = new OpenApiSchema
+        {
+            Type = "object",
+            Properties = new Dictionary<string, OpenApiSchema> {
+                {
+                    "id", new OpenApiSchema {
+                        Type = "string",
+                    }
+                }
+            },
+            Reference = new OpenApiReference
+            {
+                Id = "myobject",
+                Type = ReferenceType.Schema
+            },
+            UnresolvedReference = false
+        };
+        var document = new OpenApiDocument
+        {
+            Paths = new OpenApiPaths
+            {
+                ["answer"] = new OpenApiPathItem
+                {
+                    Operations = {
+                        [OperationType.Get] = new OpenApiOperation
+                        {
+                            Responses = new OpenApiResponses
+                            {
+                                [statusCode.ToString()] = new OpenApiResponse {
+                                    Content = {
+                                        ["application/json"] = new OpenApiMediaType {
+                                            Schema = myObjectSchema
+                                        }
+                                    }
+                                },
+                            }
+                        }
+                    }
+                }
+            },
+            Components = new()
+            {
+                Schemas = new Dictionary<string, OpenApiSchema> {
+                    {
+                        "myobject", myObjectSchema
+                    }
+                }
+            }
+        };
+        var mockLogger = new Mock<ILogger<KiotaBuilder>>();
+        var builder = new KiotaBuilder(mockLogger.Object, new GenerationConfiguration { ClientClassName = "TestClient", ClientNamespaceName = "TestSdk", ApiRootUrl = "https://localhost" }, _httpClient);
+        var node = builder.CreateUriSpace(document);
+        var codeModel = builder.CreateSourceModel(node);
+        var rbNS = codeModel.FindNamespaceByName("TestSdk.Answer");
+        Assert.NotNull(rbNS);
+        var rbClass = rbNS.Classes.FirstOrDefault(x => x.IsOfKind(CodeClassKind.RequestBuilder));
+        Assert.NotNull(rbClass);
+        Assert.Single(rbClass.Methods.Where(x => x.IsOfKind(CodeMethodKind.RequestExecutor)));
+        var executor = rbClass.Methods.FirstOrDefault(x => x.IsOfKind(CodeMethodKind.RequestExecutor));
+        Assert.NotNull(executor);
+        Assert.Equal("void", executor.ReturnType.Name);
+    }
     [InlineData(new[] { "microsoft.graph.user", "microsoft.graph.termstore.term" }, "microsoft.graph")]
     [InlineData(new[] { "microsoft.graph.user", "odata.errors.error" }, "")]
     [InlineData(new string[] { }, "")]