Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions ecommerce-be/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,15 @@
<opentelemetry-exporter-otlp.version>1.52.0</opentelemetry-exporter-otlp.version>
<micrometer-tracing.version>1.5.2</micrometer-tracing.version>
<labs64.code-formatting.version>1.0.0</labs64.code-formatting.version>
<mapstruct.version>1.6.3</mapstruct.version>
<lombok.version>1.18.34</lombok.version>
<!-- PLUGINS -->
<maven-enforcer-plugin.version>3.6.1</maven-enforcer-plugin.version>
<openapi-generator-maven-plugin.version>7.14.0</openapi-generator-maven-plugin.version>
<build-helper-maven-plugin.version>3.6.1</build-helper-maven-plugin.version>
<maven-dependency-plugin.version>3.6.0</maven-dependency-plugin.version>
<spotless-maven-plugin.version>2.44.0</spotless-maven-plugin.version>
<maven-compiler-plugin.version>3.13.0</maven-compiler-plugin.version>
</properties>

<dependencyManagement>
Expand Down Expand Up @@ -153,6 +156,13 @@
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<!-- MapStruct-->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>

<!-- Testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
Expand Down Expand Up @@ -216,7 +226,11 @@
<interfaceOnly>true</interfaceOnly>
<useSpringBoot3>true</useSpringBoot3>
<unhandledException>false</unhandledException>
<containerDefaultToNull>true</containerDefaultToNull>
</configOptions>
<typeMappings>
<typeMapping>double=BigDecimal</typeMapping>
</typeMappings>
</configuration>
</execution>
</executions>
Expand Down Expand Up @@ -288,6 +302,29 @@
</java>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>${maven-compiler-plugin.version}</version>
<configuration>
<source>21</source>
<target>21</target>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
</annotationProcessorPaths>
<fork>true</fork>
<useIncrementalCompilation>false</useIncrementalCompilation>
</configuration>
</plugin>
</plugins>
</build>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package io.labs64.ecommerce.config;

import java.time.Duration;
import java.util.List;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

import lombok.Getter;
import lombok.Setter;

@Setter
@Getter
@Component
@ConfigurationProperties(prefix = "cart")
public class CartProperties {
private String prefix;
private Duration ttl;
private List<String> currency;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;

import io.labs64.ecommerce.v1.model.Cart;

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

// key - string
// Key serializer
template.setKeySerializer(new StringRedisSerializer());

// value - JSON
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
// Value serializer
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);

mapper.activateDefaultTyping(BasicPolymorphicTypeValidator.builder().allowIfSubType(Object.class).build(),
ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);

GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(mapper);

template.setValueSerializer(serializer);

// hash
// Hash serializer
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
template.setHashValueSerializer(serializer);

template.afterPropertiesSet();
return template;
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,68 @@

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import io.labs64.ecommerce.v1.model.ErrorCode;
import io.labs64.ecommerce.v1.model.ErrorResponse;
import jakarta.servlet.http.HttpServletRequest;

