Skip to content

Commit

Permalink
[FEAT] 공공 api키 관리 기능 구현 (#148)
Browse files Browse the repository at this point in the history
## Api Key 관련 클래스 이름 리팩토링
- ApiKeyProperty -> ApiKeyProvider
- BusApiKeyProperty -> PropertyApiKeyProvider

## Persistence Api Key 구현
- 관리자 페이지에서 api키 관리 (등록/수정)
- 등록된 api키는 db에 저장되고 매일자정 사용량이 초기화됨
- 사용량을 초과한 api key는 PersistenceApiKeyProvider에서 제거되고 자정이 다시 추가됨
- 사용량은 메모리에 저장하고 20분마다 db에 업데이트

close #146
  • Loading branch information
Gyaak authored Aug 29, 2024
1 parent d2bf63c commit 4e78488
Show file tree
Hide file tree
Showing 15 changed files with 412 additions and 49 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import com.talkka.server.admin.service.AdminService;
import com.talkka.server.admin.service.CollectBusRouteService;
import com.talkka.server.admin.service.PublicApiKeyService;
import com.talkka.server.user.service.UserService;

import lombok.RequiredArgsConstructor;
Expand All @@ -18,8 +19,7 @@ public class AdminController {
private final AdminService adminService;
private final UserService userService;
private final CollectBusRouteService collectBusRouteService;
// private final PublicApiKeyService publicApiKeyService;
// private final PublicApiKeyService publicApiKeyService;
private final PublicApiKeyService publicApiKeyService;
// private final DynamicSchedulingConfig dynamicSchedulingConfig;

@GetMapping("")
Expand Down Expand Up @@ -51,11 +51,11 @@ public String collectRoute(Model model) {
return "admin/collect";
}

// @GetMapping("/key")
// public String publicApiKey(Model model) {
// model.addAttribute("apiKeys", publicApiKeyService.getKeyList());
// return "admin/key";
// }
@GetMapping("/key")
public String publicApiKey(Model model) {
model.addAttribute("apiKeys", publicApiKeyService.getKeyList());
return "admin/key";
}
//
// @GetMapping("/scheduler")
// public String scheduler(Model model) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.talkka.server.admin.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
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;

import com.talkka.server.admin.exception.PublicApiKeyAlreadyExistsException;
import com.talkka.server.admin.exception.PublicApiKeyNotFoundException;
import com.talkka.server.admin.service.PublicApiKeyService;

import lombok.RequiredArgsConstructor;

@RestController
@RequiredArgsConstructor
@RequestMapping("/admin/api/key")
public class PublicApiKeyController {
private final PublicApiKeyService publicApiKeyService;

@PostMapping("")
public ResponseEntity<?> createKey(@RequestBody String secret) {
try {
publicApiKeyService.createKey(secret);
} catch (PublicApiKeyAlreadyExistsException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
return ResponseEntity.ok().build();
}

@DeleteMapping("")
public ResponseEntity<?> deleteKey(@RequestBody String secret) {
try {
publicApiKeyService.deleteKey(secret);
} catch (PublicApiKeyNotFoundException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
return ResponseEntity.ok().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.talkka.server.admin.dao;

import java.time.LocalDateTime;

import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity(name = "public_apikey")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class PublicApiKeyEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "secret", nullable = false)
private String secret;

@Column(name = "created_at", nullable = false)
@CreatedDate
private LocalDateTime createdAt;

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.talkka.server.admin.dao;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface PublicApiKeyRepository extends JpaRepository<PublicApiKeyEntity, Long> {
boolean existsBySecret(String secret);

void deleteBySecret(String secret);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.talkka.server.admin.dto;

import java.time.LocalDateTime;
import java.util.Map;

import com.talkka.server.admin.dao.PublicApiKeyEntity;

public record PublicApiKeyRespDto(
Long id,
String secret,
Map<String, Integer> keyUsage,
LocalDateTime createdAt
) {
public static PublicApiKeyRespDto of(PublicApiKeyEntity key, Map<String, Integer> usageMap) {
return new PublicApiKeyRespDto(
key.getId(),
key.getSecret(),
usageMap,
key.getCreatedAt()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.talkka.server.admin.exception;

public class PublicApiKeyAlreadyExistsException extends RuntimeException {
private static final String MESSAGE = "이미 등록된 api key 입니다.";

public PublicApiKeyAlreadyExistsException() {
super(MESSAGE);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.talkka.server.admin.exception;

public class PublicApiKeyNotFoundException extends RuntimeException {
private static final String MESSAGE = "존재하지 않는 api key 입니다.";

public PublicApiKeyNotFoundException() {
super(MESSAGE);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.talkka.server.admin.service;

import java.util.ArrayList;
import java.util.List;
import java.util.TreeMap;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.talkka.server.admin.dao.PublicApiKeyEntity;
import com.talkka.server.admin.dao.PublicApiKeyRepository;
import com.talkka.server.admin.dto.PublicApiKeyRespDto;
import com.talkka.server.admin.exception.PublicApiKeyAlreadyExistsException;
import com.talkka.server.admin.exception.PublicApiKeyNotFoundException;
import com.talkka.server.api.datagg.config.PersistenceApiKeyProvider;

import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class PublicApiKeyService {
private final PublicApiKeyRepository publicApiKeyRepository;
private final PersistenceApiKeyProvider persistenceApiKeyProvider;

@Transactional
public PublicApiKeyRespDto createKey(String secret) throws PublicApiKeyAlreadyExistsException {
if (publicApiKeyRepository.existsBySecret(secret)) {
throw new PublicApiKeyAlreadyExistsException();
}
var key = publicApiKeyRepository.save(
PublicApiKeyEntity.builder()
.secret(secret)
.build()
);
persistenceApiKeyProvider.addKey(key);
// 처음 생성시에는 빈 사용량맵 반환
return PublicApiKeyRespDto.of(key, new TreeMap<>());
}

@Transactional
public void deleteKey(String secret) throws PublicApiKeyNotFoundException {
if (!publicApiKeyRepository.existsBySecret(secret)) {
throw new PublicApiKeyNotFoundException();
}
publicApiKeyRepository.deleteBySecret(secret);
persistenceApiKeyProvider.deleteKey(secret);
}

public List<PublicApiKeyRespDto> getKeyList() {
List<PublicApiKeyRespDto> result = new ArrayList<>();
var keyEntityList = publicApiKeyRepository.findAll();
var secretList = persistenceApiKeyProvider.getKeyList();
var usageMapList = persistenceApiKeyProvider.getUsageMap();
for (int i = 0; i < secretList.size(); i++) {
for (var keyEntity : keyEntityList) {
if (keyEntity.getSecret().equals(secretList.get(i))) {
result.add(PublicApiKeyRespDto.of(keyEntity, usageMapList.get(i)));
break;
}
}
}
return result;
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.talkka.server.api.core.config;

public interface ApiKeyProvider {
String getApiKey(String path);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package com.talkka.server.api.datagg.config;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;

import org.springframework.context.annotation.Primary;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import com.talkka.server.admin.dao.PublicApiKeyEntity;
import com.talkka.server.admin.dao.PublicApiKeyRepository;
import com.talkka.server.api.core.config.ApiKeyProvider;
import com.talkka.server.api.core.exception.ApiClientException;

import jakarta.annotation.PostConstruct;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Component
@RequiredArgsConstructor
@Primary
public class PersistenceApiKeyProvider implements ApiKeyProvider {

private final PublicApiKeyRepository publicApiKeyRepository;
@Getter
private final List<String> keyList = new ArrayList<>();
@Getter
private final List<Map<String, Integer>> usageMap = new ArrayList<>();
private int rollingKeyIndex = 0;
private final int MAX_USAGE = 950; // 일일 최대 사용량

@Override
public String getApiKey(String path) throws ApiClientException {
for (int i = 0; i < keyList.size(); i++) {
rollingKeyIndex = (rollingKeyIndex + 1) % keyList.size();
// 호출한 적이 없거나 호출횟수가 MAX_USAGE 보다 작으면 api key 제공
if (!usageMap.get(rollingKeyIndex).containsKey(path)
|| usageMap.get(rollingKeyIndex).get(path) < MAX_USAGE) {
updateUsage(rollingKeyIndex, path);
return keyList.get(rollingKeyIndex);
}
}
throw new ApiClientException("사용 가능한 키가 없습니다.");
}

// 매일 자정에 키 사용량 리셋
@PostConstruct
@Scheduled(cron = "0 0 0 * * *")
public void init() {
rollingKeyIndex = 0;
keyList.clear();
usageMap.clear();
var keys = publicApiKeyRepository.findAll();
for (var key : keys) {
keyList.add(key.getSecret());
usageMap.add(new TreeMap<>());
}
}

public void addKey(PublicApiKeyEntity key) {
keyList.add(key.getSecret());
usageMap.add(new TreeMap<>());
}

public void deleteKey(String secret) {
int idx = keyList.indexOf(secret);
keyList.remove(idx);
usageMap.remove(idx);
}

private void updateUsage(int idx, String path) {
var map = usageMap.get(idx);
if (!map.containsKey(path)) {
map.put(path, 1);
} else {
map.put(path, map.get(path) + 1);
if (map.get(path) > MAX_USAGE) {
map.remove(path);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,22 @@
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

import com.talkka.server.api.core.config.ApiKeyProperty;
import com.talkka.server.api.core.config.ApiKeyProvider;

import lombok.Getter;
import lombok.Setter;

@Getter
@Component
@ConfigurationProperties(prefix = "openapi.public.bus.service-key")
public class BusApiKeyProperty implements ApiKeyProperty {
public class PropertyApiKeyProvider implements ApiKeyProvider {
@Setter
private List<String> keys;

private int rollingKeyIndex = 0;

@Override
public String getApiKey() {
public String getApiKey(String path) {
rollingKeyIndex = (rollingKeyIndex + 1) % keys.size();
return keys.get(rollingKeyIndex);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.DefaultUriBuilderFactory;

import com.talkka.server.api.core.config.ApiKeyProvider;
import com.talkka.server.api.core.exception.ApiClientException;
import com.talkka.server.api.datagg.config.BusApiKeyProperty;
import com.talkka.server.api.datagg.dto.BusArrivalBodyDto;
import com.talkka.server.api.datagg.dto.BusArrivalRespDto;
import com.talkka.server.api.datagg.dto.BusLocationBodyDto;
Expand All @@ -36,7 +36,7 @@
@RequiredArgsConstructor
public class SimpleBusApiService implements BusApiService {
private static final Logger log = LoggerFactory.getLogger(SimpleBusApiService.class);
private final BusApiKeyProperty busApiKeyProperty;
private final ApiKeyProvider apiKeyProvider;
private final RestTemplate restTemplate = new RestTemplate();
private static final String host = "apis.data.go.kr";

Expand Down Expand Up @@ -119,7 +119,7 @@ private URI getOpenApiUri(String path, MultiValueMap<String, String> params) {
.scheme("https")
.host(host)
.path(path)
.queryParam("serviceKey", this.busApiKeyProperty.getApiKey())
.queryParam("serviceKey", this.apiKeyProvider.getApiKey(path))
.queryParams(params)
.build();
}
Expand Down
Loading

0 comments on commit 4e78488

Please sign in to comment.