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
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, cart]

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

steps:
- name: Checkout
Expand Down
85 changes: 43 additions & 42 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 | 8080 | Read-only REST API serving synthetic product catalog, pricing, and inventory availability |
| cart | 8081 | Shopping cart REST API with CRUD operations. Calls product 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: <http://localhost:8080/product/v1/>
- cart: <http://localhost:8081/cart/v1/>

To stop the services:

Expand All @@ -38,75 +38,75 @@ 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 depends on product, so product 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
docker build -t product product/
docker run -d -p 8080:8080 --name product product

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

### 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 | <http://localhost:8080/swagger-ui/index.html> | <http://localhost:8080/docs> |
| cart | <http://localhost:8081/swagger-ui/index.html> | <http://localhost:8081/docs> |

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

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

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

### Get price

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

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

### Get item

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

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

### List items

**`GET /retail_data_services/v1/items`**
**`GET /product/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/product/v1/items"
curl -X GET "http://localhost:8080/product/v1/items?small_description=jersey"
```

### Get availability

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

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

## cart-service endpoints
## cart 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 depends on product at runtime. When a cart is read, the service calls product over HTTP to enrich each line item with product details and pricing. It then calculates taxes (by product category) and delivery charges.

### Get cart

Expand All @@ -120,14 +120,14 @@ curl 'http://localhost:8081/cart/v1/carts/100' -i -X GET

**`POST /cart/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 \
-H 'Content-Type: application/json' \
-d '[
{"tcin" : "123456", "quantity": 1},
{"tcin" : "789123", "quantity": 2}
{"item_id" : "123456", "quantity": 1},
{"item_id" : "789123", "quantity": 2}
]'
```

Expand All @@ -138,12 +138,12 @@ curl 'http://localhost:8081/cart/v1/carts' -i -X POST \
```sh
curl 'http://localhost:8081/cart/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 /cart/v1/carts/{id}/items/{item_id}`**

```sh
curl 'http://localhost:8081/cart/v1/carts/100/items/456788' -i -X PATCH \
Expand All @@ -153,7 +153,7 @@ curl 'http://localhost:8081/cart/v1/carts/100/items/456788' -i -X PATCH \

### Remove item from cart

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

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

Expand All @@ -163,27 +163,27 @@ curl 'http://localhost:8081/cart/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 by creating your own CSV files and mounting them into the container. See [product/data-formats.md](product/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/induced_behaviors.md](product/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/scripts/benchmark-startup.sh`. See `product/scripts/README.md` for usage details.

## Project structure

```txt
tech-case-studies/
retail-data-services/ # Read-only data API (port 8080)
product/ # Read-only data API (port 8080)
src/
Dockerfile
build.gradle.kts
cart-service/ # Shopping cart API (port 8081)
cart/ # Shopping cart API (port 8081)
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
```

This file was deleted.

File renamed without changes.
4 changes: 2 additions & 2 deletions cart-service/Dockerfile → cart/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ FROM eclipse-temurin:17-jre

WORKDIR /app

COPY build/libs/cart-service.jar /app/cart-service.jar
COPY build/libs/cart.jar /app/cart.jar

RUN mkdir -p /data/carts/

Expand All @@ -17,4 +17,4 @@ ENTRYPOINT ["java", \
"-XX:TieredStopAtLevel=1", \
"-XX:+UseSerialGC", \
"-Xss256k", \
"-jar", "/app/cart-service.jar"]
"-jar", "/app/cart.jar"]
2 changes: 1 addition & 1 deletion cart-service/build.gradle.kts → cart/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ application {
}

tasks.named<org.springframework.boot.gradle.tasks.bundling.BootJar>("bootJar") {
archiveFileName.set("cart-service.jar")
archiveFileName.set("cart.jar")
}

dependencies {
Expand Down
10 changes: 10 additions & 0 deletions cart-service/cart-service.http → cart/cart.http
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@
### Health Check
GET http://localhost:8081/actuator/health

### Open API (json)
GET http://localhost:8081/docs
Content-Type: application/json

### Open API (yml)
GET http://localhost:8081/docs.yaml

### Swagger UI
GET http://localhost:8081/swagger-ui/index.html

### Get Cart
GET http://localhost:8081/cart/v1/carts/100

Expand Down
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 Down Expand Up @@ -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
@@ -0,0 +1,8 @@
package com.target.retail.cart.controller.dto;

import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record AddItemRequest(String itemId, Integer quantity) {
}
Loading