0%
January 2, 2025

① Swagger-UI for Spring Boot in SnapStarted Lambda ② Automation of Authorization-Header Assignment for API Testings ③ Basic Functionalities

springboot

swagger-ui

Setup in Spring Boot Application

Installation
// build.gradle.kts

dependencies {
    ...
    implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0")
}

Component: SwaggerConfig

What to configure and why?
Task 1. Solve the swagger configuration problem due to the deployment via API-Gateway.

When deploying via api-gateway we need to divide our resource paths by dev, uat and prod.

For example, a lambda function deployed to api-gateway of stage dev can be accessed via

  • https://rkfm9k8phd.execute-api.ap-northeast-1.amazonaws.com/dev By default the spring-doc framework has done the following incorrectly since the auto-generated swagger-rosources do not belong to the spring framework: The swagger-ui/index.html
  • fetches its swagger-config from /v3/api-docs/swagger-config
  • fetches its apiDoc-config from /v3/api-docs which are both incorrect as we need to start with /dev/v3/.

To solve these, we either

  • set different paths from application-<stage>.yml's
  • set them programmatically by creating the beans according to the deployment stage:
@Primary
@Bean
fun swaggerUiConfig(swaggerUiConfig: SwaggerUiConfigProperties): SwaggerUiConfigParameters {
    val prefix = getLambdaPrefix()
    return SwaggerUiConfigParameters(swaggerUiConfig).apply {
        url = "$prefix/v3/api-docs"
        configUrl = "$prefix/v3/api-docs/swagger-config"
        path = "/api"
    }
}

@Primary
@Bean
fun apiDocsConfig(apiDocsProperties: SpringDocConfigProperties): SpringDocConfigProperties {
    val prefix = getLambdaPrefix()
    return apiDocsProperties.apply {
        apiDocs.path = "$prefix/v3/api-docs"
    }
}
Task 2. Accomplish the automation of the "authorization-header assignment" once we are authenticated via login-url

As in postman with Scripts tab:

we can set the resulting token into our environment variable and start the API testing, we wish to automate this process as well in swagger-ui.

