-
Notifications
You must be signed in to change notification settings - Fork 2
feat: 병천 날씨 조회 API 추가 #2276
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
dh2906
wants to merge
10
commits into
develop
Choose a base branch
from
feat/weather-api
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
feat: 병천 날씨 조회 API 추가 #2276
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
3ece153
feat: 병천 날씨 조회 API 추가
dh2906 aeed8a1
feat: 날씨 조회 Redis 캐시 적용
dh2906 18f2fa9
docs: 날씨 API Swagger 그룹 추가
dh2906 9cc418b
fix: 날씨 API 비정상 응답 처리 보강
dh2906 02e314e
fix: 날씨 API 인증 오류 응답 보존
dh2906 addb2eb
feat: 날씨 캐시 스케줄러 갱신 적용
dh2906 6b57660
fix: 날씨 API 리뷰 피드백 반영
dh2906 5ca07f9
fix: 날씨 예보 필수 항목 검증 추가
dh2906 acb8c83
Merge branch 'develop' into feat/weather-api
dh2906 362882a
Merge branch 'develop' into feat/weather-api
dh2906 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
219 changes: 219 additions & 0 deletions
219
src/main/java/in/koreatech/koin/domain/weather/client/WeatherClient.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,219 @@ | ||
| package in.koreatech.koin.domain.weather.client; | ||
|
|
||
| import static java.net.URLEncoder.encode; | ||
|
|
||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import java.net.URL; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
| import java.util.stream.Collectors; | ||
|
|
||
| import org.springframework.beans.factory.annotation.Value; | ||
| import org.springframework.http.HttpEntity; | ||
| import org.springframework.http.HttpHeaders; | ||
| import org.springframework.http.HttpMethod; | ||
| import org.springframework.http.ResponseEntity; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.web.client.HttpStatusCodeException; | ||
| import org.springframework.web.client.RestTemplate; | ||
|
|
||
| import in.koreatech.koin.domain.weather.dto.WeatherApiResponse; | ||
| import in.koreatech.koin.domain.weather.dto.WeatherApiResponse.WeatherForecastItem; | ||
| import in.koreatech.koin.domain.weather.exception.WeatherOpenApiException; | ||
| import in.koreatech.koin.domain.weather.model.WeatherForecast; | ||
| import in.koreatech.koin.domain.weather.model.WeatherForecastRequestTime; | ||
| import in.koreatech.koin.global.exception.custom.KoinIllegalStateException; | ||
|
|
||
| @Component | ||
| public class WeatherClient { | ||
|
|
||
| private static final String OPEN_API_URL = | ||
| "https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getVilageFcst"; | ||
| private static final String ENCODE_TYPE = "UTF-8"; | ||
| private static final int BYEONGCHEON_NX = 66; | ||
| private static final int BYEONGCHEON_NY = 109; | ||
| private static final int ROW_COUNT = 1000; | ||
|
|
||
| private final String openApiKey; | ||
| private final RestTemplate restTemplate; | ||
| private final ObjectMapper objectMapper; | ||
|
|
||
| public WeatherClient( | ||
| @Value("${OPEN_API_KEY_PUBLIC}") String openApiKey, | ||
| RestTemplate restTemplate, | ||
| ObjectMapper objectMapper | ||
| ) { | ||
| this.openApiKey = openApiKey; | ||
| this.restTemplate = restTemplate; | ||
| this.objectMapper = objectMapper; | ||
| } | ||
|
|
||
| public WeatherForecast getWeatherForecast(WeatherForecastRequestTime requestTime) { | ||
| try { | ||
| return getWeatherForecastWithoutFallback(requestTime); | ||
| } catch (WeatherOpenApiException e) { | ||
| if (!canRetryWithPreviousBaseTime(e)) { | ||
| throw e; | ||
| } | ||
| return getWeatherForecastWithoutFallback(requestTime.previousBaseTime()); | ||
| } | ||
| } | ||
|
|
||
| private WeatherForecast getWeatherForecastWithoutFallback(WeatherForecastRequestTime requestTime) { | ||
| WeatherApiResponse response = getOpenApiResponse(requestTime); | ||
| List<WeatherForecastItem> items = extractForecastItems(response); | ||
| Map<String, String> forecasts = items.stream() | ||
| .filter(item -> requestTime.forecastDate().equals(item.fcstDate())) | ||
| .filter(item -> requestTime.forecastTime().equals(item.fcstTime())) | ||
| .collect(Collectors.toMap( | ||
| WeatherForecastItem::category, | ||
| WeatherForecastItem::fcstValue, | ||
| (previous, current) -> current | ||
| )); | ||
|
|
||
| if (forecasts.isEmpty()) { | ||
| throw WeatherOpenApiException.withDetail("forecastDateTime: " | ||
| + requestTime.forecastDate() + requestTime.forecastTime()); | ||
| } | ||
|
|
||
| String temperature = requireForecastValue(forecasts, "TMP"); | ||
| String sky = requireForecastValue(forecasts, "SKY"); | ||
| String precipitationType = requireForecastValue(forecasts, "PTY"); | ||
|
|
||
| try { | ||
| return new WeatherForecast( | ||
| Integer.parseInt(temperature), | ||
| sky, | ||
| precipitationType | ||
| ); | ||
| } catch (NumberFormatException e) { | ||
| throw WeatherOpenApiException.withDetail( | ||
| "invalid category: TMP, value: " + temperature | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| private String requireForecastValue(Map<String, String> forecasts, String category) { | ||
| String forecastValue = forecasts.get(category); | ||
| if (forecastValue == null || forecastValue.isBlank()) { | ||
| throw WeatherOpenApiException.withDetail( | ||
| "missing category: " + category | ||
| ); | ||
| } | ||
| return forecastValue; | ||
| } | ||
|
|
||
| private boolean canRetryWithPreviousBaseTime(WeatherOpenApiException e) { | ||
| String errorMessage = e.getFullMessage(); | ||
| return errorMessage.contains("NO_DATA") | ||
| || errorMessage.contains("response body is empty") | ||
| || errorMessage.contains("forecastDateTime"); | ||
| } | ||
|
|
||
| private WeatherApiResponse getOpenApiResponse(WeatherForecastRequestTime requestTime) { | ||
| try { | ||
| HttpHeaders headers = new HttpHeaders(); | ||
| headers.set("Accept", "*/*"); | ||
|
|
||
| HttpEntity<?> entity = new HttpEntity<>(headers); | ||
| URL url = new URL(getRequestURL(requestTime)); | ||
| ResponseEntity<String> response = restTemplate.exchange( | ||
| url.toURI(), | ||
| HttpMethod.GET, | ||
| entity, | ||
| String.class | ||
| ); | ||
| return parseResponse(response.getBody(), requestTime); | ||
| } catch (WeatherOpenApiException e) { | ||
| throw e; | ||
| } catch (HttpStatusCodeException e) { | ||
| String responseBody = e.getResponseBodyAsString(); | ||
| if (responseBody != null && !responseBody.isBlank()) { | ||
| return parseResponse(responseBody, requestTime); | ||
| } | ||
| throw WeatherOpenApiException.withDetail("baseDateTime: " | ||
| + requestTime.baseDate() + requestTime.baseTime() + ", httpStatus: " + e.getStatusCode()); | ||
| } catch (Exception e) { | ||
| throw WeatherOpenApiException.withDetail("baseDateTime: " | ||
| + requestTime.baseDate() + requestTime.baseTime() + ", cause: " + e.getClass().getSimpleName()); | ||
| } | ||
| } | ||
|
|
||
| private WeatherApiResponse parseResponse(String responseBody, WeatherForecastRequestTime requestTime) { | ||
| if (responseBody == null || responseBody.isBlank()) { | ||
| throw WeatherOpenApiException.withDetail("baseDateTime: " | ||
| + requestTime.baseDate() + requestTime.baseTime() + ", response body is empty"); | ||
| } | ||
|
|
||
| if (!responseBody.trim().startsWith("{")) { | ||
| throw WeatherOpenApiException.withDetail("baseDateTime: " | ||
| + requestTime.baseDate() + requestTime.baseTime() + ", " + extractXmlErrorMessage(responseBody)); | ||
| } | ||
|
|
||
| try { | ||
| return objectMapper.readValue(responseBody, WeatherApiResponse.class); | ||
| } catch (Exception e) { | ||
| throw WeatherOpenApiException.withDetail("baseDateTime: " | ||
| + requestTime.baseDate() + requestTime.baseTime() + ", invalid JSON response"); | ||
| } | ||
| } | ||
|
|
||
| private String getRequestURL(WeatherForecastRequestTime requestTime) { | ||
| StringBuilder urlBuilder = new StringBuilder(OPEN_API_URL); | ||
| try { | ||
| urlBuilder.append("?" + encode("serviceKey", ENCODE_TYPE) + "=" + encode(openApiKey, ENCODE_TYPE)); | ||
| urlBuilder.append("&" + encode("numOfRows", ENCODE_TYPE) + "=" | ||
| + encode(String.valueOf(ROW_COUNT), ENCODE_TYPE)); | ||
| urlBuilder.append("&" + encode("pageNo", ENCODE_TYPE) + "=" + encode("1", ENCODE_TYPE)); | ||
| urlBuilder.append("&" + encode("dataType", ENCODE_TYPE) + "=" + encode("JSON", ENCODE_TYPE)); | ||
| urlBuilder.append("&" + encode("base_date", ENCODE_TYPE) + "=" | ||
| + encode(requestTime.baseDate(), ENCODE_TYPE)); | ||
| urlBuilder.append("&" + encode("base_time", ENCODE_TYPE) + "=" | ||
| + encode(requestTime.baseTime(), ENCODE_TYPE)); | ||
| urlBuilder.append("&" + encode("nx", ENCODE_TYPE) + "=" + encode(String.valueOf(BYEONGCHEON_NX), ENCODE_TYPE)); | ||
| urlBuilder.append("&" + encode("ny", ENCODE_TYPE) + "=" + encode(String.valueOf(BYEONGCHEON_NY), ENCODE_TYPE)); | ||
| return urlBuilder.toString(); | ||
| } catch (Exception e) { | ||
| throw new KoinIllegalStateException("기상청 단기예보 API URL 생성중 문제가 발생했습니다.", "uri build failed"); | ||
| } | ||
| } | ||
|
|
||
| private String extractXmlErrorMessage(String responseBody) { | ||
| String returnAuthMsg = extractTagValue(responseBody, "returnAuthMsg"); | ||
| String returnReasonCode = extractTagValue(responseBody, "returnReasonCode"); | ||
| if (returnAuthMsg != null || returnReasonCode != null) { | ||
| return "returnAuthMsg: " + returnAuthMsg + ", returnReasonCode: " + returnReasonCode; | ||
| } | ||
| return "non JSON response"; | ||
| } | ||
|
|
||
| private String extractTagValue(String responseBody, String tagName) { | ||
| String startTag = "<" + tagName + ">"; | ||
| String endTag = "</" + tagName + ">"; | ||
| int startIndex = responseBody.indexOf(startTag); | ||
| int endIndex = responseBody.indexOf(endTag); | ||
| if (startIndex == -1 || endIndex == -1 || startIndex > endIndex) { | ||
| return null; | ||
| } | ||
| return responseBody.substring(startIndex + startTag.length(), endIndex); | ||
| } | ||
|
|
||
| private List<WeatherForecastItem> extractForecastItems(WeatherApiResponse response) { | ||
| if (response == null | ||
| || response.response() == null | ||
| || response.response().header() == null | ||
| || response.response().body() == null | ||
| || response.response().body().items() == null | ||
| || response.response().body().items().item() == null) { | ||
| throw WeatherOpenApiException.withDetail("response body is empty"); | ||
| } | ||
|
|
||
| String resultCode = response.response().header().resultCode(); | ||
| if (!"00".equals(resultCode) && !"0".equals(resultCode)) { | ||
| throw WeatherOpenApiException.withDetail( | ||
| "resultCode: " + resultCode + ", resultMsg: " + response.response().header().resultMsg() | ||
| ); | ||
| } | ||
| return response.response().body().items().item(); | ||
| } | ||
| } | ||
35 changes: 35 additions & 0 deletions
35
src/main/java/in/koreatech/koin/domain/weather/controller/WeatherApi.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| package in.koreatech.koin.domain.weather.controller; | ||
|
|
||
| import org.springframework.http.ResponseEntity; | ||
| import org.springframework.web.bind.annotation.GetMapping; | ||
|
|
||
| import in.koreatech.koin.domain.weather.dto.WeatherResponse; | ||
| import io.swagger.v3.oas.annotations.Operation; | ||
| import io.swagger.v3.oas.annotations.media.Content; | ||
| import io.swagger.v3.oas.annotations.media.ExampleObject; | ||
| 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; | ||
|
|
||
| @Tag(name = "(Normal) Weather: 날씨", description = "병천 날씨 정보를 조회한다") | ||
| public interface WeatherApi { | ||
|
|
||
| @ApiResponses( | ||
| value = { | ||
| @ApiResponse(responseCode = "200", content = @Content(mediaType = "application/json", examples = | ||
| @ExampleObject( | ||
| value = """ | ||
| { | ||
| "temperature": 21, | ||
| "weather": "맑음" | ||
| } | ||
| """ | ||
| ))), | ||
| @ApiResponse(responseCode = "500", content = @Content(schema = @Schema(hidden = true))), | ||
| } | ||
| ) | ||
| @Operation(summary = "병천 날씨 조회") | ||
| @GetMapping("/weather") | ||
| ResponseEntity<WeatherResponse> getWeather(); | ||
| } |
22 changes: 22 additions & 0 deletions
22
src/main/java/in/koreatech/koin/domain/weather/controller/WeatherController.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| package in.koreatech.koin.domain.weather.controller; | ||
|
|
||
| import org.springframework.http.ResponseEntity; | ||
| import org.springframework.web.bind.annotation.GetMapping; | ||
| import org.springframework.web.bind.annotation.RestController; | ||
|
|
||
| import in.koreatech.koin.domain.weather.dto.WeatherResponse; | ||
| import in.koreatech.koin.domain.weather.service.WeatherService; | ||
| import lombok.RequiredArgsConstructor; | ||
|
|
||
| @RestController | ||
| @RequiredArgsConstructor | ||
| public class WeatherController implements WeatherApi { | ||
|
|
||
| private final WeatherService weatherService; | ||
|
|
||
| @GetMapping("/weather") | ||
| public ResponseEntity<WeatherResponse> getWeather() { | ||
| WeatherResponse response = weatherService.getWeather(); | ||
| return ResponseEntity.ok(response); | ||
| } | ||
| } |
51 changes: 51 additions & 0 deletions
51
src/main/java/in/koreatech/koin/domain/weather/dto/WeatherApiResponse.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| package in.koreatech.koin.domain.weather.dto; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public record WeatherApiResponse( | ||
| InnerResponse response | ||
| ) { | ||
|
|
||
| public record InnerResponse( | ||
| InnerHeader header, | ||
| InnerBody body | ||
| ) { | ||
|
|
||
| } | ||
|
|
||
| public record InnerHeader( | ||
| String resultCode, | ||
| String resultMsg | ||
| ) { | ||
|
|
||
| } | ||
|
|
||
| public record InnerBody( | ||
| String dataType, | ||
| InnerItems items, | ||
| Integer numOfRows, | ||
| Integer pageNo, | ||
| Integer totalCount | ||
| ) { | ||
|
|
||
| } | ||
|
|
||
| public record InnerItems( | ||
| List<WeatherForecastItem> item | ||
| ) { | ||
|
|
||
| } | ||
|
|
||
| public record WeatherForecastItem( | ||
| String baseDate, | ||
| String baseTime, | ||
| String category, | ||
| String fcstDate, | ||
| String fcstTime, | ||
| String fcstValue, | ||
| Integer nx, | ||
| Integer ny | ||
| ) { | ||
|
|
||
| } | ||
| } |
23 changes: 23 additions & 0 deletions
23
src/main/java/in/koreatech/koin/domain/weather/dto/WeatherResponse.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| package in.koreatech.koin.domain.weather.dto; | ||
|
|
||
| import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; | ||
| import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; | ||
|
|
||
| import com.fasterxml.jackson.databind.annotation.JsonNaming; | ||
|
|
||
| import in.koreatech.koin.domain.weather.model.WeatherCondition; | ||
| import io.swagger.v3.oas.annotations.media.Schema; | ||
|
|
||
| @JsonNaming(SnakeCaseStrategy.class) | ||
| public record WeatherResponse( | ||
| @Schema(description = "기온(섭씨)", example = "21", requiredMode = REQUIRED) | ||
| Integer temperature, | ||
|
|
||
| @Schema(description = "날씨 상태", example = "맑음", requiredMode = REQUIRED) | ||
| String weather | ||
| ) { | ||
|
|
||
| public static WeatherResponse of(Integer temperature, WeatherCondition weatherCondition) { | ||
| return new WeatherResponse(temperature, weatherCondition.getValue()); | ||
| } | ||
| } |
20 changes: 20 additions & 0 deletions
20
src/main/java/in/koreatech/koin/domain/weather/exception/WeatherOpenApiException.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| package in.koreatech.koin.domain.weather.exception; | ||
|
|
||
| import in.koreatech.koin.global.exception.custom.ExternalServiceException; | ||
|
|
||
| public class WeatherOpenApiException extends ExternalServiceException { | ||
|
|
||
| private static final String DEFAULT_MESSAGE = "기상청 단기예보 API 응답이 정상적이지 않습니다."; | ||
|
|
||
| public WeatherOpenApiException(String message) { | ||
| super(message); | ||
| } | ||
|
|
||
| public WeatherOpenApiException(String message, String detail) { | ||
| super(message, detail); | ||
| } | ||
|
|
||
| public static WeatherOpenApiException withDetail(String detail) { | ||
| return new WeatherOpenApiException(DEFAULT_MESSAGE, detail); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.