Skip to content
This repository has been archived by the owner on Sep 16, 2024. It is now read-only.

Commit

Permalink
[Feat] Swagger 적용 (#41)
Browse files Browse the repository at this point in the history
* feat: Swagger를 통한 문서화

* refactor: openAPI v3 spec에 맞게 GET이 req body를 가지지 않도록 변경

* refactor: API description 작성

* refactor: query와 command URL 형식 통일

* refactor: query와 command URL 형식 통일

* fix: 반려식물 생성 시, `PlantInformation`이 제대로 설정되지 않던 문제 해결

* fix: 임시조치, 데일리 기록에 사진에 저장 가능하도록 수정

* doc: 문서 보강(tag, securityRequirement)
  • Loading branch information
goldentrash authored Jan 25, 2024
1 parent 6e6a3be commit 87f93b6
Show file tree
Hide file tree
Showing 19 changed files with 152 additions and 141 deletions.
3 changes: 3 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ dependencies {

// firebase
implementation("com.google.firebase:firebase-admin:9.2.0")

// swagger
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0")
}

tasks.withType<KotlinCompile> {
Expand Down
20 changes: 20 additions & 0 deletions src/main/kotlin/gdsc/plantory/common/config/SwaggerConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package gdsc.plantory.common.config

import gdsc.plantory.common.support.AccessDeviceToken
import gdsc.plantory.common.support.DEVICE_ID_HEADER
import io.swagger.v3.oas.models.Operation
import io.swagger.v3.oas.models.security.SecurityRequirement
import org.springdoc.core.customizers.OperationCustomizer
import org.springframework.stereotype.Component
import org.springframework.web.method.HandlerMethod

@Component
class Operation : OperationCustomizer {
override fun customize(operation: Operation?, handlerMethod: HandlerMethod?): Operation? {
if (handlerMethod?.methodParameters?.find { it.hasParameterAnnotation(AccessDeviceToken::class.java) } != null) {
operation?.addSecurityItem(SecurityRequirement().addList(DEVICE_ID_HEADER))
}

return operation
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package gdsc.plantory.common.support

import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.enums.ParameterIn

@Target(AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
@Parameter(`in` = ParameterIn.HEADER, name = DEVICE_ID_HEADER, description = "사용자 디바이스/인증 토큰")
annotation class AccessDeviceToken
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package gdsc.plantory.common.support

import org.springframework.web.context.request.NativeWebRequest

private const val DEVICE_ID_HEADER = "Device-Token"
const val DEVICE_ID_HEADER = "Device-Token"

class DeviceHeaderExtractor {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,28 @@ package gdsc.plantory.member.presentation

import gdsc.plantory.member.presentation.dto.MemberCreateRequest
import gdsc.plantory.member.service.MemberService
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.tags.Tag
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@Tag(name = "Member Command", description = "사용자 정보 수정")
@RestController
@RequestMapping("/api/v1/members")
class MemberCommandApi(
private val memberService: MemberService,
) {

@Operation(summary = "사용자 등록", description = "사용자를 등록/추가합니다.")
@ApiResponse(responseCode = "200", description = "등록 성공")
@PostMapping
fun signUp(
@RequestBody request: MemberCreateRequest,
@Parameter(description = "사용자 정보") @RequestBody request: MemberCreateRequest,
): ResponseEntity<Unit> {
memberService.signUp(request.deviceToken)
return ResponseEntity.ok().build()
Expand Down
46 changes: 32 additions & 14 deletions src/main/kotlin/gdsc/plantory/plant/presentation/PlantCommandApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,64 +4,82 @@ import BadRequestException
import gdsc.plantory.common.support.AccessDeviceToken
import gdsc.plantory.plant.domain.HistoryType
import gdsc.plantory.plant.presentation.dto.CompanionPlantCreateRequest
import gdsc.plantory.plant.presentation.dto.CompanionPlantDeleteRequest
import gdsc.plantory.plant.presentation.dto.PlantRecordCreateRequest
import gdsc.plantory.plant.presentation.dto.PlantHistoryRequest
import gdsc.plantory.plant.presentation.dto.PlantRecordCreateRequest
import gdsc.plantory.plant.service.PlantService
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.tags.Tag
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.DeleteMapping
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.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile

@Tag(name = "Plant Query", description = "반려식물 정보 조회")
@RestController
@RequestMapping("/api/v1/plants")
class PlantCommandApi(
private val plantService: PlantService,
) {

@Operation(summary = "반려식물 등록", description = "사용자의 반려식물을 등록/추가합니다.")
@ApiResponse(responseCode = "200", description = "등록 성공")
@PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.APPLICATION_JSON_VALUE])
fun create(
@RequestPart(name = "request") request: CompanionPlantCreateRequest,
@RequestPart(name = "image", required = false) image: MultipartFile?,
@Parameter(description = "반려식물 정보") @RequestPart(name = "request") request: CompanionPlantCreateRequest,
@Parameter(description = "반려식물 사진") @RequestPart(name = "image", required = false) image: MultipartFile?,
@AccessDeviceToken deviceToken: String,
): ResponseEntity<Unit> {
plantService.create(request, image, deviceToken)
return ResponseEntity.ok().build()
}

@DeleteMapping(consumes = [MediaType.APPLICATION_JSON_VALUE])
@Operation(summary = "반려식물 삭제", description = "사용자의 반려식물을 삭제합니다.")
@ApiResponse(responseCode = "204", description = "삭제 성공")
@DeleteMapping("/{companionPlantId}")
fun remove(
@RequestBody request: CompanionPlantDeleteRequest,
@Parameter(description = "삭제할 반려식물 ID") @PathVariable companionPlantId: Long,
@AccessDeviceToken deviceToken: String,
): ResponseEntity<Unit> {
plantService.remove(request, deviceToken)
plantService.remove(companionPlantId, deviceToken)
return ResponseEntity.noContent().build()
}

@PostMapping("/histories", consumes = [MediaType.APPLICATION_JSON_VALUE])
@Operation(summary = "반려식물 히스토리 등록", description = "반려식물 히스토리(데일리 기록, 물줌, 분갈이)를 등록/추가합니다.")
@ApiResponse(responseCode = "200", description = "등록 성공")
@PostMapping("/{companionPlantId}/histories", consumes = [MediaType.APPLICATION_JSON_VALUE])
fun createPlantHistory(
@RequestBody request: PlantHistoryRequest,
@Parameter(description = "히스토리를 등록할 반려식물 ID") @PathVariable companionPlantId: Long,
@Parameter(description = "히스토리 유형/종류") @RequestBody request: PlantHistoryRequest,
@AccessDeviceToken deviceToken: String,
): ResponseEntity<Unit> {
val historyType = HistoryType.byNameIgnoreCaseOrNull(request.historyType)
?: throw BadRequestException("잘못된 히스토리 타입입니다.")

plantService.createPlantHistory(request.companionPlantId, deviceToken, historyType)
plantService.createPlantHistory(companionPlantId, deviceToken, historyType)
return ResponseEntity.ok().build()
}

@PostMapping("/records", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.APPLICATION_JSON_VALUE])
@Operation(summary = "반려식물 데일리 기록 등록", description = "반려식물 데일리 기록을 등록/추가합니다.")
@ApiResponse(responseCode = "200", description = "등록 성공")
@PostMapping(
"/{companionPlantId}/records",
consumes = [MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.APPLICATION_JSON_VALUE]
)
fun createPlantRecord(
@RequestPart(name = "request") request: PlantRecordCreateRequest,
@RequestPart(name = "image", required = false) image: MultipartFile?,
@Parameter(description = "데일리 기록을 등록할 반려식물 ID") @PathVariable companionPlantId: Long,
@Parameter(description = "데일리 기록 내용") @RequestPart request: PlantRecordCreateRequest,
@Parameter(description = "데일리 기록 사진") @RequestPart(name = "image", required = false) image: MultipartFile?,
@AccessDeviceToken deviceToken: String,
): ResponseEntity<Unit> {
plantService.createRecord(request, image, deviceToken)
plantService.createRecord(companionPlantId, request, image, deviceToken)
return ResponseEntity.ok().build()
}
}
33 changes: 23 additions & 10 deletions src/main/kotlin/gdsc/plantory/plant/presentation/PlantQueryApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,31 @@ package gdsc.plantory.plant.presentation

import gdsc.plantory.common.support.AccessDeviceToken
import gdsc.plantory.plant.presentation.dto.CompanionPlantsLookupResponse
import gdsc.plantory.plant.presentation.dto.PlantHistoriesLookupRequest
import gdsc.plantory.plant.presentation.dto.PlantHistoriesLookupResponse
import gdsc.plantory.plant.presentation.dto.PlantRecordLookupRequest
import gdsc.plantory.plant.presentation.dto.PlantRecordLookupResponse
import gdsc.plantory.plant.service.PlantService
import org.springframework.http.MediaType
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.tags.Tag
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import java.time.LocalDate
import java.time.YearMonth

@Tag(name = "Plant Command", description = "반려식물 정보 수정")
@RestController
@RequestMapping("/api/v1/plants")
class PlantQueryApi(
private val plantService: PlantService
) {

@Operation(summary = "반려식물 조회", description = "사용자의 반려식물 목록을 조회합니다.")
@ApiResponse(responseCode = "200", description = "조회 성공")
@GetMapping
fun lookupAllCompanionPlantsOfMember(
@AccessDeviceToken deviceToken: String
Expand All @@ -28,21 +35,27 @@ class PlantQueryApi(
return ResponseEntity.ok().body(companionPlants)
}

@GetMapping("/histories", consumes = [MediaType.APPLICATION_JSON_VALUE])
@Operation(summary = "반려식물 히스토리(데일리 기록, 물줌, 분갈이) 조회", description = "해당 달의 반려식물 히스토리를 조회합니다.")
@ApiResponse(responseCode = "200", description = "조회 성공")
@GetMapping("/{companionPlantId}/histories")
fun lookupAllPlantHistoriesOfMonth(
@RequestBody request: PlantHistoriesLookupRequest,
@Parameter(description = "조회할 반려식물 ID") @PathVariable companionPlantId: Long,
@Parameter(description = "조회 기간(달)") @RequestParam targetMonth: YearMonth,
@AccessDeviceToken deviceToken: String
): ResponseEntity<PlantHistoriesLookupResponse> {
val histories = plantService.lookupAllPlantHistoriesOfMonth(request, deviceToken)
val histories = plantService.lookupAllPlantHistoriesOfMonth(companionPlantId, targetMonth, deviceToken)
return ResponseEntity.ok().body(histories)
}

@GetMapping("/records", consumes = [MediaType.APPLICATION_JSON_VALUE])
@Operation(summary = "반려식물 데일리 기록 조회", description = "데일리 기록(이미지, 일지 등)을 조회합니다.")
@ApiResponse(responseCode = "200", description = "조회 성공")
@GetMapping("/{companionPlantId}/records")
fun lookupPlantRecordOfDate(
@RequestBody request: PlantRecordLookupRequest,
@Parameter(description = "데일리 기록의 반려식물") @PathVariable companionPlantId: Long,
@Parameter(description = "데일리 기록의 날짜") @RequestParam recordDate: LocalDate,
@AccessDeviceToken deviceToken: String
): ResponseEntity<PlantRecordLookupResponse> {
val plantRecord = plantService.lookupPlantRecordOfDate(request, deviceToken)
val plantRecord = plantService.lookupPlantRecordOfDate(companionPlantId, recordDate, deviceToken)
return ResponseEntity.ok().body(plantRecord)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ data class CompanionPlantCreateRequest(
@PastOrPresent(message = "마지막 물주기 날짜는 과거 또는 현재의 날짜여야 합니다. lastWaterDate: \${validatedValue}")
@DateTimeFormat(pattern = "yyyy-MM-dd") val lastWaterDate: LocalDate,
) {
fun toEntity(imagePath: String, memberId: Long, waterCycle: Int): CompanionPlant {
fun toEntity(imagePath: String, memberId: Long, waterCycle: Int, plantInformationId: Long): CompanionPlant {
// TODO : Cloud 환경으로 이전 후 제거, 로컬 사진 저장 테스트 용도
val baseUrl: String = "https://nongsaro.go.kr/"
val baseUrl = "https://nongsaro.go.kr/"
return CompanionPlant(
_imageUrl = baseUrl + imagePath,
_shortDescription = this.shortDescription,
Expand All @@ -29,6 +29,7 @@ data class CompanionPlantCreateRequest(
waterCycle = waterCycle,
birthDate = this.birthDate,
memberId = memberId,
plantInformationId = plantInformationId
)
}
}

This file was deleted.

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,5 @@ package gdsc.plantory.plant.presentation.dto
import jakarta.validation.constraints.NotBlank

data class PlantHistoryRequest(
@NotBlank val companionPlantId: Long,
@NotBlank val historyType: String,
)
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package gdsc.plantory.plant.presentation.dto

data class PlantRecordCreateRequest(
val companionPlantId: Long,
val comment: String,
)

This file was deleted.

Loading

0 comments on commit 87f93b6

Please sign in to comment.