The SwaggerConfig: WebMvcConfigurer
Imports
1package dev.james.alicetimetable.commons.config
2
3import io.swagger.v3.oas.models.Components
4import io.swagger.v3.oas.models.OpenAPI
5import io.swagger.v3.oas.models.info.Info
6import io.swagger.v3.oas.models.security.SecurityRequirement
7import io.swagger.v3.oas.models.security.SecurityScheme
8import io.swagger.v3.oas.models.servers.Server
9import org.springdoc.core.properties.SpringDocConfigProperties
10import org.springdoc.webmvc.ui.SwaggerIndexTransformer
11import org.springframework.beans.factory.annotation.Value
12import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
13import org.springframework.context.annotation.Bean
14import org.springframework.context.annotation.Configuration
15import org.springframework.context.annotation.Primary
16import org.springframework.core.env.Environment
17import org.springframework.core.env.get
18import org.springframework.core.io.Resource
19import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
20import java.io.ByteArrayInputStream
21import java.io.File
22import java.io.InputStream
23import java.net.URI
24import java.net.URL
Util functions for prefixes (dev, uat, prod)
26@Configuration
27@ConditionalOnProperty(
28    name = ["springdoc.swagger-ui.enabled"],
29    havingValue = "true",
30    matchIfMissing = false
31)
32class SwaggerConfig(
33    private val env: Environment,
34    @Value("\${stage.env}") private val stage: String,
35    @Value("\${swagger-config.login-url}") private val loginUrl: String,
36    @Value("\${swagger-config.accessTokenPath}") private val accessTokenPath: String
37) : WebMvcConfigurer {
38    private fun isLambda(): Boolean {
39        return env["IS_LAMBDA"] == "true"
40    }
41
42    fun getLambdaPrefix(): String {
43        val isLambda = isLambda()
44        val prefix = when (isLambda) {
45            true -> "/$stage"
46            else -> ""
47        }
48        return prefix
49    }
Inject custom javascript to automation the process of setting authorization header after login

Next we inject the logic for requestInterceptor and responseInterceptor, they are used to automate the following:

  1. Login. When we login we will set the accessToken to local storage

  2. Make Request. When we make request we will take the accessToken from local storage and set authorization: Bearer <token> to the header.

The injection of interceptors is achieved by surgery on swagger-initializer.js. For spring boot it is as simple as creating the bean of type SwaggerIndexTransformer:

51    private class TransformedResource(
52        private val original: Resource,
53        private val content: ByteArray,
54    ) : Resource {
55        override fun getInputStream(): InputStream = ByteArrayInputStream(content)
56        override fun exists(): Boolean = true
57        override fun isOpen(): Boolean = false
58        override fun getDescription(): String = "Transformed resource"
59        override fun getFilename(): String? = original.filename
60        override fun getURL(): URL = original.url
61        override fun createRelative(relativePath: String): Resource = original.createRelative(relativePath)
62        override fun getURI(): URI = original.uri
63        override fun contentLength(): Long = content.size.toLong()
64        override fun lastModified(): Long = original.lastModified()
65        override fun getFile(): File = original.file
66    }
67
68    @Bean
69    fun swaggerIndexTransformer(): SwaggerIndexTransformer {
70        return SwaggerIndexTransformer { request, resource, transformerChain ->
71            val transformedResource = transformerChain.transform(request, resource)
72            val filename = resource.filename
73            when {
74                filename?.endsWith("swagger-initializer.js") == true -> {
75                    val content = String(transformedResource.inputStream.readAllBytes())
76                    val updatedContent = content.replace(
77                        """url: "https://petstore.swagger.io/v2/swagger.json"""",
78                        newSwaggerUiConfig()
79                    )
80                    TransformedResource(transformedResource, updatedContent.toByteArray())
81                }
82
83                else -> transformedResource
84            }
85        }
86    }

In the following injection we also define springdoc.swagger-ui.<property>'s, they only differ by the deployment stage and we don't need to make variants on application-<stage>.yml:

88    private fun newSwaggerUiConfig(): String {
89        val prefix = getLambdaPrefix()
90        val url = "$prefix/v3/api-docs"
91        val configUrl = "$prefix/v3/api-docs/swagger-config"
92        val path = "$prefix/api"
93        return """
94    url: "$url",
95    path: "$path",
96    configUrl: "$configUrl",
97    tagsSorter: "alpha",
98    requestInterceptor: (request) => {
99        const token = localStorage.getItem('bearer_token');
100        if (token) {
101            request.headers['Authorization'] = `Bearer ${'$'}{token}`;
102        }
103        return request;
104    },
105    responseInterceptor: (response) => {
106        if (response.url.endsWith('$loginUrl')) {
107            try {
108                const responseBody = JSON.parse(response.text);
109                if (responseBody.result && responseBody.${this.accessTokenPath}) {
110                    const token = responseBody.${this.accessTokenPath};
111                    localStorage.setItem('bearer_token', token);
112                    const bearerAuth = {
113                        bearerAuth: {
114                            name: "Authorization",
115                            schema: { type: "http", scheme: "bearer" },
116                            value: `Bearer ${'$'}{token}`
117                        }
118                    };
119                    window.ui.authActions.authorize(bearerAuth);
120                }
121            } catch (e) {
122                console.error('Error processing login response:', e);
123            }
124        }
125        return response;
126    },
127    onComplete: () => {
128        const storedToken = localStorage.getItem('bearer_token');
129        if (storedToken) {
130            const bearerAuth = {
131                bearerAuth: {
132                    name: "Authorization",
133                    schema: { type: "http", scheme: "bearer" },
134                    value: `Bearer ${'$'}{storedToken}`
135                }
136            };
137            window.ui.authActions.authorize(bearerAuth);
138        }
139    }
140        """
141    }
Configure baseURL's and api-info to the swagger documentation
143    @Bean
144    fun customOpenAPI(): OpenAPI {
145        val localServer = Server()
146            .url("http://localhost:$serverPort")
147            .description("Local")
148
149        val devServer = Server()
150            .url("https://rkfm9k8phd.execute-api.ap-northeast-1.amazonaws.com/dev")
151            .description("Development server for alice timetable system")
152
153        return OpenAPI()
154            .openapi("3.1.0")
155            .info(Info()
156                      .title("Alice Timetable System")
157                      .description("This is the api spec of alice timetable system, under development")
158                      .version("1.0"))
159            .servers(listOf(localServer,
160                            devServer))
161    }
162
163    @Primary
164    @Bean
165    fun apiDocsConfig(apiDocsProperties: SpringDocConfigProperties): SpringDocConfigProperties {
166        val prefix = getLambdaPrefix()
167        return apiDocsProperties.apply {
168            apiDocs.path = "$prefix/v3/api-docs"
169        }
170    }
171}
Demonstration Video

