Skip to content

Commit 9b4e4a2

Browse files
authored
Cart API (#4)
* Implement full Cart API: add service, controller, validation, tests; fix OpenAPI spec to match endpoints and DTOs * migrate to OpenAPI-generated models - Replaced manually defined DTOs with models generated via OpenAPI generator - Updated mappers and validators to work with generated classes - Adjusted tests to use generated request/response objects * support specifying allowed currencies via application.yaml
1 parent 4fb598c commit 9b4e4a2

27 files changed

+2126
-130
lines changed

ecommerce-be/pom.xml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,15 @@
3434
<opentelemetry-exporter-otlp.version>1.52.0</opentelemetry-exporter-otlp.version>
3535
<micrometer-tracing.version>1.5.2</micrometer-tracing.version>
3636
<labs64.code-formatting.version>1.0.0</labs64.code-formatting.version>
37+
<mapstruct.version>1.6.3</mapstruct.version>
38+
<lombok.version>1.18.34</lombok.version>
3739
<!-- PLUGINS -->
3840
<maven-enforcer-plugin.version>3.6.1</maven-enforcer-plugin.version>
3941
<openapi-generator-maven-plugin.version>7.14.0</openapi-generator-maven-plugin.version>
4042
<build-helper-maven-plugin.version>3.6.1</build-helper-maven-plugin.version>
4143
<maven-dependency-plugin.version>3.6.0</maven-dependency-plugin.version>
4244
<spotless-maven-plugin.version>2.44.0</spotless-maven-plugin.version>
45+
<maven-compiler-plugin.version>3.13.0</maven-compiler-plugin.version>
4346
</properties>
4447

4548
<dependencyManagement>
@@ -153,6 +156,13 @@
153156
<artifactId>spring-boot-starter-data-redis</artifactId>
154157
</dependency>
155158

159+
<!-- MapStruct-->
160+
<dependency>
161+
<groupId>org.mapstruct</groupId>
162+
<artifactId>mapstruct</artifactId>
163+
<version>${mapstruct.version}</version>
164+
</dependency>
165+
156166
<!-- Testing -->
157167
<dependency>
158168
<groupId>org.springframework.boot</groupId>
@@ -216,7 +226,11 @@
216226
<interfaceOnly>true</interfaceOnly>
217227
<useSpringBoot3>true</useSpringBoot3>
218228
<unhandledException>false</unhandledException>
229+
<containerDefaultToNull>true</containerDefaultToNull>
219230
</configOptions>
231+
<typeMappings>
232+
<typeMapping>double=BigDecimal</typeMapping>
233+
</typeMappings>
220234
</configuration>
221235
</execution>
222236
</executions>
@@ -288,6 +302,29 @@
288302
</java>
289303
</configuration>
290304
</plugin>
305+
<plugin>
306+
<groupId>org.apache.maven.plugins</groupId>
307+
<artifactId>maven-compiler-plugin</artifactId>
308+
<version>${maven-compiler-plugin.version}</version>
309+
<configuration>
310+
<source>21</source>
311+
<target>21</target>
312+
<annotationProcessorPaths>
313+
<path>
314+
<groupId>org.projectlombok</groupId>
315+
<artifactId>lombok</artifactId>
316+
<version>${lombok.version}</version>
317+
</path>
318+
<path>
319+
<groupId>org.mapstruct</groupId>
320+
<artifactId>mapstruct-processor</artifactId>
321+
<version>${mapstruct.version}</version>
322+
</path>
323+
</annotationProcessorPaths>
324+
<fork>true</fork>
325+
<useIncrementalCompilation>false</useIncrementalCompilation>
326+
</configuration>
327+
</plugin>
291328
</plugins>
292329
</build>
293330
</project>
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package io.labs64.ecommerce.config;
2+
3+
import java.time.Duration;
4+
import java.util.List;
5+
6+
import org.springframework.boot.context.properties.ConfigurationProperties;
7+
import org.springframework.stereotype.Component;
8+
9+
import lombok.Getter;
10+
import lombok.Setter;
11+
12+
@Setter
13+
@Getter
14+
@Component
15+
@ConfigurationProperties(prefix = "cart")
16+
public class CartProperties {
17+
private String prefix;
18+
private Duration ttl;
19+
private List<String> currency;
20+
}

ecommerce-be/src/main/java/io/labs64/ecommerce/config/RedisConfig.java

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@
77
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
88
import org.springframework.data.redis.serializer.StringRedisSerializer;
99

10+
import com.fasterxml.jackson.annotation.JsonInclude;
11+
import com.fasterxml.jackson.annotation.JsonTypeInfo;
12+
import com.fasterxml.jackson.databind.ObjectMapper;
13+
import com.fasterxml.jackson.databind.SerializationFeature;
14+
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
15+
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
16+
1017
import io.labs64.ecommerce.v1.model.Cart;
1118

1219
@Configuration
@@ -16,15 +23,25 @@ public RedisTemplate<String, Cart> redisTemplate(RedisConnectionFactory connecti
1623
RedisTemplate<String, Cart> template = new RedisTemplate<>();
1724
template.setConnectionFactory(connectionFactory);
1825

19-
// key - string
26+
// Key serializer
2027
template.setKeySerializer(new StringRedisSerializer());
2128

22-
// value - JSON
23-
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
29+
// Value serializer
30+
ObjectMapper mapper = new ObjectMapper();
31+
mapper.registerModule(new JavaTimeModule());
32+
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
33+
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
34+
35+
mapper.activateDefaultTyping(BasicPolymorphicTypeValidator.builder().allowIfSubType(Object.class).build(),
36+
ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
37+
38+
GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(mapper);
39+
40+
template.setValueSerializer(serializer);
2441

25-
// hash
42+
// Hash serializer
2643
template.setHashKeySerializer(new StringRedisSerializer());
27-
template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
44+
template.setHashValueSerializer(serializer);
2845

2946
template.afterPropertiesSet();
3047
return template;

ecommerce-be/src/main/java/io/labs64/ecommerce/exception/ErrorCode.java

Lines changed: 0 additions & 5 deletions
This file was deleted.

ecommerce-be/src/main/java/io/labs64/ecommerce/exception/GlobalExceptionHandler.java

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,33 +4,68 @@
44

55
import org.springframework.http.HttpStatus;
66
import org.springframework.http.ResponseEntity;
7+
import org.springframework.validation.FieldError;
8+
import org.springframework.web.bind.MethodArgumentNotValidException;
79
import org.springframework.web.bind.annotation.ExceptionHandler;
810
import org.springframework.web.bind.annotation.RestControllerAdvice;
911

12+
import io.labs64.ecommerce.v1.model.ErrorCode;
1013
import io.labs64.ecommerce.v1.model.ErrorResponse;
1114
import jakarta.servlet.http.HttpServletRequest;
1215

1316
@RestControllerAdvice
1417
public class GlobalExceptionHandler {
1518

1619
@ExceptionHandler(NotFoundException.class)
17-
public ResponseEntity<ErrorResponse> handleNotFound(NotFoundException ex, HttpServletRequest request) {
18-
String traceId = request.getHeader("X-Request-ID");
19-
ErrorResponse error = new ErrorResponse(ex.getErrorCode().name(), ex.getMessage());
20-
error.setTraceId(traceId);
21-
error.setTimestamp(OffsetDateTime.now());
20+
public ResponseEntity<ErrorResponse> handleNotFound(final NotFoundException ex, final HttpServletRequest request) {
21+
ErrorResponse error = buildErrorResponse(ex.getErrorCode(), ex.getMessage(), request);
2222

2323
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
2424
}
2525

26-
// Optional: catch-all
26+
@ExceptionHandler(MethodArgumentNotValidException.class)
27+
public ResponseEntity<ErrorResponse> handleValidationException(final MethodArgumentNotValidException ex,
28+
final HttpServletRequest request) {
29+
String errorMessage = "Validation failed";
30+
31+
FieldError firstError = ex.getBindingResult().getFieldErrors().stream().findFirst().orElse(null);
32+
33+
if (firstError != null) {
34+
errorMessage = String.format("Field '%s' is invalid: %s", firstError.getField(),
35+
firstError.getDefaultMessage());
36+
}
37+
38+
ErrorResponse error = buildErrorResponse(ErrorCode.VALIDATION_ERROR, errorMessage, request);
39+
40+
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
41+
}
42+
43+
@ExceptionHandler(ValidationException.class)
44+
public ResponseEntity<ErrorResponse> handleCustomValidation(final ValidationException ex,
45+
final HttpServletRequest request) {
46+
String errorMessage = String.format("Field '%s' is invalid: %s", ex.getField(), ex.getMessage());
47+
48+
ErrorResponse error = buildErrorResponse(ex.getErrorCode(), errorMessage, request);
49+
50+
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
51+
}
52+
2753
@ExceptionHandler(Exception.class)
28-
public ResponseEntity<ErrorResponse> handleOther(Exception ex, HttpServletRequest request) {
54+
public ResponseEntity<ErrorResponse> handleOther(final Exception ex, final HttpServletRequest request) {
55+
ErrorResponse error = buildErrorResponse(ErrorCode.INTERNAL_ERROR, "Unexpected error", request);
56+
57+
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
58+
}
59+
60+
private ErrorResponse buildErrorResponse(ErrorCode code, String message, HttpServletRequest request) {
2961
String traceId = request.getHeader("X-Request-ID");
30-
ErrorResponse error = new ErrorResponse(ErrorCode.INTERNAL_ERROR.name(), "Unexpected error");
62+
63+
ErrorResponse error = new ErrorResponse();
64+
error.setCode(code);
65+
error.setMessage(message);
3166
error.setTraceId(traceId);
3267
error.setTimestamp(OffsetDateTime.now());
3368

34-
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
69+
return error;
3570
}
3671
}

ecommerce-be/src/main/java/io/labs64/ecommerce/exception/NotFoundException.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,16 @@
33
import org.springframework.http.HttpStatus;
44
import org.springframework.web.bind.annotation.ResponseStatus;
55

6+
import io.labs64.ecommerce.v1.model.ErrorCode;
67
import lombok.Getter;
78

89
@Getter
910
@ResponseStatus(HttpStatus.NOT_FOUND)
1011
public class NotFoundException extends RuntimeException {
1112
private final ErrorCode errorCode;
1213

13-
public NotFoundException(ErrorCode errorCode, String message) {
14+
public NotFoundException(String message) {
1415
super(message);
15-
this.errorCode = errorCode;
16+
this.errorCode = ErrorCode.NOT_FOUND;
1617
}
1718
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package io.labs64.ecommerce.exception;
2+
3+
import org.springframework.http.HttpStatus;
4+
import org.springframework.web.bind.annotation.ResponseStatus;
5+
6+
import io.labs64.ecommerce.v1.model.ErrorCode;
7+
import lombok.Getter;
8+
9+
@Getter
10+
@ResponseStatus(HttpStatus.NOT_FOUND)
11+
public class ValidationException extends RuntimeException {
12+
private final ErrorCode errorCode;
13+
private final String field;
14+
15+
public ValidationException(String field, String message) {
16+
super(message);
17+
this.field = field;
18+
this.errorCode = ErrorCode.VALIDATION_ERROR;
19+
}
20+
}
Lines changed: 89 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,118 @@
11
package io.labs64.ecommerce.v1.controller;
22

3+
import java.util.Collections;
4+
import java.util.List;
5+
import java.util.Optional;
36
import java.util.UUID;
47

58
import org.springframework.http.HttpStatus;
69
import org.springframework.http.ResponseEntity;
10+
import org.springframework.web.bind.annotation.PathVariable;
11+
import org.springframework.web.bind.annotation.RequestBody;
712
import org.springframework.web.bind.annotation.RequestMapping;
813
import org.springframework.web.bind.annotation.RestController;
14+
import org.springframework.web.context.request.NativeWebRequest;
915

10-
import io.labs64.ecommerce.exception.ErrorCode;
1116
import io.labs64.ecommerce.exception.NotFoundException;
12-
import io.labs64.ecommerce.messaging.CartPublisherService;
1317
import io.labs64.ecommerce.v1.api.CartApi;
18+
import io.labs64.ecommerce.v1.api.CartItemsApi;
19+
import io.labs64.ecommerce.v1.mapper.CartMapper;
1420
import io.labs64.ecommerce.v1.model.Cart;
15-
import io.labs64.ecommerce.v1.model.ErrorResponse;
21+
import io.labs64.ecommerce.v1.model.CartItem;
22+
import io.labs64.ecommerce.v1.model.CreateCartItemRequest;
23+
import io.labs64.ecommerce.v1.model.CreateCartRequest;
24+
import io.labs64.ecommerce.v1.model.UpdateCartItemRequest;
25+
import io.labs64.ecommerce.v1.model.UpdateCartRequest;
1626
import io.labs64.ecommerce.v1.service.CartService;
27+
import jakarta.validation.Valid;
1728

1829
@RestController
1930
@RequestMapping("/api/v1")
20-
public class CartController implements CartApi {
31+
public class CartController implements CartApi, CartItemsApi {
2132

2233
private final CartService cartService;
34+
private final CartMapper cartMapper;
2335

24-
public CartController(CartService cartService) {
36+
public CartController(final CartService cartService, CartMapper cartMapper) {
2537
this.cartService = cartService;
38+
this.cartMapper = cartMapper;
2639
}
2740

2841
@Override
29-
public ResponseEntity<Cart> getCartById(final UUID cartId) {
30-
final Cart cart = cartService.getCart(cartId).orElseThrow(() -> new NotFoundException(ErrorCode.CART_NOT_FOUND,
31-
"Shopping cart with ID '" + cartId + "' not found."));
32-
return ResponseEntity.ok(cart);
42+
public Optional<NativeWebRequest> getRequest() {
43+
return CartApi.super.getRequest();
3344
}
3445

3546
@Override
36-
public ResponseEntity<Cart> saveCart(Cart cart) {
37-
cartService.saveCart(cart.getCartId(), cart);
47+
public ResponseEntity<Cart> createCart(@Valid @RequestBody final CreateCartRequest request) {
48+
final Cart cart = cartService.createCart(cartMapper.toCart(request));
3849
return ResponseEntity.status(HttpStatus.CREATED).body(cart);
3950
}
51+
52+
@Override
53+
public ResponseEntity<Cart> getCart(@PathVariable("cartId") UUID cartId) {
54+
final Cart cart = cartService.getCart(cartId)
55+
.orElseThrow(() -> new NotFoundException("Shopping cart with ID '" + cartId + "' not found."));
56+
return ResponseEntity.ok(cart);
57+
}
58+
59+
@Override
60+
public ResponseEntity<Cart> updateCart(@PathVariable("cartId") UUID cartId,
61+
@Valid @RequestBody UpdateCartRequest request) {
62+
final Cart updated = cartService.updateCart(cartId, cart -> cartMapper.updateCart(request, cart))
63+
.orElseThrow(() -> new NotFoundException("Shopping cart with ID '" + cartId + "' not found."));
64+
65+
return ResponseEntity.ok(updated);
66+
}
67+
68+
@Override
69+
public ResponseEntity<Void> deleteCart(@PathVariable("cartId") UUID cartId) {
70+
cartService.deleteCart(cartId);
71+
return ResponseEntity.noContent().build();
72+
}
73+
74+
@Override
75+
public ResponseEntity<CartItem> createCartItem(@PathVariable("cartId") UUID cartId,
76+
@Valid @RequestBody CreateCartItemRequest request) {
77+
CartItem cartItem = cartMapper.toCartItem(request);
78+
79+
cartService.updateCart(cartId, cart -> cart.getItems().add(cartItem))
80+
.orElseThrow(() -> new NotFoundException("Shopping cart with ID '" + cartId + "' not found."));
81+
82+
return ResponseEntity.ok(cartItem);
83+
}
84+
85+
@Override
86+
public ResponseEntity<CartItem> updateCartItem(@PathVariable("cartId") UUID cartId,
87+
@PathVariable("itemId") String itemId, @Valid @RequestBody UpdateCartItemRequest request) {
88+
CartItem updatedItem = cartService.updateCart(cartId, cart -> {
89+
90+
CartItem item = safeItems(cart).stream().filter(i -> itemId.equals(i.getItemId())).findFirst().orElseThrow(
91+
() -> new NotFoundException("Item with ID '" + itemId + "' not found in cart '" + cartId + "'."));
92+
93+
cartMapper.updateCartItem(request, item);
94+
}).flatMap(cart -> safeItems(cart).stream().filter(i -> itemId.equals(i.getItemId())).findFirst())
95+
.orElseThrow(() -> new NotFoundException("Shopping cart with ID '" + cartId + "' not found."));
96+
97+
return ResponseEntity.ok(updatedItem);
98+
}
99+
100+
@Override
101+
public ResponseEntity<Void> deleteCartItem(@PathVariable("cartId") UUID cartId,
102+
@PathVariable("itemId") String itemId) {
103+
cartService.updateCart(cartId, cart -> {
104+
List<CartItem> cartItems = safeItems(cart);
105+
106+
CartItem item = cartItems.stream().filter(i -> itemId.equals(i.getItemId())).findFirst().orElseThrow(
107+
() -> new NotFoundException("Item with ID '" + itemId + "' not found in cart '" + cartId + "'."));
108+
109+
cartItems.remove(item);
110+
}).orElseThrow(() -> new NotFoundException("Shopping cart with ID '" + cartId + "' not found."));
111+
112+
return ResponseEntity.noContent().build();
113+
}
114+
115+
private List<CartItem> safeItems(Cart cart) {
116+
return Optional.ofNullable(cart.getItems()).orElseGet(Collections::emptyList);
117+
}
40118
}

0 commit comments

Comments
 (0)