diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c2a45ba --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM openjdk:25-ea-4-jdk-oraclelinux9 + +WORKDIR /app + +COPY target/order-0.0.1-SNAPSHOT.jar /app/order-0.0.1-SNAPSHOT.jar + +ENTRYPOINT [ "java", "-jar", "/app/order-0.0.1-SNAPSHOT.jar" ] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c126715 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,44 @@ +services: + backend: + image: openjdk:25-ea-4-jdk-oraclelinux9 + container_name: order + ports: + - "8080:8080" + depends_on: + - order_db + environment: + - SPRING_DATASOURCE_URL=jdbc:postgresql://order_db:5432/orderDB + volumes: + - ./target:/app + - ./logs:/logs + command: [ "java", "-jar", "/app/order-0.0.1-SNAPSHOT.jar" ] + + order_db: + image: postgres:14.17 + container_name: order_db + environment: + POSTGRES_PASSWORD: 1234 + POSTGRES_USER: postgres + POSTGRES_DB: orderDB + ports: + - "5432:5432" + + loki: + image: grafana/loki:3.5.0 + container_name: loki + ports: + - "3100:3100" + command: -config.file=/etc/loki/local-config.yaml + + grafana: + image: grafana/grafana:10.4.1 + container_name: grafana + ports: + - "3000:3000" + depends_on: + - loki + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin + volumes: + - grafana_data:/var/lib/grafana diff --git a/pom.xml b/pom.xml index fd77e3d..a7ebd93 100644 --- a/pom.xml +++ b/pom.xml @@ -34,6 +34,25 @@ org.springframework.boot spring-boot-starter-data-jdbc + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.8.5 + + + org.springframework.boot + spring-boot-starter-security + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.8.5 + + + net.logstash.logback + logstash-logback-encoder + 7.4 + org.springframework.boot spring-boot-starter-data-jpa diff --git a/src/main/java/com/podzilla/order/controller/OrderController.java b/src/main/java/com/podzilla/order/controller/OrderController.java new file mode 100644 index 0000000..2e21d66 --- /dev/null +++ b/src/main/java/com/podzilla/order/controller/OrderController.java @@ -0,0 +1,141 @@ +package com.podzilla.order.controller; + +import com.podzilla.order.model.Order; +import com.podzilla.order.model.OrderStatus; +import com.podzilla.order.service.OrderService; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@RestController +@RequestMapping("/orders") +@Tag(name = "Order API", description = "Order management operations") +public class OrderController { + + private final OrderService orderService; + private static final Logger LOGGER = + LoggerFactory.getLogger(OrderController.class); + + @Autowired + public OrderController(final OrderService orderService) { + this.orderService = orderService; + } + + @PostMapping + @Operation(summary = "Create a new order", + description = "Creates a new order and returns the created order") + @ApiResponse(responseCode = "200", description = "Order created " + + "successfully") + public ResponseEntity createOrder(@RequestBody final Order order) { + LOGGER.info("Creating new order: {}", order); + Order createdOrder = orderService.createOrder(order); + return ResponseEntity.ok(createdOrder); + } + + @GetMapping + @Operation(summary = "Get all orders", + description = "Returns a list of all orders") + @ApiResponse(responseCode = "200", description = "List of orders") + public ResponseEntity> getAllOrders() { + LOGGER.info("Fetching all orders"); + List orders = orderService.getAllOrders(); + return ResponseEntity.ok(orders); + } + + @GetMapping("/{id}") + @Operation(summary = "Get order by ID", + description = "Returns an order by its ID") + @ApiResponse(responseCode = "200", description = "Order found") + public ResponseEntity getOrderById(@PathVariable final UUID id) { + LOGGER.info("Fetching order with ID: {}", id); + Order order = orderService.getOrderById(id) + .orElseThrow(() -> new RuntimeException("Order not found")); + return ResponseEntity.ok(order); + } + + @PutMapping("/{id}") + @Operation(summary = "Update an order", + description = "Updates an existing order and returns the updated " + + "order") + @ApiResponse(responseCode = "200", description = "Order updated " + + "successfully") + public ResponseEntity updateOrder(@PathVariable final UUID id, + @RequestBody final Order order) { + LOGGER.info("Updating order with ID: {}", id); + Order updatedOrder = orderService.updateOrder(id, order); + return ResponseEntity.ok(updatedOrder); + } + + @DeleteMapping("/{id}") + @Operation(summary = "Delete an order", + description = "Deletes an order by its ID") + @ApiResponse(responseCode = "204", description = "Order deleted " + + "successfully") + public ResponseEntity deleteOrder(@PathVariable final UUID id) { + LOGGER.info("Deleting order with ID: {}", id); + orderService.deleteOrder(id); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/user/{userId}") + @Operation( + summary = "Get order by user ID", + description = "Fetches an order based on the provided user ID" + ) + @ApiResponse( + responseCode = "200", description = "Order found" + ) + public ResponseEntity> getOrderByUserId( + @PathVariable final UUID userId) { + Optional order = orderService.getOrderByUserId(userId); + LOGGER.info("Order found for user ID: {}", userId); + return ResponseEntity.ok(order); + } + + @PutMapping("/cancel/{id}") + @Operation( + summary = "Cancel order", + description = "Cancels an order based on the provided order ID" + ) + @ApiResponse( + responseCode = "200", description = "Order cancelled" + ) + public ResponseEntity cancelOrder(@PathVariable final UUID id) { + Order order = orderService.cancelOrder(id); + LOGGER.info("Order with ID: {} cancelled", id); + return ResponseEntity.ok(order); + } + + @PutMapping("/status/{id}") + @Operation( + summary = "Update order status", + description = "Updates the status of an order based on " + + "the provided order ID" + ) + @ApiResponse( + responseCode = "200", description = "Order status updated" + ) + public ResponseEntity updateOrderStatus(@PathVariable final UUID id, + @RequestBody final OrderStatus status) { + Order order = orderService.updateOrderStatus(id, status); + LOGGER.info("Order status updated for ID: {}", id); + return ResponseEntity.ok(order); + } +} diff --git a/src/main/java/com/podzilla/order/exception/ErrorResponse.java b/src/main/java/com/podzilla/order/exception/ErrorResponse.java new file mode 100644 index 0000000..c752990 --- /dev/null +++ b/src/main/java/com/podzilla/order/exception/ErrorResponse.java @@ -0,0 +1,19 @@ +package com.podzilla.order.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +import java.time.LocalDateTime; + +@Getter +public class ErrorResponse { + private final String message; + private final HttpStatus status; + private final LocalDateTime timestamp; + + public ErrorResponse(final String message, final HttpStatus status) { + this.message = message; + this.status = status; + this.timestamp = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/podzilla/order/exception/GlobalExceptionHandler.java b/src/main/java/com/podzilla/order/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..0f19f36 --- /dev/null +++ b/src/main/java/com/podzilla/order/exception/GlobalExceptionHandler.java @@ -0,0 +1,62 @@ +package com.podzilla.order.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +@RestControllerAdvice +public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity handleAccessDeniedException( + final AccessDeniedException exception) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(new ErrorResponse(exception.getMessage(), + HttpStatus.FORBIDDEN)); + } + + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity handleAuthenticationException( + final AuthenticationException exception) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(new ErrorResponse(exception.getMessage(), + HttpStatus.UNAUTHORIZED)); + } + + @ExceptionHandler(NotFoundException.class) + public ResponseEntity handleNotFoundException( + final NotFoundException exception) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(new ErrorResponse(exception.getMessage(), + HttpStatus.NOT_FOUND)); + } + + @ExceptionHandler(ValidationException.class) + public ResponseEntity handleValidationException( + final ValidationException exception) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponse(exception.getMessage(), + HttpStatus.BAD_REQUEST)); + } + + @ExceptionHandler(InvalidActionException.class) + public ResponseEntity handleInvalidActionException( + final InvalidActionException exception) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponse(exception.getMessage(), + HttpStatus.BAD_REQUEST)); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException( + final Exception exception) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse(exception.getMessage(), + HttpStatus.INTERNAL_SERVER_ERROR)); + } +} diff --git a/src/main/java/com/podzilla/order/exception/InvalidActionException.java b/src/main/java/com/podzilla/order/exception/InvalidActionException.java new file mode 100644 index 0000000..89ce1d5 --- /dev/null +++ b/src/main/java/com/podzilla/order/exception/InvalidActionException.java @@ -0,0 +1,7 @@ +package com.podzilla.order.exception; + +public class InvalidActionException extends RuntimeException { + public InvalidActionException(final String message) { + super("Invalid action: " + message); + } +} diff --git a/src/main/java/com/podzilla/order/exception/NotFoundException.java b/src/main/java/com/podzilla/order/exception/NotFoundException.java new file mode 100644 index 0000000..7a2c0da --- /dev/null +++ b/src/main/java/com/podzilla/order/exception/NotFoundException.java @@ -0,0 +1,7 @@ +package com.podzilla.order.exception; + +public class NotFoundException extends RuntimeException { + public NotFoundException(final String message) { + super("Not Found: " + message); + } +} diff --git a/src/main/java/com/podzilla/order/exception/ValidationException.java b/src/main/java/com/podzilla/order/exception/ValidationException.java new file mode 100644 index 0000000..f23efb2 --- /dev/null +++ b/src/main/java/com/podzilla/order/exception/ValidationException.java @@ -0,0 +1,7 @@ +package com.podzilla.order.exception; + +public class ValidationException extends RuntimeException { + public ValidationException(final String message) { + super("Validation error: " + message); + } +} diff --git a/src/main/java/com/podzilla/order/model/Address.java b/src/main/java/com/podzilla/order/model/Address.java new file mode 100644 index 0000000..acf13c3 --- /dev/null +++ b/src/main/java/com/podzilla/order/model/Address.java @@ -0,0 +1,37 @@ +package com.podzilla.order.model; + + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "addresses") +public class Address { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + private String street; + private String city; + private String state; + private String country; + private String postalCode; + + @ManyToOne + @JoinColumn(name = "order_id", nullable = false) + private Order order; +} diff --git a/src/main/java/com/podzilla/order/model/Order.java b/src/main/java/com/podzilla/order/model/Order.java new file mode 100644 index 0000000..fbc14ee --- /dev/null +++ b/src/main/java/com/podzilla/order/model/Order.java @@ -0,0 +1,68 @@ +package com.podzilla.order.model; + + +import jakarta.persistence.Table; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Column; +import jakarta.persistence.Enumerated; +import jakarta.persistence.EnumType; +import jakarta.persistence.CascadeType; +import jakarta.persistence.GenerationType; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Entity +@Table(name = "orders") +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class Order { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private UUID id; + + private UUID userId; + + private double totalAmount; + + @OneToOne(cascade = CascadeType.ALL) + private Address shippingAddress; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private OrderStatus status; + + @Column(nullable = false) + private LocalDateTime createdAt; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = + true) + private List orderItems = new ArrayList<>(); + + public Order(final UUID userId, final double totalAmount, + final OrderStatus status, final List orderItems) { + this.userId = userId; + this.totalAmount = totalAmount; + this.status = status; + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + this.orderItems = orderItems; + } + +} diff --git a/src/main/java/com/podzilla/order/model/OrderItem.java b/src/main/java/com/podzilla/order/model/OrderItem.java new file mode 100644 index 0000000..0d7a03c --- /dev/null +++ b/src/main/java/com/podzilla/order/model/OrderItem.java @@ -0,0 +1,41 @@ +package com.podzilla.order.model; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Column; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.FetchType; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "order_items") +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class OrderItem { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private long id; + + @Column(nullable = false) + private long productId; + + @Column(nullable = false) + private int quantity; + + @Column(nullable = false) + private double price; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "order_id", nullable = false) + private Order order; +} diff --git a/src/main/java/com/podzilla/order/model/OrderStatus.java b/src/main/java/com/podzilla/order/model/OrderStatus.java new file mode 100644 index 0000000..07de71e --- /dev/null +++ b/src/main/java/com/podzilla/order/model/OrderStatus.java @@ -0,0 +1,10 @@ +package com.podzilla.order.model; + +public enum OrderStatus { + PLACED, + CANCELLED, + SHIPPED, + DELIVERED, + FAILED +} + diff --git a/src/main/java/com/podzilla/order/repository/OrderRepository.java b/src/main/java/com/podzilla/order/repository/OrderRepository.java new file mode 100644 index 0000000..106d6d0 --- /dev/null +++ b/src/main/java/com/podzilla/order/repository/OrderRepository.java @@ -0,0 +1,13 @@ +package com.podzilla.order.repository; + +import com.podzilla.order.model.Order; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface OrderRepository extends JpaRepository { + Optional findByUserId(UUID id); +} diff --git a/src/main/java/com/podzilla/order/service/OrderService.java b/src/main/java/com/podzilla/order/service/OrderService.java new file mode 100644 index 0000000..fab9aba --- /dev/null +++ b/src/main/java/com/podzilla/order/service/OrderService.java @@ -0,0 +1,114 @@ +package com.podzilla.order.service; + +import com.podzilla.order.exception.NotFoundException; +import com.podzilla.order.model.Order; +import com.podzilla.order.model.OrderStatus; +import com.podzilla.order.repository.OrderRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + + +@Slf4j +@Service +public class OrderService { + private final OrderRepository orderRepository; + + @Autowired + public OrderService(final OrderRepository orderRepository) { + this.orderRepository = orderRepository; + } + + public Order createOrder(final Order order) { + log.info("Creating new order: {}", order); + order.setCreatedAt(LocalDateTime.now()); + order.setUpdatedAt(LocalDateTime.now()); + return orderRepository.save(order); + } + + public List getAllOrders() { + log.info("Fetching all orders"); + return orderRepository.findAll(); + } + + public Optional getOrderById(final UUID id) { + log.info("Fetching order with ID: {}", id); + return orderRepository.findById(id); + } + + public Order updateOrder(final UUID id, final Order updatedOrder) { + log.info("Updating order with ID: {}", id); + Optional existingOrder = orderRepository.findById(id); + if (existingOrder.isPresent()) { + Order order = existingOrder.get(); + BeanUtils.copyProperties(updatedOrder, order, "id"); + order.setUpdatedAt(LocalDateTime.now()); + log.info("Order with id: {} was found and updated", id); + return orderRepository.save(order); + } + log.warn("Order with id: {} was not found", id); + throw new RuntimeException("Order not found with id: " + id); + } + + public void deleteOrder(final UUID id) { + if (orderRepository.existsById(id)) { + log.warn("Deleting order with ID: {}", id); + orderRepository.deleteById(id); + } else { + log.warn("Order with ID: {} not found for deletion", id); + throw new RuntimeException("Order not found with id: " + id); + } + } + + public Optional getOrderByUserId(final UUID userId) { + log.info("Fetching order with user ID: {}", userId); + + Optional order = orderRepository.findByUserId(userId); + + checkNotFoundException(order.orElse(null), + "Order not found with user ID: " + userId); + + return orderRepository.findByUserId(userId); + } + + public Order cancelOrder(final UUID id) { + log.info("Cancelling order with ID: {}", id); + + Optional existingOrder = orderRepository.findById(id); + + checkNotFoundException(existingOrder.orElse(null), + "Order not found with id: " + id); + + Order order = existingOrder.get(); + order.setStatus(OrderStatus.CANCELLED); + order.setUpdatedAt(LocalDateTime.now()); + return orderRepository.save(order); + } + + public Order updateOrderStatus(final UUID id, + final OrderStatus status) { + log.info("Updating order status with ID: {}", id); + + Optional existingOrder = orderRepository.findById(id); + + checkNotFoundException(existingOrder.orElse(null), + "Order not found with id: " + id); + + Order order = existingOrder.get(); + order.setStatus(status); + order.setUpdatedAt(LocalDateTime.now()); + return orderRepository.save(order); + } + + private void checkNotFoundException(final Object value, + final String message) { + if (value == null) { + throw new NotFoundException(message); + } + } +}