@RestControllerAdvice
public class GlobalExceptionHandler {

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

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

// Optional: catch-all
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationException(final MethodArgumentNotValidException ex,
final HttpServletRequest request) {
String errorMessage = "Validation failed";

FieldError firstError = ex.getBindingResult().getFieldErrors().stream().findFirst().orElse(null);

if (firstError != null) {
errorMessage = String.format("Field '%s' is invalid: %s", firstError.getField(),
firstError.getDefaultMessage());
}

ErrorResponse error = buildErrorResponse(ErrorCode.VALIDATION_ERROR, errorMessage, request);

return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
}

@ExceptionHandler(ValidationException.class)
public ResponseEntity<ErrorResponse> handleCustomValidation(final ValidationException ex,
final HttpServletRequest request) {
String errorMessage = String.format("Field '%s' is invalid: %s", ex.getField(), ex.getMessage());

ErrorResponse error = buildErrorResponse(ex.getErrorCode(), errorMessage, request);

return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleOther(Exception ex, HttpServletRequest request) {
public ResponseEntity<ErrorResponse> handleOther(final Exception ex, final HttpServletRequest request) {
ErrorResponse error = buildErrorResponse(ErrorCode.INTERNAL_ERROR, "Unexpected error", request);

return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}

private ErrorResponse buildErrorResponse(ErrorCode code, String message, HttpServletRequest request) {
String traceId = request.getHeader("X-Request-ID");
ErrorResponse error = new ErrorResponse(ErrorCode.INTERNAL_ERROR.name(), "Unexpected error");

ErrorResponse error = new ErrorResponse();
error.setCode(code);
error.setMessage(message);
error.setTraceId(traceId);
error.setTimestamp(OffsetDateTime.now());

return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
return error;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

import io.labs64.ecommerce.v1.model.ErrorCode;
import lombok.Getter;

@Getter
@ResponseStatus(HttpStatus.NOT_FOUND)
public class NotFoundException extends RuntimeException {
private final ErrorCode errorCode;

public NotFoundException(ErrorCode errorCode, String message) {
public NotFoundException(String message) {
super(message);
this.errorCode = errorCode;
this.errorCode = ErrorCode.NOT_FOUND;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package io.labs64.ecommerce.exception;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

import io.labs64.ecommerce.v1.model.ErrorCode;
import lombok.Getter;

@Getter
@ResponseStatus(HttpStatus.NOT_FOUND)
public class ValidationException extends RuntimeException {
private final ErrorCode errorCode;
private final String field;

public ValidationException(String field, String message) {
super(message);
this.field = field;
this.errorCode = ErrorCode.VALIDATION_ERROR;
}
}
Original file line number Diff line number Diff line change
@@ -1,40 +1,118 @@
package io.labs64.ecommerce.v1.controller;

import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.NativeWebRequest;

import io.labs64.ecommerce.exception.ErrorCode;
import io.labs64.ecommerce.exception.NotFoundException;
import io.labs64.ecommerce.messaging.CartPublisherService;
import io.labs64.ecommerce.v1.api.CartApi;
import io.labs64.ecommerce.v1.api.CartItemsApi;
import io.labs64.ecommerce.v1.mapper.CartMapper;
import io.labs64.ecommerce.v1.model.Cart;
import io.labs64.ecommerce.v1.model.ErrorResponse;
import io.labs64.ecommerce.v1.model.CartItem;
import io.labs64.ecommerce.v1.model.CreateCartItemRequest;
import io.labs64.ecommerce.v1.model.CreateCartRequest;
import io.labs64.ecommerce.v1.model.UpdateCartItemRequest;
import io.labs64.ecommerce.v1.model.UpdateCartRequest;
import io.labs64.ecommerce.v1.service.CartService;
import jakarta.validation.Valid;

@RestController
@RequestMapping("/api/v1")
public class CartController implements CartApi {
public class CartController implements CartApi, CartItemsApi {

private final CartService cartService;
private final CartMapper cartMapper;

public CartController(CartService cartService) {
public CartController(final CartService cartService, CartMapper cartMapper) {
this.cartService = cartService;
this.cartMapper = cartMapper;
}

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

@Override
public ResponseEntity<Cart> saveCart(Cart cart) {
cartService.saveCart(cart.getCartId(), cart);
public ResponseEntity<Cart> createCart(@Valid @RequestBody final CreateCartRequest request) {
final Cart cart = cartService.createCart(cartMapper.toCart(request));
return ResponseEntity.status(HttpStatus.CREATED).body(cart);
}

@Override
public ResponseEntity<Cart> getCart(@PathVariable("cartId") UUID cartId) {
final Cart cart = cartService.getCart(cartId)
.orElseThrow(() -> new NotFoundException("Shopping cart with ID '" + cartId + "' not found."));
return ResponseEntity.ok(cart);
}

@Override
public ResponseEntity<Cart> updateCart(@PathVariable("cartId") UUID cartId,
@Valid @RequestBody UpdateCartRequest request) {
final Cart updated = cartService.updateCart(cartId, cart -> cartMapper.updateCart(request, cart))
.orElseThrow(() -> new NotFoundException("Shopping cart with ID '" + cartId + "' not found."));

return ResponseEntity.ok(updated);
}

@Override
public ResponseEntity<Void> deleteCart(@PathVariable("cartId") UUID cartId) {
cartService.deleteCart(cartId);
return ResponseEntity.noContent().build();
}

@Override
public ResponseEntity<CartItem> createCartItem(@PathVariable("cartId") UUID cartId,
@Valid @RequestBody CreateCartItemRequest request) {
CartItem cartItem = cartMapper.toCartItem(request);

cartService.updateCart(cartId, cart -> cart.getItems().add(cartItem))
.orElseThrow(() -> new NotFoundException("Shopping cart with ID '" + cartId + "' not found."));

return ResponseEntity.ok(cartItem);
}

@Override
public ResponseEntity<CartItem> updateCartItem(@PathVariable("cartId") UUID cartId,
@PathVariable("itemId") String itemId, @Valid @RequestBody UpdateCartItemRequest request) {
CartItem updatedItem = cartService.updateCart(cartId, cart -> {

CartItem item = safeItems(cart).stream().filter(i -> itemId.equals(i.getItemId())).findFirst().orElseThrow(
() -> new NotFoundException("Item with ID '" + itemId + "' not found in cart '" + cartId + "'."));

cartMapper.updateCartItem(request, item);
}).flatMap(cart -> safeItems(cart).stream().filter(i -> itemId.equals(i.getItemId())).findFirst())
.orElseThrow(() -> new NotFoundException("Shopping cart with ID '" + cartId + "' not found."));

return ResponseEntity.ok(updatedItem);
}

@Override
public ResponseEntity<Void> deleteCartItem(@PathVariable("cartId") UUID cartId,
@PathVariable("itemId") String itemId) {
cartService.updateCart(cartId, cart -> {
List<CartItem> cartItems = safeItems(cart);

CartItem item = cartItems.stream().filter(i -> itemId.equals(i.getItemId())).findFirst().orElseThrow(
() -> new NotFoundException("Item with ID '" + itemId + "' not found in cart '" + cartId + "'."));

cartItems.remove(item);
}).orElseThrow(() -> new NotFoundException("Shopping cart with ID '" + cartId + "' not found."));

return ResponseEntity.noContent().build();
}

private List<CartItem> safeItems(Cart cart) {
return Optional.ofNullable(cart.getItems()).orElseGet(Collections::emptyList);
}
}
Loading
Loading