Skip to content

Commit 64053f1

Browse files
committed
feat:idempotency aop feature and test code added
1 parent 1f22417 commit 64053f1

12 files changed

+428
-0
lines changed
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package com.woobeee.auth.aop;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import com.woobeee.auth.dto.IdempotencyResult;
5+
import com.woobeee.auth.dto.response.ApiResponse;
6+
import com.woobeee.auth.exception.*;
7+
import com.woobeee.auth.service.IdempotencyService;
8+
import jakarta.servlet.http.HttpServletRequest;
9+
import lombok.RequiredArgsConstructor;
10+
import lombok.extern.slf4j.Slf4j;
11+
import org.aspectj.lang.ProceedingJoinPoint;
12+
import org.aspectj.lang.annotation.Around;
13+
import org.aspectj.lang.annotation.Aspect;
14+
import org.springframework.http.HttpStatus;
15+
import org.springframework.http.ResponseEntity;
16+
import org.springframework.stereotype.Component;
17+
import org.springframework.util.DigestUtils;
18+
import org.springframework.web.context.request.RequestContextHolder;
19+
import org.springframework.web.context.request.ServletRequestAttributes;
20+
import org.springframework.web.server.ResponseStatusException;
21+
import org.springframework.web.util.ContentCachingRequestWrapper;
22+
import org.springframework.web.util.WebUtils;
23+
24+
import java.nio.charset.StandardCharsets;
25+
import java.util.Base64;
26+
27+
@Aspect
28+
@Component
29+
@RequiredArgsConstructor
30+
@Slf4j
31+
public class IdempotencyAspect {
32+
private final IdempotencyService idempotencyService;
33+
private final ObjectMapper objectMapper;
34+
35+
@Around("@annotation(idempotent)")
36+
public Object wrap(ProceedingJoinPoint pjp, Idempotent idempotent) throws Throwable {
37+
if (!idempotent.enabled()) {
38+
return pjp.proceed();
39+
}
40+
41+
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
42+
if (attrs == null) throw new IllegalStateException("No request context");
43+
HttpServletRequest req = attrs.getRequest();
44+
45+
String clientId = req.getHeader("Client-Request-Uuid");
46+
if (clientId == null || clientId.isBlank()) {
47+
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
48+
.body(ApiResponse.fail(HttpStatus.BAD_REQUEST, "Client-Request-Uuid required"));
49+
}
50+
51+
String domainId = req.getHeader("Domain-Id");
52+
if (domainId == null || domainId.isBlank()) {
53+
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
54+
.body(ApiResponse.fail(HttpStatus.BAD_REQUEST, "Domain-Id required"));
55+
}
56+
57+
String requestHash = buildRequestHash(req);
58+
IdempotencyResult begin = idempotencyService.begin(clientId, domainId, requestHash);
59+
60+
if (begin.inProgress()) {
61+
return ResponseEntity.status(HttpStatus.CONFLICT)
62+
.body(ApiResponse.fail(HttpStatus.CONFLICT, "Request is in progress"));
63+
}
64+
65+
if (begin.proceed()) {
66+
Object cached = objectMapper.readValue(begin.responseBody(), Object.class);
67+
return ResponseEntity.status(begin.responseCode() != null ? begin.responseCode() : 200).body(cached);
68+
}
69+
70+
try {
71+
Object result = pjp.proceed();
72+
73+
if (result instanceof ResponseEntity<?> re) {
74+
idempotencyService.complete(clientId, domainId, re.getStatusCode().value(), re.getBody());
75+
} else {
76+
idempotencyService.complete(clientId, domainId, HttpStatus.OK.value(), result);
77+
}
78+
return result;
79+
80+
} catch (Throwable t) {
81+
82+
HttpStatus status = resolveStatus(t);
83+
ApiResponse<?> failBody = ApiResponse.fail(status, safeMessage(t));
84+
85+
idempotencyService.fail(
86+
clientId,
87+
domainId,
88+
HttpStatus.INTERNAL_SERVER_ERROR.value(),
89+
failBody);
90+
throw t;
91+
}
92+
}
93+
94+
private String safeMessage(Throwable t) {
95+
String msg = t.getMessage();
96+
return (msg == null || msg.isBlank()) ? "Unexpected error" : msg;
97+
}
98+
99+
private String buildRequestHash(HttpServletRequest req) {
100+
String method = req.getMethod();
101+
String uri = req.getRequestURI();
102+
String query = req.getQueryString() == null ? "" : req.getQueryString();
103+
104+
byte[] bodyBytes = new byte[0];
105+
ContentCachingRequestWrapper wrapper =
106+
WebUtils.getNativeRequest(req, ContentCachingRequestWrapper.class);
107+
108+
if (wrapper != null) {
109+
bodyBytes = wrapper.getContentAsByteArray();
110+
}
111+
112+
String bodyBase64 = bodyBytes.length == 0
113+
? ""
114+
: Base64.getEncoder().encodeToString(bodyBytes);
115+
116+
String base = method + "|" + uri + "|" + query + "|" + bodyBase64;
117+
118+
return DigestUtils.md5DigestAsHex(base.getBytes(StandardCharsets.UTF_8));
119+
}
120+
121+
private HttpStatus resolveStatus(Throwable t) {
122+
// 401
123+
if (t instanceof JwtExpiredException) return HttpStatus.UNAUTHORIZED;
124+
if (t instanceof JwtNotValidException) return HttpStatus.UNAUTHORIZED;
125+
126+
// 400
127+
if (t instanceof PasswordNotMatchException) return HttpStatus.BAD_REQUEST;
128+
if (t instanceof UserConflictException) return HttpStatus.CONFLICT;
129+
130+
// 404
131+
if (t instanceof UserNotFoundException) return HttpStatus.NOT_FOUND;
132+
133+
// 409
134+
if (t instanceof CustomConflictException) return HttpStatus.CONFLICT;
135+
136+
if (t instanceof ResponseStatusException rse)
137+
return HttpStatus.valueOf(rse.getStatusCode().value());
138+
139+
return HttpStatus.INTERNAL_SERVER_ERROR;
140+
}
141+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.woobeee.auth.service;
2+
3+
4+
import com.woobeee.auth.dto.IdempotencyResult;
5+
6+
public interface IdempotencyService {
7+
IdempotencyResult begin(String clientId, String idemKey, String requestHash);
8+
void complete(String clientId, String idemKey, int code, Object body);
9+
void fail(String clientId, String idemKey, int code, Object body);
10+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package com.woobeee.auth.service;
2+
3+
import com.fasterxml.jackson.core.JsonProcessingException;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import com.woobeee.auth.dto.IdempotencyResult;
6+
import com.woobeee.auth.entity.IdempotencyRecord;
7+
import com.woobeee.auth.exception.CustomConflictException;
8+
import com.woobeee.auth.exception.ErrorCode;
9+
import com.woobeee.auth.repository.IdempotencyRecordRepository;
10+
import lombok.RequiredArgsConstructor;
11+
import lombok.extern.slf4j.Slf4j;
12+
import org.springframework.dao.DataIntegrityViolationException;
13+
import org.springframework.stereotype.Service;
14+
import org.springframework.transaction.annotation.Propagation;
15+
import org.springframework.transaction.annotation.Transactional;
16+
17+
import java.time.Duration;
18+
19+
20+
@RequiredArgsConstructor
21+
@Slf4j
22+
@Service
23+
@Transactional
24+
public class IdempotencyServiceImpl implements IdempotencyService{
25+
private final IdempotencyRecordRepository idempotencyRecordRepository;
26+
private final ObjectMapper objectMapper;
27+
28+
@Override
29+
@Transactional(propagation = Propagation.REQUIRES_NEW)
30+
public IdempotencyResult begin(String clientId, String domainKey, String requestHash) {
31+
try {
32+
idempotencyRecordRepository.saveAndFlush(
33+
(IdempotencyRecord.inProgress(clientId, domainKey, requestHash, Duration.ofHours(24))));
34+
return new IdempotencyResult(false, false, null, null);
35+
} catch (DataIntegrityViolationException dup) {
36+
37+
return idempotencyRecordRepository
38+
.findByClientIdAndDomainKey(clientId, domainKey)
39+
.map(existing -> {
40+
if (!existing.getRequestHash().equals(requestHash)) {
41+
throw new CustomConflictException(
42+
ErrorCode.api_idempotencyKeyConflictStopTryingToMessWithMyServer
43+
);
44+
}
45+
if (existing.getStatus() == IdempotencyRecord.Status.COMPLETED
46+
|| existing.getStatus() == IdempotencyRecord.Status.FAILED) {
47+
return new IdempotencyResult(
48+
true,
49+
false,
50+
existing.getResponseCode(),
51+
existing.getResponseBody()
52+
);
53+
}
54+
return new IdempotencyResult(false, true, null, null);
55+
})
56+
.orElseThrow(() ->
57+
// DB Unique 키가 저장은 막겠지만 MySql 같은 경우는 Commit 전에 조회가 될 수 있기 때문에 안전상 에러 호출
58+
new CustomConflictException(
59+
ErrorCode.api_idempotencyKeyConflict)
60+
);
61+
}
62+
}
63+
64+
@Override
65+
public void complete(String clientId, String domainKey, int code, Object body) {
66+
IdempotencyRecord r = idempotencyRecordRepository
67+
.findByClientIdAndDomainKey(clientId, domainKey).orElseThrow();
68+
69+
try {
70+
String b = objectMapper.writeValueAsString(body);
71+
r.markCompleted(code, b);
72+
idempotencyRecordRepository.saveAndFlush(r);
73+
} catch (JsonProcessingException e) {
74+
throw new RuntimeException("Failed to serialize response body", e);
75+
}
76+
}
77+
78+
@Override
79+
public void fail(String clientId, String domainKey, int code, Object body) {
80+
IdempotencyRecord r = idempotencyRecordRepository
81+
.findByClientIdAndDomainKey(clientId, domainKey).orElseThrow();
82+
83+
try {
84+
String b = objectMapper.writeValueAsString(body);
85+
r.markFailed(code, b);
86+
idempotencyRecordRepository.saveAndFlush(r);
87+
} catch (JsonProcessingException e) {
88+
throw new RuntimeException("Failed to serialize response body", e);
89+
}
90+
}
91+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.woobeee.auth.aop;
2+
3+
import java.lang.annotation.*;
4+
5+
@Target(ElementType.METHOD)
6+
@Retention(RetentionPolicy.RUNTIME)
7+
@Documented
8+
public @interface Idempotent {
9+
boolean enabled() default true;
10+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.woobeee.auth.dto;
2+
3+
import lombok.Builder;
4+
5+
@Builder
6+
public record IdempotencyResult(
7+
boolean proceed,
8+
boolean inProgress,
9+
Integer responseCode,
10+
String responseBody
11+
) {
12+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package com.woobeee.auth.dto.response;
2+
3+
import com.fasterxml.jackson.annotation.JsonInclude;
4+
import lombok.Builder;
5+
import org.springframework.http.HttpStatus;
6+
7+
import java.time.LocalDateTime;
8+
9+
@JsonInclude(JsonInclude.Include.NON_NULL)
10+
@Builder
11+
public record ApiResponse<T>(
12+
Header header,
13+
T data
14+
) {
15+
@Builder
16+
public record Header(
17+
boolean isSuccessful,
18+
String message,
19+
int resultCode
20+
) {}
21+
22+
public static <T> ApiResponse<T> success(T data, String message) {
23+
return new ApiResponse<>(
24+
new Header(true, message, HttpStatus.OK.value()),
25+
data
26+
);
27+
}
28+
29+
public static <T> ApiResponse<T> success(String message) {
30+
return new ApiResponse<>(
31+
new Header(true, message, HttpStatus.OK.value()),
32+
null
33+
);
34+
}
35+
36+
public static <T> ApiResponse<T> createSuccess(T data, String message) {
37+
return new ApiResponse<>(
38+
new Header(true, message, HttpStatus.CREATED.value()),
39+
data
40+
);
41+
}
42+
43+
public static <T> ApiResponse<T> createSuccess(String message) {
44+
return new ApiResponse<>(
45+
new Header(true, message, HttpStatus.CREATED.value()),
46+
null
47+
);
48+
}
49+
50+
public static <T> ApiResponse<T> deleteSuccess(T data, String message) {
51+
return new ApiResponse<>(
52+
new Header(true, message, HttpStatus.NO_CONTENT.value()),
53+
data
54+
);
55+
}
56+
57+
public static ApiResponse<LocalDateTime> fail(HttpStatus errorCode, String message) {
58+
return new ApiResponse<>(
59+
new Header(false, message, errorCode.value()),
60+
LocalDateTime.now()
61+
);
62+
}
63+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package com.woobeee.auth.entity;
2+
3+
import jakarta.persistence.*;
4+
import lombok.Getter;
5+
6+
import java.time.Duration;
7+
import java.time.LocalDateTime;
8+
9+
@Entity
10+
@Getter
11+
@Table(
12+
uniqueConstraints = @UniqueConstraint(columnNames = {"clientId", "domainKey"})
13+
)
14+
public class IdempotencyRecord {
15+
@Id
16+
@GeneratedValue(strategy = GenerationType.IDENTITY)
17+
private Long id;
18+
19+
private String clientId;
20+
private String domainKey;
21+
private String requestHash;
22+
23+
24+
@Enumerated(EnumType.STRING)
25+
private Status status;
26+
27+
private Integer responseCode;
28+
@Lob
29+
private String responseBody;
30+
31+
private LocalDateTime createdAt;
32+
private LocalDateTime expiresAt;
33+
34+
public enum Status { PROGRESS, COMPLETED, FAILED }
35+
36+
37+
public static IdempotencyRecord inProgress(
38+
String clientId, String domainKey, String requestHash, Duration ttl
39+
) {
40+
IdempotencyRecord r = new IdempotencyRecord();
41+
r.clientId = clientId;
42+
r.domainKey = domainKey;
43+
r.requestHash = requestHash;
44+
r.status = Status.PROGRESS;
45+
r.createdAt = LocalDateTime.now();
46+
r.expiresAt = r.createdAt.plus(ttl);
47+
return r;
48+
}
49+
50+
public void markCompleted(int responseCode, String responseBody) {
51+
this.status = Status.COMPLETED;
52+
this.responseCode = responseCode;
53+
this.responseBody = responseBody;
54+
}
55+
56+
57+
public void markFailed(int responseCode, String responseBody) {
58+
this.status = Status.FAILED;
59+
this.responseCode = responseCode;
60+
this.responseBody = responseBody;
61+
}
62+
63+
}

0 commit comments

Comments
 (0)