Skip to content

Commit 658eb23

Browse files
Initial commit: Store microservices (api-read, api-rw, api-write) with Keycloak OAuth, Redis, Kafka, k8s/Terraform infra
0 parents  commit 658eb23

117 files changed

Lines changed: 8742 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Copy this file to .env and fill in your values.
2+
# .env is git-ignored — never commit real credentials.
3+
4+
# ── New Relic ───────────────────────────────────────────────────────────────
5+
# Get these from: New Relic → (avatar, top-right) → API Keys
6+
NEW_RELIC_ENABLED=false
7+
NEW_RELIC_API_KEY=NRAK-xxxxxxxxxxxxxxxxxxxxxxxxxxxx
8+
NEW_RELIC_ACCOUNT_ID=1234567

.gitignore

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# credentials — never commit
2+
.env
3+
4+
# Maven build output
5+
**/target/
6+
7+
# IDE
8+
.idea/
9+
*.iml
10+
.vscode/
11+
12+
# OS
13+
.DS_Store
14+
Thumbs.db

API-DOCS.md

Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
# Store — API Documentation
2+
3+
## Services Overview
4+
5+
| Service | Port | Role | Swagger UI |
6+
|---------|------|------|------------|
7+
| **api-read** (Product Catalog) | `8081` | Read products from DB + Redis cache | http://localhost:8081/swagger-ui.html |
8+
| **api-write** (Order Processing) | `8082` | Write orders → Kafka → PostgreSQL | http://localhost:8082/swagger-ui.html |
9+
| **api-rw** (Inventory) | `8083` | Reserve stock via Redis hot path or DB lock | http://localhost:8083/swagger-ui.html |
10+
11+
Raw OpenAPI specs: replace `/swagger-ui.html``/v3/api-docs`
12+
13+
---
14+
15+
## Authentication
16+
17+
All endpoints require a **Bearer JWT** from Keycloak.
18+
19+
**Keycloak (local):** http://localhost:8180
20+
**Realm:** `store-realm`
21+
22+
### Get a token
23+
24+
```bash
25+
TOKEN=$(curl -s -X POST \
26+
http://localhost:8180/realms/store-realm/protocol/openid-connect/token \
27+
-H "Content-Type: application/x-www-form-urlencoded" \
28+
-d "grant_type=password&client_id=store-client&username=customer1&password=password" \
29+
| jq -r .access_token)
30+
```
31+
32+
Use in requests:
33+
```bash
34+
curl -H "Authorization: Bearer $TOKEN" ...
35+
```
36+
37+
Or paste the token in Swagger UI → **Authorize** → Bearer field.
38+
39+
---
40+
41+
## api-read — Product Catalog (`localhost:8081`)
42+
43+
### Endpoints
44+
45+
#### `GET /api/v1/products/{id}`
46+
Returns a single product by ID.
47+
**Auth:** `ROLE_CUSTOMER` or `ROLE_MANAGER`
48+
49+
**Flow:**
50+
1. Spring Security validates JWT → extracts `realm_access.roles` from Keycloak token
51+
2. `ProductController.getProduct(id)` — annotated with `@CircuitBreaker(name="productService")`
52+
3. `ProductService.getProduct(id)``@Cacheable(value="products", key="#id")`
53+
- **Cache hit** → returns from Redis (TTL: 60s by default)
54+
- **Cache miss**`ProductRepository.findById(id)` → PostgreSQL → stored in Redis
55+
4. If DB is unreachable and circuit breaker is OPEN → `fallback()` → HTTP 503 `{status: "DEGRADED"}`
56+
57+
```bash
58+
curl -s -H "Authorization: Bearer $TOKEN" \
59+
http://localhost:8081/api/v1/products/1 | jq
60+
```
61+
62+
Expected response:
63+
```json
64+
{
65+
"status": "OK",
66+
"data": {
67+
"id": 1,
68+
"name": "Product A",
69+
"price": 29.99,
70+
"stockLevel": 500
71+
}
72+
}
73+
```
74+
75+
---
76+
77+
#### `GET /api/v1/products?page=0&size=20`
78+
Returns a paginated list of products.
79+
**Auth:** `ROLE_CUSTOMER` or `ROLE_MANAGER`
80+
81+
**Flow:**
82+
1. JWT validation (same as above)
83+
2. `ProductService.listProducts(page, size)``@Cacheable(value="product-list", key="#page + '-' + #size")`
84+
- Cache hit → Redis
85+
- Cache miss → `ProductRepository.findAllPaged(page, size)` → stored in Redis
86+
87+
```bash
88+
curl -s -H "Authorization: Bearer $TOKEN" \
89+
"http://localhost:8081/api/v1/products?page=0&size=5" | jq
90+
```
91+
92+
| Param | Default | Description |
93+
|-------|---------|-------------|
94+
| `page` | `0` | Zero-based page index |
95+
| `size` | `20` | Items per page |
96+
97+
---
98+
99+
### Circuit Breaker (api-read)
100+
| Config | Value |
101+
|--------|-------|
102+
| Sliding window | 10 requests |
103+
| Failure threshold | 50% |
104+
| Wait in OPEN state | 10s |
105+
106+
---
107+
108+
## api-write — Order Processing (`localhost:8082`)
109+
110+
### Endpoints
111+
112+
#### `POST /api/v1/orders`
113+
Creates a new order. Returns **202 Accepted** immediately (async).
114+
**Auth:** `ROLE_CUSTOMER` or `ROLE_MANAGER`
115+
116+
**Flow:**
117+
1. JWT validation
118+
2. `OrderController.createOrder(request)` — receives `{productId, quantity}`
119+
3. `OrderService.publishOrderEvent(payload)``@CircuitBreaker(name="kafkaProducer")`
120+
- **Kafka healthy** → publishes to topic `order-events` → returns `orderId` (UUID)
121+
- **Kafka circuit open**`kafkaFallback()``OutboxPublisher.publish()` writes to `outbox_events` table in PostgreSQL (at-least-once guarantee across restarts)
122+
4. `OrderConsumer.consumeBatch()``@KafkaListener(topics="order-events", batch=true)`, concurrency=3
123+
- Reads up to 100 messages per poll
124+
- `OrderRepository.batchInsert()` — single `JdbcTemplate.batchUpdate` into `orders` table
125+
- Manual-acks after successful DB write
126+
127+
```bash
128+
curl -s -X POST \
129+
-H "Authorization: Bearer $TOKEN" \
130+
-H "Content-Type: application/json" \
131+
-d '{"productId": 1, "quantity": 2}' \
132+
http://localhost:8082/api/v1/orders | jq
133+
```
134+
135+
Expected response (202):
136+
```json
137+
{
138+
"status": "ACCEPTED",
139+
"orderId": "550e8400-e29b-41d4-a716-446655440000"
140+
}
141+
```
142+
143+
---
144+
145+
#### `PUT /api/v1/orders/{id}`
146+
Updates an existing order.
147+
**Auth:** `ROLE_MANAGER` only
148+
149+
**Flow:**
150+
1. JWT validation — must have `ROLE_MANAGER`
151+
2. `OrderService.publishOrderUpdateEvent(id, payload)` — publishes to `order-events` with key `"update-{id}"`
152+
3. Returns `eventId` (UUID) with HTTP 202
153+
154+
```bash
155+
curl -s -X PUT \
156+
-H "Authorization: Bearer $TOKEN" \
157+
-H "Content-Type: application/json" \
158+
-d '{"productId": 1, "quantity": 5}' \
159+
http://localhost:8082/api/v1/orders/123 | jq
160+
```
161+
162+
---
163+
164+
### Outbox Pattern (api-write)
165+
When Kafka is unavailable, events land in `outbox_events`:
166+
```sql
167+
SELECT * FROM outbox_events WHERE processed = false ORDER BY created_at;
168+
```
169+
A background relay process picks these up and re-publishes to Kafka once it recovers.
170+
171+
### Circuit Breaker (api-write)
172+
| Config | Value |
173+
|--------|-------|
174+
| Sliding window | 10 requests |
175+
| Failure threshold | 50% |
176+
| Wait in OPEN state | 10s |
177+
178+
---
179+
180+
## api-rw — Inventory Management (`localhost:8083`)
181+
182+
### Endpoints
183+
184+
#### `POST /api/v1/inventory/{productId}/reserve-hot`
185+
**HOT PATH** — Redis atomic decrement. ~100k ops/sec capacity.
186+
**Auth:** `ROLE_CUSTOMER` or `ROLE_MANAGER`
187+
188+
**Flow:**
189+
1. JWT validation
190+
2. `InventoryService.reserveStockHot(productId, request)`
191+
3. `StringRedisTemplate.decrement("inventory:stock:{productId}", quantity)`
192+
- **remaining >= 0** → success → publishes audit event to `inventory-audit` Kafka topic (fire-and-forget)
193+
- **remaining < 0**`INCR` by same quantity to restore → throws `IllegalStateException` → HTTP 409
194+
4. Returns `{productId, reserved, remaining}`
195+
196+
```bash
197+
curl -s -X POST \
198+
-H "Authorization: Bearer $TOKEN" \
199+
-H "Content-Type: application/json" \
200+
-d '{"quantity": 1}' \
201+
http://localhost:8083/api/v1/inventory/1/reserve-hot | jq
202+
```
203+
204+
Expected response:
205+
```json
206+
{
207+
"productId": 1,
208+
"reserved": 1,
209+
"remaining": 499
210+
}
211+
```
212+
213+
Out-of-stock (409):
214+
```json
215+
{
216+
"error": "Insufficient stock"
217+
}
218+
```
219+
220+
---
221+
222+
#### `PUT /api/v1/inventory/{id}/reserve`
223+
**STANDARD PATH** — PostgreSQL pessimistic lock + Redisson distributed lock. ~200 TPS per product.
224+
**Auth:** `ROLE_CUSTOMER` or `ROLE_MANAGER`
225+
226+
**Flow:**
227+
1. JWT validation
228+
2. `InventoryService.reserveStock(id, request)``@Transactional` + `@CacheEvict(value="products", key="#id")`
229+
3. Acquires Redisson `RLock("inventory-lock:{id}")` — 3s wait, 10s lease
230+
4. `inventoryRepository.findByProductIdForUpdate(id)``SELECT ... FOR UPDATE`
231+
5. `inventoryRepository.decrementStock(productId, qty)` — JPQL `UPDATE SET stockLevel -= qty WHERE stockLevel >= qty`
232+
- Returns 0 → throws `IllegalStateException` → HTTP 409
233+
6. Deletes Redis hot-path stock key to re-seed from DB on next request
234+
7. Publishes audit event to `inventory-audit`
235+
8. Releases lock in `finally`
236+
237+
```bash
238+
curl -s -X PUT \
239+
-H "Authorization: Bearer $TOKEN" \
240+
-H "Content-Type: application/json" \
241+
-d '{"quantity": 1}' \
242+
http://localhost:8083/api/v1/inventory/1/reserve | jq
243+
```
244+
245+
---
246+
247+
### Hot Path vs Standard Path
248+
249+
| | `reserve-hot` | `reserve` |
250+
|--|---|---|
251+
| Storage | Redis only | PostgreSQL + Redis evict |
252+
| Throughput | ~100k ops/s | ~200 TPS/product |
253+
| Consistency | Eventually consistent | Strongly consistent |
254+
| Lock type | Redis atomic `DECR` | Redisson `RLock` + `SELECT FOR UPDATE` |
255+
| Use case | Flash sale, high concurrency | Normal checkout |
256+
257+
---
258+
259+
## Request/Response Model
260+
261+
All APIs return responses wrapped in `ApiResponse<T>` from the `common` module:
262+
263+
```json
264+
{
265+
"status": "OK",
266+
"data": { ... }
267+
}
268+
```
269+
270+
Error responses:
271+
```json
272+
{
273+
"status": "DEGRADED",
274+
"message": "Service unavailable"
275+
}
276+
```
277+
278+
---
279+
280+
## How to Test with Swagger UI
281+
282+
1. Open the Swagger UI for the service you want to test:
283+
- api-read: http://localhost:8081/swagger-ui.html
284+
- api-write: http://localhost:8082/swagger-ui.html
285+
- api-rw: http://localhost:8083/swagger-ui.html
286+
287+
2. Get a token (see [Authentication](#authentication) section above).
288+
289+
3. Click **Authorize** (top-right lock icon in Swagger UI).
290+
291+
4. Paste the token in the **bearerAuth** field (no `Bearer ` prefix — Swagger adds it).
292+
293+
5. Click any endpoint → **Try it out** → fill params → **Execute**.
294+
295+
---
296+
297+
## Actuator / Health Endpoints
298+
299+
Each service exposes these (no auth required):
300+
301+
| Endpoint | Description |
302+
|----------|-------------|
303+
| `/actuator/health` | Liveness + readiness probes |
304+
| `/actuator/health/liveness` | Liveness probe |
305+
| `/actuator/health/readiness` | Readiness probe |
306+
| `/actuator/metrics` | JVM + app metrics |
307+
| `/actuator/prometheus` | Prometheus scrape endpoint |
308+
309+
```bash
310+
curl http://localhost:8081/actuator/health | jq
311+
curl http://localhost:8082/actuator/health | jq
312+
curl http://localhost:8083/actuator/health | jq
313+
```
314+
315+
---
316+
317+
## End-to-End Flow Example
318+
319+
```
320+
Client → [POST /api/v1/orders] → api-write (8082)
321+
└─ Kafka: order-events
322+
└─ api-write consumer → orders table
323+
324+
Client → [POST /api/v1/inventory/1/reserve-hot] → api-rw (8083)
325+
└─ Redis DECR inventory:stock:1
326+
└─ Kafka: inventory-audit (async)
327+
328+
Client → [GET /api/v1/products/1] → api-read (8081)
329+
├─ Redis cache hit → return
330+
└─ cache miss → PostgreSQL → store in Redis
331+
```
332+
333+
---
334+
335+
## Debug Ports (local)
336+
337+
| Service | Debug Port |
338+
|---------|-----------|
339+
| api-read | `5005` |
340+
| api-write | `5006` |
341+
| api-rw | `5007` |
342+
343+
Attach a remote JVM debugger in your IDE to `localhost:<port>`.

0 commit comments

Comments
 (0)