Skip to content
Open
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
4 changes: 2 additions & 2 deletions .github/workflows/cd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
contents: read
strategy:
matrix:
service: [retail-data-services, cart-service]
service: [product-api, cart-service]

steps:
- name: Checkout
Expand Down Expand Up @@ -56,7 +56,7 @@ jobs:
packages: write
strategy:
matrix:
service: [retail-data-services, cart-service]
service: [product-api, cart-service]

steps:
- name: Checkout
Expand Down
91 changes: 46 additions & 45 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ A collection of containerized microservices used for technical interviews. Candi

## Services

| Service | Port | Description |
| -------------------- | ---- | ----------------------------------------------------------------------------------------------------------------- |
| retail-data-services | 8080 | Read-only REST API serving synthetic product catalog, pricing, and inventory availability |
| cart-service | 8081 | Shopping cart REST API with CRUD operations. Calls retail-data-services at runtime for item and price enrichment. |
| Service | Port | Description |
| ------------ | ---- | -------------------------------------------------------------------------------------------------------- |
| product-api | 8080 | Read-only REST API serving synthetic product catalog, pricing, and inventory availability |
| cart-service | 8081 | Shopping cart REST API with CRUD operations. Calls product-api at runtime for item and price enrichment. |

**Note:** All data returned by these services is mocked/sample data intended for interviewing purposes only. It does not represent real or production retail data.

Expand All @@ -27,8 +27,8 @@ docker compose up

The services will be available at:

- retail-data-services: <http://localhost:8080/retail_data_services/v1/>
- cart-service: <http://localhost:8081/cart/v1/>
- product-api: <http://localhost:8080/v1/>
- cart-service: <http://localhost:8081/v1/>

To stop the services:

Expand All @@ -38,148 +38,148 @@ docker compose down

### Option 2: docker run (individual services)

Build and run each service separately. Note that cart-service depends on retail-data-services, so retail-data-services must be running first.
Build and run each service separately. Note that cart-service depends on product-api, so product-api must be running first.

```sh
# Build both JARs
./gradlew clean build

# Build and run retail-data-services
docker build -t retail-data-services retail-data-services/
docker run -d -p 8080:8080 --name data retail-data-services
# Build and run product-api
docker build -t product-api product-api/
docker run -d -p 8080:8080 --name product-api product-api

# Build and run cart-service
docker build -t cart-service cart-service/
docker run -p 8081:8081 --name cart --link data:data cart-service
docker run -p 8081:8081 --name cart --link product-api:product-api cart-service
```

### OpenAPI specs

Both services expose Swagger UI and OpenAPI docs:

| Service | Swagger UI | API docs |
| -------------------- | --------------------------------------------------------------------- | -------------------------------------------------------- |
| retail-data-services | <http://localhost:8080/retail_data_services/v1/swagger-ui/index.html> | <http://localhost:8080/retail_data_services/v1/api-docs> |
| cart-service | <http://localhost:8081/swagger-ui/index.html> | <http://localhost:8081/api-docs> |
| Service | Swagger UI | API docs |
| ------------ | --------------------------------------------- | -------------------------------- |
| product-api | <http://localhost:8080/swagger-ui/index.html> | <http://localhost:8080/api-docs> |
| cart-service | <http://localhost:8081/swagger-ui/index.html> | <http://localhost:8081/api-docs> |

HTTP request files for use with IntelliJ or VS Code are available at:

- `retail-data-services/retail-data-services.http`
- `product-api/product-api.http`
- `cart-service/cart-service.http`

## retail-data-services endpoints
## product-api endpoints

### Get price

**`GET /retail_data_services/v1/prices/{id}`**
**`GET /v1/prices/{id}`**

```sh
curl -X GET "http://localhost:8080/retail_data_services/v1/prices/123456"
curl -X GET "http://localhost:8080/v1/prices/123456"
```

### Get item

**`GET /retail_data_services/v1/items/{id}`**
**`GET /v1/items/{id}`**

```sh
curl -X GET "http://localhost:8080/retail_data_services/v1/items/123456"
curl -X GET "http://localhost:8080/v1/items/123456"
```

