Lambda function configuration
serverless.yml
Note that the highlighted line enables us to snapshot a lambda function:
service: <your-application-name> package: individually: true artifact: build/libs/function.jar provider: name: aws region: ap-southeast-2 stage: dev runtime: java17 functions: api: timeout: 900 handler: com.your.package.LambdaHandler snapStart: true environment: SPRING_PROFILES_ACTIVE: dev MAIN_CLASS: com.your.package.ApplicationKt events: - http: ANY / - http: ANY /{proxy+} custom: scriptable: hooks: "before:package:createDeploymentArtifacts": > docker run --rm -v %cd%:/app -w /app gradle:jdk17 gradle lambdaJar plugins: - serverless-scriptable-plugin
Here %cd%
is for windows machine, if you are using linux, please change it to $(pwd)
.
The LambdaHandler
package com.your.package import com.amazonaws.serverless.proxy.model.AwsProxyRequest import com.amazonaws.serverless.proxy.model.AwsProxyResponse import com.amazonaws.services.lambda.runtime.Context import com.amazonaws.services.lambda.runtime.RequestHandler import com.amazonaws.serverless.proxy.spring.SpringBootLambdaContainerHandler class LambdaHandler : RequestHandler<AwsProxyRequest, AwsProxyResponse> { companion object { private val handler = SpringBootLambdaContainerHandler.getAwsProxyHandler(BillieApplication::class.java) } override fun handleRequest(input: AwsProxyRequest, context: Context): AwsProxyResponse { return handler.proxy(input, context) } }
Minimal build.gradle.kts for a Complete Project
import org.springframework.boot.gradle.tasks.bundling.BootJar plugins { kotlin("jvm") version "1.9.25" kotlin("plugin.spring") version "1.9.25" id("org.springframework.boot") version "3.3.0-SNAPSHOT" id("io.spring.dependency-management") version "1.1.6" kotlin("plugin.jpa") version "1.9.25" kotlin("plugin.serialization") version "2.0.0" } group = "com.your" // com.your.project, here "your" is usually a company version = "0.0.1-SNAPSHOT" java { toolchain { languageVersion = JavaLanguageVersion.of(17) } } repositories { mavenCentral() maven { url = uri("https://repo.spring.io/milestone") } maven { url = uri("https://repo.spring.io/snapshot") } } dependencies { implementation("org.springframework.boot:spring-boot-starter") implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-aop") implementation("com.amazonaws.serverless:aws-serverless-java-container-springboot3:2.0.3") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1") implementation("io.github.oshai:kotlin-logging-jvm:5.1.0") implementation("com.google.api-client:google-api-client:2.0.0") implementation("com.google.oauth-client:google-oauth-client-jetty:1.34.1") implementation("com.google.apis:google-api-services-gmail:v1-rev20220404-2.0.0") implementation("com.google.code.gson:gson:2.11.0") implementation("javax.mail:mail:1.4.7") implementation("com.amazonaws:aws-lambda-java-core:1.2.3") implementation("com.amazonaws:aws-lambda-java-events:3.11.3") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("io.fusionauth:fusionauth-jwt:5.3.3") implementation("at.favre.lib:bcrypt:0.10.2") runtimeOnly("org.postgresql:postgresql") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } kotlin { compilerOptions { freeCompilerArgs.addAll("-Xjsr305=strict") } } allOpen { annotation("jakarta.persistence.Entity") annotation("jakarta.persistence.MappedSuperclass") annotation("jakarta.persistence.Embeddable") } tasks.withType<Test> { useJUnitPlatform() } tasks.register<Jar>("lambdaJar") { archiveFileName.set("function.jar") destinationDirectory.set(layout.buildDirectory.dir("libs")) from(sourceSets.main.get().output) into("lib") { from(configurations.runtimeClasspath) { exclude("**/tomcat-*.jar") } } duplicatesStrategy = DuplicatesStrategy.EXCLUDE } tasks.named<Jar>("lambdaJar") { group = "application" description = "Creates a jar file suitable for AWS Lambda deployment" } tasks.withType<BootJar> { targetJavaVersion = JavaVersion.VERSION_17 }
Spring Boot in Node.js Developer Perspective
Replacement of Middleware by AOP Programming
In node.js we usually write
app.use("/file", jwtAuthMiddleware, fileRouter); app.use("/search", jwtAuthMiddleware, searchRouter);
to validate incoming requests or to inject desired object into our context object before reaching any of our routers (in express
case, the context is our req
).
Filter
and HandlerInterceptor
, no, not what we want
Filter
and HandlerInterceptor
, no, not what we wantIn spring boot there are two similar concepts that serve this purpose:
Filter
(Servlet Level)Interceptor
(Application Level in which@Bean
's are available)
The drawback using these approches is the interception is highly implicit. For example, to add an interceptor at applicaiton level we need to define our customer HandlerInterceptor
class object and add it manually:
@Configuration class JwtWebMvcConfigurer( private val jwtHandlerInterceptor: JwtHandlerInterceptor ) : WebMvcConfigurer { override fun addInterceptors(registry: InterceptorRegistry) { registry.addInterceptor(jwtHandlerInterceptor).addPathPatterns("/course/**") } }
Adding a filter follows a simular pattern.
Adding middleware via direct annotation
Instead we can annotate a controller by @AccessToken
which do all the token-validation and "user-data-injection" for us:
@RestController @RequestMapping("/hello") @AccessToken class HelloController( @Value("\${stage.env}") private val env: String, private val orderRepository: OrderRepository, private val eventRepository: EventRepository, private val gmailService: GmailService, private val roleRepository: RoleRepository, ) { @GetMapping("/create-relation") @Transactional ...
Let's define our @AccessToke
!
Define an Aspect Triggered by @AccessToken
@AccessToken
package com.your.package.commons.aop import com.wonderbricks.billie.service.JwtPayload import com.wonderbricks.billie.service.JwtService import org.aspectj.lang.annotation.Aspect import org.aspectj.lang.annotation.Pointcut import org.springframework.stereotype.Component import org.springframework.web.context.request.RequestContextHolder import org.springframework.web.context.request.ServletRequestAttributes import io.fusionauth.jwt.JWTExpiredException import org.aspectj.lang.ProceedingJoinPoint import org.aspectj.lang.annotation.Around import org.aspectj.lang.reflect.MethodSignature @Target(AnnotationTarget.CLASS) @Retention(AnnotationRetention.RUNTIME) annotation class AccessToken @Target(AnnotationTarget.VALUE_PARAMETER) @Retention(AnnotationRetention.RUNTIME) annotation class RequestUser @Aspect @Component class AccessTokenAspect(private val jwtService: JwtService) { private val authHeader: String = "authorization" @Pointcut("@within(com.wonderbricks.billie.commons.aop.AccessToken)") fun getUserPointcut() { } @Around("getUserPointcut()") fun logBefore(joinPoint: ProceedingJoinPoint): Any? { val requestAttributes = RequestContextHolder.getRequestAttributes() as? ServletRequestAttributes val request = requestAttributes?.request try { val accessToken = request?.getHeader(authHeader)?.replace("Bearer ", "") ?: "" if (accessToken == "") { throw Exception("AccessToken cannot be empty") } val payload: JwtPayload = jwtService.parseAndVerifyToken(accessToken)!! val method = (joinPoint.signature as MethodSignature).method val args = joinPoint.args val modifiedArgs = Array(args.size) { index -> if (method.parameters[index].isAnnotationPresent(RequestUser::class.java)) { payload } else { args[index] } } return joinPoint.proceed(modifiedArgs) } catch (exception: Exception) { val errorMessage = when (exception) { is JWTExpiredException -> "JWT_EXPIRED" else -> exception.toString() } throw Exception(errorMessage) } } }
For more refined access control using custom annotations, we may simply ask chat-gpt for the code implementation.
HandlerMethodArgumentResolver
: Configure Spring Boot to Resolve @RequestUser
HandlerMethodArgumentResolver
: Configure Spring Boot to Resolve @RequestUser
We want to access our data annotated by @RequestUser
in the same fashion as @RequestBody
.
However by default spring boot will validate all parameters passing through the argument of each method (e.g., @RequestBody
or @PathVariable
), in which it has no idea how to validate our @RequestUser
-annotated data.
Therefore we need to make configuration to let spring boot ignore this annotation in the input argument of a controller method:
package com.your.package.commons.config import com.wonderbricks.billie.commons.aop.RequestUser import org.springframework.context.annotation.Configuration import org.springframework.core.MethodParameter import org.springframework.stereotype.Component import org.springframework.web.bind.support.WebDataBinderFactory import org.springframework.web.context.request.NativeWebRequest import org.springframework.web.method.support.HandlerMethodArgumentResolver import org.springframework.web.method.support.ModelAndViewContainer import org.springframework.web.servlet.config.annotation.WebMvcConfigurer @Component class RequestUserArgumentResolver : HandlerMethodArgumentResolver { override fun supportsParameter(parameter: MethodParameter): Boolean { return parameter.hasParameterAnnotation(RequestUser::class.java) } override fun resolveArgument( parameter: MethodParameter, mavContainer: ModelAndViewContainer?, webRequest: NativeWebRequest, binderFactory: WebDataBinderFactory? ): Any? { // By returning null, it skips Spring's default parameter resolution, allowing custom logic (such as an aspect) to inject the value. return null } } @Configuration class WebMvcConfig(private var requestUserArgumentResolver: RequestUserArgumentResolver) : WebMvcConfigurer { override fun addArgumentResolvers(resolvers: MutableList<HandlerMethodArgumentResolver>) { resolvers.add(requestUserArgumentResolver) } }
Now we get the parsed user object easily via anntation!
File Uploading
In node.js we handle file uploading (with formdata
as request body) by using
import multiparty from "multiparty"; const handler = (req: Request, res: Response) => { const form = new multiparty.Form(); form.parse(req); form.on("part", async (inputStream: multiparty.Part) => { ... }) }
Now in spring boot:
@PostMapping("/upload) fun fileUpload(@RequestPart("file") file: MultipartFile?) { val uploadFile = file?.inputStream?.let { ... } }
Remember to enable Multipart
file option:
# appplication.yml spring: servlet: multipart: enabled: true max-file-size: 10MB max-request-size: 10MB
JPA using relation tables
The Prisma Model
By mentioning Prisma with spring boot, it implicitly means that we are using database-first approach. Therefore we need to reverse-engineer existing database into jpa @Entity
classes. We have mentioned how to do it in this article with the help of JOOQ.
Beware of the detail of:
- how to handle PostgreSQL enums and
- how to config PostgreSQL to enclose the table name in every single query by double quotes. These are discussed in depth in the article as well.
Let's read about a prisma definition of our 3 tables, which is basically a one-to-many model (one role has many permissions):
model Role { id String @id @default(dbgenerated("ulid_as_uuid()")) @db.Uuid name String displayName String description String createdAt Float @default(dbgenerated("gen_created_at()")) createdAtHK String @default(dbgenerated("gen_created_at_hk_timestr()")) updatedAt Float @default(0) RelRolePermission Rel_Role_Permission[] RelProjectRole Rel_Project_Role[] @@unique([name]) @@index([id]) } model Permission { id String @id @default(dbgenerated("ulid_as_uuid()")) @db.Uuid codeName String displayName String description String createdAt Float @default(dbgenerated("gen_created_at()")) createdAtHK String @default(dbgenerated("gen_created_at_hk_timestr()")) updatedAt Float @default(0) RelRolePermission Rel_Role_Permission[] @@unique([codeName]) @@index([id]) } model Rel_Role_Permission { id Int @id @default(autoincrement()) roleId String @db.Uuid Role Role @relation(fields: [roleId], references: [id]) permissionId String @db.Uuid Permission Permission @relation(fields: [permissionId], references: [id]) createdAtHK String @default(dbgenerated("gen_created_at_hk_timestr()")) createdAt Float @default(dbgenerated("gen_created_at()")) updatedAt Float @default(0) @@unique([permissionId, roleId]) @@index([permissionId, roleId]) }
The JPA Equivalent Definition
Now let's try to model this relation by an @Entity
class in jpa
:
@Entity @DynamicInsert @Table( name = "Role", schema = "public" ) class Role( @Id @Column(name = "id") @GeneratedValue(generator = "ulid_as_uuid") var id: UUID? = null, @Column(name = "name", nullable = false) var name: String, @Column(name = "displayName", nullable = false) var displayname: String, @Column(name = "description", nullable = false) var description: String, @Column(name = "createdAt") var createdat: Double? = null, @Column(name = "createdAtHK") var createdathk: String? = null, @Column(name = "updatedAt") var updatedat: Double? = null ) { @OneToMany @Cascade(CascadeType.ALL) @JoinTable( name = "\"Rel_Role_Permission\"", joinColumns = [JoinColumn(name = "roleId", referencedColumnName = "id")], inverseJoinColumns = [JoinColumn(name = "permissionId", referencedColumnName = "id")] ) val permissions: MutableSet<Permission> = mutableSetOf() }
-
Note that our relation table name must be enclosed by double quotes due to the presence of capital letters.
-
We use
@Cascade(CascadeType.ALL)
to askjpa
to help us persist the in-memory state ofRel_Role_Permission
andPermission
tables.Note that we even didn't mention the presense of the
Permission
table in our@JoinTable
definition! It is implicit in our foreign-key relation (if defined correctly).
The Magic Happens
Now in the past when creating a relation with data-centric approach we need to
- persist an entity in
Role
- persist an entity in
Permission
- finally persist an entity in
Rel_Role_Permission
Now the creation of these entities boils down to an object-oriented orchestration:
@Transactional fun createRoleAndPermission () { val newRole = Role(name = "TEST_ROLE", displayname = "Test Role", description = "This is a test role") val newPermission = Permission(codename = "TEST_PERMISSION_$index", displayname = "Test Permission $index", description = "this is a test permission number $index") newRole.add(newPermission) roleRepository.save(newRole) }
and 3 entities are persisted automatically.
The Problem
In spring boot there is a well-known trap for beginners called problem. Which basically means
- query for fetching entities and
- each of entities dispatches one additional query; causing a total of queries for a single data-fetching.
Let's explain it and solve it by a concrete example. But before that let's add the following to investigate the generated SQL:
# application.yml spring: jpa: open-in-view: false show-sql: true properties: hibernate: format_sql: true
Now consider the following relations:
which by code is modelled as follows:
class Projectmember( @Id @Column(name = "id") @GeneratedValue(generator = "ulid_as_uuid") var id: UUID? = null, @Column(name = "userId", nullable = false) var userid: UUID, @Column(name = "roleId", nullable = false) var roleid: UUID, @Column(name = "createdAt") var createdat: Double? = null, @Column(name = "createdAtHK") var createdathk: String? = null, ) { @OneToOne @JoinTable( name = "Rel_Project_ProjectMember", joinColumns = [JoinColumn(name = "projectMemberId", referencedColumnName = "id")], inverseJoinColumns = [JoinColumn(name = "projectId", referencedColumnName = "id")] ) var project: Project? = null }
Suppose that we run
@Transactional fun getMembersRecord() { return projectMemberRepository.findByUserid(UUID.fromString(userId)) }
then we get two records from our database:
Trouble Happended. Since we directly return the result, behind the scene an eager-loading is triggered to dispatch 2 additional queries due to the @OneToOne
relation (the case is worse if it is @OneToMany
), resulting in queries:
Hibernate: select p1_0."id", p1_0."createdAt", p1_0."createdAtHK", p1_0."roleId", p1_0."userId", p1_1."projectId" from "public"."ProjectMember" p1_0 left join "Rel_Project_ProjectMember" p1_1 on p1_0."id"=p1_1."projectMemberId" where p1_0."userId"=? Hibernate: select p1_0."id", p1_0."address", p1_0."avatarUrl", p1_0."companyId", p1_0."createdAt", p1_0."createdAtHK", p1_0."lat", p1_0."long", p1_0."name", p1_0."region", p1_0."userId", p1_0."utc" from "public"."Project" p1_0 where p1_0."id"=? Hibernate: select p1_0."id", p1_0."address", p1_0."avatarUrl", p1_0."companyId", p1_0."createdAt", p1_0."createdAtHK", p1_0."lat", p1_0."long", p1_0."name", p1_0."region", p1_0."userId", p1_0."utc" from "public"."Project" p1_0 where p1_0."id"=?
To solve it, in our repository we add:
interface ProjectMemberRepository : CrudRepository<Projectmember, UUID> { // or @EntityGraph(attributePaths = ["project"]) @Query("SELECT pm FROM Projectmember pm LEFT JOIN FETCH pm.project where pm.userid = ?1") fun findByUserid(userid: UUID): List<Projectmember>? }
Now our new results are all fetched by simply one query!
Hibernate: select p1_0."id", p1_0."createdAt", p1_0."createdAtHK", p1_0."roleId", p1_0."userId", p2_0."id", p2_0."address", p2_0."avatarUrl", p2_0."companyId", p2_0."createdAt", p2_0."createdAtHK", p2_0."lat", p2_0."long", p2_0."name", p2_0."region", p2_0."userId", p2_0."utc" from "public"."ProjectMember" p1_0 left join "Rel_Project_ProjectMember" p1_1 on p1_0."id"=p1_1."projectMemberId" left join "public"."Project" p2_0 on p2_0."id"=p1_1."projectId" where p1_0."userId"=?
Remarks.
-
Here we prefer to use the JPQL
@Query
approach because it provides a certain extent of type-safty (we even get an auto-completion based on the properties in the class ofProjectmember
) while@EntityGraph
does not. -
Also try to avoid having multiple
left join fetch
's in a single JPQL as it might be buggy and not performant:The error cannot simultaneously fetch multiple bags occurs in JPA when trying to eagerly fetch multiple collection relationships (like @OneToMany or @ManyToMany) in a single query. This is due to limitations in how JPA handles collections with JOIN fetches.
You can define
findByUseridFetchA
,findByUseridFetchB
with differentleft join fetch
clauses based on your actual need.