swagger-ui icon indicating copy to clipboard operation
swagger-ui copied to clipboard

Authorize button doesn't work if OpenAPI's securityScheme.type is upper-case

Open NadChel opened this issue 1 year ago • 0 comments

Finally! I pinned it down! It took me a while. Here's an MRE:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.6.4</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example</groupId>
    <artifactId>swagger-ui-mre</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>swagger-ui-mre</name>
    <description>swagger-ui-mre</description>
    <properties>
        <java.version>17</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-ui</artifactId>
            <version>1.7.0</version>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>
server:
  port: 8100
springdoc:
  swagger-ui:
    path: /swagger-ui
package com.example.swaggeruimre;

import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.info.Info;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@OpenAPIDefinition(info = @Info(version = "1.0", title = "Hello API"))
public class SwaggerUiMreApplication {

    public static void main(String[] args) {
        SpringApplication.run(SwaggerUiMreApplication.class, args);
    }

}
package com.example.swaggeruimre;

import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
import io.swagger.v3.oas.annotations.security.SecurityScheme;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "Hello Controller")
@RestController
@SecurityScheme(type = SecuritySchemeType.HTTP, name = "bearer-key",
        description = "authorization with JWT token", scheme = "bearer",
        bearerFormat = "JWT")
public class HelloController {
    @GetMapping("/hello")
    public HelloMessage getHello() {
        return new HelloMessage();
    }

    @Getter
    @NoArgsConstructor
    public static class HelloMessage {
        private final String message = "Hello!";
    }
}

2024-02-11_14-11-14

The Authorize button works!

Let's copy the OpenAPI JSON. It's a good JSON, one that produces a button

2024-02-11_14-26-53

Now, let's imagine that we receive that JSON from elsewhere. For simplicity, the source and the recipient are going to be the same

server:
  port: 8100
springdoc:
  swagger-ui:
    path: /swagger-ui
    url: /open-api
package com.example.swaggeruimre;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class OpenApiController {
    @GetMapping("/open-api")
    public String getOpenApi() {
        return """             
                {
                  "openapi": "3.0.1",
                  "info": {
                    "title": "Hello API",
                    "version": "1.0"
                  },
                  "servers": [
                    {
                      "url": "http://localhost:8100",
                      "description": "Generated server url"
                    }
                  ],
                  "paths": {
                    "/hello": {
                      "get": {
                        "tags": [
                          "Hello Controller"
                        ],
                        "operationId": "getHello",
                        "responses": {
                          "200": {
                            "description": "OK",
                            "content": {
                              "*/*": {
                                "schema": {
                                  "$ref": "#/components/schemas/HelloMessage"
                                }
                              }
                            }
                          }
                        }
                      }
                    }
                  },
                  "components": {
                    "schemas": {
                      "HelloMessage": {
                        "type": "object",
                        "properties": {
                          "message": {
                            "type": "string"
                          }
                        }
                      }
                    },
                    "securitySchemes": {
                      "bearer-key": {
                        "type": "http",
                        "description": "authorization with JWT token",
                        "scheme": "bearer",
                        "bearerFormat": "JWT"
                      }
                    }
                  }
                }
                """;
    }
}

2024-02-11_14-37-46

Still works!

Now, let's change the JSON we return and make it a bad JSON. I already know the trick. Replace "type": "http" with "type": "HTTP". The result:

2024-02-11_14-42-52

Now the button doesn't work!

You may be wondering, "Why would you want to turn a good JSON into a bad JSON?" To simulate what OpenApiV3Parser does!

Suppose I return not a string, but an object:

<!-- you need to add this -->

        <dependency>
            <groupId>io.swagger.parser.v3</groupId>
            <artifactId>swagger-parser</artifactId>
            <version>2.1.18</version>
        </dependency>
package com.example.swaggeruimre;

import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.parser.OpenAPIV3Parser;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class OpenApiController {

    @GetMapping("/open-api")
    public OpenAPI getOpenApi() {
        return new OpenAPIV3Parser().readContents("""             
                {
                  "openapi": "3.0.1",
                  "info": {
                    "title": "Hello API",
                    "version": "1.0"
                  },
                  "servers": [
                    {
                      "url": "http://localhost:8100",
                      "description": "Generated server url"
                    }
                  ],
                  "paths": {
                    "/hello": {
                      "get": {
                        "tags": [
                          "Hello Controller"
                        ],
                        "operationId": "getHello",
                        "responses": {
                          "200": {
                            "description": "OK",
                            "content": {
                              "*/*": {
                                "schema": {
                                  "$ref": "#/components/schemas/HelloMessage"
                                }
                              }
                            }
                          }
                        }
                      }
                    }
                  },
                  "components": {
                    "schemas": {
                      "HelloMessage": {
                        "type": "object",
                        "properties": {
                          "message": {
                            "type": "string"
                          }
                        }
                      }
                    },
                    "securitySchemes": {
                      "bearer-key": {
                        "type": "http",
                        "description": "authorization with JWT token",
                        "scheme": "bearer",
                        "bearerFormat": "JWT"
                      }
                    }
                  }
                }
                """).getOpenAPI();
    }
}

Let's see 2024-02-11_14-56-33 2024-02-11_15-01-26 2024-02-11_14-57-59

Maybe we just need to tell our ObjectMapper to ignore nulls?

package com.example.swaggeruimre;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MyConfig {
    @Bean
    public ObjectMapper objectMapper() {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
        return objectMapper;
    }
}

2024-02-11_15-06-21 2024-02-11_15-06-47 2024-02-11_15-07-41

Nope! It's precisely because once we return objects instead of strings, those nested enum properties, e.g. SecurityScheme.Type, are serialized into upper-case strings which Swagger UI can't handle!

Considering you override toString() for each of your enums to return lower-case values

// like so

public class SecurityScheme {
    /**
     * Gets or Sets type
     */
    public enum Type {
        APIKEY("apiKey"),
        HTTP("http"),
        OAUTH2("oauth2"),
        OPENIDCONNECT("openIdConnect"),
        MUTUALTLS("mutualTLS");

        private String value;

        Type(String value) {
            this.value = value;
        }

        @Override
        public String toString() {
            return String.valueOf(value);
        }
    }

I imagine you had a problem with this before

I hate to break it to you, but it's not solved completely!

To recap:

  1. If we return an OpenAPI object (not a string), ObjectMapper serializes it in a way that results in upper-case enum values. Your toString()s are not called during serialization!
  2. When Swagger UI's underlying OpenAPI has upper-case enum values, specifically SecurityScheme.Type, the Authorize button doesn't work!

I see these approaches:

  1. Make Swagger UI stomach upper-case (good)
  2. Tell Jackson to serialize your enums to lower-case strings (a kludge)
// here's one way to do it

public class SecurityScheme {
    /**
     * Gets or Sets type
     */
    public enum Type {
        APIKEY("apiKey"),
        HTTP("http"),
        OAUTH2("oauth2"),
        OPENIDCONNECT("openIdConnect"),
        MUTUALTLS("mutualTLS");

        private String value;

        Type(String value) {
            this.value = value;
        }

        @Override
        @JsonValue
        public String toString() {
            return String.valueOf(value);
        }
    }

I could fork and do the latter, but I believe it's better to do the former (I don't know how)

NadChel avatar Feb 11 '24 12:02 NadChel