### List items

**`GET /retail_data_services/v1/items`**
**`GET /v1/items`**

Supports filtering by `small_description` query parameter.

```sh
curl -X GET "http://localhost:8080/retail_data_services/v1/items"
curl -X GET "http://localhost:8080/retail_data_services/v1/items?small_description=jersey"
curl -X GET "http://localhost:8080/v1/items"
curl -X GET "http://localhost:8080/v1/items?small_description=jersey"
```

### Get availability

**`GET /retail_data_services/v1/availability/{id}`**
**`GET /v1/availability/{id}`**

```sh
curl -X GET "http://localhost:8080/retail_data_services/v1/availability/123456"
curl -X GET "http://localhost:8080/v1/availability/123456"
```

## cart-service endpoints

cart-service depends on retail-data-services at runtime. When a cart is read, the service calls retail-data-services over HTTP to enrich each line item with product details and pricing. It then calculates taxes (by product category) and delivery charges.
cart-service depends on product-api at runtime. When a cart is read, the service calls product-api over HTTP to enrich each line item with product details and pricing. It then calculates taxes (by product category) and delivery charges.

### Get cart

**`GET /cart/v1/carts/{id}`**
**`GET /v1/carts/{id}`**

```sh
curl 'http://localhost:8081/cart/v1/carts/100' -i -X GET
curl 'http://localhost:8081/v1/carts/100' -i -X GET
```

### Create cart

**`POST /cart/v1/carts`**
**`POST /v1/carts`**

Request body: array of objects with `tcin` (string) and `quantity` (integer).
Request body: array of objects with `item_id` (string) and `quantity` (integer).

```sh
curl 'http://localhost:8081/cart/v1/carts' -i -X POST \
curl 'http://localhost:8081/v1/carts' -i -X POST \
-H 'Content-Type: application/json' \
-d '[
{"tcin" : "123456", "quantity": 1},
{"tcin" : "789123", "quantity": 2}
{"item_id" : "123456", "quantity": 1},
{"item_id" : "789123", "quantity": 2}
]'
```

### Add item to cart

**`POST /cart/v1/carts/{id}/items`**
**`POST /v1/carts/{id}/items`**

```sh
curl 'http://localhost:8081/cart/v1/carts/100/items' -i -X POST \
curl 'http://localhost:8081/v1/carts/100/items' -i -X POST \
-H 'Content-Type: application/json' \
-d '{"tcin" : "456788", "quantity": 2}'
-d '{"item_id" : "456788", "quantity": 2}'
```

### Update item quantity

**`PATCH /cart/v1/carts/{id}/items/{tcin}`**
**`PATCH /v1/carts/{id}/items/{item_id}`**

```sh
curl 'http://localhost:8081/cart/v1/carts/100/items/456788' -i -X PATCH \
curl 'http://localhost:8081/v1/carts/100/items/456788' -i -X PATCH \
-H 'Content-Type: application/json' \
-d '{"quantity": 3}'
```

### Remove item from cart

**`DELETE /cart/v1/carts/{id}/items/{tcin}`**
**`DELETE /v1/carts/{id}/items/{item_id}`**

Removing the last item from a cart also removes the cart.

```sh
curl 'http://localhost:8081/cart/v1/carts/100/items/456788' -i -X DELETE
curl 'http://localhost:8081/v1/carts/100/items/456788' -i -X DELETE
```

## Customizing data

You can customize the data returned by retail-data-services by creating your own CSV files and mounting them into the container. See [retail-data-services/data-formats.md](retail-data-services/data-formats.md) for details.
You can customize the data returned by product-api by creating your own CSV files and mounting them into the container. See [product-api/data-formats.md](product-api/data-formats.md) for details.

## Induced behaviors (latency and failure simulation)

Both services support configurable induced behaviors that simulate latency and failures. By setting the `DEFAULT_BEHAVIOR` environment variable, you can run the same APIs in different modes (normal, slow, or randomly failing) without changing any code.

