diff --git a/.deploy/Dockerfile b/.deploy/Dockerfile index f4b99de..b26334e 100644 --- a/.deploy/Dockerfile +++ b/.deploy/Dockerfile @@ -1,6 +1,6 @@ FROM amazoncorretto:17-alpine-jdk -ARG TARGET_JAR=/api/build/libs/api.jar +ARG TARGET_JAR=/api/build/libs/app.jar COPY ${TARGET_JAR} /app.jar diff --git a/README.md b/README.md new file mode 100644 index 0000000..f51918e --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# Lettering Backend + + + +## System Architecture + + +### Overview + + +```markdown +. +├── app/ +│ └── domain/ +│ ├── api +│ ├── controller +│ └── dto +└── core +``` \ No newline at end of file diff --git a/api/build.gradle.kts b/app/build.gradle.kts similarity index 100% rename from api/build.gradle.kts rename to app/build.gradle.kts diff --git a/api/src/main/kotlin/com/asap/api/ApiApplication.kt b/app/src/main/kotlin/com/asap/app/AppApplication.kt similarity index 63% rename from api/src/main/kotlin/com/asap/api/ApiApplication.kt rename to app/src/main/kotlin/com/asap/app/AppApplication.kt index 61e8bee..26dc975 100644 --- a/api/src/main/kotlin/com/asap/api/ApiApplication.kt +++ b/app/src/main/kotlin/com/asap/app/AppApplication.kt @@ -1,13 +1,13 @@ -package com.asap.api +package com.asap.app import org.springframework.boot.SpringApplication import org.springframework.boot.autoconfigure.SpringBootApplication @SpringBootApplication -class ApiApplication { +class AppApplication { } fun main(args: Array) { - SpringApplication.run(ApiApplication::class.java, *args) + SpringApplication.run(AppApplication::class.java, *args) } diff --git a/app/src/main/kotlin/com/asap/app/auth/api/AuthApi.kt b/app/src/main/kotlin/com/asap/app/auth/api/AuthApi.kt new file mode 100644 index 0000000..bcd4ea8 --- /dev/null +++ b/app/src/main/kotlin/com/asap/app/auth/api/AuthApi.kt @@ -0,0 +1,51 @@ +package com.asap.app.auth.api + +import com.asap.app.auth.dto.SocialLoginRequest +import com.asap.app.auth.dto.SocialLoginResponse +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.ResponseEntity +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 + +@Tag(name = "Auth", description = "Auth API") +@RequestMapping("/api/v1/auth") +interface AuthApi { + + @Operation(summary = "소셜 로그인") + @PostMapping("/login/{provider}") + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "기존 회원 로그인 성공", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = SocialLoginResponse.Success::class) + ) + ] + ), + ApiResponse( + responseCode = "401", + description = "신규 회원 가입 필요", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = SocialLoginResponse.NonRegistered::class) + ) + ] + ) + ] + ) + fun socialLogin( + @PathVariable provider: String, + @RequestBody request: SocialLoginRequest + ): ResponseEntity +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/asap/app/auth/controller/AuthController.kt b/app/src/main/kotlin/com/asap/app/auth/controller/AuthController.kt new file mode 100644 index 0000000..341a233 --- /dev/null +++ b/app/src/main/kotlin/com/asap/app/auth/controller/AuthController.kt @@ -0,0 +1,28 @@ +package com.asap.app.auth.controller + +import com.asap.app.auth.api.AuthApi +import com.asap.app.auth.dto.SocialLoginRequest +import com.asap.app.auth.dto.SocialLoginResponse +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.RestController + +@RestController +class AuthController( +) : AuthApi { + + override fun socialLogin( + provider: String, + request: SocialLoginRequest + ): ResponseEntity { + when (request.accessToken) { + "nonRegistered" -> return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(SocialLoginResponse.NonRegistered("registerToken")) + + "registered" -> return ResponseEntity + .ok(SocialLoginResponse.Success("accessToken", "refreshToken")) + + else -> return ResponseEntity.badRequest().build() + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/asap/app/auth/dto/SocialLoginRequest.kt b/app/src/main/kotlin/com/asap/app/auth/dto/SocialLoginRequest.kt new file mode 100644 index 0000000..2007225 --- /dev/null +++ b/app/src/main/kotlin/com/asap/app/auth/dto/SocialLoginRequest.kt @@ -0,0 +1,9 @@ +package com.asap.app.auth.dto + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "소셜 로그인 요청") +data class SocialLoginRequest( + @Schema(description = "oauth access token") + val accessToken: String, +) diff --git a/app/src/main/kotlin/com/asap/app/auth/dto/SocialLoginResponse.kt b/app/src/main/kotlin/com/asap/app/auth/dto/SocialLoginResponse.kt new file mode 100644 index 0000000..60dda7f --- /dev/null +++ b/app/src/main/kotlin/com/asap/app/auth/dto/SocialLoginResponse.kt @@ -0,0 +1,28 @@ +package com.asap.app.auth.dto + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema( + description = "소셜 로그인 응답", + oneOf = [ + SocialLoginResponse.Success::class, + SocialLoginResponse.NonRegistered::class + ] +) +sealed class SocialLoginResponse{ + + @Schema(description = "기존 회원 로그인 성공") + data class Success( + @Schema(description = "access token") + val accessToken: String, + @Schema(description = "refresh token") + val refreshToken: String + ) : SocialLoginResponse() + + + @Schema(description = "신규 회원 가입 필요") + data class NonRegistered( + @Schema(description = "register token, 회원가입을 위한 토큰") + val registerToken: String + ) : SocialLoginResponse() +} diff --git a/app/src/main/kotlin/com/asap/app/user/api/UserApi.kt b/app/src/main/kotlin/com/asap/app/user/api/UserApi.kt new file mode 100644 index 0000000..e1821fc --- /dev/null +++ b/app/src/main/kotlin/com/asap/app/user/api/UserApi.kt @@ -0,0 +1,39 @@ +package com.asap.app.user.api + +import com.asap.app.user.dto.RegisterUserRequest +import com.asap.app.user.dto.RegisterUserResponse +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.ResponseEntity +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping + +@Tag(name = "User", description = "User API") +@RequestMapping("/api/v1/users") +interface UserApi { + + @Operation(summary = "회원 가입") + @PostMapping() + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "회원 가입 성공", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = RegisterUserResponse::class) + ) + ] + ) + ] + ) + fun registerUser( + @RequestBody request: RegisterUserRequest + ): ResponseEntity +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/asap/app/user/controller/UserController.kt b/app/src/main/kotlin/com/asap/app/user/controller/UserController.kt new file mode 100644 index 0000000..c7808f2 --- /dev/null +++ b/app/src/main/kotlin/com/asap/app/user/controller/UserController.kt @@ -0,0 +1,20 @@ +package com.asap.app.user.controller + +import com.asap.app.user.api.UserApi +import com.asap.app.user.dto.RegisterUserRequest +import com.asap.app.user.dto.RegisterUserResponse +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.RestController + +@RestController +class UserController( + +) : UserApi{ + + override fun registerUser(request: RegisterUserRequest): ResponseEntity { + when(request.registerToken){ + "register" -> return ResponseEntity.ok(RegisterUserResponse("accessToken", "refreshToken")) + else -> return ResponseEntity.badRequest().build() + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/asap/app/user/dto/RegisterUserRequest.kt b/app/src/main/kotlin/com/asap/app/user/dto/RegisterUserRequest.kt new file mode 100644 index 0000000..e22ae89 --- /dev/null +++ b/app/src/main/kotlin/com/asap/app/user/dto/RegisterUserRequest.kt @@ -0,0 +1,19 @@ +package com.asap.app.user.dto + +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDate + +@Schema(description = "회원 가입 요청") +data class RegisterUserRequest( + @Schema(description = "register_token, 소셜 로그인이로부터 전달받은 토큰") + val registerToken: String, + @Schema(description = "서비스 이용약관 동의") + val servicePermission: Boolean, + @Schema(description = "개인정보 수집 및 이용 동의") + val privatePermission: Boolean, + @Schema(description = "마케팅 정보 수신 동의") + val marketingPermission: Boolean, + @Schema(description = "생년 월일, yyyy-MM-dd, 값이 안넘어올 수 있음") + val birthday: LocalDate? +) { +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/asap/app/user/dto/RegisterUserResponse.kt b/app/src/main/kotlin/com/asap/app/user/dto/RegisterUserResponse.kt new file mode 100644 index 0000000..057e82f --- /dev/null +++ b/app/src/main/kotlin/com/asap/app/user/dto/RegisterUserResponse.kt @@ -0,0 +1,12 @@ +package com.asap.app.user.dto + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "회원 가입 응답") +data class RegisterUserResponse( + @Schema(description = "access token") + val accessToken: String, + @Schema(description = "refresh token") + val refreshToken: String +) { +} \ No newline at end of file diff --git a/api/src/main/resources/application.yml b/app/src/main/resources/application.yml similarity index 100% rename from api/src/main/resources/application.yml rename to app/src/main/resources/application.yml diff --git a/app/src/test/java/com/asap/app/auth/controller/AuthControllerTest.kt b/app/src/test/java/com/asap/app/auth/controller/AuthControllerTest.kt new file mode 100644 index 0000000..0e8bbf6 --- /dev/null +++ b/app/src/test/java/com/asap/app/auth/controller/AuthControllerTest.kt @@ -0,0 +1,88 @@ +package com.asap.app.auth.controller + +import com.asap.app.auth.dto.SocialLoginRequest +import com.fasterxml.jackson.databind.ObjectMapper +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.post + +@WebMvcTest(AuthController::class) +@AutoConfigureMockMvc +class AuthControllerTest { + + @Autowired + private lateinit var mockMvc: MockMvc + + private val objectMapper: ObjectMapper = ObjectMapper() + + + @Test + fun socialLoginSuccessTest(){ + // given + val request = SocialLoginRequest("registered") + // when + val response = mockMvc.post("/api/v1/auth/login/{provider}", "kakao") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(request) + } + + // then + response.andExpect { + status { isOk() } + jsonPath("$.accessToken") { + exists() + isString() + isNotEmpty() + } + jsonPath("$.refreshToken") { + exists() + isString() + isNotEmpty() + } + } + } + + @Test + fun socialLoginNonRegisteredTest(){ + // given + val request = SocialLoginRequest("nonRegistered") + // when + val response = mockMvc.post("/api/v1/auth/login/{provider}", "kakao") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(request) + } + + // then + response.andExpect { + status { isUnauthorized() } + jsonPath("$.registerToken") { + exists() + isString() + isNotEmpty() + } + } + } + + + @Test + fun socialLoginBadRequestTest(){ + // given + val request = SocialLoginRequest("invalid") + // when + val response = mockMvc.post("/api/v1/auth/login/{provider}", "kakao") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(request) + } + + // then + response.andExpect { + status { isBadRequest() } + } + } + + +} \ No newline at end of file diff --git a/app/src/test/java/com/asap/app/user/controller/UserControllerTest.kt b/app/src/test/java/com/asap/app/user/controller/UserControllerTest.kt new file mode 100644 index 0000000..e90cdb3 --- /dev/null +++ b/app/src/test/java/com/asap/app/user/controller/UserControllerTest.kt @@ -0,0 +1,63 @@ +package com.asap.app.user.controller + +import com.asap.app.user.dto.RegisterUserRequest +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.post +import java.time.LocalDate + +@WebMvcTest(UserController::class) +@AutoConfigureMockMvc +class UserControllerTest { + + @Autowired + private lateinit var mockMvc: MockMvc + + private val objectMapper: ObjectMapper = ObjectMapper().registerModules(JavaTimeModule()) + + @Test + fun registerUserTest(){ + // given + val request = RegisterUserRequest("register", true, true, true, LocalDate.now()) + // when + val response = mockMvc.post("/api/v1/users") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(request) + } + // then + response.andExpect { + status { isOk() } + jsonPath("$.accessToken") { + exists() + isString() + isNotEmpty() + } + jsonPath("$.refreshToken") { + exists() + isString() + isNotEmpty() + } + } + } + + @Test + fun registerUserNotExistsRegisterTokenTest(){ + // given + val request = RegisterUserRequest("nonExistsToken", false, true, true, LocalDate.now()) + // when + val response = mockMvc.post("/api/v1/users") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(request) + } + // then + response.andExpect { + status { isBadRequest() } + } + } +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index f34b9aa..8a5aef6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -17,6 +17,7 @@ allprojects { version = "" val javaVersion = "17" + val kotestVersion = "5.0.0" tasks.withType { sourceCompatibility = javaVersion @@ -30,7 +31,7 @@ allprojects { } tasks.withType { - if (project.name == "api") { + if (project.name == "app") { enabled = true } else { enabled = false @@ -59,6 +60,14 @@ allprojects { testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testRuntimeOnly("org.junit.platform:junit-platform-launcher") + + + // kotest + testImplementation("io.kotest:kotest-runner-junit5:$kotestVersion") + + // jackson + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") } kotlin { diff --git a/settings.gradle.kts b/settings.gradle.kts index f5234f3..3200b6a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,3 +1,3 @@ rootProject.name = "Lettering-Backend" -include(":api") +include(":app")