More on Decoding Configuration in application.yml

Issues from API-Gateway

By default response header for the content-type of js-bundles generated by springdoc-openapi-starter is

text/javascript

from which API-Gateway has no clue how to decode it, and therefore generated something like

À:"A",Á:"A",Â:"A",Ã:"A",Ä:"A",Å:"A",à:"a",á:"a",
â:"a",ã:"a",ä:"a",Ã¥:"a",Ç:"C",ç:"c",Ð:"D",ð:"d",È:"E",É:"E",Ã

in the js files. To get around this problem, let's force every response header to have charset=UTF-8 by setting

// application.yml
server:
  servlet:
    encoding:
      charset: UTF-8
      force: true

in application.yml. Now API-Gateway understands how to decode it when they read:

Common Usages

Add multiple servers for API testing

This is suitable for testing the same api in local, dev, uat, prod, etc, environments. It provides a dropdown list that we can switch very easily.

Let's extend the customOpenAPI from the previous section:

    @Bean
    fun customOpenAPI(): OpenAPI {
        val devServer = Server()
            .url("https://rkfm9k8phd.execute-api.ap-northeast-1.amazonaws.com/dev")
            .description("Development server for alice timetable system")
        val prodServer = Server()
            .url("")
            .description("Production server for alice timetable system (not created yet)")

        return OpenAPI().components(
            Components().addSecuritySchemes("bearer-jwt",
                                            SecurityScheme()
                                                .type(SecurityScheme.Type.HTTP)
                                                .scheme("bearer")
                                                .bearerFormat("JWT")
                                                .`in`(SecurityScheme.In.HEADER)
                                                .name("Authorization")))
            .addSecurityItem(SecurityRequirement().addList("bearer-jwt"))
            .info(Info()
                      .title("Alice Timetable System")
                      .version("1.0"))
            .servers(listOf(devServer,
                            prodServer))
    }
Add default example value to @RequestBody
    data class LoginRequest(
        @field:Schema(
            description = "User Email",
            example = "test@gmail.com"
        )
        val email: String,
        @field:Schema(
            description = "Password",
            example = "some-password!"
        )
        val password: String,
    )

    @PostMapping("/login")
    fun login(@RequestBody loginRequest: LoginRequest): APIResponse<LoginResult> {
        val (accessToken, refreshToken, accessTokenPayload) = authApplicationService.handleLoginRequest(
            loginRequest
        )
        return APIResponse(LoginResult(accessToken = accessToken,
                                       refreshToken = refreshToken,
                                       user = accessTokenPayload))
    }

Which results in

Add default example value to @PathVariable and @RequestParam
    @GetMapping("/{studentId}/student-packages")
    fun getStudentPackages(
        @Parameter(
            description = "Student ID",
            example = "4b05543b-4ee5-4ce7-b045-a8975b305b09",
            required = true
        )
        @PathVariable("studentId") studentId: String
    ): APIResponse<List<StudentPackageResposne>> {
        val packages = AliceLoggingUtil.hibernateSQL {
            studentApplicationService.getStudentPackages(studentId)
        }
        return APIResponse(packages)
    }

Which results in

Add ordering to the Controllers by Tags

Sometimes we wish a simple controller (like auth) to be always on top of others (as we access it most frequently once our accessToken expires).

Here is how we do this:

@Tag(name = "01. Auth Controller")
@RestController
@RequestMapping("/auth")
class AuthContorller(
    private val jwtService: JwtService,
    private val authApplicationService: AuthApplicationService,
    private val authService: AuthService,
) {
    ...
}

Note that by default swagger-ui arranges them in descending order. To order the controllers correctly let's add:

# application.yml
springdoc:
  swagger-ui:
    tags-sorter: alpha

And we are done!