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