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: Theswagger-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
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:
-
Login. When we login we will set the
accessToken
to local storage -
Make Request. When we make request we will take the
accessToken
from local storage and setauthorization: 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
baseURL
's and api-info to the swagger documentation143 @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
@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
@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!