diff --git a/authentication/pom.xml b/authentication/pom.xml
new file mode 100644
index 0000000..17d0b87
--- /dev/null
+++ b/authentication/pom.xml
@@ -0,0 +1,58 @@
+
+
+ 4.0.0
+
+ com.coded.spring
+ YousefTech
+ 0.0.1-SNAPSHOT
+
+
+ authentication
+
+
+
+ src/main/kotlin
+ src/test/kotlin
+
+
+ org.jetbrains.kotlin
+ kotlin-maven-plugin
+
+
+ compile
+ compile
+
+ compile
+
+
+
+ test-compile
+ test-compile
+
+ test-compile
+
+
+
+
+
+ maven-surefire-plugin
+ 2.22.2
+
+
+ maven-failsafe-plugin
+ 2.22.2
+
+
+ org.codehaus.mojo
+ exec-maven-plugin
+ 1.6.0
+
+ MainKt
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/main/kotlin/com/coded/spring/ordering/Application.kt b/authentication/src/main/kotlin/com/coded/authentication/AuthApplication.kt
similarity index 64%
rename from src/main/kotlin/com/coded/spring/ordering/Application.kt
rename to authentication/src/main/kotlin/com/coded/authentication/AuthApplication.kt
index 8554e49..30f1c05 100644
--- a/src/main/kotlin/com/coded/spring/ordering/Application.kt
+++ b/authentication/src/main/kotlin/com/coded/authentication/AuthApplication.kt
@@ -1,11 +1,11 @@
-package com.coded.spring.ordering
+package com.coded.authentication
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
-class Application
+class AuthApplication
fun main(args: Array) {
- runApplication(*args)
+ runApplication(*args)
}
diff --git a/authentication/src/main/kotlin/com/coded/authentication/InitUserRunner.kt b/authentication/src/main/kotlin/com/coded/authentication/InitUserRunner.kt
new file mode 100644
index 0000000..51eafe4
--- /dev/null
+++ b/authentication/src/main/kotlin/com/coded/authentication/InitUserRunner.kt
@@ -0,0 +1,33 @@
+package com.coded.authentication
+
+import com.coded.authentication.users.UserEntity
+import com.coded.authentication.users.UserRepository
+import org.springframework.boot.CommandLineRunner
+import org.springframework.boot.autoconfigure.SpringBootApplication
+import org.springframework.context.annotation.Bean
+import org.springframework.security.crypto.password.PasswordEncoder
+
+@SpringBootApplication
+class InitUserRunner {
+ @Bean
+ fun initUsers(userRepository: com.coded.authentication.users.UserRepository, passwordEncoder: PasswordEncoder) = CommandLineRunner {
+ val user = com.coded.authentication.users.UserEntity(
+ name = "admin user",
+ username = "adminUser",
+ password = passwordEncoder.encode("password123"),
+ email = "adminUser@ordering.com"
+ )
+ 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()
+//}
+
+// COMMENT to avoid multiple main function reference during compilation
\ No newline at end of file
diff --git a/authentication/src/main/kotlin/com/coded/authentication/auth/AuthApiController.kt b/authentication/src/main/kotlin/com/coded/authentication/auth/AuthApiController.kt
new file mode 100644
index 0000000..0ff4846
--- /dev/null
+++ b/authentication/src/main/kotlin/com/coded/authentication/auth/AuthApiController.kt
@@ -0,0 +1,115 @@
+package com.coded.authentication.auth
+
+
+import com.coded.authentication.auth.dtos.JwtResponseDto
+import com.coded.authentication.auth.dtos.LoginRequestDto
+import com.coded.authentication.auth.dtos.ValidateTokenResponseDto
+import com.coded.authentication.users.UserService
+import com.coded.authentication.users.dtos.UserCreateRequestDto
+import com.coded.authentication.users.dtos.toEntity
+import io.swagger.v3.oas.annotations.tags.Tag
+import io.swagger.v3.oas.annotations.Operation
+import io.swagger.v3.oas.annotations.media.Content
+import io.swagger.v3.oas.annotations.media.Schema
+import io.swagger.v3.oas.annotations.responses.ApiResponse
+import io.swagger.v3.oas.annotations.responses.ApiResponses
+import jakarta.validation.Valid
+import org.springframework.http.HttpStatus
+import org.springframework.http.ResponseEntity
+import org.springframework.security.authentication.AuthenticationManager
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
+import org.springframework.security.core.userdetails.UserDetailsService
+import org.springframework.security.core.userdetails.UsernameNotFoundException
+import org.springframework.security.crypto.password.PasswordEncoder
+import org.springframework.web.bind.annotation.*
+import java.security.Principal
+
+
+@Tag(name="Auth Api")
+@RestController
+@RequestMapping("/api/v1/auth")
+class AuthApiController(
+ private val authenticationManager: AuthenticationManager,
+ private val userDetailsService: UserDetailsService,
+ private val jwtService: com.coded.authentication.auth.JwtService,
+ private val userService: com.coded.authentication.users.UserService,
+ private val passwordEncoder: PasswordEncoder,
+) {
+ @Operation(summary = "User login endpoint to receive JWT token")
+ @ApiResponses(
+ ApiResponse(
+ responseCode = "200",
+ description = "Successful login",
+ content = [
+ Content(
+ schema = Schema(implementation = JwtResponseDto::class),
+ mediaType = "application/json")
+ ]),
+ ApiResponse(
+ responseCode = "400",
+ description = "Bad request",
+ content = [
+ Content(mediaType = "application/json")
+ ]),
+ )
+ @PostMapping(path = ["/login"])
+ fun login(@Valid @RequestBody loginRequestDto: LoginRequestDto): ResponseEntity<*> {
+ val authToken = UsernamePasswordAuthenticationToken(
+ loginRequestDto.username,
+ loginRequestDto.password
+ )
+ val authenticated = authenticationManager.authenticate(authToken)
+
+ if (authenticated.isAuthenticated.not()) {
+ throw UsernameNotFoundException("Invalid credentials")
+ }
+
+ val userDetails = userDetailsService.loadUserByUsername(loginRequestDto.username)
+ val token = jwtService.generateToken(userDetails.username)
+ return ResponseEntity(JwtResponseDto(token), HttpStatus.OK)
+ }
+
+
+ @Operation(summary = "Create a new user and receive a JWT token")
+ @ApiResponses(
+ ApiResponse(
+ responseCode = "200",
+ description = "Successful registration",
+ content = [
+ Content(
+ schema = Schema(implementation = JwtResponseDto::class),
+ mediaType = "application/json")
+ ]),
+ ApiResponse(
+ responseCode = "400",
+ description = "Bad request",
+ content = [
+ Content(mediaType = "application/json")
+ ]),
+ )
+ @PostMapping(path = ["/register"])
+ fun createUser(
+ @Valid @RequestBody user: UserCreateRequestDto
+ ): ResponseEntity {
+ val userEntity = userService.createUser(
+ user.copy(
+ password = passwordEncoder.encode(
+ user.password
+ )
+ ).toEntity()
+ )
+ val token = jwtService.generateToken(userEntity.username)
+ return ResponseEntity(JwtResponseDto(token), HttpStatus.OK)
+ }
+
+
+ @PostMapping("/check-token")
+ fun checkToken(principal: Principal
+ ): ValidateTokenResponseDto {
+ val user = userService.findByUserName(principal.name)
+ ?: throw UsernameNotFoundException("User not found")
+ return ValidateTokenResponseDto(user.id!!)
+ }
+
+}
+
diff --git a/authentication/src/main/kotlin/com/coded/authentication/auth/CustomerDetailsService.kt b/authentication/src/main/kotlin/com/coded/authentication/auth/CustomerDetailsService.kt
new file mode 100644
index 0000000..9958360
--- /dev/null
+++ b/authentication/src/main/kotlin/com/coded/authentication/auth/CustomerDetailsService.kt
@@ -0,0 +1,23 @@
+package com.coded.authentication.auth
+
+import com.coded.authentication.users.UserRepository
+import org.springframework.security.core.userdetails.UserDetails
+import org.springframework.security.core.userdetails.UserDetailsService
+import org.springframework.security.core.userdetails.User
+import org.springframework.security.core.userdetails.UsernameNotFoundException
+import org.springframework.stereotype.Service
+
+@Service
+class CustomUserDetailsService(
+ private val usersRepository: com.coded.authentication.users.UserRepository,
+) : UserDetailsService {
+ override fun loadUserByUsername(username: String): UserDetails {
+ val user = usersRepository.findByUsername(username)
+ ?: throw UsernameNotFoundException("User not found")
+
+ return User.builder()
+ .username(user.username)
+ .password(user.password)
+ .build()
+ }
+}
\ No newline at end of file
diff --git a/authentication/src/main/kotlin/com/coded/authentication/auth/JwtAuthenticationFilter.kt b/authentication/src/main/kotlin/com/coded/authentication/auth/JwtAuthenticationFilter.kt
new file mode 100644
index 0000000..cc36a07
--- /dev/null
+++ b/authentication/src/main/kotlin/com/coded/authentication/auth/JwtAuthenticationFilter.kt
@@ -0,0 +1,59 @@
+package com.coded.authentication.auth
+
+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 ")) {
+ logger.warn("Missing or malformed Authorization header")
+ filterChain.doFilter(request, response)
+ return
+ }
+
+ val token = authHeader.removePrefix("Bearer ").trim()
+ logger.info("Received JWT: $token")
+
+ try {
+ val username = jwtService.extractUsername(token)
+ logger.info("Extracted username from token: $username")
+
+ if (SecurityContextHolder.getContext().authentication == null) {
+ if (jwtService.isTokenValid(token, username)) {
+ logger.info("Token is valid for username: $username")
+
+ val userDetails = userDetailsService.loadUserByUsername(username)
+ val authToken = UsernamePasswordAuthenticationToken(
+ userDetails, null, userDetails.authorities
+ )
+ authToken.details = WebAuthenticationDetailsSource().buildDetails(request)
+ SecurityContextHolder.getContext().authentication = authToken
+ logger.info("SecurityContext set for user: $username")
+ } else {
+ logger.warn("Token is NOT valid for username: $username")
+ }
+ }
+ } catch (e: Exception) {
+ logger.error("JWT validation failed: ${e.message}", e)
+ }
+
+ filterChain.doFilter(request, response)
+ }
+
+}
\ No newline at end of file
diff --git a/authentication/src/main/kotlin/com/coded/authentication/auth/JwtService.kt b/authentication/src/main/kotlin/com/coded/authentication/auth/JwtService.kt
new file mode 100644
index 0000000..aef3ea1
--- /dev/null
+++ b/authentication/src/main/kotlin/com/coded/authentication/auth/JwtService.kt
@@ -0,0 +1,46 @@
+package com.coded.authentication.auth
+
+import io.jsonwebtoken.*
+import io.jsonwebtoken.security.Keys
+import org.springframework.beans.factory.annotation.Value
+import org.springframework.stereotype.Component
+import java.util.*
+import javax.crypto.SecretKey
+
+@Component
+class JwtService (
+ @Value("\${jwt-secret}")
+ private val secretKeyString: String
+){
+
+ private val secretKey: SecretKey = Keys.hmacShaKeyFor(secretKeyString.encodeToByteArray())
+ 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 =
+ Jwts.parserBuilder()
+ .setSigningKey(secretKey)
+ .build()
+ .parseClaimsJws(token)
+ .body
+ .subject
+
+ fun isTokenValid(token: String, username: String): Boolean {
+ return try {
+ extractUsername(token) == username
+ } catch (e: Exception) {
+ false
+ }
+ }
+}
\ No newline at end of file
diff --git a/authentication/src/main/kotlin/com/coded/authentication/auth/dtos/JwtResponseDto.kt b/authentication/src/main/kotlin/com/coded/authentication/auth/dtos/JwtResponseDto.kt
new file mode 100644
index 0000000..079384c
--- /dev/null
+++ b/authentication/src/main/kotlin/com/coded/authentication/auth/dtos/JwtResponseDto.kt
@@ -0,0 +1,5 @@
+package com.coded.authentication.auth.dtos
+
+data class JwtResponseDto(
+ val token: String,
+)
diff --git a/authentication/src/main/kotlin/com/coded/authentication/auth/dtos/LoginRequestDto.kt b/authentication/src/main/kotlin/com/coded/authentication/auth/dtos/LoginRequestDto.kt
new file mode 100644
index 0000000..9f2e9df
--- /dev/null
+++ b/authentication/src/main/kotlin/com/coded/authentication/auth/dtos/LoginRequestDto.kt
@@ -0,0 +1,14 @@
+package com.coded.authentication.auth.dtos
+
+import jakarta.validation.constraints.NotBlank
+import jakarta.validation.constraints.Size
+
+
+data class LoginRequestDto(
+ @field:NotBlank
+ @field:Size(min = 1, max = 50)
+ val username: String,
+ @field:NotBlank
+ @field:Size(min = 6, max = 50)
+ val password: String
+)
diff --git a/authentication/src/main/kotlin/com/coded/authentication/auth/dtos/ValidateTokenResponseDto.kt b/authentication/src/main/kotlin/com/coded/authentication/auth/dtos/ValidateTokenResponseDto.kt
new file mode 100644
index 0000000..ebcfbb0
--- /dev/null
+++ b/authentication/src/main/kotlin/com/coded/authentication/auth/dtos/ValidateTokenResponseDto.kt
@@ -0,0 +1,5 @@
+package com.coded.authentication.auth.dtos
+
+data class ValidateTokenResponseDto (
+ val userId: Long
+)
\ No newline at end of file
diff --git a/authentication/src/main/kotlin/com/coded/authentication/config/LoggingFilter.kt b/authentication/src/main/kotlin/com/coded/authentication/config/LoggingFilter.kt
new file mode 100644
index 0000000..de9e307
--- /dev/null
+++ b/authentication/src/main/kotlin/com/coded/authentication/config/LoggingFilter.kt
@@ -0,0 +1,41 @@
+package com.coded.authentication.config
+
+import jakarta.servlet.FilterChain
+import jakarta.servlet.http.HttpServletRequest
+import jakarta.servlet.http.HttpServletResponse
+import org.springframework.core.Ordered
+import org.springframework.core.annotation.Order
+import org.springframework.stereotype.Component
+import org.springframework.web.filter.OncePerRequestFilter
+import org.springframework.web.util.ContentCachingRequestWrapper
+import org.springframework.web.util.ContentCachingResponseWrapper
+
+@Component
+@Order(Ordered.HIGHEST_PRECEDENCE)
+class LoggingFilter: OncePerRequestFilter() {
+ override fun doFilterInternal(
+ request: HttpServletRequest,
+ response: HttpServletResponse,
+ filterChain: FilterChain,
+ ) {
+ val cachedRequest = ContentCachingRequestWrapper(request)
+ val cachedResponse = ContentCachingResponseWrapper(response)
+
+ filterChain.doFilter(cachedRequest, cachedResponse)
+
+ cachedResponse.copyBodyToResponse()
+
+ logRequest(cachedRequest)
+ logResponse(cachedResponse)
+ }
+
+ 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/com/coded/authentication/config/SecurityConfig.kt b/authentication/src/main/kotlin/com/coded/authentication/config/SecurityConfig.kt
new file mode 100644
index 0000000..4706264
--- /dev/null
+++ b/authentication/src/main/kotlin/com/coded/authentication/config/SecurityConfig.kt
@@ -0,0 +1,56 @@
+package com.coded.authentication.config
+
+import com.coded.authentication.auth.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 userDetailsService: UserDetailsService,
+ private val jwtAuthFilter: JwtAuthenticationFilter,
+) {
+
+ @Bean
+ fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
+
+ @Bean
+ fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
+ http.csrf { it.disable() }
+ .authorizeHttpRequests {
+ it.requestMatchers("/api/v1/auth/login", "/api/v1/auth/register").permitAll()
+ .anyRequest().authenticated()
+ }
+ .sessionManagement {
+ it.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
+ }
+ .authenticationProvider(authenticationProvider())
+ .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter::class.java)
+
+ return http.build();
+ }
+
+ @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/com/coded/authentication/users/UserEntity.kt b/authentication/src/main/kotlin/com/coded/authentication/users/UserEntity.kt
new file mode 100644
index 0000000..763b91b
--- /dev/null
+++ b/authentication/src/main/kotlin/com/coded/authentication/users/UserEntity.kt
@@ -0,0 +1,26 @@
+package com.coded.authentication.users
+
+import jakarta.persistence.*
+
+@Entity
+@Table(name = "users")
+class UserEntity(
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name="id")
+ val id: Long? = null,
+
+ @Column(name="username", nullable = false, unique = true)
+ val username: String = "",
+
+ @Column(name="name", nullable = false)
+ val name: String = "",
+
+ @Column(name="email", unique = true)
+ val email: String = "",
+
+ @Column(name="password")
+ val password: String = "",
+) {
+ constructor(): this(null, "", "", "", "")
+}
diff --git a/authentication/src/main/kotlin/com/coded/authentication/users/UserRepository.kt b/authentication/src/main/kotlin/com/coded/authentication/users/UserRepository.kt
new file mode 100644
index 0000000..2f92bb0
--- /dev/null
+++ b/authentication/src/main/kotlin/com/coded/authentication/users/UserRepository.kt
@@ -0,0 +1,9 @@
+package com.coded.authentication.users
+
+import org.springframework.data.jpa.repository.JpaRepository
+import org.springframework.stereotype.Repository
+
+@Repository
+interface UserRepository: JpaRepository {
+ fun findByUsername(username: String): com.coded.authentication.users.UserEntity?
+}
\ No newline at end of file
diff --git a/authentication/src/main/kotlin/com/coded/authentication/users/UserService.kt b/authentication/src/main/kotlin/com/coded/authentication/users/UserService.kt
new file mode 100644
index 0000000..7ecd490
--- /dev/null
+++ b/authentication/src/main/kotlin/com/coded/authentication/users/UserService.kt
@@ -0,0 +1,10 @@
+package com.coded.authentication.users
+
+import com.coded.authentication.users.UserEntity
+
+interface UserService {
+ fun findAll(): List
+ fun createUser(user: com.coded.authentication.users.UserEntity): com.coded.authentication.users.UserEntity
+ fun findById(id: Long): com.coded.authentication.users.UserEntity?
+ fun findByUserName(userName: String): com.coded.authentication.users.UserEntity?
+}
\ No newline at end of file
diff --git a/authentication/src/main/kotlin/com/coded/authentication/users/UserServiceImpl.kt b/authentication/src/main/kotlin/com/coded/authentication/users/UserServiceImpl.kt
new file mode 100644
index 0000000..ad9d9d3
--- /dev/null
+++ b/authentication/src/main/kotlin/com/coded/authentication/users/UserServiceImpl.kt
@@ -0,0 +1,13 @@
+package com.coded.authentication.users
+
+import org.springframework.data.repository.findByIdOrNull
+import org.springframework.stereotype.Service
+
+@Service
+class UserServiceImpl (private val userRepository: com.coded.authentication.users.UserRepository):
+ com.coded.authentication.users.UserService {
+ override fun findAll(): List = userRepository.findAll()
+ override fun createUser(user: com.coded.authentication.users.UserEntity): com.coded.authentication.users.UserEntity = userRepository.save(user)
+ override fun findById(id: Long): com.coded.authentication.users.UserEntity? = userRepository.findByIdOrNull(id)
+ override fun findByUserName(userName: String): com.coded.authentication.users.UserEntity? = userRepository.findByUsername(userName)
+}
\ No newline at end of file
diff --git a/authentication/src/main/kotlin/com/coded/authentication/users/dtos/UserCreateRequestDto.kt b/authentication/src/main/kotlin/com/coded/authentication/users/dtos/UserCreateRequestDto.kt
new file mode 100644
index 0000000..edb9f46
--- /dev/null
+++ b/authentication/src/main/kotlin/com/coded/authentication/users/dtos/UserCreateRequestDto.kt
@@ -0,0 +1,32 @@
+package com.coded.authentication.users.dtos
+
+import com.coded.authentication.users.UserEntity
+import jakarta.validation.constraints.Email
+import jakarta.validation.constraints.NotBlank
+import jakarta.validation.constraints.Pattern
+import jakarta.validation.constraints.Size
+
+
+data class UserCreateRequestDto(
+ @field:NotBlank(message = "Name is required")
+ val name: String,
+
+ @field:NotBlank(message = "Email is required")
+ val username: String,
+
+ @field:NotBlank(message = "Password is required")
+ @field:Email(message = "Email is too short")
+ val email: String,
+
+ @field:NotBlank(message = "Password is required")
+ @field:Size(min = 6, message = "Password is too short")
+ @field:Pattern(regexp = """(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).*""", message = "Password is too simple")
+ val password: String
+)
+
+fun UserCreateRequestDto.toEntity() = com.coded.authentication.users.UserEntity(
+ name = name,
+ username = username,
+ email = email,
+ password = password
+)
diff --git a/authentication/src/main/kotlin/com/coded/authentication/users/dtos/UserResponseDto.kt b/authentication/src/main/kotlin/com/coded/authentication/users/dtos/UserResponseDto.kt
new file mode 100644
index 0000000..6a466d5
--- /dev/null
+++ b/authentication/src/main/kotlin/com/coded/authentication/users/dtos/UserResponseDto.kt
@@ -0,0 +1,16 @@
+package com.coded.authentication.users.dtos
+
+import com.coded.authentication.users.UserEntity
+data class UserResponseDto(
+ val id: Long,
+ val email: String,
+ val username: String,
+ val name: String
+)
+
+fun com.coded.authentication.users.UserEntity.toDto() = UserResponseDto(
+ id = id!!,
+ email = email,
+ username = username,
+ name = name
+)
\ 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..d6606b3
--- /dev/null
+++ b/authentication/src/main/resources/application.properties
@@ -0,0 +1,12 @@
+spring.application.name=Kotlin.SpringbootV2
+
+logging.level.org.springframework.web= DEBUG
+
+spring.datasource.driver-class-name=org.postgresql.Driver
+spring.datasource.url=jdbc:postgresql://localhost:5432/shopping
+spring.datasource.username=postgres
+spring.datasource.password=changemelater
+spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
+spring.jpa.show-sql=true
+server.port=8081
+springdoc.api-docs.path=/api-docs
diff --git a/src/test/kotlin/com/coded/spring/ordering/ApplicationTests.kt b/authentication/src/test/kotlin/auth/AuthApplicationTests.kt
similarity index 56%
rename from src/test/kotlin/com/coded/spring/ordering/ApplicationTests.kt
rename to authentication/src/test/kotlin/auth/AuthApplicationTests.kt
index b2e2320..fa9836b 100644
--- a/src/test/kotlin/com/coded/spring/ordering/ApplicationTests.kt
+++ b/authentication/src/test/kotlin/auth/AuthApplicationTests.kt
@@ -1,13 +1,13 @@
-package com.coded.spring.ordering
+package auth
import org.junit.jupiter.api.Test
import org.springframework.boot.test.context.SpringBootTest
@SpringBootTest
-class ApplicationTests {
+class AuthApplicationTests {
- @Test
- fun contextLoads() {
- }
+// @Test
+// fun contextLoads() {
+// }
}
diff --git a/curl.sh b/curl.sh
new file mode 100644
index 0000000..a4f0f17
--- /dev/null
+++ b/curl.sh
@@ -0,0 +1,9 @@
+curl localhost:8080/api/v1/hello-world
+
+curl localhost:8080/api/v1/orders
+
+curl --header "Content-Type: application/json" \
+ --request POST \
+ --data '{"user":"Sultan","restaurant":"Meme Curry", "items": ["2x Meme Monster", "1x Tempura Appetizer"]}' \
+ http://localhost:8080/api/v1/orders
+
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..1c3e607
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,8 @@
+services:
+ db:
+ image: postgres:latest
+ ports:
+ - "5432:5432"
+ restart: always
+ environment:
+ POSTGRES_PASSWORD: changemelater
\ No newline at end of file
diff --git a/ordering/pom.xml b/ordering/pom.xml
new file mode 100644
index 0000000..3094b7e
--- /dev/null
+++ b/ordering/pom.xml
@@ -0,0 +1,57 @@
+
+
+ 4.0.0
+
+ com.coded.spring
+ YousefTech
+ 0.0.1-SNAPSHOT
+
+
+
+ ordering
+ 0.0.1-SNAPSHOT
+
+
+ ${project.basedir}/src/main/kotlin
+ ${project.basedir}/src/test/kotlin
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+ org.jetbrains.kotlin
+ kotlin-maven-plugin
+
+
+ -Xjsr305=strict
+
+
+ spring
+ jpa
+ all-open
+
+
+
+
+
+
+
+
+
+ org.jetbrains.kotlin
+ kotlin-maven-allopen
+ ${kotlin.version}
+
+
+ org.jetbrains.kotlin
+ kotlin-maven-noarg
+ ${kotlin.version}
+
+
+
+
+
+
+
diff --git a/ordering/src/main/kotlin/com/coded/ordering/OrderingApplication.kt b/ordering/src/main/kotlin/com/coded/ordering/OrderingApplication.kt
new file mode 100644
index 0000000..f39d132
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/OrderingApplication.kt
@@ -0,0 +1,19 @@
+package com.coded.ordering
+
+import org.springframework.boot.autoconfigure.SpringBootApplication
+import org.springframework.boot.runApplication
+import com.hazelcast.config.Config;
+import com.hazelcast.core.Hazelcast
+import com.hazelcast.core.HazelcastInstance
+
+@SpringBootApplication
+class OrderingApplication
+
+fun main(args: Array) {
+ runApplication(*args)
+ menuItemsConfig.getMapConfig("menuItems").setTimeToLiveSeconds(5)
+
+}
+
+val menuItemsConfig = Config("menuItems")
+val serverCache: HazelcastInstance = Hazelcast.newHazelcastInstance(menuItemsConfig)
diff --git a/ordering/src/main/kotlin/com/coded/ordering/config/LoggingFilter.kt b/ordering/src/main/kotlin/com/coded/ordering/config/LoggingFilter.kt
new file mode 100644
index 0000000..882e3b3
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/config/LoggingFilter.kt
@@ -0,0 +1,41 @@
+package com.coded.spring.ordering.config
+
+import jakarta.servlet.FilterChain
+import jakarta.servlet.http.HttpServletRequest
+import jakarta.servlet.http.HttpServletResponse
+import org.springframework.core.Ordered
+import org.springframework.core.annotation.Order
+import org.springframework.stereotype.Component
+import org.springframework.web.filter.OncePerRequestFilter
+import org.springframework.web.util.ContentCachingRequestWrapper
+import org.springframework.web.util.ContentCachingResponseWrapper
+
+@Component
+@Order(Ordered.HIGHEST_PRECEDENCE)
+class LoggingFilter: OncePerRequestFilter() {
+ override fun doFilterInternal(
+ request: HttpServletRequest,
+ response: HttpServletResponse,
+ filterChain: FilterChain,
+ ) {
+ val cachedRequest = ContentCachingRequestWrapper(request)
+ val cachedResponse = ContentCachingResponseWrapper(response)
+
+ filterChain.doFilter(cachedRequest, cachedResponse)
+
+ cachedResponse.copyBodyToResponse()
+
+ logRequest(cachedRequest)
+ logResponse(cachedResponse)
+ }
+
+ 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/ordering/src/main/kotlin/com/coded/ordering/config/RemoteAuthenticationFilter.kt b/ordering/src/main/kotlin/com/coded/ordering/config/RemoteAuthenticationFilter.kt
new file mode 100644
index 0000000..f9550f5
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/config/RemoteAuthenticationFilter.kt
@@ -0,0 +1,55 @@
+package com.coded.ordering.config
+
+import com.coded.ordering.providers.JwtAuthProvider
+import jakarta.servlet.FilterChain
+import jakarta.servlet.http.HttpServletRequest
+import jakarta.servlet.http.HttpServletResponse
+import org.springframework.http.HttpStatus
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
+import org.springframework.security.core.authority.SimpleGrantedAuthority
+import org.springframework.security.core.context.SecurityContextHolder
+import org.springframework.stereotype.Component
+import org.springframework.web.filter.OncePerRequestFilter
+
+@Component
+class RemoteAuthenticationFilter(
+ private val jwtAuthProvider: JwtAuthProvider
+) : OncePerRequestFilter() {
+
+ override fun doFilterInternal(
+ request: HttpServletRequest,
+ response: HttpServletResponse,
+ filterChain: FilterChain
+ ) {
+ val bearerToken: String? = request.getHeader("Authorization")
+ logger.info("Token received in filter = $bearerToken")
+
+ if (bearerToken.isNullOrBlank() || !bearerToken.startsWith("Bearer ")) {
+ logger.warn("Missing or malformed Authorization header.")
+ response.sendError(HttpStatus.UNAUTHORIZED.value(), "Authorization header is missing or invalid")
+ return
+ }
+
+ try {
+ val token = bearerToken.substring(7)
+ val result = jwtAuthProvider.authenticateToken(token)
+ logger.info("userId: ${result.userId}")
+
+ request.setAttribute("userId", result.userId)
+
+ val authentication = UsernamePasswordAuthenticationToken(
+ result.userId,
+ null,
+ listOf(SimpleGrantedAuthority("ROLE_USER"))
+ )
+ SecurityContextHolder.getContext().authentication = authentication
+
+ logger.info("Authentication set in SecurityContext for userId = ${result.userId}")
+ filterChain.doFilter(request, response)
+
+ } catch (ex: Exception) {
+ logger.error("Token validation failed", ex)
+ response.sendError(HttpStatus.FORBIDDEN.value(), "Invalid token")
+ }
+ }
+}
diff --git a/ordering/src/main/kotlin/com/coded/ordering/config/SecurityConfig.kt b/ordering/src/main/kotlin/com/coded/ordering/config/SecurityConfig.kt
new file mode 100644
index 0000000..2e194f2
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/config/SecurityConfig.kt
@@ -0,0 +1,29 @@
+package com.coded.ordering.config
+
+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 remoteAuthenticationFilter: RemoteAuthenticationFilter,
+) {
+ @Bean
+ fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
+ http.csrf { it.disable() }
+ .authorizeHttpRequests {
+ it.requestMatchers("/api/v1/menus", "/api-docs", "/api/v1/welcome").permitAll()
+ .anyRequest().authenticated()
+ }
+ .sessionManagement {
+ it.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
+ }
+ .addFilterBefore(remoteAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java)
+ return http.build();
+ }
+}
\ No newline at end of file
diff --git a/ordering/src/main/kotlin/com/coded/ordering/domain/entities/MenuEntity.kt b/ordering/src/main/kotlin/com/coded/ordering/domain/entities/MenuEntity.kt
new file mode 100644
index 0000000..e794401
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/domain/entities/MenuEntity.kt
@@ -0,0 +1,27 @@
+package com.coded.ordering.domain.entities
+
+import com.fasterxml.jackson.annotation.JsonBackReference
+import jakarta.persistence.*
+import java.math.BigDecimal
+
+@Entity
+@Table(name = "menus")
+data class MenuEntity(
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name="id")
+ val id: Long? = null,
+
+ @Column(name="name", nullable = false)
+ val name: String = "",
+
+ @ManyToOne(fetch = FetchType.EAGER, cascade = [CascadeType.DETACH])
+ @JoinColumn(name="restaurant_id")
+ @JsonBackReference
+ val restaurant: RestaurantEntity? = null,
+
+ @Column(name="price", precision = 10, scale = 2, nullable = false)
+ val price: BigDecimal = BigDecimal(0)
+) {
+ constructor(): this(null, "", null, BigDecimal(0.0))
+}
\ No newline at end of file
diff --git a/ordering/src/main/kotlin/com/coded/ordering/domain/entities/OrderEntity.kt b/ordering/src/main/kotlin/com/coded/ordering/domain/entities/OrderEntity.kt
new file mode 100644
index 0000000..2460734
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/domain/entities/OrderEntity.kt
@@ -0,0 +1,26 @@
+ package com.coded.ordering.domain.entities
+
+ import jakarta.persistence.*
+
+ @Entity
+ @Table(name = "orders")
+ class OrderEntity(
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name="id")
+ val id: Long? = null,
+
+ @JoinColumn(name="user_id")
+ val userId: Long?,
+
+ @ManyToOne(fetch = FetchType.EAGER, cascade = [CascadeType.DETACH])
+ @JoinColumn(name="restaurant_id")
+ val restaurant: RestaurantEntity?,
+
+
+ @OneToMany(mappedBy = "order", cascade = [CascadeType.ALL], fetch = FetchType.EAGER)
+ val orderItems: List? = null
+ ) {
+ constructor(): this(null, null, null)
+
+ }
\ No newline at end of file
diff --git a/ordering/src/main/kotlin/com/coded/ordering/domain/entities/OrderItemEntity.kt b/ordering/src/main/kotlin/com/coded/ordering/domain/entities/OrderItemEntity.kt
new file mode 100644
index 0000000..9d2cc7b
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/domain/entities/OrderItemEntity.kt
@@ -0,0 +1,24 @@
+package com.coded.ordering.domain.entities
+
+import jakarta.persistence.*
+
+@Entity
+@Table(name = "orders_items")
+class OrderItemEntity(
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ val id: Long? = null,
+
+ @ManyToOne(fetch = FetchType.EAGER, cascade = [CascadeType.DETACH])
+ @JoinColumn(name = "menu_id")
+ val item: MenuEntity? = null,
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "order_id")
+ val order: OrderEntity? = null,
+
+ @Column(name = "quantity")
+ val quantity: Int? = null
+) {
+ constructor(): this(null, null, null, null)
+}
diff --git a/ordering/src/main/kotlin/com/coded/ordering/domain/entities/ProfileEntity.kt b/ordering/src/main/kotlin/com/coded/ordering/domain/entities/ProfileEntity.kt
new file mode 100644
index 0000000..5384924
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/domain/entities/ProfileEntity.kt
@@ -0,0 +1,27 @@
+package com.coded.ordering.domain.entities
+
+import jakarta.persistence.*
+
+@Entity
+@Table(name = "profiles")
+data class ProfileEntity (
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ val id: Long? = null,
+
+ @JoinColumn(name="user_id", unique = true)
+ val user: Long? = null,
+
+ @Column(name="first_name")
+ val firstName: String? = null,
+
+ @Column(name="last_name")
+ val lastName: String? = null,
+
+ @Column(name="phone_number")
+ val phoneNumber: String? = null,
+
+) {
+ constructor(): this(null, null, "", "", "")
+}
\ No newline at end of file
diff --git a/ordering/src/main/kotlin/com/coded/ordering/domain/entities/RestaurantEntity.kt b/ordering/src/main/kotlin/com/coded/ordering/domain/entities/RestaurantEntity.kt
new file mode 100644
index 0000000..48bd913
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/domain/entities/RestaurantEntity.kt
@@ -0,0 +1,23 @@
+package com.coded.ordering.domain.entities
+
+import com.fasterxml.jackson.annotation.JsonManagedReference
+import jakarta.persistence.*
+
+@Entity
+@Table(name = "restaurants")
+class RestaurantEntity(
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name="id")
+ val id: Long? = null,
+
+ @Column(name="name", nullable = false)
+ val name: String = "",
+
+ @OneToMany(mappedBy = "restaurant", cascade = [CascadeType.ALL], fetch = FetchType.EAGER)
+ @JsonManagedReference
+ val menus: List? = emptyList()
+
+) {
+ constructor(): this(null, "", emptyList())
+}
diff --git a/ordering/src/main/kotlin/com/coded/ordering/domain/projections/MenuBasicInfoProjection.kt b/ordering/src/main/kotlin/com/coded/ordering/domain/projections/MenuBasicInfoProjection.kt
new file mode 100644
index 0000000..35e191d
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/domain/projections/MenuBasicInfoProjection.kt
@@ -0,0 +1,18 @@
+package com.coded.ordering.domain.projections
+
+import java.math.BigDecimal
+
+interface MenuBasicInfoProjection {
+ val name: String
+ val id: Long
+ val price: BigDecimal
+}
+
+interface MenuInfoSearchProjection : MenuBasicInfoProjection {
+ val restaurant:RestaurantInfoProjection
+
+ interface RestaurantInfoProjection {
+ val id: Float
+ val name: String
+ }
+}
\ No newline at end of file
diff --git a/ordering/src/main/kotlin/com/coded/ordering/domain/projections/OrderInfoProjection.kt b/ordering/src/main/kotlin/com/coded/ordering/domain/projections/OrderInfoProjection.kt
new file mode 100644
index 0000000..e77a8d6
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/domain/projections/OrderInfoProjection.kt
@@ -0,0 +1,24 @@
+package com.coded.ordering.domain.projections
+
+import com.coded.ordering.domain.entities.RestaurantEntity
+import java.math.BigDecimal
+
+
+
+interface OrderInfoProjection {
+ val id: Long
+ val userId: Long
+ val restaurant: RestaurantEntity
+ val orderItems: List
+
+ interface OrderItemInfo {
+ val item: MenuInfo
+ val quantity: Int
+ }
+
+ interface MenuInfo {
+ val id: Long
+ val name: String
+ val price: BigDecimal
+ }
+}
\ No newline at end of file
diff --git a/ordering/src/main/kotlin/com/coded/ordering/domain/projections/RestaurantInfoProjection.kt b/ordering/src/main/kotlin/com/coded/ordering/domain/projections/RestaurantInfoProjection.kt
new file mode 100644
index 0000000..1d50c9a
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/domain/projections/RestaurantInfoProjection.kt
@@ -0,0 +1,7 @@
+package com.coded.ordering.domain.projections
+
+interface RestaurantInfoProjection {
+ val id: Float
+ val name: String
+ val menus: List
+}
\ No newline at end of file
diff --git a/ordering/src/main/kotlin/com/coded/ordering/helloWorld/HelloWorldApiController.kt b/ordering/src/main/kotlin/com/coded/ordering/helloWorld/HelloWorldApiController.kt
new file mode 100644
index 0000000..0cddb65
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/helloWorld/HelloWorldApiController.kt
@@ -0,0 +1,77 @@
+package com.coded.ordering.helloWorld
+
+import io.swagger.v3.oas.annotations.Operation
+import io.swagger.v3.oas.annotations.media.Content
+import io.swagger.v3.oas.annotations.media.Schema
+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.RequestAttribute
+import org.springframework.web.bind.annotation.RestController
+
+@Tag(name = "Hello world")
+@RestController
+class HelloWorldApiController {
+
+ @Operation(summary = "Test endpoint that requires JWT token")
+ @ApiResponses(
+ ApiResponse(
+ responseCode = "200",
+ description = "Generic hello world endpoint for authenticated users",
+ content = [
+ Content(
+ schema = Schema(implementation = String::class),
+ mediaType = "application/json"
+ )
+ ]
+ ),
+ ApiResponse(
+ responseCode = "401",
+ description = "Forbidden",
+ content = [
+ Content(mediaType = "application/json")
+ ]
+ ),
+ )
+ @GetMapping("/api/v1/hello-world")
+ fun helloWorld(
+ @RequestAttribute("userId") userId: Long): String = "Hello World! $userId"
+
+
+ @Operation(summary = "Returns a welcome message or festive greeting")
+ @ApiResponses(
+ ApiResponse(
+ responseCode = "200",
+ description = "Returns a welcome message or greeting",
+ content = [
+ Content(
+ schema = Schema(implementation = String::class),
+ mediaType = "application/json"
+ )
+ ]
+ ),
+ ApiResponse(
+ responseCode = "401",
+ description = "Forbidden",
+ content = [
+ Content(mediaType = "application/json")
+ ]
+ ),
+ )
+ @GetMapping("/api/v1/welcome")
+ fun welcomeMessage(): String {
+ val stringBuilder = StringBuilder()
+ stringBuilder.append("Welcome to Online Ordering")
+
+ System.getenv("companyName")?.let {
+ stringBuilder.append(" by $it!")
+ }
+
+ val festivity = System.getenv("festivity")
+ return when {
+ !festivity.isNullOrBlank() -> festivity
+ else -> stringBuilder.toString()
+ }
+ }
+}
\ No newline at end of file
diff --git a/ordering/src/main/kotlin/com/coded/ordering/menus/MenuApiController.kt b/ordering/src/main/kotlin/com/coded/ordering/menus/MenuApiController.kt
new file mode 100644
index 0000000..c710132
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/menus/MenuApiController.kt
@@ -0,0 +1,113 @@
+package com.coded.ordering.menus
+
+import com.coded.ordering.menus.dtos.MenuDetailResponse
+import com.coded.ordering.domain.entities.MenuEntity
+import com.coded.ordering.domain.entities.RestaurantEntity
+import com.coded.ordering.domain.projections.MenuInfoSearchProjection
+import com.coded.ordering.menus.dtos.MenuCreateRequestDto
+import com.coded.ordering.menus.dtos.toEntity
+import com.coded.ordering.restaurants.RestaurantService
+import io.swagger.v3.oas.annotations.Operation
+import io.swagger.v3.oas.annotations.media.Content
+import io.swagger.v3.oas.annotations.media.Schema
+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.HttpStatus
+import org.springframework.http.ResponseEntity
+import org.springframework.web.bind.annotation.*
+
+@Tag(name = "Menu API")
+@RestController
+@RequestMapping("/api/v1/menus")
+class MenuApiController(
+ private val menuService: MenuService,
+ private val restaurantService: RestaurantService
+) {
+
+ @Operation(summary = "Receive a list of all menu items available")
+ @ApiResponses(
+ ApiResponse(
+ responseCode = "200",
+ description = "Return a list of menu items",
+ content = [
+ Content(
+ mediaType = "application/json")
+ ])
+ )
+ @GetMapping
+ fun getAll(): ResponseEntity> = ResponseEntity.ok(menuService.findAll())
+
+
+ @Operation(summary = "Create a new menu item")
+ @ApiResponses(
+ ApiResponse(
+ responseCode = "200",
+ description = "Successfully created a new menu for authenticated users",
+ content = [
+ Content(
+ schema = Schema(implementation = MenuCreateRequestDto::class),
+ mediaType = "application/json")
+ ]),
+ ApiResponse(
+ responseCode = "400",
+ description = "Bad request",
+ content = [
+ Content(mediaType = "application/json")
+ ]),
+ )
+ @PostMapping(path=["/create"])
+ fun createMenu(
+ @RequestBody menuCreateRequestDto: MenuCreateRequestDto
+ ): ResponseEntity {
+ val restaurant: RestaurantEntity = restaurantService.findById(menuCreateRequestDto.restaurantId)
+ ?: return ResponseEntity.badRequest().build()
+ val newMenu = menuService.create(menuCreateRequestDto.toEntity(restaurant))
+ return ResponseEntity(newMenu, HttpStatus.CREATED)
+ }
+
+ @Operation(summary = "Get a menu item by id")
+ @ApiResponses(
+ ApiResponse(
+ responseCode = "200",
+ description = "Successfully returns a menu by id for authenticated users",
+ content = [
+ Content(
+ schema = Schema(implementation = MenuDetailResponse::class),
+ mediaType = "application/json")
+ ]),
+ ApiResponse(
+ responseCode = "404",
+ description = "Not request",
+ content = [
+ Content(mediaType = "application/json")
+ ]),
+ )
+ @GetMapping(path=["/details/{menuId}"])
+ fun getMenu(@PathVariable("menuId") menuId: Long): ResponseEntity {
+ val foundMenu = menuService.findById(menuId)
+ ?: return ResponseEntity.notFound().build()
+ return ResponseEntity(foundMenu, HttpStatus.OK)
+ }
+
+ @Operation(summary = "Search all restaurants for menu items")
+ @ApiResponses(
+ ApiResponse(
+ responseCode = "200",
+ description = "Returns a menu list by item name or restaurant name for authenticated users",
+ content = [
+ Content(
+ mediaType = "application/json")
+ ]),
+ )
+ @GetMapping(path=["/search"])
+ fun search(
+ @RequestParam("restName") restName: String?=null,
+ @RequestParam("menuName") foodName: String?=null
+ ): ResponseEntity> {
+ return ResponseEntity(
+ menuService.searchMenus(foodName, restName),
+ HttpStatus.OK
+ )
+ }
+}
diff --git a/ordering/src/main/kotlin/com/coded/ordering/menus/MenuService.kt b/ordering/src/main/kotlin/com/coded/ordering/menus/MenuService.kt
new file mode 100644
index 0000000..482a40d
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/menus/MenuService.kt
@@ -0,0 +1,18 @@
+package com.coded.ordering.menus
+
+import com.coded.ordering.menus.dtos.MenuDetailResponse
+import com.coded.ordering.domain.entities.MenuEntity
+import com.coded.ordering.domain.projections.MenuBasicInfoProjection
+import com.coded.ordering.domain.projections.MenuInfoSearchProjection
+
+
+interface MenuService {
+ fun findAll(): List
+ fun create(menuItem: MenuEntity): MenuEntity
+ fun findById(id: Long): MenuDetailResponse?
+ fun findAllIn(items: List): List
+ fun findByRestaurantId(restaurantId: Long): List
+ fun getMenusInRequestOrder(menuIds: List): List
+ fun searchMenus(menuName: String?=null, restName: String?)
+ : List
+}
\ No newline at end of file
diff --git a/ordering/src/main/kotlin/com/coded/ordering/menus/MenuServiceImpl.kt b/ordering/src/main/kotlin/com/coded/ordering/menus/MenuServiceImpl.kt
new file mode 100644
index 0000000..fb33688
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/menus/MenuServiceImpl.kt
@@ -0,0 +1,73 @@
+package com.coded.ordering.menus
+
+import com.coded.ordering.serverCache
+import com.coded.ordering.menus.dtos.MenuDetailResponse
+import com.coded.ordering.menus.dtos.toResponse
+import com.coded.ordering.domain.entities.MenuEntity
+import com.coded.ordering.domain.projections.MenuBasicInfoProjection
+import com.coded.ordering.domain.projections.MenuInfoSearchProjection
+import com.coded.ordering.repositories.MenuRepository
+import org.springframework.data.repository.findByIdOrNull
+import org.springframework.stereotype.Service
+import java.math.BigDecimal
+
+@Service
+class MenuServiceImpl(
+ private val menuRepository: MenuRepository
+) : MenuService {
+ override fun findAll(): List {
+ val cachedMenuItems = menuItemsCache["menuItems"]
+ val discountActive = (System.getenv("discountActive") ?: "false") == "true"
+ return if (cachedMenuItems?.size == 0 || cachedMenuItems == null) {
+ println("caching menu items")
+ val menuItems = menuRepository.findAll().map {
+ if (discountActive) {
+ it.copy(
+ price = it.price.subtract(
+ it.price.multiply(BigDecimal.valueOf(0.2))
+ )).toResponse()
+ } else {
+ it.toResponse()
+ }
+ }
+ menuItemsCache.put("menuItems", menuItems)
+ menuItems
+ } else {
+ println("retrieving ${cachedMenuItems.size} menu items")
+ menuItemsCache["menuItems"] ?: listOf()
+ }
+ }
+
+ override fun create(menuItem: MenuEntity): MenuEntity {
+ val menu = menuRepository.save(menuItem)
+ return menu
+ }
+
+ override fun findById(id: Long): MenuDetailResponse? = menuRepository.findByIdOrNull(id)?.toResponse()
+
+ override fun findAllIn(items: List): List {
+ return menuRepository.findAllByIdIn(items)
+ }
+
+ override fun findByRestaurantId(restaurantId: Long)
+ : List = menuRepository.findByRestaurant_Id(restaurantId)
+
+ override fun getMenusInRequestOrder(menuIds: List): List {
+ return menuRepository.findAllByIdIn(menuIds)
+ }
+
+ override fun searchMenus(menuName: String?, restName: String?): List {
+ return when {
+ !menuName.isNullOrBlank() && !restName.isNullOrBlank() ->
+ menuRepository.searchByMenuAndRestaurantName(
+ menuName=menuName,
+ restName=restName
+ )
+ !menuName.isNullOrBlank() -> menuRepository.findByNameContainingIgnoreCase(menuName)
+ !restName.isNullOrBlank() -> menuRepository.findByRestaurantNameContainingIgnoreCase(restName)
+ else -> emptyList()
+ }
+ }
+}
+
+val menuItemsCache = serverCache.getMap>("menuItems")
\ No newline at end of file
diff --git a/ordering/src/main/kotlin/com/coded/ordering/menus/dtos/MenuDetailResponse.kt b/ordering/src/main/kotlin/com/coded/ordering/menus/dtos/MenuDetailResponse.kt
new file mode 100644
index 0000000..c67d642
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/menus/dtos/MenuDetailResponse.kt
@@ -0,0 +1,20 @@
+package com.coded.ordering.menus.dtos
+
+import com.coded.ordering.restaurants.dtos.RestaurantInfoResponse
+import com.coded.ordering.restaurants.dtos.toResponse
+import com.coded.ordering.domain.entities.MenuEntity
+import java.math.BigDecimal
+
+data class MenuDetailResponse (
+ val id: Long,
+ val name: String,
+ val price: BigDecimal,
+ val restaurant: RestaurantInfoResponse,
+)
+
+fun MenuEntity.toResponse() = MenuDetailResponse(
+ id = id!!,
+ name = name,
+ price = price,
+ restaurant = restaurant!!.toResponse(),
+)
\ No newline at end of file
diff --git a/ordering/src/main/kotlin/com/coded/ordering/menus/dtos/MenuItemCreateRequestDto.kt b/ordering/src/main/kotlin/com/coded/ordering/menus/dtos/MenuItemCreateRequestDto.kt
new file mode 100644
index 0000000..bc842e6
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/menus/dtos/MenuItemCreateRequestDto.kt
@@ -0,0 +1,27 @@
+package com.coded.ordering.menus.dtos
+
+import com.coded.ordering.domain.entities.MenuEntity
+import com.coded.ordering.domain.entities.RestaurantEntity
+import jakarta.validation.constraints.NotBlank
+import jakarta.validation.constraints.Positive
+import org.hibernate.validator.constraints.Length
+import org.jetbrains.annotations.NotNull
+import java.math.BigDecimal
+
+data class MenuCreateRequestDto(
+ @field:NotBlank(message = "Menu Name is required")
+ @field:Length(min = 3, message = "Menu Name must be between 3 and 6")
+ val name: String,
+
+ @field:NotNull
+ @field:Positive(message = "Restaurant ID be positive")
+ val restaurantId: Long,
+
+ val price: BigDecimal,
+)
+
+fun MenuCreateRequestDto.toEntity(restaurant: RestaurantEntity): MenuEntity = MenuEntity(
+ name=name,
+ restaurant=restaurant,
+ price=price
+)
\ No newline at end of file
diff --git a/ordering/src/main/kotlin/com/coded/ordering/orders/OrderApiController.kt b/ordering/src/main/kotlin/com/coded/ordering/orders/OrderApiController.kt
new file mode 100644
index 0000000..c5e363f
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/orders/OrderApiController.kt
@@ -0,0 +1,83 @@
+package com.coded.ordering.orders
+
+import com.coded.ordering.domain.projections.OrderInfoProjection
+import com.coded.ordering.orders.dtos.OrderCreateRequestDto
+import com.coded.ordering.orders.dtos.toCreateDto
+import com.coded.ordering.restaurants.RestaurantService
+import io.swagger.v3.oas.annotations.Operation
+import io.swagger.v3.oas.annotations.media.Content
+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.validation.Valid
+import org.springframework.http.HttpStatus
+import org.springframework.http.ResponseEntity
+import org.springframework.security.core.Authentication
+import org.springframework.security.core.userdetails.UserDetails
+import org.springframework.web.bind.annotation.*
+
+@Tag(name = "Order API")
+@RestController
+@RequestMapping("/api/v1/orders")
+class OrderApiController(
+ private val orderService: OrderService,
+ private val restaurantService: RestaurantService,
+){
+
+ @Operation(summary = "User get a list of all orders for authenticated users")
+ @ApiResponses(
+ ApiResponse(
+ responseCode = "200",
+ description = "Returns a list of all orders",
+ content = [
+ Content(
+ mediaType = "application/json")
+ ]),
+ )
+ @GetMapping
+ fun getAllOrders(@RequestAttribute("userId") userId: Long,
+ ): ResponseEntity> {
+ return ResponseEntity.ok(orderService.getAllOrdersByUserId(userId))
+ }
+
+
+ @Operation(summary = "Create a new order for authenticated users")
+ @ApiResponses(
+ ApiResponse(
+ responseCode = "201",
+ description = "Successful Created a new order",
+ content = [
+ Content(
+ mediaType = "application/json")
+ ]),
+ ApiResponse(
+ responseCode = "400",
+ description = "Bad request",
+ content = [
+ Content(mediaType = "application/json")
+ ]),
+ ApiResponse(
+ responseCode = "404",
+ description = "Not Found - Restaurant not found",
+ content = [
+ Content(mediaType = "application/json")
+ ]),
+ )
+ @PostMapping
+ fun createOrder(
+ @Valid @RequestBody newOrderDto: OrderCreateRequestDto,
+ @RequestAttribute("userId") userId: Long,
+ ): ResponseEntity {
+ val restaurant = restaurantService.findById(newOrderDto.restaurantId)
+ ?: return ResponseEntity(HttpStatus.NOT_FOUND)
+
+ val order = orderService.create(
+ newOrderDto.toCreateDto(
+ userId,
+ restaurant,
+ newOrderDto.items.map { it.toCreateDto() }
+ )
+ )
+ return ResponseEntity(order, HttpStatus.CREATED)
+ }
+}
\ No newline at end of file
diff --git a/ordering/src/main/kotlin/com/coded/ordering/orders/OrderService.kt b/ordering/src/main/kotlin/com/coded/ordering/orders/OrderService.kt
new file mode 100644
index 0000000..7ff02c5
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/orders/OrderService.kt
@@ -0,0 +1,13 @@
+package com.coded.ordering.orders
+
+import com.coded.ordering.orders.dtos.OrderCreateDto
+import com.coded.ordering.domain.entities.OrderEntity
+import com.coded.ordering.domain.projections.OrderInfoProjection
+import com.coded.ordering.orders.dtos.OrderCreateResponse
+
+interface OrderService {
+ fun findAll(): List
+ fun create(newOrder: OrderCreateDto): OrderCreateResponse
+ fun findById(id: Long): OrderEntity?
+ fun getAllOrdersByUserId(userId: Long): List
+}
\ No newline at end of file
diff --git a/ordering/src/main/kotlin/com/coded/ordering/orders/OrderServiceImpl.kt b/ordering/src/main/kotlin/com/coded/ordering/orders/OrderServiceImpl.kt
new file mode 100644
index 0000000..2fb38ac
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/orders/OrderServiceImpl.kt
@@ -0,0 +1,66 @@
+package com.coded.ordering.orders
+
+import com.coded.ordering.domain.entities.OrderEntity
+import com.coded.ordering.domain.entities.OrderItemEntity
+import com.coded.ordering.domain.projections.OrderInfoProjection
+import com.coded.ordering.orders.dtos.*
+import com.coded.ordering.repositories.MenuRepository
+import com.coded.ordering.repositories.OrderItemRepository
+import com.coded.ordering.repositories.OrderRepository
+import jakarta.transaction.Transactional
+import org.springframework.data.repository.findByIdOrNull
+import org.springframework.stereotype.Service
+
+// TODO: feature switch on for discount on items based on env config
+@Service
+class OrderServiceImpl(
+ private val orderRepository: OrderRepository,
+ private val orderItemRepository: OrderItemRepository,
+ private val menuRepository: MenuRepository,
+) : OrderService {
+ override fun findAll(): List = orderRepository.findAllProjectedBy()
+
+ @Transactional
+ override fun create(
+ newOrder: OrderCreateDto
+ ): OrderCreateResponse {
+
+ val menuIds = newOrder.items.map { it.itemId }
+ val foundMenus = menuRepository.findAllByIdIn(menuIds)
+
+ val foundIds = foundMenus.mapNotNull { it.id }.toSet()
+ val missingIds = menuIds.filterNot { it in foundIds }
+ if (missingIds.isNotEmpty()) {
+ throw IllegalStateException("Menus not found: $missingIds")
+ }
+
+ val order: OrderEntity = orderRepository.save(newOrder.toOrderEntity())
+ val orderItems = orderItemRepository.saveAll(
+ newOrder.items.map { itemDto ->
+ val menu = foundMenus.find { menu -> menu.id == itemDto.itemId }
+ ?: throw IllegalStateException("Menu not found for id: ${itemDto.itemId}")
+ OrderItemEntity(
+ item = menu,
+ order = order,
+ quantity = itemDto.quantity
+ )
+ }
+ )
+
+ return OrderCreateResponse(
+ id = order.id!!,
+ userId = newOrder.user,
+ restaurantId = newOrder.restaurant.id!!,
+ items = orderItems.map { it ->
+ OrderItemResponse(
+ id = it.id!!,
+ item = foundMenus.find { menu -> it.item?.id == menu.id }!!.toItemResponse(),
+ quantity = it.quantity!!
+ )
+ }
+ )
+ }
+
+ override fun findById(id: Long): OrderEntity? = orderRepository.findByIdOrNull(id)
+ override fun getAllOrdersByUserId(userId: Long): List = orderRepository.findAllByUserId(userId)
+}
\ No newline at end of file
diff --git a/ordering/src/main/kotlin/com/coded/ordering/orders/dtos/OrderCreateDto.kt b/ordering/src/main/kotlin/com/coded/ordering/orders/dtos/OrderCreateDto.kt
new file mode 100644
index 0000000..2678907
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/orders/dtos/OrderCreateDto.kt
@@ -0,0 +1,14 @@
+package com.coded.ordering.orders.dtos
+
+import com.coded.ordering.domain.entities.*
+
+data class OrderCreateDto(
+ val user: Long,
+ val restaurant: RestaurantEntity,
+ val items: List
+)
+
+fun OrderCreateDto.toOrderEntity(): OrderEntity = OrderEntity(
+ userId = user,
+ restaurant = restaurant
+)
\ No newline at end of file
diff --git a/ordering/src/main/kotlin/com/coded/ordering/orders/dtos/OrderCreateRequestDto.kt b/ordering/src/main/kotlin/com/coded/ordering/orders/dtos/OrderCreateRequestDto.kt
new file mode 100644
index 0000000..3b87246
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/orders/dtos/OrderCreateRequestDto.kt
@@ -0,0 +1,32 @@
+package com.coded.ordering.orders.dtos
+
+import com.coded.ordering.domain.entities.*
+import jakarta.validation.constraints.Positive
+import org.jetbrains.annotations.NotNull
+
+data class OrderItemCreateRequestDto (
+ @field:NotNull
+ @field:Positive(message = "Item ID must be positive")
+ val itemId: Long,
+
+ @field:NotNull
+ @field:Positive(message = "Amount must be positive")
+ val quantity: Int,
+)
+
+data class OrderCreateRequestDto(
+ @field:NotNull
+ @field:Positive(message = "Restaurant Id is must be positive")
+ val restaurantId: Long,
+
+ val items: List,
+)
+
+fun OrderCreateRequestDto.toCreateDto(
+ user: Long,
+ restaurant: RestaurantEntity,
+ items: List,
+) = OrderCreateDto(user=user, restaurant=restaurant, items=items)
+
+fun OrderItemCreateRequestDto.toCreateDto()
+ = OrderItemCreateDto(itemId=itemId, quantity=quantity)
\ No newline at end of file
diff --git a/ordering/src/main/kotlin/com/coded/ordering/orders/dtos/OrderItemCreate.kt b/ordering/src/main/kotlin/com/coded/ordering/orders/dtos/OrderItemCreate.kt
new file mode 100644
index 0000000..0695f63
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/orders/dtos/OrderItemCreate.kt
@@ -0,0 +1,6 @@
+package com.coded.ordering.orders.dtos
+
+data class OrderItemCreateDto(
+ val itemId: Long,
+ val quantity: Int,
+)
\ No newline at end of file
diff --git a/ordering/src/main/kotlin/com/coded/ordering/orders/dtos/OrderResponse.kt b/ordering/src/main/kotlin/com/coded/ordering/orders/dtos/OrderResponse.kt
new file mode 100644
index 0000000..0d6f88e
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/orders/dtos/OrderResponse.kt
@@ -0,0 +1,28 @@
+package com.coded.ordering.orders.dtos
+
+import com.coded.ordering.domain.entities.MenuEntity
+
+
+data class ItemResponse (
+ val id: Long,
+ val name: String,
+)
+
+data class OrderItemResponse (
+ val id : Long,
+ val item: ItemResponse,
+ val quantity: Int,
+)
+
+data class OrderCreateResponse (
+ val id: Long,
+ val userId: Long,
+ val restaurantId: Long,
+ val items: List,
+)
+
+
+fun MenuEntity.toItemResponse() = ItemResponse(
+ id = id!!,
+ name = name
+)
diff --git a/ordering/src/main/kotlin/com/coded/ordering/profiles/ProfileApiController.kt b/ordering/src/main/kotlin/com/coded/ordering/profiles/ProfileApiController.kt
new file mode 100644
index 0000000..c890fcd
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/profiles/ProfileApiController.kt
@@ -0,0 +1,80 @@
+package com.coded.ordering.profiles
+
+import com.coded.ordering.profiles.dtos.ProfileCreateRequestDto
+import com.coded.ordering.profiles.dtos.ProfileResponseDto
+import com.coded.ordering.profiles.dtos.toResponseDto
+import io.swagger.v3.oas.annotations.Operation
+import io.swagger.v3.oas.annotations.media.Content
+import io.swagger.v3.oas.annotations.media.Schema
+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.validation.Valid
+import org.springframework.http.HttpStatus
+import org.springframework.http.ResponseEntity
+import org.springframework.security.core.Authentication
+import org.springframework.security.core.userdetails.UserDetails
+import org.springframework.web.bind.annotation.*
+
+@Tag(name = "Profile API")
+@RestController
+@RequestMapping("/api/v1/profiles")
+class ProfileApiController(
+ private val profileService: ProfileService,
+) {
+
+ @Operation(summary = "Returns a list of all profiles for authenticated users")
+ @ApiResponses(
+ ApiResponse(
+ responseCode = "200",
+ description = "Returns a list of all profiles",
+ content = [
+ Content(
+ mediaType = "application/json")
+ ]),
+ )
+ @GetMapping
+ fun getProfiles() = profileService.findAll().map { it.toResponseDto() }
+
+
+ @Operation(summary = "Authenticated users can create/update a profile")
+ @ApiResponses(
+ ApiResponse(
+ responseCode = "201",
+ description = "Users can create or edits their profile",
+ content = [
+ Content(
+ mediaType = "application/json",
+ schema = Schema(implementation = ProfileResponseDto::class)
+ )
+ ]),
+ ApiResponse(
+ responseCode = "400",
+ description = "Bad request",
+ content = [
+ Content(mediaType = "application/json",
+ )
+ ]),
+ ApiResponse(
+ responseCode = "500",
+ description = "Internal server error",
+ content = [
+ Content(mediaType = "application/json")
+ ]),
+ )
+ @PostMapping
+ fun createProfile(
+ @Valid @RequestBody profileCreateDto: ProfileCreateRequestDto,
+ @RequestAttribute("authUserId") userId: Long,
+ )
+ : ResponseEntity {
+ return try {
+ val profile = profileService.createProfile(profileCreateDto, userId)
+ ResponseEntity(profile.toResponseDto(), HttpStatus.CREATED)
+ } catch (e: IllegalArgumentException) {
+ ResponseEntity(e.message, HttpStatus.BAD_REQUEST)
+ } catch (e: Exception) {
+ ResponseEntity(null, HttpStatus.INTERNAL_SERVER_ERROR)
+ }
+ }
+}
\ No newline at end of file
diff --git a/ordering/src/main/kotlin/com/coded/ordering/profiles/ProfileService.kt b/ordering/src/main/kotlin/com/coded/ordering/profiles/ProfileService.kt
new file mode 100644
index 0000000..90705c1
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/profiles/ProfileService.kt
@@ -0,0 +1,9 @@
+package com.coded.ordering.profiles
+
+import com.coded.ordering.profiles.dtos.ProfileCreateRequestDto
+import com.coded.ordering.domain.entities.ProfileEntity
+
+interface ProfileService {
+ fun findAll(): List
+ fun createProfile(profile: ProfileCreateRequestDto, userId: Long): ProfileEntity
+}
\ No newline at end of file
diff --git a/ordering/src/main/kotlin/com/coded/ordering/profiles/ProfileServiceImpl.kt b/ordering/src/main/kotlin/com/coded/ordering/profiles/ProfileServiceImpl.kt
new file mode 100644
index 0000000..4af1524
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/profiles/ProfileServiceImpl.kt
@@ -0,0 +1,27 @@
+package com.coded.ordering.profiles
+
+import com.coded.ordering.profiles.dtos.ProfileCreateRequestDto
+import com.coded.ordering.domain.entities.ProfileEntity
+import com.coded.ordering.profiles.dtos.toEntity
+import com.coded.ordering.repositories.ProfileRepository
+import org.springframework.stereotype.Service
+
+@Service
+class ProfileServiceImpl(
+ private val profileRepository: ProfileRepository,
+): ProfileService {
+ override fun findAll(): List {
+ return profileRepository.findAll()
+ }
+
+ override fun createProfile(profile: ProfileCreateRequestDto, userId: Long): ProfileEntity {
+
+ val profileExists = profileRepository.findByUser(userId)
+
+ if (profileExists != null) {
+ throw IllegalArgumentException("User already has a profile")
+ }
+
+ return profileRepository.save(profile.toEntity().copy(user = userId))
+ }
+}
\ No newline at end of file
diff --git a/ordering/src/main/kotlin/com/coded/ordering/profiles/dtos/ProfileCreateRequestDto.kt b/ordering/src/main/kotlin/com/coded/ordering/profiles/dtos/ProfileCreateRequestDto.kt
new file mode 100644
index 0000000..88d5502
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/profiles/dtos/ProfileCreateRequestDto.kt
@@ -0,0 +1,28 @@
+package com.coded.ordering.profiles.dtos
+
+import com.coded.ordering.domain.entities.ProfileEntity
+import jakarta.validation.constraints.*
+
+data class ProfileCreateRequestDto(
+ @field:NotBlank
+ @field:Size(min = 3, max = 100)
+ @field:Pattern(regexp = "^[a-zA-Z]+$", message = "Name should contain only letters")
+ val firstName: String,
+
+ @field:NotBlank
+ @field:Size(min = 3, max = 100)
+ @field:Pattern(regexp = "^[a-zA-Z]+$", message = "Name should contain only letters")
+ val lastName: String,
+
+ @field:NotBlank
+ @field:Size(min = 7, max = 12)
+ @field:Pattern(regexp = "^\\d{7,12}$", message = "Phone number must be digits only and 7-12 characters long")
+ val phoneNumber: String,
+)
+
+
+fun ProfileCreateRequestDto.toEntity() = ProfileEntity(
+ firstName = firstName,
+ lastName = lastName,
+ phoneNumber = phoneNumber,
+)
\ No newline at end of file
diff --git a/ordering/src/main/kotlin/com/coded/ordering/profiles/dtos/ProfileResponseDto.kt b/ordering/src/main/kotlin/com/coded/ordering/profiles/dtos/ProfileResponseDto.kt
new file mode 100644
index 0000000..a38b0af
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/profiles/dtos/ProfileResponseDto.kt
@@ -0,0 +1,20 @@
+package com.coded.ordering.profiles.dtos
+
+import com.coded.ordering.domain.entities.ProfileEntity
+
+data class ProfileResponseDto(
+ val id: Long,
+ val userId: Long,
+ val firstName: String,
+ val lastName: String,
+ val phoneNumber: String,
+)
+
+
+fun ProfileEntity.toResponseDto() = ProfileResponseDto(
+ id=id!!,
+ userId=user!!,
+ firstName=firstName!!,
+ lastName=lastName!!,
+ phoneNumber=phoneNumber!!
+)
diff --git a/ordering/src/main/kotlin/com/coded/ordering/providers/JwtAuthProvider.kt b/ordering/src/main/kotlin/com/coded/ordering/providers/JwtAuthProvider.kt
new file mode 100644
index 0000000..eb7dfed
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/providers/JwtAuthProvider.kt
@@ -0,0 +1,33 @@
+package com.coded.ordering.providers
+
+import jakarta.inject.Named
+import org.springframework.beans.factory.annotation.Value
+import org.springframework.core.ParameterizedTypeReference
+import org.springframework.http.*
+import org.springframework.util.MultiValueMap
+import org.springframework.web.client.RestTemplate
+import org.springframework.web.client.exchange
+
+@Named
+class JwtAuthProvider (
+ @Value("\${authService.url}")
+ private val authServiceURL: String
+){
+ fun authenticateToken(token: String): ValidateTokenResponseDto {
+ val restTemplate = RestTemplate()
+ val response = restTemplate.exchange(
+ url = authServiceURL,
+ method = HttpMethod.POST,
+ requestEntity = HttpEntity(
+ MultiValueMap.fromMultiValue(mapOf("Authorization" to listOf("Bearer $token")))
+ ),
+ object : ParameterizedTypeReference() {
+ }
+ )
+ return response.body ?: throw IllegalStateException("Check token response has no body ...")
+ }
+}
+
+data class ValidateTokenResponseDto (
+ val userId: Long
+)
\ No newline at end of file
diff --git a/ordering/src/main/kotlin/com/coded/ordering/repositories/MenuRepository.kt b/ordering/src/main/kotlin/com/coded/ordering/repositories/MenuRepository.kt
new file mode 100644
index 0000000..a3cd580
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/repositories/MenuRepository.kt
@@ -0,0 +1,37 @@
+package com.coded.ordering.repositories
+
+import com.coded.ordering.domain.entities.MenuEntity
+import com.coded.ordering.domain.projections.MenuBasicInfoProjection
+import com.coded.ordering.domain.projections.MenuInfoSearchProjection
+import org.springframework.data.jpa.repository.JpaRepository
+import org.springframework.data.jpa.repository.Query
+import org.springframework.data.repository.query.Param
+import org.springframework.stereotype.Repository
+
+@Repository
+interface MenuRepository: JpaRepository {
+ fun findByRestaurant_Id(restaurantId: Long): List
+ fun findAllByIdIn(menuIds: List): List
+
+ @Query("""
+ SELECT m FROM MenuEntity m
+ WHERE LOWER(m.name) LIKE LOWER(CONCAT('%', :menuName, '%'))
+ AND LOWER(m.restaurant.name) LIKE LOWER(CONCAT('%', :restName, '%'))
+ """)
+ fun searchByMenuAndRestaurantName(
+ @Param("menuName") menuName: String,
+ @Param("restName") restName: String
+ ): List
+
+ fun findByNameContainingIgnoreCase(name: String)
+ : List
+
+ @Query("""
+ SELECT m FROM MenuEntity m
+ WHERE LOWER(m.restaurant.name) LIKE LOWER(CONCAT('%', :restName, '%'))
+ """)
+
+ fun findByRestaurantNameContainingIgnoreCase(
+ @Param("restName") restName: String
+ ): List
+}
\ No newline at end of file
diff --git a/ordering/src/main/kotlin/com/coded/ordering/repositories/OrderItemRepository.kt b/ordering/src/main/kotlin/com/coded/ordering/repositories/OrderItemRepository.kt
new file mode 100644
index 0000000..22e5b7f
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/repositories/OrderItemRepository.kt
@@ -0,0 +1,10 @@
+package com.coded.ordering.repositories
+
+import com.coded.ordering.domain.entities.OrderItemEntity
+import org.springframework.data.jpa.repository.JpaRepository
+import org.springframework.stereotype.Repository
+
+
+@Repository
+interface OrderItemRepository: JpaRepository {
+}
\ No newline at end of file
diff --git a/ordering/src/main/kotlin/com/coded/ordering/repositories/OrderRepository.kt b/ordering/src/main/kotlin/com/coded/ordering/repositories/OrderRepository.kt
new file mode 100644
index 0000000..5a1aa94
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/repositories/OrderRepository.kt
@@ -0,0 +1,18 @@
+package com.coded.ordering.repositories
+
+import com.coded.ordering.domain.entities.OrderEntity
+import com.coded.ordering.domain.projections.OrderInfoProjection
+import org.springframework.data.jpa.repository.JpaRepository
+import org.springframework.data.jpa.repository.Query
+import org.springframework.data.repository.query.Param
+import org.springframework.stereotype.Repository
+
+
+@Repository
+interface OrderRepository: JpaRepository {
+ @Query("SELECT o FROM OrderEntity o")
+ fun findAllProjectedBy(): List
+
+ @Query("SELECT o FROM OrderEntity o WHERE o.userId = :userId")
+ fun findAllByUserId(@Param("userId") userId: Long): List
+}
\ No newline at end of file
diff --git a/ordering/src/main/kotlin/com/coded/ordering/repositories/ProfileRepository.kt b/ordering/src/main/kotlin/com/coded/ordering/repositories/ProfileRepository.kt
new file mode 100644
index 0000000..ee0f46c
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/repositories/ProfileRepository.kt
@@ -0,0 +1,10 @@
+package com.coded.ordering.repositories
+
+import com.coded.ordering.domain.entities.ProfileEntity
+import org.springframework.data.jpa.repository.JpaRepository
+import org.springframework.stereotype.Repository
+
+@Repository
+interface ProfileRepository: JpaRepository {
+ fun findByUser(userId: Long): ProfileEntity?
+}
\ No newline at end of file
diff --git a/ordering/src/main/kotlin/com/coded/ordering/repositories/RestaurantRepository.kt b/ordering/src/main/kotlin/com/coded/ordering/repositories/RestaurantRepository.kt
new file mode 100644
index 0000000..9277fee
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/repositories/RestaurantRepository.kt
@@ -0,0 +1,16 @@
+package com.coded.ordering.repositories
+
+import com.coded.ordering.domain.entities.RestaurantEntity
+import com.coded.ordering.domain.projections.RestaurantInfoProjection
+import org.springframework.data.jpa.repository.JpaRepository
+import org.springframework.data.jpa.repository.Query
+import org.springframework.stereotype.Repository
+
+@Repository
+interface RestaurantRepository: JpaRepository {
+
+ fun findByName(name: String): RestaurantEntity?
+
+ @Query("SELECT r FROM RestaurantEntity r")
+ fun details(): List
+}
\ No newline at end of file
diff --git a/ordering/src/main/kotlin/com/coded/ordering/restaurants/RestaurantApiController.kt b/ordering/src/main/kotlin/com/coded/ordering/restaurants/RestaurantApiController.kt
new file mode 100644
index 0000000..0fd4aab
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/restaurants/RestaurantApiController.kt
@@ -0,0 +1,101 @@
+package com.coded.ordering.restaurants
+
+import com.coded.ordering.domain.entities.RestaurantEntity
+import com.coded.ordering.domain.projections.MenuBasicInfoProjection
+import com.coded.ordering.menus.MenuService
+import com.coded.ordering.profiles.dtos.ProfileResponseDto
+import com.coded.ordering.restaurants.dtos.RestaurantCreateRequestDto
+import com.coded.ordering.restaurants.dtos.toEntity
+import io.swagger.v3.oas.annotations.Operation
+import io.swagger.v3.oas.annotations.media.Content
+import io.swagger.v3.oas.annotations.media.Schema
+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.HttpStatus
+import org.springframework.http.ResponseEntity
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.PathVariable
+import org.springframework.web.bind.annotation.PostMapping
+import org.springframework.web.bind.annotation.RequestBody
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RestController
+
+
+@Tag(name = "Restaurants Api")
+@RestController
+@RequestMapping("/api/v1/restaurants")
+class RestaurantApiController(
+ private val restaurantService: RestaurantService,
+ private val menuService: MenuService,
+) {
+
+ @Operation(summary = "Get all restaurants for authenticated users")
+ @ApiResponses(
+ ApiResponse(
+ responseCode = "200",
+ description = "Return all restaurants",
+ content = [
+ Content(
+ mediaType = "application/json",
+ )
+ ]),
+ )
+ @GetMapping
+ fun getRestaurants() = restaurantService.findAll()
+
+ @Operation(summary = "Create a new restaurant for authenticated users")
+ @ApiResponses(
+ ApiResponse(
+ responseCode = "201",
+ description = "Create a new restaurant",
+ content = [
+ Content(
+ mediaType = "application/json",
+ schema = Schema(implementation = RestaurantEntity::class)
+ )
+ ]),
+
+ ApiResponse(
+ responseCode = "400",
+ description = "Bad request",
+ content = [
+ Content(mediaType = "application/json")
+ ]),
+ ApiResponse(
+ responseCode = "403",
+ description = "Forbidden",
+ content = [
+ Content(mediaType = "application/json")
+ ]),
+ )
+ @PostMapping
+ fun createRestaurant(@RequestBody restaurant: RestaurantCreateRequestDto) =
+ restaurantService.create(restaurant.toEntity())
+
+ @Operation(summary = "Get menu items for restaurant by id for authenticated users")
+ @ApiResponses(
+ ApiResponse(
+ responseCode = "200",
+ description = "Get restaurant's menu items",
+ content = [
+ Content(
+ mediaType = "application/json",
+ )
+ ]),
+
+ ApiResponse(
+ responseCode = "404",
+ description = "Not Found",
+ content = [
+ Content(mediaType = "application/json")
+ ]),
+ )
+ @GetMapping(path = ["/{id}/menu"])
+ fun getRestaurantMenu(@PathVariable("id") id: Long): ResponseEntity> {
+ val restaurant = restaurantService.findById(id)
+ ?: return ResponseEntity.badRequest().build()
+ val menus = menuService.findByRestaurantId(restaurantId = restaurant.id!!)
+ return ResponseEntity.ok(menus)
+ }
+}
\ No newline at end of file
diff --git a/ordering/src/main/kotlin/com/coded/ordering/restaurants/RestaurantService.kt b/ordering/src/main/kotlin/com/coded/ordering/restaurants/RestaurantService.kt
new file mode 100644
index 0000000..7537027
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/restaurants/RestaurantService.kt
@@ -0,0 +1,12 @@
+package com.coded.ordering.restaurants
+
+import com.coded.ordering.domain.entities.RestaurantEntity
+import com.coded.ordering.domain.projections.RestaurantInfoProjection
+
+interface RestaurantService {
+ fun findAll(): List
+ fun getInto(): List
+ fun create(restaurant: RestaurantEntity): RestaurantEntity
+ fun findById(id: Long): RestaurantEntity?
+ fun findByName(name: String): RestaurantEntity?
+}
\ No newline at end of file
diff --git a/ordering/src/main/kotlin/com/coded/ordering/restaurants/RestaurantServiceImpl.kt b/ordering/src/main/kotlin/com/coded/ordering/restaurants/RestaurantServiceImpl.kt
new file mode 100644
index 0000000..eb35b4d
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/restaurants/RestaurantServiceImpl.kt
@@ -0,0 +1,19 @@
+package com.coded.ordering.restaurants
+
+import com.coded.ordering.domain.entities.RestaurantEntity
+import com.coded.ordering.domain.projections.RestaurantInfoProjection
+import com.coded.ordering.repositories.RestaurantRepository
+import org.springframework.data.repository.findByIdOrNull
+import org.springframework.stereotype.Service
+
+@Service
+class RestaurantServiceImpl(private val restaurantRepository: RestaurantRepository) : RestaurantService {
+ override fun findAll(): List = restaurantRepository.findAll()
+ override fun getInto(): List {
+ return restaurantRepository.details()
+ }
+
+ override fun create(restaurant: RestaurantEntity): RestaurantEntity = restaurantRepository.save(restaurant)
+ override fun findById(id: Long): RestaurantEntity? = restaurantRepository.findByIdOrNull(id)
+ override fun findByName(name: String): RestaurantEntity? = restaurantRepository.findByName(name)
+}
\ No newline at end of file
diff --git a/ordering/src/main/kotlin/com/coded/ordering/restaurants/dtos/RestaurantCreateRequestDto.kt b/ordering/src/main/kotlin/com/coded/ordering/restaurants/dtos/RestaurantCreateRequestDto.kt
new file mode 100644
index 0000000..33c2d5c
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/restaurants/dtos/RestaurantCreateRequestDto.kt
@@ -0,0 +1,14 @@
+package com.coded.ordering.restaurants.dtos
+
+import com.coded.ordering.domain.entities.RestaurantEntity
+import jakarta.validation.constraints.NotBlank
+import jakarta.validation.constraints.Size
+
+
+data class RestaurantCreateRequestDto(
+ @field:NotBlank(message = "Name is required")
+ @field:Size(min = 3, message = "Name is too short")
+ val name: String
+)
+
+fun RestaurantCreateRequestDto.toEntity() = RestaurantEntity(name = name)
\ No newline at end of file
diff --git a/ordering/src/main/kotlin/com/coded/ordering/restaurants/dtos/RestaurantInfoResponse.kt b/ordering/src/main/kotlin/com/coded/ordering/restaurants/dtos/RestaurantInfoResponse.kt
new file mode 100644
index 0000000..3278d62
--- /dev/null
+++ b/ordering/src/main/kotlin/com/coded/ordering/restaurants/dtos/RestaurantInfoResponse.kt
@@ -0,0 +1,13 @@
+package com.coded.ordering.restaurants.dtos
+
+import com.coded.ordering.domain.entities.RestaurantEntity
+
+data class RestaurantInfoResponse(
+ val id: Long,
+ val name: String
+)
+
+fun RestaurantEntity.toResponse() = RestaurantInfoResponse(
+ id=id!!,
+ name=name
+)
\ No newline at end of file
diff --git a/ordering/src/main/resources/V1_sample.sql b/ordering/src/main/resources/V1_sample.sql
new file mode 100644
index 0000000..13a271e
--- /dev/null
+++ b/ordering/src/main/resources/V1_sample.sql
@@ -0,0 +1,45 @@
+INSERT INTO public.users (username, name)
+VALUES
+ ('dude', 'some dude'),
+ ('bro', 'some bro'),
+ ('chief', 'master chief'),
+ ('spart', 'leonidas k'),
+ ('bama', 'pr. obama'),
+ ('santa', 'st. clause'),
+ ('py', 'pythonista');
+
+SELECT * FROM public.users;
+
+
+INSERT INTO public.restaurants (name)
+VALUES
+ ('memes curry'),
+ ('five guys'),
+ ('pick');
+
+SELECT * FROM public.restaurants;
+
+INSERT INTO public.menus (name, restaurant_id, price)
+VALUES
+ ('noodles', 3, 5.5),
+ ('ramen', 1, 6.9),
+ ('burgers', 2, 3.5);
+
+SELECT * FROM public.menus;
+
+INSERT INTO public.orders (user_id, restaurant_id)
+VALUES
+ (1, 1),
+ (1, 3);
+
+SELECT * FROM public.orders;
+
+
+INSERT INTO public.orders_items (menu_id, order_id, quantity)
+VALUES
+ (1, 1, 3),
+ (2, 2, 5),
+ (1, 1, 1);
+
+SELECT * FROM public.orders_items;
+
diff --git a/ordering/src/main/resources/V2_sample.sql b/ordering/src/main/resources/V2_sample.sql
new file mode 100644
index 0000000..a169268
--- /dev/null
+++ b/ordering/src/main/resources/V2_sample.sql
@@ -0,0 +1,11 @@
+-- Requires V1 data and migrations upto V2
+-- Updates users up to ID 100 with new data for email and password
+DO $FN$
+ BEGIN
+ FOR counter IN 1..100 LOOP
+ UPDATE public.users
+ SET email= 'someEmail' || counter || '@exmaple.com', password='secretPassword123'
+ WHERE id = counter;
+ END LOOP;
+ END;
+$FN$
\ 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..bf7d861
--- /dev/null
+++ b/ordering/src/main/resources/application.properties
@@ -0,0 +1,14 @@
+spring.application.name=Kotlin.SpringbootV2
+
+logging.level.org.springframework.web= DEBUG
+
+spring.datasource.driver-class-name=org.postgresql.Driver
+spring.datasource.url=jdbc:postgresql://localhost:5432/shopping
+spring.datasource.username=postgres
+spring.datasource.password=changemelater
+spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
+spring.jpa.show-sql=true
+server.port=8080
+springdoc.api-docs.path=/api-docs
+
+authService.url=http://localhost:8081/api/v1/auth/check-token
diff --git a/ordering/src/main/resources/db/migration/V1__init_db.sql b/ordering/src/main/resources/db/migration/V1__init_db.sql
new file mode 100644
index 0000000..6ead5f9
--- /dev/null
+++ b/ordering/src/main/resources/db/migration/V1__init_db.sql
@@ -0,0 +1,39 @@
+DROP TABLE IF EXISTS users;
+CREATE TABLE "users" (
+ "id" SERIAL PRIMARY KEY ,
+ "username" VARCHAR(200) NOT NULL UNIQUE,
+ "name" VARCHAR(200)
+);
+
+DROP TABLE IF EXISTS "restaurants";
+CREATE TABLE "restaurants" (
+ "id" SERIAL PRIMARY KEY ,
+ "name" VARCHAR(200) UNIQUE
+);
+
+DROP TABLE IF EXISTS "menus";
+CREATE TABLE "menus" (
+ "id" SERIAL PRIMARY KEY,
+ "name" VARCHAR(255),
+ "restaurant_id" INT REFERENCES restaurants (id),
+ "price" NUMERIC NOT NULL
+);
+
+DROP TABLE IF EXISTS "orders";
+CREATE TABLE "orders" (
+ "id" SERIAL PRIMARY KEY,
+ "user_id" INT REFERENCES users (id),
+ "restaurant_id" INT REFERENCES restaurants (id)
+);
+
+DROP TABLE IF EXISTS "orders_items";
+CREATE TABLE "orders_items" (
+ "id" SERIAL PRIMARY KEY,
+ "menu_id" INT REFERENCES menus (id),
+ "order_id" INT REFERENCES orders (id),
+ "quantity" INT NOT NULL,
+ CONSTRAINT quantity_non_negative CHECK(quantity>=0)
+);
+
+
+
diff --git a/ordering/src/main/resources/db/migration/V2__menu_column_change.sql b/ordering/src/main/resources/db/migration/V2__menu_column_change.sql
new file mode 100644
index 0000000..85d9295
--- /dev/null
+++ b/ordering/src/main/resources/db/migration/V2__menu_column_change.sql
@@ -0,0 +1,10 @@
+ALTER TABLE public.users
+ ADD email VARCHAR(255)
+ CONSTRAINT unique_user_email UNIQUE
+ DEFAULT NULL;
+
+ALTER TABLE public.users
+ ADD password VARCHAR(255)
+ DEFAULT NULL;
+
+SELECT * FROM public.users;
\ No newline at end of file
diff --git a/ordering/src/main/resources/db/migration/V3__profile_table_create.sql b/ordering/src/main/resources/db/migration/V3__profile_table_create.sql
new file mode 100644
index 0000000..5029e6e
--- /dev/null
+++ b/ordering/src/main/resources/db/migration/V3__profile_table_create.sql
@@ -0,0 +1,8 @@
+DROP TABLE IF EXISTS "profiles";
+CREATE TABLE "profiles" (
+ "id" SERIAL PRIMARY KEY ,
+ "user_id" INT REFERENCES public.users (id),
+ "first_name" VARCHAR(255) NOT NULL,
+ "last_name" VARCHAR(255) NOT NULL,
+ "phone_number" VARCHAR(255) NOT NULL
+);
\ No newline at end of file
diff --git a/ordering/src/test/kotlin/com/coded/ordering/OrderingApplicationTests.kt b/ordering/src/test/kotlin/com/coded/ordering/OrderingApplicationTests.kt
new file mode 100644
index 0000000..7535539
--- /dev/null
+++ b/ordering/src/test/kotlin/com/coded/ordering/OrderingApplicationTests.kt
@@ -0,0 +1,13 @@
+package com.coded.ordering
+
+import org.junit.jupiter.api.Test
+import org.springframework.boot.test.context.SpringBootTest
+
+@SpringBootTest
+class OrderingApplicationTests {
+
+// @Test
+// fun contextLoads() {
+// }
+
+}
diff --git a/pom.xml b/pom.xml
index 163ad53..cadd6cb 100644
--- a/pom.xml
+++ b/pom.xml
@@ -9,10 +9,11 @@
com.coded.spring
- Ordering
+ YousefTech
0.0.1-SNAPSHOT
- Kotlin.SpringbootV2
- Kotlin.SpringbootV2
+ pom
+ YousefTech
+ YousefTech
@@ -20,6 +21,12 @@
+
+
+
+ authentication
+ ordering
+
@@ -35,6 +42,27 @@
org.springframework.boot
spring-boot-starter-web
+
+ 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
+
com.fasterxml.jackson.module
jackson-module-kotlin
@@ -47,6 +75,31 @@
org.jetbrains.kotlin
kotlin-stdlib
+
+ jakarta.inject
+ jakarta.inject-api
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ com.h2database
+ h2
+
+
+ org.postgresql
+ postgresql
+
+
+ com.hazelcast
+ hazelcast
+ 5.3.8
+
org.springframework.boot
@@ -58,6 +111,28 @@
kotlin-test-junit5
test
+
+ io.cucumber
+ cucumber-java
+ 7.21.0
+ test
+
+
+ io.cucumber
+ cucumber-spring
+ 7.14.0
+ test
+
+
+ org.springdoc
+ springdoc-openapi-starter-webmvc-api
+ 2.6.0
+
+
+
+
+
+
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
deleted file mode 100644
index 3704dc6..0000000
--- a/src/main/resources/application.properties
+++ /dev/null
@@ -1 +0,0 @@
-spring.application.name=Kotlin.SpringbootV2
diff --git a/swagger.json b/swagger.json
new file mode 100644
index 0000000..33d55c5
--- /dev/null
+++ b/swagger.json
@@ -0,0 +1,855 @@
+{
+ "openapi": "3.0.1",
+ "info": {
+ "title": "OpenAPI definition",
+ "version": "v0"
+ },
+ "servers": [
+ {
+ "url": "http://localhost:8080",
+ "description": "Generated server url"
+ }
+ ],
+ "paths": {
+ "/api/v1/restaurants": {
+ "get": {
+ "tags": [
+ "Restaurants Api"
+ ],
+ "summary": "Get all restaurants for authenticated users",
+ "operationId": "getRestaurants",
+ "responses": {
+ "200": {
+ "description": "Return all restaurants",
+ "content": {
+ "application/json": {
+
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "tags": [
+ "Restaurants Api"
+ ],
+ "summary": "Create a new restaurant for authenticated users",
+ "operationId": "createRestaurant",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RestaurantCreateRequestDto"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "400": {
+ "description": "Bad request",
+ "content": {
+ "application/json": {
+
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+
+ }
+ }
+ },
+ "201": {
+ "description": "Create a new restaurant",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RestaurantEntity"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/profiles": {
+ "get": {
+ "tags": [
+ "Profile API"
+ ],
+ "summary": "Returns a list of all profiles for authenticated users",
+ "operationId": "getProfiles",
+ "responses": {
+ "200": {
+ "description": "Returns a list of all profiles",
+ "content": {
+ "application/json": {
+
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "tags": [
+ "Profile API"
+ ],
+ "summary": "Authenticated users can create/update a profile",
+ "operationId": "createProfile",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProfileCreateRequestDto"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "400": {
+ "description": "Bad request",
+ "content": {
+ "application/json": {
+
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+
+ }
+ }
+ },
+ "201": {
+ "description": "Users can create or edits their profile",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProfileResponseDto"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/orders": {
+ "get": {
+ "tags": [
+ "Order API"
+ ],
+ "summary": "User get a list of all orders for authenticated users",
+ "operationId": "getAllOrders",
+ "responses": {
+ "200": {
+ "description": "Returns a list of all orders",
+ "content": {
+ "application/json": {
+
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "tags": [
+ "Order API"
+ ],
+ "summary": "Create a new order for authenticated users",
+ "operationId": "createOrder",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OrderCreateRequestDto"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "404": {
+ "description": "Not Found - Restaurant not found",
+ "content": {
+ "application/json": {
+
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request",
+ "content": {
+ "application/json": {
+
+ }
+ }
+ },
+ "201": {
+ "description": "Successful Created a new order",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/JwtResponseDto"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/menus/create": {
+ "post": {
+ "tags": [
+ "Menu API"
+ ],
+ "summary": "Create a new menu item",
+ "operationId": "createMenu",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/MenuCreateRequestDto"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successfully created a new menu for authenticated users",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/MenuCreateRequestDto"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request",
+ "content": {
+ "application/json": {
+
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/auth/register": {
+ "post": {
+ "tags": [
+ "Auth Ppi"
+ ],
+ "summary": "Create a new user and receive a JWT token",
+ "operationId": "createUser",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UserCreateRequestDto"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "400": {
+ "description": "Bad request",
+ "content": {
+ "application/json": {
+
+ }
+ }
+ },
+ "200": {
+ "description": "Successful registration",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/JwtResponseDto"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/auth/login": {
+ "post": {
+ "tags": [
+ "Auth Ppi"
+ ],
+ "summary": "User login endpoint to receive JWT token",
+ "operationId": "login",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/LoginRequestDto"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "400": {
+ "description": "Bad request",
+ "content": {
+ "application/json": {
+
+ }
+ }
+ },
+ "200": {
+ "description": "Successful login",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/JwtResponseDto"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/users": {
+ "get": {
+ "tags": [
+ "User API"
+ ],
+ "summary": "Get a list of all users for authenticated users",
+ "operationId": "getUsers",
+ "responses": {
+ "200": {
+ "description": "List all users",
+ "content": {
+ "application/json": {
+
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/users/{id}": {
+ "get": {
+ "tags": [
+ "User API"
+ ],
+ "summary": "Get user details by id for authenticated users",
+ "operationId": "getUser",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int64"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Return user details",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UserResponseDto"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "content": {
+ "application/json": {
+
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/restaurants/{id}/menu": {
+ "get": {
+ "tags": [
+ "Restaurants Api"
+ ],
+ "summary": "Get menu items for restaurant by id for authenticated users",
+ "operationId": "getRestaurantMenu",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int64"
+ }
+ }
+ ],
+ "responses": {
+ "404": {
+ "description": "Not Found",
+ "content": {
+ "application/json": {
+
+ }
+ }
+ },
+ "200": {
+ "description": "Get restaurant's menu items",
+ "content": {
+ "application/json": {
+
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/menus": {
+ "get": {
+ "tags": [
+ "Menu API"
+ ],
+ "summary": "Receive a list of all menu items available",
+ "operationId": "getAll",
+ "responses": {
+ "200": {
+ "description": "Return a list of menu items",
+ "content": {
+ "application/json": {
+
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/menus/search": {
+ "get": {
+ "tags": [
+ "Menu API"
+ ],
+ "summary": "Search all restaurants for menu items",
+ "operationId": "search",
+ "parameters": [
+ {
+ "name": "restName",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "menuName",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Returns a menu list by item name or restaurant name for authenticated users",
+ "content": {
+ "application/json": {
+
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/menus/details/{menuId}": {
+ "get": {
+ "tags": [
+ "Menu API"
+ ],
+ "summary": "Get a menu item by id",
+ "operationId": "getMenu",
+ "parameters": [
+ {
+ "name": "menuId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int64"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successfully returns a menu by id for authenticated users",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/MenuDetailResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not request",
+ "content": {
+ "application/json": {
+
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/hello-world": {
+ "get": {
+ "tags": [
+ "Hello world"
+ ],
+ "summary": "Test endpoint that requires JWT token",
+ "operationId": "helloWorld",
+ "responses": {
+ "200": {
+ "description": "Generic hello world endpoint for authenticated users",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "components": {
+ "schemas": {
+ "RestaurantCreateRequestDto": {
+ "required": [
+ "name"
+ ],
+ "type": "object",
+ "properties": {
+ "name": {
+ "maxLength": 2147483647,
+ "minLength": 3,
+ "type": "string"
+ }
+ }
+ },
+ "MenuEntity": {
+ "required": [
+ "name",
+ "price"
+ ],
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "name": {
+ "type": "string"
+ },
+ "restaurant": {
+ "$ref": "#/components/schemas/RestaurantEntity"
+ },
+ "price": {
+ "type": "number"
+ }
+ }
+ },
+ "RestaurantEntity": {
+ "required": [
+ "name"
+ ],
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "name": {
+ "type": "string"
+ },
+ "menus": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/MenuEntity"
+ }
+ }
+ }
+ },
+ "ProfileCreateRequestDto": {
+ "required": [
+ "firstName",
+ "lastName",
+ "phoneNumber"
+ ],
+ "type": "object",
+ "properties": {
+ "firstName": {
+ "maxLength": 100,
+ "minLength": 3,
+ "pattern": "^[a-zA-Z]+$",
+ "type": "string"
+ },
+ "lastName": {
+ "maxLength": 100,
+ "minLength": 3,
+ "pattern": "^[a-zA-Z]+$",
+ "type": "string"
+ },
+ "phoneNumber": {
+ "maxLength": 12,
+ "minLength": 7,
+ "pattern": "^\\d{7,12}$",
+ "type": "string"
+ }
+ }
+ },
+ "ProfileResponseDto": {
+ "required": [
+ "firstName",
+ "id",
+ "lastName",
+ "phoneNumber",
+ "userId"
+ ],
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "userId": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "firstName": {
+ "type": "string"
+ },
+ "lastName": {
+ "type": "string"
+ },
+ "phoneNumber": {
+ "type": "string"
+ }
+ }
+ },
+ "OrderCreateRequestDto": {
+ "required": [
+ "items",
+ "restaurantId"
+ ],
+ "type": "object",
+ "properties": {
+ "restaurantId": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "items": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/OrderItemCreateRequestDto"
+ }
+ }
+ }
+ },
+ "OrderItemCreateRequestDto": {
+ "required": [
+ "itemId",
+ "quantity"
+ ],
+ "type": "object",
+ "properties": {
+ "itemId": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "quantity": {
+ "type": "integer",
+ "format": "int32"
+ }
+ }
+ },
+ "JwtResponseDto": {
+ "required": [
+ "token"
+ ],
+ "type": "object",
+ "properties": {
+ "token": {
+ "type": "string"
+ }
+ }
+ },
+ "MenuCreateRequestDto": {
+ "required": [
+ "name",
+ "price",
+ "restaurantId"
+ ],
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "restaurantId": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "price": {
+ "type": "number"
+ }
+ }
+ },
+ "UserCreateRequestDto": {
+ "required": [
+ "email",
+ "name",
+ "password",
+ "username"
+ ],
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "username": {
+ "type": "string"
+ },
+ "email": {
+ "type": "string"
+ },
+ "password": {
+ "maxLength": 2147483647,
+ "minLength": 6,
+ "pattern": "(?=.*\\d)(?=.*[a-z])(?=.*[A-Z]).*",
+ "type": "string"
+ }
+ }
+ },
+ "LoginRequestDto": {
+ "required": [
+ "password",
+ "username"
+ ],
+ "type": "object",
+ "properties": {
+ "username": {
+ "maxLength": 50,
+ "minLength": 1,
+ "type": "string"
+ },
+ "password": {
+ "maxLength": 50,
+ "minLength": 6,
+ "type": "string"
+ }
+ }
+ },
+ "UserResponseDto": {
+ "required": [
+ "email",
+ "id",
+ "name",
+ "username"
+ ],
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "email": {
+ "type": "string"
+ },
+ "username": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ }
+ },
+ "MenuDetailResponse": {
+ "required": [
+ "id",
+ "name",
+ "price",
+ "restaurant"
+ ],
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "name": {
+ "type": "string"
+ },
+ "price": {
+ "type": "number"
+ },
+ "restaurant": {
+ "$ref": "#/components/schemas/RestaurantInfoResponse"
+ }
+ }
+ },
+ "RestaurantInfoResponse": {
+ "required": [
+ "id",
+ "name"
+ ],
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "name": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file