diff --git a/authentication/pom.xml b/authentication/pom.xml new file mode 100644 index 0000000..d0d26cc --- /dev/null +++ b/authentication/pom.xml @@ -0,0 +1,21 @@ + + + 4.0.0 + + com.coded.spring + Ordering + 0.0.1-SNAPSHOT + + + authentication + + + org.junit.jupiter + junit-jupiter-api + + + + + \ No newline at end of file diff --git a/authentication/src/main/kotlin/authentication/AuthenticationApplication.kt b/authentication/src/main/kotlin/authentication/AuthenticationApplication.kt new file mode 100644 index 0000000..44c3107 --- /dev/null +++ b/authentication/src/main/kotlin/authentication/AuthenticationApplication.kt @@ -0,0 +1,11 @@ +package authentication + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication + +@SpringBootApplication +class AuthenticationApplication + +fun main(args: Array) { + runApplication(*args) +} diff --git a/authentication/src/main/kotlin/authentication/config/LoggingFilter.kt b/authentication/src/main/kotlin/authentication/config/LoggingFilter.kt new file mode 100644 index 0000000..0dc9618 --- /dev/null +++ b/authentication/src/main/kotlin/authentication/config/LoggingFilter.kt @@ -0,0 +1,42 @@ +package authentication.config + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter +import org.springframework.web.util.ContentCachingRequestWrapper +import org.springframework.web.util.ContentCachingResponseWrapper + +@Component +class LoggingFilter : OncePerRequestFilter() { + + private val logger = LoggerFactory.getLogger(LoggingFilter::class.java) + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + val cachedRequest = ContentCachingRequestWrapper(request) + val cachedResponse = ContentCachingResponseWrapper(response) + + filterChain.doFilter(cachedRequest, cachedResponse) + + logRequest(cachedRequest) + logResponse(cachedResponse) + + cachedResponse.copyBodyToResponse() + } + + private fun logRequest(request: ContentCachingRequestWrapper) { + val requestBody = String(request.contentAsByteArray) + logger.info("Request: method=${request.method}, uri=${request.requestURI}, body=$requestBody") + } + + private fun logResponse(response: ContentCachingResponseWrapper) { + val responseBody = String(response.contentAsByteArray) + logger.info("Response: status=${response.status}, body=$responseBody") + } +} \ No newline at end of file diff --git a/authentication/src/main/kotlin/authentication/jwt/JwtAuthenticationFilter.kt b/authentication/src/main/kotlin/authentication/jwt/JwtAuthenticationFilter.kt new file mode 100644 index 0000000..6ba9ce3 --- /dev/null +++ b/authentication/src/main/kotlin/authentication/jwt/JwtAuthenticationFilter.kt @@ -0,0 +1,45 @@ +package authentication.jwt + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.* +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter + +@Component +class JwtAuthenticationFilter( + private val jwtService: JwtService, + private val userDetailsService: UserDetailsService +) : OncePerRequestFilter() { + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + val authHeader = request.getHeader("Authorization") + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response) + return + } + + val token = authHeader.substring(7) + val username = jwtService.extractUsername(token) + + if (SecurityContextHolder.getContext().authentication == null) { + if (jwtService.isTokenValid(token, username)) { + val userDetails = userDetailsService.loadUserByUsername(username) + val authToken = UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.authorities + ) + authToken.details = WebAuthenticationDetailsSource().buildDetails(request) + SecurityContextHolder.getContext().authentication = authToken + } + } + + filterChain.doFilter(request, response) + } +} \ No newline at end of file diff --git a/authentication/src/main/kotlin/authentication/jwt/JwtService.kt b/authentication/src/main/kotlin/authentication/jwt/JwtService.kt new file mode 100644 index 0000000..a83dfb7 --- /dev/null +++ b/authentication/src/main/kotlin/authentication/jwt/JwtService.kt @@ -0,0 +1,50 @@ +package authentication.jwt + +import io.jsonwebtoken.* +import io.jsonwebtoken.security.Keys +import org.springframework.stereotype.Component +import java.util.* +import javax.crypto.SecretKey + + +@Component +class JwtService { + + private val secretKey: SecretKey = Keys.secretKeyFor(SignatureAlgorithm.HS256) + private val expirationMs: Long = 1000 * 60 * 60 + + fun generateToken(username: String): String { + val now = Date() + val expiry = Date(now.time + expirationMs) + + return Jwts.builder() + .setSubject(username) + .setIssuedAt(now) + .setExpiration(expiry) + .signWith(secretKey) + .compact() + } + + fun extractUsername(token: String): String { + return Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .body.subject + } + + fun isTokenValid(token: String, username: String): Boolean { + return try { + val extractedUsername = Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .body + .subject + + extractedUsername == username + } catch (e: Exception) { + false + } + } +} \ No newline at end of file diff --git a/authentication/src/main/kotlin/authentication/profile/InvalidProfileException.kt b/authentication/src/main/kotlin/authentication/profile/InvalidProfileException.kt new file mode 100644 index 0000000..b985ade --- /dev/null +++ b/authentication/src/main/kotlin/authentication/profile/InvalidProfileException.kt @@ -0,0 +1,3 @@ +package com.coded.spring.ordering.exceptions + +class InvalidProfileException(message: String) : Exception(message) \ No newline at end of file diff --git a/authentication/src/main/kotlin/authentication/profile/ProfileController.kt b/authentication/src/main/kotlin/authentication/profile/ProfileController.kt new file mode 100644 index 0000000..ac8a584 --- /dev/null +++ b/authentication/src/main/kotlin/authentication/profile/ProfileController.kt @@ -0,0 +1,63 @@ +package authentication.profile + +import com.coded.spring.ordering.exceptions.InvalidProfileException +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import java.security.Principal + +@RestController +@RequestMapping("/profile") +@Tag(name = "PROFILE", description = "Endpoints for managing user profiles") +class ProfileController( + private val profileService: ProfileService +) { + + @PostMapping + @Operation(summary = "Submit user profile", description = "Creates a new profile for the logged-in user") + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Profile created successfully"), + ApiResponse(responseCode = "400", description = "Invalid profile data or already exists"), + ApiResponse(responseCode = "500", description = "Internal server error") + ] + ) + fun submitProfile( + @RequestBody request: ProfileRequest, + principal: Principal + ): ResponseEntity { + return try { + profileService.saveProfile(principal.name, request) + ResponseEntity.ok(mapOf("message" to "Profile created successfully.")) + } catch (e: InvalidProfileException) { + ResponseEntity.badRequest().body(mapOf("error" to e.message)) + } catch (e: Exception) { + e.printStackTrace() + ResponseEntity.internalServerError().body(mapOf("error" to "Something went wrong.")) + } + } + + @GetMapping + @Operation(summary = "Get user profile", description = "Fetches the profile for the logged-in user") + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Profile fetched successfully"), + ApiResponse(responseCode = "400", description = "Profile not found or invalid request"), + ApiResponse(responseCode = "500", description = "Internal server error") + ] + ) + fun getProfile(principal: Principal): ResponseEntity { + return try { + val profile = profileService.getByUsername(principal.name) + ResponseEntity.ok(profile) + } catch (e: InvalidProfileException) { + ResponseEntity.badRequest().body(mapOf("error" to e.message)) + } catch (e: Exception) { + e.printStackTrace() + ResponseEntity.internalServerError().body(mapOf("error" to "Something went wrong.")) + } + } +} \ No newline at end of file diff --git a/authentication/src/main/kotlin/authentication/profile/ProfileDTO.kt b/authentication/src/main/kotlin/authentication/profile/ProfileDTO.kt new file mode 100644 index 0000000..110899b --- /dev/null +++ b/authentication/src/main/kotlin/authentication/profile/ProfileDTO.kt @@ -0,0 +1,7 @@ +package authentication.profile + +data class ProfileRequest( + val firstName: String, + val lastName: String, + val phoneNumber: String +) \ No newline at end of file diff --git a/authentication/src/main/kotlin/authentication/profile/ProfileRepository.kt b/authentication/src/main/kotlin/authentication/profile/ProfileRepository.kt new file mode 100644 index 0000000..4cec5be --- /dev/null +++ b/authentication/src/main/kotlin/authentication/profile/ProfileRepository.kt @@ -0,0 +1,25 @@ +package authentication.profile + +import jakarta.inject.Named +import jakarta.persistence.* +import org.springframework.data.jpa.repository.JpaRepository + +@Named +interface ProfileRepository : JpaRepository { + fun findByUserId(userId: Long): ProfileEntity? +} + +@Entity +@Table(name = "profiles") +data class ProfileEntity( + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + val firstName: String = "", + val lastName: String = "", + val phoneNumber: String = "", + val userId: Long = 0 + +) \ No newline at end of file diff --git a/authentication/src/main/kotlin/authentication/profile/ProfileService.kt b/authentication/src/main/kotlin/authentication/profile/ProfileService.kt new file mode 100644 index 0000000..7cf8fb6 --- /dev/null +++ b/authentication/src/main/kotlin/authentication/profile/ProfileService.kt @@ -0,0 +1,68 @@ +package authentication.profile + +import authentication.users.UsersRepository +import com.coded.spring.ordering.exceptions.InvalidProfileException +import org.springframework.security.core.userdetails.UsernameNotFoundException +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +open class ProfileService( + private val profileRepository: ProfileRepository, + private val usersRepository: UsersRepository +) { + + fun isValidName(name: String): Boolean { + return name.matches(Regex("^[A-Za-z]+$")) + } + + fun isValidPhone(phone: String): Boolean { + return phone.matches(Regex("^[0-9]{8}$")) + } + + @Transactional + fun saveProfile(username: String, request: ProfileRequest): ProfileEntity { + val user = usersRepository.findByUsername(username) + ?: throw UsernameNotFoundException("User not found") + + val existingProfile = profileRepository.findByUserId(user.id!!) + if (existingProfile != null) { + throw InvalidProfileException("Profile already exists for this user") + } + + if (!isValidName(request.firstName)) { + throw InvalidProfileException("First name must contain only letters") + } + + if (!isValidName(request.lastName)) { + throw InvalidProfileException("Last name must contain only letters") + } + + if (!isValidPhone(request.phoneNumber)) { + throw InvalidProfileException("Phone number must be exactly 8 digits") + } + + val profile = ProfileEntity( + firstName = request.firstName, + lastName = request.lastName, + phoneNumber = request.phoneNumber, + userId = user.id!! + ) + + return profileRepository.save(profile) + } + + fun getByUsername(username: String): ProfileEntity { + val user = usersRepository.findByUsername(username) + if (user == null) { + throw UsernameNotFoundException("User not found") + } + + val profile = profileRepository.findByUserId(user.id!!) + if (profile == null) { + throw InvalidProfileException("Profile not found for this user") + } + + return profile + } +} \ No newline at end of file diff --git a/authentication/src/main/kotlin/authentication/security/AuthenticationController.kt b/authentication/src/main/kotlin/authentication/security/AuthenticationController.kt new file mode 100644 index 0000000..54b8878 --- /dev/null +++ b/authentication/src/main/kotlin/authentication/security/AuthenticationController.kt @@ -0,0 +1,47 @@ +package authentication.security + +import authentication.jwt.JwtService +import authentication.users.UsersService +import io.swagger.v3.oas.annotations.* +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.security.authentication.* +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.core.userdetails.UsernameNotFoundException +import org.springframework.web.bind.annotation.* +import java.security.Principal + +@Tag(name = "AUTHENTICATION") +@RestController +@RequestMapping("/auth/v1") +class AuthenticationController( + private val authenticationManager: AuthenticationManager, + private val userDetailsService: UserDetailsService, + private val jwtService: JwtService, + private val usersService: UsersService +) { + + @PostMapping("/login") + fun login(@RequestBody authRequest: AuthenticationRequest): AuthenticationResponse { + val authToken = UsernamePasswordAuthenticationToken(authRequest.username, authRequest.password) + val authentication = authenticationManager.authenticate(authToken) + + if (authentication.isAuthenticated) { + val token = jwtService.generateToken(authRequest.username) + return AuthenticationResponse(token) + } else { + throw UsernameNotFoundException("Invalid login request.") + } + } + + @PostMapping("/check-token") + fun checkToken(principal: Principal): TokenCheckResponse { + println("🔐 Received token for user: ${principal.name}") + val user = usersService.findByUsername(principal.name) + println("✅ Token is valid, userId=${user.id}") + return TokenCheckResponse(userId = user.id!!) + } +} + +data class AuthenticationRequest(val username: String, val password: String) +data class AuthenticationResponse(val token: String) +data class TokenCheckResponse(val userId: Long) \ No newline at end of file diff --git a/authentication/src/main/kotlin/authentication/security/CustomerUserDetailsService.kt b/authentication/src/main/kotlin/authentication/security/CustomerUserDetailsService.kt new file mode 100644 index 0000000..103b99d --- /dev/null +++ b/authentication/src/main/kotlin/authentication/security/CustomerUserDetailsService.kt @@ -0,0 +1,24 @@ +package authentication.security + +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.core.userdetails.UsernameNotFoundException +import org.springframework.stereotype.Service +import authentication.users.UsersRepository + +@Service +class CustomerUserDetailsService( + private val usersRepository: UsersRepository +) : UserDetailsService { + + override fun loadUserByUsername(username: String): UserDetails { + val user = usersRepository.findByUsername(username) + ?: throw UsernameNotFoundException("User not found") + + return User.withUsername(user.username) + .username(user.username) + .password(user.password) + .build() + } +} \ No newline at end of file diff --git a/authentication/src/main/kotlin/authentication/security/SecurityConfig.kt b/authentication/src/main/kotlin/authentication/security/SecurityConfig.kt new file mode 100644 index 0000000..301d536 --- /dev/null +++ b/authentication/src/main/kotlin/authentication/security/SecurityConfig.kt @@ -0,0 +1,57 @@ +package authentication.security + +import authentication.jwt.JwtAuthenticationFilter +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.authentication.AuthenticationManager +import org.springframework.security.authentication.AuthenticationProvider +import org.springframework.security.authentication.dao.DaoAuthenticationProvider +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter + +@Configuration +@EnableWebSecurity +class SecurityConfig( + private val jwtAuthFilter: JwtAuthenticationFilter, + private val userDetailsService: UserDetailsService +) { + + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http.csrf { it.disable() } + .authorizeHttpRequests { + it.requestMatchers("/auth/**").permitAll() + it.requestMatchers("/users/v1/register").permitAll() + .anyRequest().authenticated() + } + .sessionManagement { + it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + } + .authenticationProvider(authenticationProvider()) + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter::class.java) + + return http.build() + } + + @Bean + fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder() + + @Bean + fun authenticationManager(config: AuthenticationConfiguration): AuthenticationManager = + config.authenticationManager + + @Bean + fun authenticationProvider(): AuthenticationProvider { + val provider = DaoAuthenticationProvider() + provider.setUserDetailsService(userDetailsService) + provider.setPasswordEncoder(passwordEncoder()) + return provider + } +} \ No newline at end of file diff --git a/authentication/src/main/kotlin/authentication/users/TransferFundsException.kt b/authentication/src/main/kotlin/authentication/users/TransferFundsException.kt new file mode 100644 index 0000000..a132135 --- /dev/null +++ b/authentication/src/main/kotlin/authentication/users/TransferFundsException.kt @@ -0,0 +1,3 @@ +package authentication.users + +class TransferFundsException(msg: String) : Exception(msg) \ No newline at end of file diff --git a/authentication/src/main/kotlin/authentication/users/UserDTO.kt b/authentication/src/main/kotlin/authentication/users/UserDTO.kt new file mode 100644 index 0000000..be0e6b8 --- /dev/null +++ b/authentication/src/main/kotlin/authentication/users/UserDTO.kt @@ -0,0 +1,13 @@ +package authentication.users + +data class UserRequest( + val name: String, + val age: Int, + val username: String, + val password: String +) + +data class UserResponse( + val id: Long, + val username: String +) \ No newline at end of file diff --git a/authentication/src/main/kotlin/authentication/users/UserRepository.kt b/authentication/src/main/kotlin/authentication/users/UserRepository.kt new file mode 100644 index 0000000..a9137f9 --- /dev/null +++ b/authentication/src/main/kotlin/authentication/users/UserRepository.kt @@ -0,0 +1,27 @@ +package authentication.users + +import jakarta.inject.Named +import jakarta.persistence.* +import org.springframework.data.jpa.repository.JpaRepository + +@Named +interface UsersRepository : JpaRepository { + fun age(age: Int): MutableList + fun findByUsername(username: String): UserEntity? +} + +@Entity +@Table(name = "users") +data class UserEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + val name: String, + val age: Int, + + val username: String, + val password: String, + +){ + constructor() : this(null, "", 0,"","") +} \ No newline at end of file diff --git a/authentication/src/main/kotlin/authentication/users/UsersController.kt b/authentication/src/main/kotlin/authentication/users/UsersController.kt new file mode 100644 index 0000000..002e4d9 --- /dev/null +++ b/authentication/src/main/kotlin/authentication/users/UsersController.kt @@ -0,0 +1,43 @@ +package authentication.users + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/users") +@Tag(name = "AUTHENTICATION", description = "Handles user registration and listing") +class UsersController( + private val usersService: UsersService +) { + + @PostMapping("/v1/register") + @Operation( + summary = "Register a new user", + description = "Registers a new user using username and password", + responses = [ + ApiResponse(responseCode = "200", description = "User successfully registered"), + ApiResponse(responseCode = "400", description = "Validation failed or user already exists") + ] + ) + fun registerUser(@RequestBody request: UserRequest): ResponseEntity { + return try { + val newUser = usersService.registerUser(request) + ResponseEntity.ok(newUser) + } catch (e: TransferFundsException) { + ResponseEntity.badRequest().body(mapOf("error" to e.message)) + } + } + + @GetMapping("/v1/list") + @Operation( + summary = "List all users", + description = "Returns a list of all registered users", + responses = [ + ApiResponse(responseCode = "200", description = "Users listed successfully") + ] + ) + fun users() = usersService.listUsers() +} \ No newline at end of file diff --git a/authentication/src/main/kotlin/authentication/users/UsersService.kt b/authentication/src/main/kotlin/authentication/users/UsersService.kt new file mode 100644 index 0000000..fd25a90 --- /dev/null +++ b/authentication/src/main/kotlin/authentication/users/UsersService.kt @@ -0,0 +1,53 @@ +package authentication.users + +import jakarta.inject.Named +import org.springframework.security.core.userdetails.UsernameNotFoundException +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Service + +const val USERNAME_MIN_LENGTH = 4 +const val USERNAME_MAX_LENGTH = 30 +const val PASSWORD_MIN_LENGTH = 9 +const val PASSWORD_MAX_LENGTH = 30 + +@Named +@Service +class UsersService( + private val usersRepository: UsersRepository, + private val passwordEncoder: PasswordEncoder +) { + + fun registerUser(request: UserRequest): UserResponse { + if (request.username.length !in USERNAME_MIN_LENGTH..USERNAME_MAX_LENGTH) { + throw IllegalArgumentException("Username must be between $USERNAME_MIN_LENGTH and $USERNAME_MAX_LENGTH characters") + } + + if (request.password.length !in PASSWORD_MIN_LENGTH..PASSWORD_MAX_LENGTH) { + throw IllegalArgumentException("Password must be between $PASSWORD_MIN_LENGTH and $PASSWORD_MAX_LENGTH characters") + } + + val user = UserEntity( + name = request.name, + age = request.age, + username = request.username, + password = passwordEncoder.encode(request.password) + ) + + val saved = usersRepository.save(user) + return UserResponse(id = saved.id!!, username = saved.username) + } + + fun findByUsername(username: String): UserEntity { + return usersRepository.findByUsername(username) + ?: throw UsernameNotFoundException("User not found for username: $username") + } + + fun listUsers(): List { + return usersRepository.findAll().map { + UserResponse( + id = it.id ?: 0, + username = it.username + ) + } + } +} \ No newline at end of file diff --git a/authentication/src/main/resources/application.properties b/authentication/src/main/resources/application.properties new file mode 100644 index 0000000..f66e6f8 --- /dev/null +++ b/authentication/src/main/resources/application.properties @@ -0,0 +1,9 @@ +spring.application.name=Kotlin.SpringbootV2 +server.port = 8081 + +spring.datasource.url=jdbc:postgresql://localhost:5432/myHelloDatabase +spring.datasource.username=postgres +spring.datasource.password=yosaka +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect + +springdoc.api-docs.path=/api-docs diff --git a/ordering/pom.xml b/ordering/pom.xml new file mode 100644 index 0000000..2bac8ac --- /dev/null +++ b/ordering/pom.xml @@ -0,0 +1,62 @@ + + + 4.0.0 + + com.coded.spring + Ordering + 0.0.1-SNAPSHOT + + + ordering + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.hibernate.validator + hibernate-validator + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.postgresql + postgresql + runtime + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + com.fasterxml.jackson.module + jackson-module-kotlin + + + + + org.jetbrains.kotlin + kotlin-reflect + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + + + + \ No newline at end of file diff --git a/ordering/src/main/kotlin/order/OrderingApplication.kt b/ordering/src/main/kotlin/order/OrderingApplication.kt new file mode 100644 index 0000000..c541d92 --- /dev/null +++ b/ordering/src/main/kotlin/order/OrderingApplication.kt @@ -0,0 +1,11 @@ +package order + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication + +@SpringBootApplication +class OrderingApplication + +fun main(args: Array) { + runApplication(*args) +} \ No newline at end of file diff --git a/ordering/src/main/kotlin/order/client/AuthenticationClient.kt b/ordering/src/main/kotlin/order/client/AuthenticationClient.kt new file mode 100644 index 0000000..f20357a --- /dev/null +++ b/ordering/src/main/kotlin/order/client/AuthenticationClient.kt @@ -0,0 +1,47 @@ +package order.client + +import org.springframework.core.ParameterizedTypeReference +import org.springframework.http.* +import org.springframework.stereotype.Component +import org.springframework.web.client.RestTemplate + +data class CheckTokenResponse(val userId: Long) + +@Component +class AuthenticationClient { + + private val restTemplate = RestTemplate() + private val authServerUrl = "http://localhost:8081/auth/v1/check-token" + + fun checkToken(token: String): CheckTokenResponse { + println("🔐 [AuthenticationClient] Checking token...") + + val headers = HttpHeaders() + headers.setBearerAuth(token) + val requestEntity = HttpEntity(headers) + + println("📤 Sending request to auth server at $authServerUrl with token: $token") + + try { + val response = restTemplate.exchange( + authServerUrl, + HttpMethod.POST, + requestEntity, + object : ParameterizedTypeReference() {} + ) + + println("✅ Received response from auth server: ${response.statusCode}") + println("📦 Response body: ${response.body}") + + if (response.statusCode != HttpStatus.OK) { + throw IllegalStateException("❌ Invalid token, status: ${response.statusCode}") + } + + return response.body ?: throw IllegalStateException("❌ Missing response body from auth service") + + } catch (ex: Exception) { + println("🔥 Exception while calling auth server: ${ex.message}") + throw IllegalStateException("Failed to validate token: ${ex.message}", ex) + } + } +} \ No newline at end of file diff --git a/ordering/src/main/kotlin/order/config/LoggingFilter.kt b/ordering/src/main/kotlin/order/config/LoggingFilter.kt new file mode 100644 index 0000000..fd024ee --- /dev/null +++ b/ordering/src/main/kotlin/order/config/LoggingFilter.kt @@ -0,0 +1,35 @@ +package order.config + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter +import org.springframework.web.util.ContentCachingRequestWrapper +import org.springframework.web.util.ContentCachingResponseWrapper + +@Component +class LoggingFilter : OncePerRequestFilter() { + + private val logger = LoggerFactory.getLogger(LoggingFilter::class.java) + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + val wrappedRequest = ContentCachingRequestWrapper(request) + val wrappedResponse = ContentCachingResponseWrapper(response) + + filterChain.doFilter(wrappedRequest, wrappedResponse) + + val requestBody = String(wrappedRequest.contentAsByteArray) + logger.info("Request: method=${request.method}, uri=${request.requestURI}, body=$requestBody") + + val responseBody = String(wrappedResponse.contentAsByteArray) + logger.info("Response: status=${response.status}, body=$responseBody") + + wrappedResponse.copyBodyToResponse() + } +} \ No newline at end of file diff --git a/ordering/src/main/kotlin/order/menu/MenuController.kt b/ordering/src/main/kotlin/order/menu/MenuController.kt new file mode 100644 index 0000000..9afb089 --- /dev/null +++ b/ordering/src/main/kotlin/order/menu/MenuController.kt @@ -0,0 +1,28 @@ +package order.menu + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@Tag(name = "HOME_PAGE", description = "Endpoints related to displaying the homepage menu") +class MenuController( + private val menuService: MenuService +) { + + @GetMapping("/order/menu/v1/list") + @Operation( + summary = "List all menu items for homepage", + description = "Returns all menu entries including discounts if active" + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Menu listed successfully"), + ApiResponse(responseCode = "500", description = "Server error while fetching menu") + ] + ) + fun getMenu(): List = menuService.getMenu() +} \ No newline at end of file diff --git a/ordering/src/main/kotlin/order/menu/MenuRepository.kt b/ordering/src/main/kotlin/order/menu/MenuRepository.kt new file mode 100644 index 0000000..1f3501c --- /dev/null +++ b/ordering/src/main/kotlin/order/menu/MenuRepository.kt @@ -0,0 +1,20 @@ +package order.menu + +import jakarta.persistence.* +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface MenuRepository : JpaRepository +@Entity +@Table(name = "order/menu") +data class MenuEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + val name: String, + val description: String, + val price: Double +) { + constructor(): this(null, "", "", 0.0) +} \ No newline at end of file diff --git a/ordering/src/main/kotlin/order/menu/MenuService.kt b/ordering/src/main/kotlin/order/menu/MenuService.kt new file mode 100644 index 0000000..99e2f8f --- /dev/null +++ b/ordering/src/main/kotlin/order/menu/MenuService.kt @@ -0,0 +1,12 @@ +package order.menu + +import jakarta.inject.Named +import order.menu.MenuEntity +import order.menu.MenuRepository + +@Named +class MenuService( + private val menuRepository: MenuRepository +) { + fun getMenu(): List = menuRepository.findAll() +} \ No newline at end of file diff --git a/ordering/src/main/kotlin/order/orders/ItemsRepository.kt b/ordering/src/main/kotlin/order/orders/ItemsRepository.kt new file mode 100644 index 0000000..dc27389 --- /dev/null +++ b/ordering/src/main/kotlin/order/orders/ItemsRepository.kt @@ -0,0 +1,34 @@ +package order.orders + +import jakarta.persistence.* +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface ItemsRepository : JpaRepository{ + fun findByOrderId(orderId: Long): List +} + +@Entity +@Table(name = "items") +data class ItemEntity( + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null, + + @Column(name = "items") + var name: String? = null, + + var quantity: Long? = null, + + var note: String? = null, + + var price: Double? = null, + + @Column(name = "order_id") + var orderId: Long? = null + +) { + constructor() : this(null, "", 1, "", 0.0, null) +} \ No newline at end of file diff --git a/ordering/src/main/kotlin/order/orders/OrderController.kt b/ordering/src/main/kotlin/order/orders/OrderController.kt new file mode 100644 index 0000000..c50dfb6 --- /dev/null +++ b/ordering/src/main/kotlin/order/orders/OrderController.kt @@ -0,0 +1,50 @@ +package order.orders + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.servlet.http.HttpServletRequest +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/order/orders/v1") +@Tag(name = "ORDERING", description = "Endpoints related to order submission and retrieval") +class OrderController( + private val ordersRepository: OrdersRepository, + private val ordersService: OrdersService +) { + + @PostMapping("/create") + @Operation(summary = "Create a new order", description = "Creates a new order along with items") + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Order created successfully"), + ApiResponse(responseCode = "400", description = "Invalid input"), + ApiResponse(responseCode = "403", description = "Unauthorized access") + ] + ) + fun createOrder( + @RequestBody request: CreateOrderRequest, + servletRequest: HttpServletRequest + ): ResponseEntity { + println("🔥 [OrderController] createOrder() hit") + val userId = servletRequest.getAttribute("userId") as Long + val order = ordersService.createOrder(userId, request.items) + return ResponseEntity.ok(order) + } + + @GetMapping + @Operation(summary = "List user's orders", description = "Lists all orders submitted by the logged-in user") + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Orders retrieved successfully"), + ApiResponse(responseCode = "403", description = "Unauthorized access") + ] + ) + fun listMyOrders(servletRequest: HttpServletRequest): List { + val userId = servletRequest.getAttribute("userId") as Long + return ordersService.listOrdersForUser(userId) + } +} \ No newline at end of file diff --git a/ordering/src/main/kotlin/order/orders/OrderDTO.kt b/ordering/src/main/kotlin/order/orders/OrderDTO.kt new file mode 100644 index 0000000..9514bdc --- /dev/null +++ b/ordering/src/main/kotlin/order/orders/OrderDTO.kt @@ -0,0 +1,20 @@ +package order.orders + +data class Item( + val id: Long?, + val orderId: Long?, + val name: String?, + val quantity: Long?, + val note: String?, + val price: Double? +) + +data class Order( + val id: Long?, + val userId: Long?, + val items: List +) + +data class CreateOrderRequest( + val items: List +) diff --git a/ordering/src/main/kotlin/order/orders/OrdersRepository.kt b/ordering/src/main/kotlin/order/orders/OrdersRepository.kt new file mode 100644 index 0000000..3acbe00 --- /dev/null +++ b/ordering/src/main/kotlin/order/orders/OrdersRepository.kt @@ -0,0 +1,25 @@ +package order.orders +import jakarta.persistence.* +import org.springframework.data.jpa.repository.JpaRepository + +import jakarta.inject.Named + +@Named +interface OrdersRepository: JpaRepository{ + fun findByUserId(userId: Long): List +} + +@Entity +@Table(name = "orders") +class OrderEntity( + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null, + + @Column(name = "user_id") + var userId: Long? = null, + +) { + constructor() : this(null, null) +} \ No newline at end of file diff --git a/ordering/src/main/kotlin/order/orders/OrdersService.kt b/ordering/src/main/kotlin/order/orders/OrdersService.kt new file mode 100644 index 0000000..cb2c4e2 --- /dev/null +++ b/ordering/src/main/kotlin/order/orders/OrdersService.kt @@ -0,0 +1,64 @@ +package order.orders + +import jakarta.inject.Named + +@Named +class OrdersService( + private val ordersRepository: OrdersRepository, + private val itemsRepository: ItemsRepository +) { + + fun createOrder(userId: Long, items: List): Order { + val newOrder = ordersRepository.save(OrderEntity(userId = userId)) + + val itemEntities = items.map { + ItemEntity( + name = it.name, + quantity = it.quantity, + note = it.note, + price = it.price, + orderId = newOrder.id!! + ) + } + + itemsRepository.saveAll(itemEntities) + + return Order( + id = newOrder.id!!, + userId = userId, + items = itemEntities.map { + Item( + id = it.id, + orderId = it.orderId, + name = it.name, + quantity = it.quantity, + note = it.note, + price = it.price + ) + } + ) + } + + fun listOrdersForUser(userId: Long): List { + return ordersRepository.findByUserId(userId).map { orderEntity -> + val itemEntities = itemsRepository.findByOrderId(orderEntity.id!!) + + val items = itemEntities.map { + Item( + id = it.id, + orderId = it.orderId, + name = it.name, + quantity = it.quantity, + note = it.note, + price = it.price + ) + } + + Order( + id = orderEntity.id!!, + userId = userId, + items = items + ) + } + } +} \ No newline at end of file diff --git a/ordering/src/main/kotlin/order/security/RemoteAuthenticationFilter.kt b/ordering/src/main/kotlin/order/security/RemoteAuthenticationFilter.kt new file mode 100644 index 0000000..581f6ac --- /dev/null +++ b/ordering/src/main/kotlin/order/security/RemoteAuthenticationFilter.kt @@ -0,0 +1,46 @@ +package order.security + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import order.client.AuthenticationClient +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter +@Component +class RemoteAuthenticationFilter( + private val authenticationClient: AuthenticationClient +) : OncePerRequestFilter() { + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + println("✅ RemoteAuthenticationFilter START") + + val authHeader = request.getHeader("Authorization") + println("🟨 Authorization Header: $authHeader") + + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + println("⚠️ No Bearer token found. Skipping auth.") + filterChain.doFilter(request, response) + return + } + + val token = authHeader.removePrefix("Bearer ").trim() + println("🔑 Extracted Token: $token") + + try { + val result = authenticationClient.checkToken(token) + println("✅ Token validated. userId=${result.userId}") + request.setAttribute("userId", result.userId) + } catch (ex: Exception) { + println("❌ Token validation failed: ${ex.message}") + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid token") + return + } + + println("➡️ Proceeding with filter chain") + filterChain.doFilter(request, response) + } +} \ No newline at end of file diff --git a/ordering/src/main/kotlin/order/security/SecurityConfig.kt b/ordering/src/main/kotlin/order/security/SecurityConfig.kt new file mode 100644 index 0000000..556c8b1 --- /dev/null +++ b/ordering/src/main/kotlin/order/security/SecurityConfig.kt @@ -0,0 +1,30 @@ +package order.security + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter + +@Configuration +@EnableWebSecurity +class SecurityConfig( + private val remoteAuthFilter: RemoteAuthenticationFilter +) { + + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http.csrf { it.disable() } + .authorizeHttpRequests { + it.anyRequest().permitAll() // restrict if needed + } + .sessionManagement { + it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + } + .addFilterBefore(remoteAuthFilter, UsernamePasswordAuthenticationFilter::class.java) + + return http.build() + } +} \ No newline at end of file diff --git a/ordering/src/main/resources/application.properties b/ordering/src/main/resources/application.properties new file mode 100644 index 0000000..2a25485 --- /dev/null +++ b/ordering/src/main/resources/application.properties @@ -0,0 +1,15 @@ +spring.application.name=Kotlin.SpringbootV2 +server.port = 8082 + +spring.datasource.url=jdbc:postgresql://localhost:5432/myHelloDatabase +spring.datasource.username=postgres +spring.datasource.password=yosaka +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect + +management.endpoints.web.exposure.include=* + +springdoc.api-docs.path=/api-docs + +logging.level.org.springframework.web=DEBUG +logging.level.org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod=DEBUG +spring.mvc.log-request-details=true diff --git a/pom.xml b/pom.xml index 163ad53..8393248 100644 --- a/pom.xml +++ b/pom.xml @@ -11,6 +11,7 @@ com.coded.spring Ordering 0.0.1-SNAPSHOT + pom Kotlin.SpringbootV2 Kotlin.SpringbootV2 @@ -20,6 +21,11 @@ + + authentication + ordering + welcome + @@ -31,6 +37,13 @@ 1.9.25 + + + + + + + org.springframework.boot spring-boot-starter-web @@ -58,6 +71,97 @@ kotlin-test-junit5 test + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + com.h2database + h2 + test + + + + jakarta.inject + jakarta.inject-api + 2.0.1 + + + + org.postgresql + postgresql + compile + + + + org.springframework.boot + spring-boot-starter-security + + + + io.jsonwebtoken + jjwt-api + 0.11.5 + + + io.jsonwebtoken + jjwt-impl + runtime + 0.11.5 + + + io.jsonwebtoken + jjwt-jackson + runtime + 0.11.5 + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.jetbrains.kotlin + kotlin-test-junit5 + test + + + + org.springdoc + springdoc-openapi-starter-webmvc-api + 2.6.0 + + + + io.cucumber + cucumber-java + 7.11.0 + test + + + + io.cucumber + cucumber-spring + 7.11.0 + test + + + + io.cucumber + cucumber-junit + 7.11.0 + test + + + + + + + + diff --git a/src/main/kotlin/com/coded/spring/ordering/config/LoggingFilter.kt b/src/main/kotlin/com/coded/spring/ordering/config/LoggingFilter.kt new file mode 100644 index 0000000..116f0a7 --- /dev/null +++ b/src/main/kotlin/com/coded/spring/ordering/config/LoggingFilter.kt @@ -0,0 +1,42 @@ +package com.coded.spring.ordering.config + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter +import org.springframework.web.util.ContentCachingRequestWrapper +import org.springframework.web.util.ContentCachingResponseWrapper + +@Component +class LoggingFilter : OncePerRequestFilter() { + + private val logger = LoggerFactory.getLogger(LoggingFilter::class.java) + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + val cachedRequest = ContentCachingRequestWrapper(request) + val cachedResponse = ContentCachingResponseWrapper(response) + + filterChain.doFilter(cachedRequest, cachedResponse) + + logRequest(cachedRequest) + logResponse(cachedResponse) + + cachedResponse.copyBodyToResponse() + } + + private fun logRequest(request: ContentCachingRequestWrapper) { + val requestBody = String(request.contentAsByteArray) + logger.info("Request: method=${request.method}, uri=${request.requestURI}, body=$requestBody") + } + + private fun logResponse(response: ContentCachingResponseWrapper) { + val responseBody = String(response.contentAsByteArray) + logger.info("Response: status=${response.status}, body=$responseBody") + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coded/spring/ordering/exceptions/TransferFundsException.kt b/src/main/kotlin/com/coded/spring/ordering/exceptions/TransferFundsException.kt new file mode 100644 index 0000000..4fccd82 --- /dev/null +++ b/src/main/kotlin/com/coded/spring/ordering/exceptions/TransferFundsException.kt @@ -0,0 +1,3 @@ +package com.coded.spring.ordering.exceptions + +class TransferFundsException(msg: String) : Exception(msg) \ No newline at end of file diff --git a/src/main/kotlin/com/coded/spring/ordering/items/ItemDTO.kt b/src/main/kotlin/com/coded/spring/ordering/items/ItemDTO.kt new file mode 100644 index 0000000..b0db87e --- /dev/null +++ b/src/main/kotlin/com/coded/spring/ordering/items/ItemDTO.kt @@ -0,0 +1,17 @@ +package items + +data class Item( + val id: Long?, + val orderId: Long?, + val name: String?, + val quantity: Long?, + val note: String?, + val price: Double? +) + +data class SubmitItemRequest( + val name: String, + val quantity: Long, + val note: String?, + val price: Double +) \ No newline at end of file diff --git a/src/main/kotlin/com/coded/spring/ordering/items/ItemsController.kt b/src/main/kotlin/com/coded/spring/ordering/items/ItemsController.kt new file mode 100644 index 0000000..a8835b7 --- /dev/null +++ b/src/main/kotlin/com/coded/spring/ordering/items/ItemsController.kt @@ -0,0 +1,49 @@ +package items + +import jakarta.servlet.http.HttpServletRequest +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import client.AuthenticationClient +import orders.ItemEntity +import org.springframework.web.bind.annotation.* + +@RestController +@Tag(name = "ORDERING", description = "Endpoints related to managing menu items") +class ItemsController( + private val itemsService: ItemsService, + private val authenticationClient: AuthenticationClient +) { + + @GetMapping("/listItems") + @Operation(summary = "List all menu items", description = "Returns a list of all available items.") + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Items listed successfully"), + ApiResponse(responseCode = "500", description = "Server error while retrieving items") + ] + ) + fun listItems(): List = itemsService.listItems() + + @PostMapping("/submitItems") + @Operation(summary = "Submit a new menu item", description = "Allows authenticated users to add a new item to the menu.") + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Item submitted successfully"), + ApiResponse(responseCode = "400", description = "Invalid item data"), + ApiResponse(responseCode = "401", description = "Unauthorized request") + ] + ) + fun submitItem( + @RequestBody request: SubmitItemRequest, + servletRequest: HttpServletRequest + ): ItemEntity { + val authHeader = servletRequest.getHeader("Authorization") + ?: throw IllegalStateException("Missing Authorization header") + + authenticationClient.checkToken(authHeader.removePrefix("Bearer ").trim()) + + return itemsService.submitItem(request) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coded/spring/ordering/items/ItemsService.kt b/src/main/kotlin/com/coded/spring/ordering/items/ItemsService.kt new file mode 100644 index 0000000..0b7c85e --- /dev/null +++ b/src/main/kotlin/com/coded/spring/ordering/items/ItemsService.kt @@ -0,0 +1,40 @@ +package items + +import orders.ItemEntity +import orders.ItemsRepository +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service + +@Service +class ItemsService( + private val itemsRepository: ItemsRepository, + @Value("\${festive-mode:false}") + private val festiveMode: Boolean +) { + + fun listItems(): List = itemsRepository.findAll().map { entity -> + Item( + id = entity.id, + orderId = entity.orderId, + name = entity.name, + quantity = entity.quantity, + note = entity.note, + price = calculatePrice(entity.price) + ) + } + + fun submitItem(request: SubmitItemRequest): ItemEntity { + val item = ItemEntity( + name = request.name, + quantity = request.quantity ?: 0, + note = request.note, + price = request.price ?: 0.0, + orderId = 0 // Default to 0, actual value set on order placement + ) + return itemsRepository.save(item) + } + + private fun calculatePrice(originalPrice: Double?): Double? { + return if (festiveMode) originalPrice?.times(0.8) else originalPrice + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coded/spring/ordering/script/InitUserRunner.kt b/src/main/kotlin/com/coded/spring/ordering/script/InitUserRunner.kt new file mode 100644 index 0000000..67a8062 --- /dev/null +++ b/src/main/kotlin/com/coded/spring/ordering/script/InitUserRunner.kt @@ -0,0 +1,32 @@ +package com.coded.spring.ordering.script + +import com.coded.spring.ordering.users.UserEntity +import com.coded.spring.ordering.users.UsersRepository +import org.springframework.boot.CommandLineRunner +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication +import org.springframework.context.annotation.Bean +import org.springframework.security.crypto.password.PasswordEncoder + +@SpringBootApplication +class InitUserRunner { + @Bean + fun initUsers(userRepository: UsersRepository, passwordEncoder: PasswordEncoder) = CommandLineRunner { + val user = UserEntity( + name = "Ahmed", + username = "Ahmed123", + password = passwordEncoder.encode("password123"), + age = 22, + ) + if (userRepository.findByUsername(user.username) == null) { + println("Creating user ${user.username}") + userRepository.save(user) + } else { + println("User ${user.username} already exists") + } + } +} + +fun main(args: Array) { + runApplication(*args).close() +} \ No newline at end of file diff --git a/src/main/kotlin/com/coded/spring/ordering/users/UserRepository.kt b/src/main/kotlin/com/coded/spring/ordering/users/UserRepository.kt new file mode 100644 index 0000000..79fb067 --- /dev/null +++ b/src/main/kotlin/com/coded/spring/ordering/users/UserRepository.kt @@ -0,0 +1,27 @@ +package com.coded.spring.ordering.users + +import jakarta.inject.Named +import jakarta.persistence.* +import org.springframework.data.jpa.repository.JpaRepository + +@Named +interface UsersRepository : JpaRepository { + fun age(age: Int): MutableList + fun findByUsername(username: String): UserEntity? +} + +@Entity +@Table(name = "users") +data class UserEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + val name: String, + val age: Int, + + val username: String, + val password: String, + +){ + constructor() : this(null, "", 0,"","") +} \ No newline at end of file diff --git a/src/main/kotlin/com/coded/spring/ordering/users/UsersController.kt b/src/main/kotlin/com/coded/spring/ordering/users/UsersController.kt new file mode 100644 index 0000000..74bbbad --- /dev/null +++ b/src/main/kotlin/com/coded/spring/ordering/users/UsersController.kt @@ -0,0 +1,45 @@ +package com.coded.spring.ordering.users + +import com.coded.spring.ordering.DTO.UserRequest +import com.coded.spring.ordering.exceptions.TransferFundsException +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/users") +@Tag(name = "AUTHENTICATION", description = "Handles user registration and listing") +class UsersController( + private val usersService: UsersService +) { + + @PostMapping("/v1/register") + @Operation( + summary = "Register a new user", + description = "Registers a new user using username and password", + responses = [ + ApiResponse(responseCode = "200", description = "User successfully registered"), + ApiResponse(responseCode = "400", description = "Validation failed or user already exists") + ] + ) + fun registerUser(@RequestBody request: UserRequest): ResponseEntity { + return try { + val newUser = usersService.registerUser(request) + ResponseEntity.ok(newUser) + } catch (e: TransferFundsException) { + ResponseEntity.badRequest().body(mapOf("error" to e.message)) + } + } + + @GetMapping("/v1/list") + @Operation( + summary = "List all users", + description = "Returns a list of all registered users", + responses = [ + ApiResponse(responseCode = "200", description = "Users listed successfully") + ] + ) + fun users() = usersService.listUsers() +} \ No newline at end of file diff --git a/src/main/kotlin/com/coded/spring/ordering/users/UsersService.kt b/src/main/kotlin/com/coded/spring/ordering/users/UsersService.kt new file mode 100644 index 0000000..e38cc74 --- /dev/null +++ b/src/main/kotlin/com/coded/spring/ordering/users/UsersService.kt @@ -0,0 +1,57 @@ +package com.coded.spring.ordering.users +import com.coded.spring.ordering.DTO.UserRequest +import com.coded.spring.ordering.DTO.UserResponse +import com.coded.spring.ordering.exceptions.TransferFundsException +import jakarta.inject.Named +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Service + +const val USERNAME_MIN_LENGTH = 4 +const val USERNAME_MAX_LENGTH = 30 +const val PASSWORD_MIN_LENGTH = 9 +const val PASSWORD_MAX_LENGTH = 30 + +@Named +@Service +class UsersService( + private val usersRepository: UsersRepository, + private val passwordEncoder: PasswordEncoder +) { + + fun registerUser(request: UserRequest): UserResponse { + + if (request.username.length < USERNAME_MIN_LENGTH || + request.username.length > USERNAME_MAX_LENGTH) { + throw TransferFundsException( + "Username must be between ${USERNAME_MIN_LENGTH} and ${USERNAME_MAX_LENGTH} characters") + } + + if (request.password.length < PASSWORD_MIN_LENGTH || + request.password.length > PASSWORD_MAX_LENGTH) { + throw TransferFundsException( + "Password must be between ${PASSWORD_MIN_LENGTH} and ${PASSWORD_MAX_LENGTH} characters") + } + + val encodedPassword = passwordEncoder.encode(request.password) + + val createUser = UserEntity( + name = request.name, + age = request.age, + username = request.username, + password = request.password + ) + + val savedUser = usersRepository.save(createUser) + return UserResponse(id = savedUser.id!!, username = savedUser.username) + } + + fun listUsers(): List = usersRepository.findAll().map { + UserRequest( + name = it.name, + age = it.age, + username = it.username, + password = it.password + + ) + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 3704dc6..bf02763 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,11 @@ spring.application.name=Kotlin.SpringbootV2 +server.port = 8080 + +spring.datasource.url=jdbc:postgresql://localhost:5432/myHelloDatabase +spring.datasource.username=postgres +spring.datasource.password=yosaka +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect + +springdoc.api-docs.path=/api-docs + +company.name=FreshEats diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 0000000..08232a8 --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,10 @@ + + + + + Welcome to SpeedDash! + + + + + \ No newline at end of file diff --git a/src/test/kotlin/com/coded/spring/ordering/ApplicationTests.kt b/src/test/kotlin/com/coded/spring/ordering/ApplicationTests.kt index b2e2320..effb76e 100644 --- a/src/test/kotlin/com/coded/spring/ordering/ApplicationTests.kt +++ b/src/test/kotlin/com/coded/spring/ordering/ApplicationTests.kt @@ -1,13 +1,17 @@ package com.coded.spring.ordering +import io.cucumber.spring.CucumberContextConfiguration import org.junit.jupiter.api.Test import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ActiveProfiles -@SpringBootTest -class ApplicationTests { +@CucumberContextConfiguration +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class OnlineOrderingApplicationTests { @Test fun contextLoads() { - } + } } diff --git a/src/test/kotlin/com/coded/spring/ordering/UserIntegrationTest.kt b/src/test/kotlin/com/coded/spring/ordering/UserIntegrationTest.kt new file mode 100644 index 0000000..f192467 --- /dev/null +++ b/src/test/kotlin/com/coded/spring/ordering/UserIntegrationTest.kt @@ -0,0 +1,42 @@ +package com.coded.spring.ordering + +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.http.* +import kotlin.test.assertEquals + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class UserRequestIntegrationTest { + + @Autowired + lateinit var restTemplate: TestRestTemplate + + @Test + fun createUser() { + val user = mapOf( + "name" to "Ali", + "age" to 23, + "username" to "Ali123", + "password" to "password123" + ) + + val headers = HttpHeaders() + headers.contentType = MediaType.APPLICATION_JSON + + val response = restTemplate.postForEntity( + "/users/v1/register", + HttpEntity(user, headers), + String::class.java + ) + + assertEquals(HttpStatus.OK, response.statusCode) + } + + @Test + fun getUsers() { + val response = restTemplate.getForEntity("/users/v1/list", String::class.java) + assertEquals(HttpStatus.OK, response.statusCode) + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/coded/spring/ordering/config/TestHooks.kt b/src/test/kotlin/com/coded/spring/ordering/config/TestHooks.kt new file mode 100644 index 0000000..433c778 --- /dev/null +++ b/src/test/kotlin/com/coded/spring/ordering/config/TestHooks.kt @@ -0,0 +1,71 @@ +package com.coded.spring.ordering.config + +import com.coded.spring.ordering.utils.GlobalToken +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import io.cucumber.java.Before +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.http.* + +class TestHooks { + + @Autowired + lateinit var restTemplate: TestRestTemplate + + companion object { + private var isTokenGenerated = false + } + + @Before + fun generateJwtToken() { + if (isTokenGenerated) return + + println("🚀 Generating Global JWT Token...") + + val headers = HttpHeaders() + headers.contentType = MediaType.APPLICATION_JSON + + // Register + val registrationPayload = """ + { + "name": "Global User", + "age": 25, + "username": "GlobalUser", + "password": "password123" + } + """.trimIndent() + val registerRequest = HttpEntity(registrationPayload, headers) + var regResponse =restTemplate.postForEntity("/users/v1/register", registerRequest, String::class.java) + + println(regResponse) + // Login + val loginPayload = """ + { + "username": "GlobalUser", + "password": "password123" + } + """.trimIndent() + val loginRequest = HttpEntity(loginPayload, headers) + val response = restTemplate.postForEntity("/auth/login", loginRequest, String::class.java) + + println("🔎 Login Response Body: ${response.body}") + + // Extract JWT token from response JSON + val responseBody = response.body + val token = if (!responseBody.isNullOrBlank()) { + val mapper = jacksonObjectMapper() + try { + mapper.readTree(responseBody).get("token")?.asText() + } catch (e: Exception) { + println("❌ Failed to extract token: ${e.message}") + null + } + } else { + null + } + + GlobalToken.jwtToken = token + println("✅ Global JWT Token Generated: $token") + isTokenGenerated = true + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/coded/spring/ordering/steps/GetProfileSteps.kt b/src/test/kotlin/com/coded/spring/ordering/steps/GetProfileSteps.kt new file mode 100644 index 0000000..5940daa --- /dev/null +++ b/src/test/kotlin/com/coded/spring/ordering/steps/GetProfileSteps.kt @@ -0,0 +1,50 @@ +package com.coded.spring.ordering.steps + +import com.coded.spring.ordering.DTO.ProfileRequest +import com.coded.spring.ordering.authentication.jwt.JwtService +import io.cucumber.java.en.Given +import io.cucumber.java.en.Then +import io.cucumber.java.en.When +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.http.* +import kotlin.test.assertEquals + +class GetProfileSteps { + + @Autowired + lateinit var restTemplate: TestRestTemplate + + @Autowired + lateinit var jwtService: JwtService + + lateinit var headers: HttpHeaders + lateinit var response: ResponseEntity + + @Given("A profile is already created for user {string}") + fun a_profile_is_already_created(username: String) { + headers = HttpHeaders() + headers.contentType = MediaType.APPLICATION_JSON + headers.setBearerAuth(jwtService.generateToken(username)) + + val profileRequest = ProfileRequest( + firstName = "John", + lastName = "Doe", + phoneNumber = "12345678" + ) + + val requestEntity = HttpEntity(profileRequest, headers) + restTemplate.postForEntity("/profile", requestEntity, String::class.java) + } + + @When("I send an authenticated GET request to {string} to retrieve profile") + fun i_send_authenticated_get_request(url: String) { + val entity = HttpEntity(headers) + response = restTemplate.exchange(url, HttpMethod.GET, entity, String::class.java) + } + + @Then("I should receive a 200 response for retrieving profile") + fun i_should_receive_200_response_for_retrieving_profile() { + assertEquals(200, response.statusCode.value()) + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/coded/spring/ordering/steps/ListItemsStepDefinitions.kt b/src/test/kotlin/com/coded/spring/ordering/steps/ListItemsStepDefinitions.kt new file mode 100644 index 0000000..4afbfac --- /dev/null +++ b/src/test/kotlin/com/coded/spring/ordering/steps/ListItemsStepDefinitions.kt @@ -0,0 +1,34 @@ +package com.coded.spring.ordering.steps + +import com.coded.spring.ordering.authentication.jwt.JwtService +import io.cucumber.java.en.Then +import io.cucumber.java.en.When +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.http.* +import kotlin.test.assertEquals + +class ListItemsSteps { + + @Autowired + lateinit var restTemplate: TestRestTemplate + + @Autowired + lateinit var jwtService: JwtService + + lateinit var response: ResponseEntity + + @When("I send a GET request to {string}") + fun i_send_a_get_request_to(url: String) { + val headers = HttpHeaders() + headers.setBearerAuth(jwtService.generateToken("GlobalUser")) // ✅ JWT Token injected + val entity = HttpEntity(headers) + + response = restTemplate.exchange(url, HttpMethod.GET, entity, String::class.java) + } + + @Then("I should receive a {int} response for listing items") + fun i_should_receive_a_response_for_listing_items(status: Int) { + assertEquals(status, response.statusCode.value()) + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/coded/spring/ordering/steps/ListOrdersStepDefinitions.kt b/src/test/kotlin/com/coded/spring/ordering/steps/ListOrdersStepDefinitions.kt new file mode 100644 index 0000000..7eb47fe --- /dev/null +++ b/src/test/kotlin/com/coded/spring/ordering/steps/ListOrdersStepDefinitions.kt @@ -0,0 +1,34 @@ +package com.coded.spring.ordering.steps + +import com.coded.spring.ordering.authentication.jwt.JwtService +import io.cucumber.java.en.Then +import io.cucumber.java.en.When +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.http.* +import kotlin.test.assertEquals + +class ListOrdersStepDefinitions { + + @Autowired + lateinit var jwtService: JwtService + + @Autowired + lateinit var restTemplate: TestRestTemplate + + lateinit var getResponse: ResponseEntity + + @When("I request my orders from {string}") + fun i_request_orders(url: String) { + val headers = HttpHeaders() + headers.setBearerAuth(jwtService.generateToken("GlobalUser")) + val entity = HttpEntity(headers) + + getResponse = restTemplate.exchange(url, HttpMethod.GET, entity, String::class.java) + } + + @Then("I should receive a 200 response with my orders") + fun i_should_receive_orders_response() { + assertEquals(200, getResponse.statusCode.value()) + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/coded/spring/ordering/steps/ListUsersStepDefinitions.kt b/src/test/kotlin/com/coded/spring/ordering/steps/ListUsersStepDefinitions.kt new file mode 100644 index 0000000..bf16063 --- /dev/null +++ b/src/test/kotlin/com/coded/spring/ordering/steps/ListUsersStepDefinitions.kt @@ -0,0 +1,34 @@ +package com.coded.spring.ordering.steps + +import com.coded.spring.ordering.authentication.jwt.JwtService +import io.cucumber.java.en.Then +import io.cucumber.java.en.When +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.http.* +import kotlin.test.assertEquals + +class ListUsersStepDefinitions { + + @Autowired + lateinit var jwtService: JwtService + + @Autowired + lateinit var restTemplate: TestRestTemplate + lateinit var response: ResponseEntity + + @When("I send an authenticated GET request to {string}") + fun i_send_authenticated_get_request(url: String) { + val headers = HttpHeaders() + //GlobalToken.jwtToken?.let { headers.setBearerAuth(it) } + headers.setBearerAuth(jwtService.generateToken("GlobalUser")) + val entity = HttpEntity(headers) + + response = restTemplate.exchange(url, HttpMethod.GET, entity, String::class.java) + } + + @Then("I should receive a 200 response for listing users") + fun i_should_receive_200_response_for_listing_users() { + assertEquals(200, response.statusCode.value()) + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/coded/spring/ordering/steps/MenuStepDefinitions.kt b/src/test/kotlin/com/coded/spring/ordering/steps/MenuStepDefinitions.kt new file mode 100644 index 0000000..eeb42a1 --- /dev/null +++ b/src/test/kotlin/com/coded/spring/ordering/steps/MenuStepDefinitions.kt @@ -0,0 +1,31 @@ +package com.coded.spring.ordering.steps + +import io.cucumber.java.en.Then +import io.cucumber.java.en.When +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.http.ResponseEntity +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class MenuStepDefinitions { + + @Autowired + lateinit var restTemplate: TestRestTemplate + + private lateinit var response: ResponseEntity + + @When("I request the menu") + fun iRequestTheMenu() { + response = restTemplate.getForEntity("/menu/v1/list", String::class.java) + } + + @Then("I should receive a list of menu items") + fun iShouldReceiveMenuItems() { + assertEquals(200, response.statusCode.value()) + assertNotNull(response.body) + println("✅ Menu Response: ${response.body}") + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/coded/spring/ordering/steps/SubmitItemSteps.kt b/src/test/kotlin/com/coded/spring/ordering/steps/SubmitItemSteps.kt new file mode 100644 index 0000000..a76adb3 --- /dev/null +++ b/src/test/kotlin/com/coded/spring/ordering/steps/SubmitItemSteps.kt @@ -0,0 +1,48 @@ +package com.coded.spring.ordering.steps + +import com.coded.spring.ordering.DTO.SubmitItemRequest +import io.cucumber.java.en.Given +import io.cucumber.java.en.Then +import io.cucumber.java.en.When +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.http.* +import kotlin.test.assertEquals +import com.coded.spring.ordering.authentication.jwt.JwtService + + +class SubmitItemSteps { + + @Autowired + lateinit var restTemplate: TestRestTemplate + + @Autowired + lateinit var jwtService: JwtService + + lateinit var requestEntity: HttpEntity + lateinit var response: ResponseEntity + + @Given("I have an item with name {string}, quantity {int}, note {string}, and price {double}") + fun i_have_an_item(name: String, quantity: Int, note: String, price: Double) { + val itemRequest = SubmitItemRequest( + name = name, + quantity = quantity.toLong(), + note = note, + price = price + ) + val headers = HttpHeaders() + headers.contentType = MediaType.APPLICATION_JSON + headers.setBearerAuth(jwtService.generateToken("GlobalUser")) // ✅ JWT Injection here + requestEntity = HttpEntity(itemRequest, headers) + } + + @When("I submit an item to endpoint {string}") + fun i_send_post_request(url: String) { + response = restTemplate.postForEntity(url, requestEntity, String::class.java) + } + + @Then("I should receive a 200 response for submitting item") + fun i_should_receive_success() { + assertEquals(200, response.statusCode.value()) + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/coded/spring/ordering/steps/SubmitOrderStepDefinitions.kt b/src/test/kotlin/com/coded/spring/ordering/steps/SubmitOrderStepDefinitions.kt new file mode 100644 index 0000000..b282950 --- /dev/null +++ b/src/test/kotlin/com/coded/spring/ordering/steps/SubmitOrderStepDefinitions.kt @@ -0,0 +1,41 @@ +package com.coded.spring.ordering.steps + +import com.coded.spring.ordering.authentication.jwt.JwtService +import io.cucumber.java.en.Then +import io.cucumber.java.en.When +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.http.* +import kotlin.test.assertEquals + +class SubmitOrderStepDefinitions { + + @Autowired + lateinit var jwtService: JwtService + + @Autowired + lateinit var restTemplate: TestRestTemplate + + lateinit var postResponse: ResponseEntity + + @When("I submit an order with itemIds to {string}") + fun i_submit_order(url: String) { + val headers = HttpHeaders() + headers.contentType = MediaType.APPLICATION_JSON + headers.setBearerAuth(jwtService.generateToken("GlobalUser")) + + val orderPayload = """ + { + "itemIds": [1, 2] + } + """.trimIndent() + + val request = HttpEntity(orderPayload, headers) + postResponse = restTemplate.postForEntity(url, request, String::class.java) + } + + @Then("I should receive a 200 response for order submission") + fun i_should_receive_200_response_for_submission() { + assertEquals(200, postResponse.statusCode.value()) + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/coded/spring/ordering/steps/SubmitProfileSteps.kt b/src/test/kotlin/com/coded/spring/ordering/steps/SubmitProfileSteps.kt new file mode 100644 index 0000000..bd53c3a --- /dev/null +++ b/src/test/kotlin/com/coded/spring/ordering/steps/SubmitProfileSteps.kt @@ -0,0 +1,48 @@ +package com.coded.spring.ordering.steps + +import com.coded.spring.ordering.DTO.ProfileRequest +import com.coded.spring.ordering.authentication.jwt.JwtService +import io.cucumber.java.en.Given +import io.cucumber.java.en.Then +import io.cucumber.java.en.When +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.http.* +import kotlin.test.assertEquals + +class SubmitProfileSteps { + + @Autowired + lateinit var jwtService: JwtService + + @Autowired + lateinit var restTemplate: TestRestTemplate + + lateinit var requestEntity: HttpEntity + lateinit var response: ResponseEntity + + @Given("I have a profile with first name {string}, last name {string}, and phone number {string}") + fun i_have_a_profile(firstName: String, lastName: String, phoneNumber: String) { + val profileRequest = ProfileRequest( + firstName = firstName, + lastName = lastName, + phoneNumber = phoneNumber + ) + + val headers = HttpHeaders() + headers.contentType = MediaType.APPLICATION_JSON + headers.setBearerAuth(jwtService.generateToken("GlobalUser")) + + requestEntity = HttpEntity(profileRequest, headers) + } + + @When("I submit the profile to {string}") + fun i_submit_the_profile(url: String) { + response = restTemplate.postForEntity(url, requestEntity, String::class.java) + } + + @Then("I should receive a 200 response for submitting profile") + fun i_should_receive_200_response_for_profile() { + assertEquals(200, response.statusCode.value()) + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/coded/spring/ordering/steps/UserRegistrationSteps.kt b/src/test/kotlin/com/coded/spring/ordering/steps/UserRegistrationSteps.kt new file mode 100644 index 0000000..18ff93d --- /dev/null +++ b/src/test/kotlin/com/coded/spring/ordering/steps/UserRegistrationSteps.kt @@ -0,0 +1,45 @@ +package com.coded.spring.ordering.steps + +import io.cucumber.java.en.Given +import io.cucumber.java.en.When +import io.cucumber.java.en.Then +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.http.* +import kotlin.test.assertEquals + +class UserRegistrationSteps { + + @Autowired + lateinit var restTemplate: TestRestTemplate + + private lateinit var response: ResponseEntity + private lateinit var requestEntity: HttpEntity + + @Given("I have a user with name {string}, age {int}, username {string}, and password {string}") + fun i_have_a_user_payload(name: String, age: Int, username: String, password: String) { + val jsonPayload = """ + { + "name": "$name", + "age": $age, + "username": "$username", + "password": "$password" + } + """.trimIndent() + + val headers = HttpHeaders() + headers.contentType = MediaType.APPLICATION_JSON + requestEntity = HttpEntity(jsonPayload, headers) + } + + @When("I send a POST request to {string}") + fun i_send_post_request(url: String) { + response = restTemplate.postForEntity(url, requestEntity, String::class.java) + } + + @Then("I should receive a {int} response") + fun i_should_receive_http_code(httpCode: Int) { + assertEquals(httpCode, response.statusCode.value()) + } + +} \ No newline at end of file diff --git a/src/test/kotlin/com/coded/spring/ordering/utils/GlobalToken.kt b/src/test/kotlin/com/coded/spring/ordering/utils/GlobalToken.kt new file mode 100644 index 0000000..1aaeae7 --- /dev/null +++ b/src/test/kotlin/com/coded/spring/ordering/utils/GlobalToken.kt @@ -0,0 +1,5 @@ +package com.coded.spring.ordering.utils + +object GlobalToken { + var jwtToken: String? = null +} \ No newline at end of file diff --git a/src/test/kotlin/resources/application-test.properties b/src/test/kotlin/resources/application-test.properties new file mode 100644 index 0000000..15722c5 --- /dev/null +++ b/src/test/kotlin/resources/application-test.properties @@ -0,0 +1,14 @@ +#spring.application.name=Kotlin.SpringbootV2 +# +## H2 In-Memory DB for Testing +#spring.datasource.url=jdbc:h2:mem:testdb +#spring.datasource.driverClassName=org.h2.Driver +#spring.datasource.username=sa +#spring.datasource.password= +#spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +#spring.jpa.hibernate.ddl-auto=create-drop +#spring.jpa.show-sql=true +# +## Enable H2 Console for Debugging +#spring.h2.console.enabled=true +#spring.h2.console.path=/h2-console \ No newline at end of file diff --git a/src/test/kotlin/resources/feature/get_profile.feature b/src/test/kotlin/resources/feature/get_profile.feature new file mode 100644 index 0000000..0387ba9 --- /dev/null +++ b/src/test/kotlin/resources/feature/get_profile.feature @@ -0,0 +1,6 @@ +Feature: Get Profile + + Scenario: Successfully retrieve profile + Given A profile is already created for user "GlobalUser" + When I send an authenticated GET request to "/profile" to retrieve profile + Then I should receive a 200 response for retrieving profile \ No newline at end of file diff --git a/src/test/kotlin/resources/feature/list_items.feature b/src/test/kotlin/resources/feature/list_items.feature new file mode 100644 index 0000000..8bb96a2 --- /dev/null +++ b/src/test/kotlin/resources/feature/list_items.feature @@ -0,0 +1,4 @@ +Feature: List Items + Scenario: Successfully retrieve items + When I send a GET request to "/listItems" + Then I should receive a 200 response for listing items \ No newline at end of file diff --git a/src/test/kotlin/resources/feature/list_orders.feature b/src/test/kotlin/resources/feature/list_orders.feature new file mode 100644 index 0000000..866542e --- /dev/null +++ b/src/test/kotlin/resources/feature/list_orders.feature @@ -0,0 +1,5 @@ +Feature: List Orders + + Scenario: Successfully retrieve my orders + When I request my orders from "/orders/v1" + Then I should receive a 200 response with my orders \ No newline at end of file diff --git a/src/test/kotlin/resources/feature/list_users.feature b/src/test/kotlin/resources/feature/list_users.feature new file mode 100644 index 0000000..860d85a --- /dev/null +++ b/src/test/kotlin/resources/feature/list_users.feature @@ -0,0 +1,5 @@ +Feature: List Users with Authentication + + Scenario: Successfully list users using Global JWT Token + When I send an authenticated GET request to "/users/v1/list" + Then I should receive a 200 response for listing users \ No newline at end of file diff --git a/src/test/kotlin/resources/feature/menu.feature b/src/test/kotlin/resources/feature/menu.feature new file mode 100644 index 0000000..3d8f488 --- /dev/null +++ b/src/test/kotlin/resources/feature/menu.feature @@ -0,0 +1,5 @@ +Feature: Menu Retrieval + + Scenario: Get all menu items + When I request the menu + Then I should receive a list of menu items \ No newline at end of file diff --git a/src/test/kotlin/resources/feature/submit_item.feature b/src/test/kotlin/resources/feature/submit_item.feature new file mode 100644 index 0000000..dbbcd1a --- /dev/null +++ b/src/test/kotlin/resources/feature/submit_item.feature @@ -0,0 +1,5 @@ +Feature: Submit Item + Scenario: Successfully submit a new item + Given I have an item with name "Burger", quantity 2, note "No onions", and price 3.5 + When I submit an item to endpoint "/submitItems" + Then I should receive a 200 response for submitting item \ No newline at end of file diff --git a/src/test/kotlin/resources/feature/submit_order.feature b/src/test/kotlin/resources/feature/submit_order.feature new file mode 100644 index 0000000..ccb71e1 --- /dev/null +++ b/src/test/kotlin/resources/feature/submit_order.feature @@ -0,0 +1,5 @@ +Feature: Submit Order + + Scenario: Successfully submit an order with item IDs + When I submit an order with itemIds to "/orders/v1" + Then I should receive a 200 response for order submission \ No newline at end of file diff --git a/src/test/kotlin/resources/feature/submit_profile.feature b/src/test/kotlin/resources/feature/submit_profile.feature new file mode 100644 index 0000000..93d435f --- /dev/null +++ b/src/test/kotlin/resources/feature/submit_profile.feature @@ -0,0 +1,6 @@ +Feature: Submit Profile + + Scenario: Successfully submit a profile + Given I have a profile with first name "John", last name "Doe", and phone number "12345678" + When I submit the profile to "/profile" + Then I should receive a 200 response for submitting profile \ No newline at end of file diff --git a/src/test/kotlin/resources/feature/user_register.feature b/src/test/kotlin/resources/feature/user_register.feature new file mode 100644 index 0000000..8508fc1 --- /dev/null +++ b/src/test/kotlin/resources/feature/user_register.feature @@ -0,0 +1,10 @@ +Feature: User Registration + + Scenario: Successful user registration + Given I have a user with name "Ali", age 23, username "Ali123", and password "password123" + When I send a POST request to "/users/v1/register" + Then I should receive a 200 response + +# Scenario: List all users +# When I send a GET request to "/users/v1/list" +# Then I should receive a 200 response \ No newline at end of file diff --git a/swaggerJson/online-ordering-swagger-01.json b/swaggerJson/online-ordering-swagger-01.json new file mode 100644 index 0000000..3fd7b00 --- /dev/null +++ b/swaggerJson/online-ordering-swagger-01.json @@ -0,0 +1 @@ +{"openapi":"3.0.1","info":{"title":"OpenAPI definition","version":"v0"},"servers":[{"url":"http://localhost:8080","description":"Generated server url"}],"paths":{"/users/v1/register":{"post":{"tags":["users-controller"],"operationId":"registerUser","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"object"}}}}}}},"/submitItems":{"post":{"tags":["items-controller"],"operationId":"submitItem","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubmitItemRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ItemEntity"}}}}}}},"/orders/v1":{"get":{"tags":["order-controller"],"operationId":"listMyOrders","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Order"}}}}}}},"post":{"tags":["order-controller"],"operationId":"submitOrder","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubmitOrderRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"string"}}}}}}},"/auth/login":{"post":{"tags":["authentication-controller"],"operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthenticationRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AuthenticationResponse"}}}}}}},"/users/v1/list":{"get":{"tags":["users-controller"],"operationId":"users","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/UserRequest"}}}}}}}},"/menus/v1/menu":{"get":{"tags":["menu-controller"],"operationId":"getMenu","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/MenuEntity"}}}}}}}},"/listItems":{"get":{"tags":["items-controller"],"operationId":"listItems","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Item"}}}}}}}}},"components":{"schemas":{"UserRequest":{"required":["age","name","password","username"],"type":"object","properties":{"name":{"type":"string"},"age":{"type":"integer","format":"int32"},"username":{"type":"string"},"password":{"type":"string"}}},"SubmitItemRequest":{"required":["name","price","quantity"],"type":"object","properties":{"name":{"type":"string"},"quantity":{"type":"integer","format":"int64"},"note":{"type":"string"},"price":{"type":"number","format":"double"}}},"ItemEntity":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"},"quantity":{"type":"integer","format":"int64"},"note":{"type":"string"},"price":{"type":"number","format":"double"},"order":{"$ref":"#/components/schemas/OrderEntity"}}},"OrderEntity":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"user":{"$ref":"#/components/schemas/UserEntity"},"items":{"type":"array","items":{"$ref":"#/components/schemas/ItemEntity"}}}},"UserEntity":{"required":["age","name","password","username"],"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"},"age":{"type":"integer","format":"int32"},"username":{"type":"string"},"password":{"type":"string"}}},"SubmitOrderRequest":{"required":["itemIds"],"type":"object","properties":{"itemIds":{"type":"array","items":{"type":"integer","format":"int64"}}}},"AuthenticationRequest":{"required":["password","username"],"type":"object","properties":{"username":{"type":"string"},"password":{"type":"string"}}},"AuthenticationResponse":{"required":["token"],"type":"object","properties":{"token":{"type":"string"}}},"Item":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"order_id":{"type":"integer","format":"int64"},"name":{"type":"string"},"quantity":{"type":"integer","format":"int64"},"note":{"type":"string"},"price":{"type":"number","format":"double"}}},"Order":{"required":["items"],"type":"object","properties":{"id":{"type":"integer","format":"int64"},"user_id":{"type":"integer","format":"int64"},"items":{"type":"array","items":{"$ref":"#/components/schemas/Item"}}}},"MenuEntity":{"required":["description","name","price"],"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"},"description":{"type":"string"},"price":{"type":"number","format":"double"}}}}}} \ No newline at end of file diff --git a/swaggerJson/online-ordering-swagger-02.json b/swaggerJson/online-ordering-swagger-02.json new file mode 100644 index 0000000..8fadecd --- /dev/null +++ b/swaggerJson/online-ordering-swagger-02.json @@ -0,0 +1 @@ +{"openapi":"3.0.1","info":{"title":"OpenAPI definition","version":"v0"},"servers":[{"url":"http://localhost:8080","description":"Generated server url"}],"tags":[{"name":"AUTHENTICATION","description":"Endpoints related to user login and token generation"}],"paths":{"/users/v1/register":{"post":{"tags":["users-controller"],"operationId":"registerUser","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"object"}}}}}}},"/submitItems":{"post":{"tags":["items-controller"],"operationId":"submitItem","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubmitItemRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ItemEntity"}}}}}}},"/profile":{"get":{"tags":["profile-controller"],"operationId":"getProfile","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"object"}}}}}},"post":{"tags":["profile-controller"],"operationId":"submitProfile","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"object"}}}}}}},"/orders/v1":{"get":{"tags":["order-controller"],"operationId":"listMyOrders","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Order"}}}}}}},"post":{"tags":["order-controller"],"operationId":"submitOrder","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubmitOrderRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"string"}}}}}}},"/auth/login":{"post":{"tags":["AUTHENTICATION"],"summary":"Login and generate JWT token","description":"Accepts username and password and returns a JWT token upon successful authentication","operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthenticationRequest"}}},"required":true},"responses":{"200":{"description":"Successfully authenticated","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AuthenticationResponse"}}}},"400":{"description":"Invalid username or password"}}}},"/users/v1/list":{"get":{"tags":["users-controller"],"operationId":"users","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/UserRequest"}}}}}}}},"/menu/v1/list":{"get":{"tags":["menu-controller"],"operationId":"getMenu","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/MenuEntity"}}}}}}}},"/listItems":{"get":{"tags":["items-controller"],"operationId":"listItems","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Item"}}}}}}}},"/hello":{"get":{"tags":["welcome-controller"],"operationId":"sayHello","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"string"}}}}}}}},"components":{"schemas":{"UserRequest":{"required":["age","name","password","username"],"type":"object","properties":{"name":{"type":"string"},"age":{"type":"integer","format":"int32"},"username":{"type":"string"},"password":{"type":"string"}}},"SubmitItemRequest":{"required":["name","price","quantity"],"type":"object","properties":{"name":{"type":"string"},"quantity":{"type":"integer","format":"int64"},"note":{"type":"string"},"price":{"type":"number","format":"double"}}},"ItemEntity":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"},"quantity":{"type":"integer","format":"int64"},"note":{"type":"string"},"price":{"type":"number","format":"double"},"order":{"$ref":"#/components/schemas/OrderEntity"}}},"OrderEntity":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"user":{"$ref":"#/components/schemas/UserEntity"},"items":{"type":"array","items":{"$ref":"#/components/schemas/ItemEntity"}}}},"UserEntity":{"required":["age","name","password","username"],"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"},"age":{"type":"integer","format":"int32"},"username":{"type":"string"},"password":{"type":"string"}}},"ProfileRequest":{"required":["firstName","lastName","phoneNumber"],"type":"object","properties":{"firstName":{"type":"string"},"lastName":{"type":"string"},"phoneNumber":{"type":"string"}}},"SubmitOrderRequest":{"required":["itemIds"],"type":"object","properties":{"itemIds":{"type":"array","items":{"type":"integer","format":"int64"}}}},"AuthenticationRequest":{"required":["password","username"],"type":"object","properties":{"username":{"type":"string"},"password":{"type":"string"}}},"AuthenticationResponse":{"required":["token"],"type":"object","properties":{"token":{"type":"string"}}},"Item":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"order_id":{"type":"integer","format":"int64"},"name":{"type":"string"},"quantity":{"type":"integer","format":"int64"},"note":{"type":"string"},"price":{"type":"number","format":"double"}}},"Order":{"required":["items"],"type":"object","properties":{"id":{"type":"integer","format":"int64"},"user_id":{"type":"integer","format":"int64"},"items":{"type":"array","items":{"$ref":"#/components/schemas/Item"}}}},"MenuEntity":{"required":["description","name","price"],"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"},"description":{"type":"string"},"price":{"type":"number","format":"double"}}}}}} \ No newline at end of file diff --git a/welcome/pom.xml b/welcome/pom.xml new file mode 100644 index 0000000..ffb9749 --- /dev/null +++ b/welcome/pom.xml @@ -0,0 +1,24 @@ + + + 4.0.0 + + com.coded.spring + Ordering + 0.0.1-SNAPSHOT + + + welcome + + + + + org.junit.jupiter + junit-jupiter + 5.10.0 + test + + + + \ No newline at end of file diff --git a/welcome/src/main/kotlin/helloworld/HelloWorldController.kt b/welcome/src/main/kotlin/helloworld/HelloWorldController.kt new file mode 100644 index 0000000..97c1651 --- /dev/null +++ b/welcome/src/main/kotlin/helloworld/HelloWorldController.kt @@ -0,0 +1,32 @@ +package helloworld + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.beans.factory.annotation.Value +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@Tag(name = "HOME_PAGE", description = "General welcome and homepage information") +class WelcomeController( + @Value("\${company.name}") val companyName: String, + @Value("\${festive-mode:false}") val festiveMode: Boolean +) { + + @GetMapping("/hello") + @Operation(summary = "Display welcome message", description = "Returns a greeting message based on festive mode") + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Welcome message returned successfully") + ] + ) + fun sayHello(): String { + return if (festiveMode) { + "🎉 Eidkom Mubarak from $companyName!" + } else { + "Welcome to Online Ordering by $companyName" + } + } +} \ No newline at end of file diff --git a/welcome/src/main/resources/application.properties b/welcome/src/main/resources/application.properties new file mode 100644 index 0000000..d588ae9 --- /dev/null +++ b/welcome/src/main/resources/application.properties @@ -0,0 +1,9 @@ +spring.application.name=Kotlin.SpringbootV2 +server.port = 8083 + +spring.datasource.url=jdbc:postgresql://localhost:5432/myHelloDatabase +spring.datasource.username=postgres +spring.datasource.password=yosaka +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect + +springdoc.api-docs.path=/api-docs