See [retail-data-services/induced_behaviors.md](retail-data-services/induced_behaviors.md) for available modes, environment variables, and usage examples.
See [product-api/induced_behaviors.md](product-api/induced_behaviors.md) for available modes, environment variables, and usage examples.

## Performance benchmarking

A startup time benchmarking script is available at `retail-data-services/scripts/benchmark-startup.sh`. See `retail-data-services/scripts/README.md` for usage details.
A startup time benchmarking script is available at `product-api/scripts/benchmark-startup.sh`. See `product-api/scripts/README.md` for usage details.

## Project structure

```txt
tech-case-studies/
retail-data-services/ # Read-only data API (port 8080)
product-api/ # Read-only data API (port 8080)
src/
Dockerfile
build.gradle.kts
Expand All @@ -192,3 +192,4 @@ tech-case-studies/
settings.gradle.kts # Multi-project includes
gradle/libs.versions.toml # Shared dependency versions
```

Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import com.target.retail.cart.controller.dto.CartResponse;
import com.target.retail.cart.controller.dto.UpdateItemRequest;
import com.target.retail.cart.model.Cart;
import com.target.retail.cart.model.CartLineItem;
import com.target.retail.cart.service.CartService;
import com.target.retail.cart.service.behavior.Behaviors;
import io.swagger.v3.oas.annotations.Operation;
Expand All @@ -21,7 +20,7 @@
import java.util.stream.Collectors;

@RestController
@RequestMapping("/cart/v1")
@RequestMapping("/v1")
public class CartController {

private CartService cartService;
Expand All @@ -39,18 +38,18 @@ public CartController(CartService cartService, Behaviors behaviors) {
@ApiResponse(responseCode = "200", description = "Successfully created cart",
content = {@Content(mediaType = "application/json",
schema = @Schema(implementation = CartResponse.class))}),
@ApiResponse(responseCode = "400", description = "Invalid request due to duplicate TCINs",
@ApiResponse(responseCode = "400", description = "Invalid request due to duplicate item IDs",
content = @Content)
})
@PostMapping("/carts")
public ResponseEntity<CartResponse> createCart(@RequestBody List<AddItemRequest> addItems) {

if (addItems.stream().map(AddItemRequest::tcin).distinct().count() != addItems.size()) {
if (addItems.stream().map(AddItemRequest::itemId).distinct().count() != addItems.size()) {
return ResponseEntity.badRequest().build();
}

Map<String, Integer> itemsInCart = addItems.stream()
.collect(Collectors.toMap(AddItemRequest::tcin, AddItemRequest::quantity));
.collect(Collectors.toMap(AddItemRequest::itemId, AddItemRequest::quantity));

String cartId = cartService.createCart(itemsInCart);

Expand Down Expand Up @@ -83,12 +82,9 @@ public ResponseEntity<CartResponse> getCart(@PathVariable String id) {
@ApiResponse(responseCode = "404", description = "Cart not found",
content = @Content)
})
@DeleteMapping("/carts/{id}/items/{tcin}")
public ResponseEntity<CartResponse> removeItemFromCart(@PathVariable String id, @PathVariable String tcin) {
if(cartService.getCart(id).isEmpty()) {
return ResponseEntity.notFound().build();
}
cartService.removeItem(id, tcin);
@DeleteMapping("/carts/{id}/items/{itemId}")
public ResponseEntity<CartResponse> removeItemFromCart(@PathVariable String id, @PathVariable String itemId) {
cartService.removeItem(id, itemId);
if (cartService.getCart(id).isEmpty()) {
return ResponseEntity.noContent().build();
} else {
Expand All @@ -106,10 +102,7 @@ public ResponseEntity<CartResponse> removeItemFromCart(@PathVariable String id,
})
@PostMapping("/carts/{id}/items")
public ResponseEntity<CartResponse> addItem(@PathVariable String id, @RequestBody AddItemRequest addItemRequest) {
if(cartService.getCart(id).isEmpty()) {
return ResponseEntity.notFound().build();
}
cartService.addItem(id, addItemRequest.tcin(), addItemRequest.quantity());
cartService.addItem(id, addItemRequest.itemId(), addItemRequest.quantity());
return getCart(id);
}

Expand All @@ -121,18 +114,10 @@ public ResponseEntity<CartResponse> addItem(@PathVariable String id, @RequestBod
@ApiResponse(responseCode = "404", description = "Cart or item not found",
content = @Content)
})
@PatchMapping("/carts/{id}/items/{tcin}")
public ResponseEntity<CartResponse> updateItem(@PathVariable String id, @PathVariable String tcin, @RequestBody UpdateItemRequest updateItemRequest) {

Optional<CartLineItem> cartLineItem = cartService.getCart(id)
.flatMap( it -> it.findByTcin(tcin));
if(cartLineItem.isEmpty()) {
return ResponseEntity.notFound().build();
}

cartService.updateCartItem(id, tcin, updateItemRequest.quantity());
@PatchMapping("/carts/{id}/items/{itemId}")
public ResponseEntity<CartResponse> updateItem(@PathVariable String id, @PathVariable String itemId, @RequestBody UpdateItemRequest updateItemRequest) {
cartService.updateCartItem(id, itemId, updateItemRequest.quantity());
return getCart(id);

}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.target.retail.cart.controller;

import com.target.retail.cart.controller.dto.ErrorResponse;
import com.target.retail.cart.data.DataException;
import com.target.retail.cart.exception.CartLineItemNotFoundException;
import com.target.retail.cart.exception.CartNotFoundException;
import com.target.retail.cart.exception.InducedFailureException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler({CartNotFoundException.class, CartLineItemNotFoundException.class})
public ResponseEntity<ErrorResponse> handleNotFound(RuntimeException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse(HttpStatus.NOT_FOUND.value(), ex.getMessage()));
}

@ExceptionHandler(InducedFailureException.class)
public ResponseEntity<ErrorResponse> handleInducedFailure(InducedFailureException ex) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new ErrorResponse(HttpStatus.SERVICE_UNAVAILABLE.value(), ex.getMessage()));
}

@ExceptionHandler(DataException.class)
public ResponseEntity<ErrorResponse> handleDataException(DataException ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR.value(), "An internal data error occurred"));
}

