NSwag: nswag v13 can't handle text\plain and string response

This is very old issue and it was fixed in v12, but appears again in v13: #701

Controller’s attributes:

[Produces("text/plain")]
[ProducesResponseType(typeof(FileContentResult), (int)HttpStatusCode.OK)]
[ProducesResponseType(typeof(string), (int)HttpStatusCode.OK)]
[ProducesResponseType(typeof(ErrorResponseMessage), (int)HttpStatusCode.BadRequest)]
[ProducesResponseType(typeof(ErrorResponseMessage), (int)HttpStatusCode.NotFound)]

Swagger:

"get": {
        "tags": [ "Binary" ],
        "summary": "Download binary as base64 string.",
        "operationId": "DownloadBinaryAsBase64",
        "consumes": [],
        "produces": [ "text/plain" ],
        "parameters": [
          {
            "name": "binaryId",
            "in": "path",
            "description": "Id of binary to fetch.",
            "required": true,
            "type": "string",
            "format": "guid"
          }
        ],
        "responses": {
          "200": {
            "description": "Content downloaded.",
            "schema": { "type": "string" }
          },
          "400": {
            "description": "Request contains wrong data.",
            "schema": { "$ref": "#/definitions/ErrorResponseMessage" }
          },
          "404": {
            "description": "Not Found",
            "schema": { "$ref": "#/definitions/ErrorResponseMessage" }
          }
        }

Produced code (part):

var status_ = ((int)response_.StatusCode).ToString();
if (status_ == "200") 
{
    var objectResponse_ = await ReadObjectResponseAsync<string>(response_, headers_).ConfigureAwait(false);
    return objectResponse_.Object;
}

ReadObjectResponseAsync always try to deserialize the string and of course it will fail.

Revert to v12.2.5 fix the issue.

About this issue

  • Original URL
  • State: open
  • Created 5 years ago
  • Reactions: 8
  • Comments: 64 (17 by maintainers)

Commits related to this issue

Most upvoted comments

Any idea when this patch will be released?

can you please add a hard-copy of the bad generated code to your repo?

Done.

Also, is the issue still reproducible with the latest versions 13.16.1 and later?

Yes, tested with 13.16.1.

This is still an issue with the default templates.

I worked around it by altering lines 13 to 22 in the Client.Class.ReadResponseObject.liquid template.

var isStringResponse = headers["Content-Type"].FirstOrDefault() == "text/plain; charset=utf-8";
var isStringResponse = headers["Content-Type"].FirstOrDefault() == "text/plain; charset=utf-8";
if (ReadResponseAsString || isStringResponse)
{
    var responseText = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
    try
    {
        if (isStringResponse && typeof(T) == typeof(string))
            return new ObjectResponseResult<T>((T)(object)responseText, responseText);

...

Client.Class.ReadObjectResponse.liquid.txt (remove the .txt extension if you want to use this file)

I was having the same issue with OpenAPI spec generated from a .NET core API (Swashbuckle.AspNetCore 6.4.0), and then using Visual Studio’s Connected Services in a MAUI app to consume it. There are multiple pieces in this chain you need to get right.

TL;DR:

  • upgrade Microsoft.Extensions.ApiDescription.Client to the latest 6.0.10, not 3.x
  • upgrade NSwag.ApiDescription.Client to version 13.17.0 not 13.05
  • don’t generate any other contents except for text/json (in this one particular endpoint)
  • when you update your swagger.json spec, in your client/consuming project (e.g. with <CodeGenerator>NSwagCSharp</CodeGenerator>) make sure you:
  • close the auto generated code file swaggerClient.cs as it won’t regenerate or delete otherwise
  • delete the bin and obj folders if using Visual Studio Connected Services. Just cleaning and rebuilding doesn’t work.
  • and don’t forget to copy new specs to your client project!

The following action method and attributes:

    [HttpPut, Route("/foo")]
    [ProducesResponseType(typeof(String), StatusCodes.Status200OK, "text/plain")]
    public async Task<IActionResult> Login(FooDto foo)
    {
        return Ok("hello, world!");
    }

doesn’t produce any content in the spec:

        "responses": {
        ...
          "200": {
            "description": "Success"
          },

(In fact any set of attributes that included application/json in the 200 response, made the generated code return nothing). So I settled on this:

    [Produces("text/plain")]
    [ProducesResponseType(typeof(string), StatusCodes.Status200OK)]

and now the spec shows

        "responses": {
        ...
          "200": {
            "description": "Success",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },

So long as I have only one content type, text/plain, and return a string, with the included NSwag.ApiDescription.Client version 13.05, and the included (but obsolete) Microsoft.Extensions.ApiDescription.Client 3.0.3 I get the autogenerated code:

    if (status_ == "200") 
    {
        var objectResponse_ = await ReadObjectResponseAsync<string>(response_, headers_).ConfigureAwait(false);
        return objectResponse_.Object;
    }

This throws an ApiException:

Could not deserialize the response body stream as System.String.

Status: 200
Response: 

 ---> Newtonsoft.Json.JsonReaderException: Unexpected character encountered while parsing value: e. Path '', line 1, position 1.
   at Newtonsoft.Json.JsonTextReader.ReadStringValue(ReadType readType)
   at Newtonsoft.Json.JsonTextReader.ReadAsString()
   at Newtonsoft.Json.JsonReader.ReadForType(JsonContract contract, Boolean hasConverter)
   at Newtonsoft.Json.Serialization.JsonSerializerInternalReader.Deserialize(JsonReader reader, Type objectType, Boolean checkAdditionalContent)
   at Newtonsoft.Json.JsonSerializer.DeserializeInternal(JsonReader reader, Type objectType)
   at Newtonsoft.Json.JsonSerializer.Deserialize(JsonReader reader, Type objectType)
   at Newtonsoft.Json.JsonSerializer.Deserialize[String](JsonReader reader)
   at MauiClient.<ReadObjectResponseAsync>d__44`1[[System.String, System.Private.CoreLib, Version=6.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e]].MoveNext() in 
...etc

However upgrading Microsoft.Extensions.ApiDescription.Client to the latest 6.0.10 and NSwag.ApiDescription.Client to version 13.17.0 gives me slightly different code:

    if (status_ == 200)
    {
        var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false);
        var result_ = (string)System.Convert.ChangeType(responseData_, typeof(string));
        return result_;
    }

And this works 😁 😁 😁

It’s hard to isolate just one or the other package, as downgrading isn’t so simple, but I think it is NSwag.ApiDescription.Client that needs the latest 13.17 version.

Various other combinations of content type, or no content (see the attributes above) caused the generated code:

    if (status_ == 200)
    {
        return;
    }

So I’ve got it working, but there are many parts in the chain where it can fail.

thanks for all the suggestions from this thread!

I’m getting this issue in 13.15.10 also. It is setting the accept header to text/plain then trying to parse the result as if it had set application/json.

Perhaps all this is a result of an apparent lack of dynamism on the generated code’s behalf, and it should be saying “accept a or b” then examining what the response header says is in the message, a vs b, and acting based on that rather than a “swagger says a or b is available, ask for a, expect a” - this latter approach is less “be strict in what you send and liberal in what you receive” than the former

I tried with 13.9.3. I have "produces": ["text/plain"] in my swagger.json 2.0 I still got generated code like this:

if (status_ == 200)
                        {
                            var objectResponse_ = await ReadObjectResponseAsync<string>(response_, headers_).ConfigureAwait(false);
                            if (objectResponse_.Object == null)
                            {
                                throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null);
                            }
                            return objectResponse_.Object;
                        }

await ReadObjectResponseAsync<string>(....) will cause the problem.

I can confirm that the nswag.msbuild 13.20 package still has this issue:

relevant excerpt of my openapi spec


 "responses": {
   "200": {
     "description": "Success"
   },
   "400": {
     "description": "Bad Request"
   },
   "500": {
     "description": "Server Error",
     "content": {
       "application/json": {
         "schema": {
           "$ref": "#/components/schemas/ProblemDetails"
         }
       }
     }
   },
   "401": {
     "description": "Unauthorized",
     "content": {
       "application/json": {
         "schema": {
           "$ref": "#/components/schemas/ProblemDetails"
         }
       }
     }
   },

Generated code excerpt:


                    var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
                    var disposeResponse_ = true;
                    try
                    {
                        var headers_ = System.Linq.Enumerable.ToDictionary(response_.Headers, h_ => h_.Key, h_ => h_.Value);
                        if (response_.Content != null && response_.Content.Headers != null)
                        {
                            foreach (var item_ in response_.Content.Headers)
                                headers_[item_.Key] = item_.Value;
                        }

                        await ProcessResponseAsync(client_, response_, cancellationToken).ConfigureAwait(false);

                        var status_ = (int)response_.StatusCode;
                        if (status_ == 200)
                        {
                            return;
                        }
                        else
                        if (status_ == 400)
                        {
                            string responseText_ = ( response_.Content == null ) ? string.Empty : await response_.Content.ReadAsStringAsync().ConfigureAwait(false);
                            throw new ApiException("Bad Request", status_, responseText_, headers_, null);
                        }
                        else
                        if (status_ == 500)
                        {
                            var objectResponse_ = await ReadObjectResponseAsync<ProblemDetails>(response_, headers_, cancellationToken).ConfigureAwait(false);
                            if (objectResponse_.Object == null)
                            {
                                throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null);
                            }
                            throw new ApiException<ProblemDetails>("Server Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null);
                        }

im using the following attributes on my controller action method:

 [ProducesResponseType(typeof(string), StatusCodes.Status200OK, "text/plain")]
 [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest, "application/json")]
 [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError, "application/json")]

And the return result is an ActionResult<string>.

When i leave out the explicit content type parameters of the ProducesResponseType attribute the client generator interprets everything as json. And tries to deserialise the string content as json, which is wrong.

Why it now generates a VOID response is unknown to me.

Im working around this by returning a wrapper object instead of a string. But its not ideal.

I used NSwag to generate a client from an API which resulted in the following code:

if (status_ == "200") 
{
    var objectResponse_ = await ReadObjectResponseAsync<string>(response_, headers_).ConfigureAwait(false);
    return objectResponse_.Object;
}

And since the generated ReadObjectResponseAsync<T>() method tries to do the following:

var typedBody = Newtonsoft.Json.JsonConvert.DeserializeObject<T>(responseText, JsonSerializerSettings);

It also threw an error everytime the API returned a 200, since deserializing a string into a string doesnt work. While waiting for this to be solved I created the following class in my project as a fix since I couldnt use an earlier version of NSwag:

public class MyCustomClient : GeneratedClient
    {
        public MyCustomClient (string baseUrl, HttpClient httpClient) : base(baseUrl, httpClient)
        {

        }

        protected override async Task<ObjectResponseResult<T>> ReadObjectResponseAsync<T>(HttpResponseMessage response, IReadOnlyDictionary<string, IEnumerable<string>> headers)
        {
            if (typeof(T) == typeof(string))
            {
                if (response == null || response.Content == null)
                {
                    return new ObjectResponseResult<T>(default!, string.Empty);
                }
                var responseText = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
                var responseObject = (T)Convert.ChangeType(responseText, typeof(T));
                return new ObjectResponseResult<T>(responseObject, responseText);
            }
            else
            {
                return await base.ReadObjectResponseAsync<T>(response, headers);
            }
        }
    }

Not the best solution but it works for now (while i wait for the fix) and its simple, plus it allows me to (as seen in the last code snippet) “modify” the generated code while also allowing for easy regeneration of the client if the api changes without losing my changes.

I’ve just tested it with 13.16.1 and it works flawless for me. visual studio has installed 13.0.5 automatical, an update solved the problem

@shawty Please post text, not screenshots - we can’t search nor copy+paste code and data from screenshots: https://meta.stackoverflow.com/questions/303812/discourage-screenshots-of-code-and-or-errors


As for your problem:

If you’re returning plain-text (as in text/plain) in your 404 responses then your [ProducesResponseType] attributes are wrong because typeof(string) combined with Content-Type set to application/json means that your action will return JSON string for 404 - not that it will return a text/plain response. Rephrased: The String type does not correspond to any particular HTTP response Content-Type header value.

Also, application/json is also inappropriate and incorrect for FileStreamResult - but that didn’t cause any problems so far because FileStreamResult sets its own response Content-Type header anyway). You’ll notice that the OpenAPI JSON does not list application/octet-stream for 200 responses, even though it should.

Unfortunately (and annoyingly) the [ProducesResponseType] does not support specifying an explicit Content-Type value for per-status-code responses. The workaround you’ll need to customize the generated OpenAPI to change the Content-Type for those error responses:

(The below code is just an example, I haven’t compiled nor tested it).

// In your `ConfigureServices(IServiceCollection services)` method, change your `AddSwaggerDocument` to include the following:

services.AddSwaggerDocument( ( AspNetCoreOpenApiDocumentGeneratorSettings config ) =>
{
    config.PostProcess = ( OpenApiDocument document ) =>
    {
        foreach( OpenApiPathItem path in document.Paths )
        {
            foreach( OpenApiOperation op in path.Values )
            {
                foreach( OpenApiResponse errorResponse in op.Responses.Where( kvp => kvp.Key.StartsWith("4") || kvp.Key.StartsWith("5") ) )
                {
                    if( !errorResponse.Content.ContainsKey( "text/plain" ) )
                    {
                        errorResponse.Content.Add( "text/plain", new OpenApiMediaType( ... ) );
                    }
                }
            }
        }
    };
});

That might be a bit of a problem 😃

I generate the client code direct into my project using NSwag, I don’t save or make a copy of the open API spec code.

Scrap that comment 😃

image

I screen shotted NSwag.

Hi @RicoSuter , yea sure… here you go

image

Had to reduce the font a little to make it fit on the screen…

Last bottom little bit is just the dispose calls

image

Please test with the latest version of NSwag, this has been changed a bit and might solve your problem.