openapi-generator icon indicating copy to clipboard operation
openapi-generator copied to clipboard

[BUG] Do not auto-select content type as application/json if available in content types list

Open tiholic opened this issue 4 years ago • 1 comments

Bug Report Checklist

  • [x] Have you provided a full/minimal spec to reproduce the issue?
  • [x] Have you validated the input using an OpenAPI validator (example)?
  • [ ] Have you tested with the latest master to confirm the issue still exists?

Couldn't build the project in local due to missing JDK. However, I see no changes to the code templates.

  • [x] Have you searched for related issues/PRs?
  • [x] What's the actual output vs expected output?
  • [ ] [Optional] Sponsorship to speed up the bug fix or feature request (example)
Description

If multiple payload content types are set, ex: json and multipart, json is always picked as default header.

My requirement: If the payload is a json payload, the server expects a file_key which was uploaded via an upload endpoint, and if it is multipart, a file is expected.

openapi-generator version

5.1.1

OpenAPI declaration file content or url

https://gist.github.com/tiholic/99cf45e7eebe24ff4fc3bc02802f4485

Generation Details
openapi-generator generate -i http://nestjs-server.localhost/docs-json/ -o ./ -g python --additional-properties generateSourceCodeOnly=true,packageName=example_api.example
Steps to reproduce

Use the schema from gist and try to call the action endpoint with a file payload open('a.txt', 'rb') and you can see that Content-Type is posted as application/json.

api_client = example.ApiClient(configuration)
api_client.action(open('a.txt', 'rb'))  # errors out from server as server receives no file
Related issues/PRs

None that I can find very close, but there are PRs and issues reported that revolve around enhancing select_header_content_type function.

Suggest a fix

A Content-Type vs Open API Type map must be available to auto select content type based in the input.

For example, from this schema (see gist for full schema),

  "requestBody": {
    "content": {
      "multipart/form-data": {
        "schema": {
          "$ref": "#/components/schemas/FileInput"
        }
      },
      "application/json": {
        "schema": {
          "$ref": "#/components/schemas/FileInputAsJson"
        }
      }
    }
  }

we can create a map like this (or whatever makes sense w.r.t language)

{
  "#/components/schemas/FileInput": "multipart/form-data",
  "#/components/schemas/FileInputAsJson": "application/json"
}

A hack I use to get out of this situation, currently:

I create an 2 api clients, one for forcing multipart content type,

multipart_api_client = example.ApiClient(configuration)
multipart_api_client.set_default_headers('Content-Type', 'multipart/form-data')

multipart_api_client.action(open('a.txt', 'rb'))  # works

and other for default

default_api_client = example.ApiClient(configuration)

tiholic avatar Jul 16 '21 12:07 tiholic

I found the same problem.

What follows is what I could gather from inspecting the python templates and messing around with them as well as tweaking my API schema. Don't take this for a good answer. Verify. I'm not maintainer of this repo. I'm just starting to use it.

Big Assumption

When an endpoint accepts multiple content-types, the client code is generated from the first available option.

Example

given the order x-www-form-urlencoded, json, as in

{                                                                                              
  "post": {  
    "operationId": "api_token_auth_create",
    "requestBody": {
      "content": {
        "application/x-www-form-urlencoded": {
          "schema": {                                                                          
            "$ref": "#/components/schemas/AuthTokenRequest"
          }
        },
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/AuthTokenRequest"
          }
        }
...

This code is generated

    def api_token_auth_create(                                                                 
        self,                                  
        username: Annotated[str, Field(min_length=1, strict=True)],
        password: Annotated[str, Field(min_length=1, strict=True)],
        _request_timeout: Union[                                                               
            None,                              
            Annotated[StrictFloat, Field(gt=0)],
            Tuple[                             
                Annotated[StrictFloat, Field(gt=0)], Annotated[StrictFloat, Field(gt=0)]       
            ],                                 
        ] = None,                              
        _request_auth: Optional[Dict[StrictStr, Any]] = None,
        _content_type: Optional[StrictStr] = None,
        _headers: Optional[Dict[StrictStr, Any]] = None,
        _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
    ) -> ApiResponse[AuthToken]:

Swapping them, this code is generated.

    @validate_call                                                                                                                                                                             
    def api_token_auth_create(                                                                                                                                                                 
        self,                                                                                                                                                                                  
        auth_token_request: AuthTokenRequest,                                                                                                                                                  
        _request_timeout: Union[                                                                                                                                                               
            None,                                                                                                                                                                              
            Annotated[StrictFloat, Field(gt=0)],                                                                                                                                               
            Tuple[                             
                Annotated[StrictFloat, Field(gt=0)], Annotated[StrictFloat, Field(gt=0)]
            ],                                                                                 
        ] = None,                              
        _request_auth: Optional[Dict[StrictStr, Any]] = None,
        _content_type: Optional[StrictStr] = None,
        _headers: Optional[Dict[StrictStr, Any]] = None,
        _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
    ) -> ApiResponse[AuthToken]: 

Note the difference: the first arguments (username / password vs. AuthTokenRequest). This per se is not the problem. It's just to show that code generator seems to be using the first available content-type.

consequence

There are more differences later (I'm not including them to avoid writing a book here) but in the first scenario the username and password will end up being lost (not sent at all), due to the wrong content-type selection.

I think there is an error in the selection of the request content type. If you don' specify it, then self.api_client.select_header_content_type is called, which will always favor application/json if it's available.

Workarounds:

  1. swap content type options in your schema

  2. Force correct content-type on a single request

This is the safest and easiest.

Every call to an operation accepts _content_type as an argument so you may change that on a per request basis (no need to set_default_headers, which may wrongfully impact other requests later).

  1. Modify the default content-type selection logic

Use a custom template, removing the "favor json" logic:

         if not content_types:
             return None
 
-        for content_type in content_types:
-            if re.search('json', content_type, re.IGNORECASE):
-                return content_type
-
         return content_types[0]

It remains to be seen what else I may be breaking here.

mmodenesi avatar Aug 24 '24 10:08 mmodenesi

just ran into the same issue where the server json is just broken and I need to "enforce" xml. Poking around but I might up manipulating the swagger to to no select json at all

sruehl avatar Jul 24 '25 11:07 sruehl