@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ErrorResponse> handleRuntimeException(RuntimeException ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR.value(), "An unexpected error occurred"));
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
package com.target.retail.cart.controller.dto;

public record AddItemRequest(String tcin, Integer quantity) {
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record AddItemRequest(String itemId, Integer quantity) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ private String formatNumber(BigDecimal bigDecimal) {
}


public record ItemResponse(String tcin, String title, String description, String brand, String merchClass, Integer quantity, PriceResponse price, ImageResponse imageData) {
public record ItemResponse(String itemId, String title, String description, String brand, Integer merchClass, Integer quantity, PriceResponse price, ImageResponse imageData) {

public record PriceResponse(BigDecimal regular, BigDecimal sale) {}

Expand All @@ -50,9 +50,9 @@ public record ImageResponse(String primary, String alternate, String baseUrl) {}

public static CartResponse from(Cart cart) {

List<ItemResponse> items = cart.cartLineItems().stream().map(it -> new ItemResponse(it.item().tcin(),
List<ItemResponse> items = cart.cartLineItems().stream().map(it -> new ItemResponse(it.item().itemId(),
it.item().title(), it.item().description(), it.item().brand(), it.item().merchClass(),
it.quantity(), new ItemResponse.PriceResponse(it.price().regularPrice(), it.price().salePrice().orElse(null)), new ItemResponse.ImageResponse(it.item().primaryImage(), it.item().alternateImage(), it.item().baseUrl()))).toList();
it.quantity(), new ItemResponse.PriceResponse(it.price().regular(), it.price().sale().orElse(null)), new ItemResponse.ImageResponse(it.item().primary(), it.item().alternate(), it.item().baseUrl()))).toList();

return new CartResponse(cart.id(), items, cart.subTotal(), cart.deliveryCharges(), cart.totalTax(), cart.getTotal(), cart.createdOn(), cart.updatedOn());
}
Expand Down
Loading