refactor: migrate docs-dev from storybook to vitepress config and update devcontainer configuration
This commit is contained in:
@@ -0,0 +1,476 @@
|
||||
|
||||
# Enterprise API Engine (`@repo/core-api`)
|
||||
|
||||
The platform-agnostic API engine for the monorepo. Provides an isolated HTTP client factory, a unified observability pipeline (Grafana Faro + OpenTelemetry), and a generic data services engine.
|
||||
|
||||
**This package enforces App Autonomy (IoC).** The core provides the engine and interceptor pipelines, but the consuming applications (`apps/web`, `apps/landing`) inject their own specific configurations, authentication tokens, and error handling behaviors.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
```mermaid
|
||||
graph TD
|
||||
%% ─── Styling Definitions (Dark-Mode Friendly Enterprise Palette) ───
|
||||
classDef appEntity fill:#dbeafe,stroke:#3b82f6,stroke-width:2px,color:#1e3a8a
|
||||
classDef coreEngine fill:#6366f1,stroke:#4338ca,stroke-width:2px,color:#ffffff
|
||||
classDef dataService fill:#10b981,stroke:#047857,stroke-width:2px,color:#ffffff
|
||||
classDef observability fill:#f59e0b,stroke:#b45309,stroke-width:2px,color:#ffffff
|
||||
classDef errorNode fill:#f43f5e,stroke:#be123c,stroke-width:2px,color:#ffffff
|
||||
|
||||
%% ─── Subgraphs ───
|
||||
subgraph Apps ["apps/* (App Autonomy)"]
|
||||
WEB([apps/web])
|
||||
LAND([apps/landing])
|
||||
DESK([apps/desktop])
|
||||
end
|
||||
|
||||
subgraph Core ["@repo/core-api (Engine)"]
|
||||
subgraph HTTP ["http-client"]
|
||||
FACTORY[createHttpClient]
|
||||
end
|
||||
subgraph OBS ["observability"]
|
||||
FARO[faroAdapter]
|
||||
end
|
||||
subgraph DATA ["data-services"]
|
||||
BASE[BaseRemoteDataServices]
|
||||
COMMON[CommonRemoteDataServices]
|
||||
end
|
||||
subgraph ERRORS ["errors"]
|
||||
API_ERR[ApiError]
|
||||
end
|
||||
end
|
||||
|
||||
%% ─── Flow & Relationships ───
|
||||
WEB & LAND & DESK ===>|instantiates| FACTORY
|
||||
WEB & LAND & DESK ===>|extends| COMMON
|
||||
|
||||
COMMON --->|executes via| FACTORY
|
||||
FACTORY -.->|reports via| FARO
|
||||
FACTORY -.->|throws| API_ERR
|
||||
|
||||
%% ─── Apply Styles ───
|
||||
class WEB,LAND,DESK appEntity;
|
||||
class FACTORY coreEngine;
|
||||
class BASE,COMMON dataService;
|
||||
class FARO observability;
|
||||
class API_ERR errorNode;
|
||||
|
||||
%% ─── Subgraph Backgrounds (Transparent for Native GitHub Support) ───
|
||||
style Apps fill:transparent,stroke:#3b82f6,stroke-width:2px,stroke-dasharray: 5 5
|
||||
style Core fill:transparent,stroke:#94a3b8,stroke-width:2px,stroke-dasharray: 5 5
|
||||
|
||||
%% Nested subgraphs also need transparent backgrounds to prevent glaring white boxes in dark mode
|
||||
style HTTP fill:transparent,stroke:#cbd5e1,stroke-width:1px,stroke-dasharray: 5 5
|
||||
style OBS fill:transparent,stroke:#cbd5e1,stroke-width:1px,stroke-dasharray: 5 5
|
||||
style DATA fill:transparent,stroke:#cbd5e1,stroke-width:1px,stroke-dasharray: 5 5
|
||||
style ERRORS fill:transparent,stroke:#cbd5e1,stroke-width:1px,stroke-dasharray: 5 5
|
||||
```
|
||||
|
||||
### Data Flow Lifecycle
|
||||
|
||||
Every HTTP request flows through this precise interceptor pipeline:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
|
||||
%% ─── Dark-Mode Friendly RGBA Boxes ───
|
||||
box rgba(59,130,246,0.1) App Layer - Consumers
|
||||
participant C as UI Component
|
||||
end
|
||||
|
||||
box rgba(148,163,184,0.1) Core Engine - @repo/core-api
|
||||
participant S as Data Service
|
||||
participant H as HTTP Client
|
||||
participant F as Faro Adapter
|
||||
end
|
||||
|
||||
box rgba(16,185,129,0.1) App Logic - IoC
|
||||
participant A as App Hooks
|
||||
end
|
||||
|
||||
box rgba(245,158,11,0.1) External
|
||||
participant N as Network
|
||||
end
|
||||
|
||||
%% ─── Execution Flow ───
|
||||
C->>S: getMany()
|
||||
S->>H: request()
|
||||
H->>F: onRequestStart() (Log + Span)
|
||||
H->>A: hooks.onRequest() (Inject Token)
|
||||
A->>N: fetch/XHR
|
||||
|
||||
alt Success (2xx)
|
||||
N-->>A: return Response
|
||||
A->>F: onRequestEnd() (Close Span)
|
||||
F->>A: hooks.onResponse()
|
||||
A-->>S: return data
|
||||
else Error (4xx / 5xx)
|
||||
N-->>A: return Rejection
|
||||
A->>F: onRequestError() (Log Error)
|
||||
F->>A: hooks.onResponseError() (Redirect/Refresh)
|
||||
A-->>S: throw ApiError
|
||||
end
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Observability adapter errors are **caught internally** via try-catch in the interceptor chain. An adapter crash will never swallow or replace the original API error — the UI always receives the correct rejection.
|
||||
|
||||
---
|
||||
|
||||
## HTTP Client
|
||||
|
||||
### `createHttpClient(config, hooks?)`
|
||||
|
||||
Creates an **isolated** Axios instance. Each app receives its own interceptor chain — no globals are shared or mutated.
|
||||
|
||||
```typescript
|
||||
import { createHttpClient } from '@repo/core-api/http-client';
|
||||
import { faroAdapter } from '@repo/core-api/observability';
|
||||
|
||||
export const apiClient = createHttpClient(
|
||||
{
|
||||
baseURL: import.meta.env.VITE_API_URL ?? 'http://localhost:8080/api/v1',
|
||||
timeout: 15000,
|
||||
observability: faroAdapter,
|
||||
},
|
||||
{
|
||||
onRequest: async (config) => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
},
|
||||
onResponseError: async (error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('access_token');
|
||||
window.location.href = '/auth/login';
|
||||
}
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `baseURL` | `string` | *required* | Base URL for all requests |
|
||||
| `timeout` | `number` | `15000` | Default request timeout (ms) |
|
||||
| `defaultHeaders` | `Record<string, string>` | `{}` | Headers applied to every request |
|
||||
| `observability` | `IObservabilityAdapter` | `noopAdapter` | Observability adapter (Faro or no-op) |
|
||||
|
||||
### Interceptor Hooks
|
||||
|
||||
| Hook | Signature | Purpose |
|
||||
|---|---|---|
|
||||
| `onRequest` | `(config) => config` | Inject auth tokens, tenant headers |
|
||||
| `onResponse` | `(response) => response` | Transform response shapes |
|
||||
| `onResponseError` | `(error) => never` | App-specific error handling (e.g., 401 redirect) |
|
||||
|
||||
---
|
||||
|
||||
## Observability
|
||||
|
||||
### Strategy: Opt-In Custom Spans + Faro/Loki Baseline
|
||||
|
||||
The observability layer operates in two complementary modes:
|
||||
|
||||
| Mode | Activation | What it does |
|
||||
|---|---|---|
|
||||
| **Baseline** (always on) | Automatic | Pushes structured logs to Faro/Loki on every request with `module.key`, `module.action`, HTTP method, and URL |
|
||||
| **Custom Span** (opt-in) | Via `telemetryContext.customSpanName` | Creates an explicit OTel span with custom tags, visible in Grafana Tempo |
|
||||
|
||||
> [!NOTE]
|
||||
> `trace.getActiveSpan()` returns `undefined` inside Axios interceptors due to browser XHR/Fetch lifecycle race conditions with Faro's `TracingInstrumentation`. The adapter does **not** attempt to enrich auto-instrumented spans. HTTP span capture is handled entirely by `TracingInstrumentation` auto-instrumentation.
|
||||
|
||||
### Initialization
|
||||
|
||||
Call `initTelemetry()` **once** at the top of your app's entry point, before any React code:
|
||||
|
||||
```typescript
|
||||
import { initTelemetry } from '@repo/core-api/observability/setup';
|
||||
|
||||
initTelemetry({
|
||||
appName: 'fe-monorepo-web',
|
||||
appVersion: '1.0.0',
|
||||
telemetryUrl: '[https://telemetry.eigen.co.id/collect](https://telemetry.eigen.co.id/collect)',
|
||||
environment: 'production',
|
||||
// Optional: direct OTLP export to Grafana Tempo
|
||||
otlpTraceUrl: '[https://telemetry.eigen.co.id/v1/traces](https://telemetry.eigen.co.id/v1/traces)',
|
||||
});
|
||||
```
|
||||
|
||||
### `TelemetryConfig`
|
||||
|
||||
| Property | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `appName` | `string` | ✅ | Application name for Faro + OTel resource attributes |
|
||||
| `appVersion` | `string` | ✅ | SemVer version |
|
||||
| `telemetryUrl` | `string` | ✅ | Grafana Faro collector URL |
|
||||
| `environment` | `string` | ✅ | Deployment environment (`production`, `staging`, `development`) |
|
||||
| `otlpTraceUrl` | `string` | — | Separate OTLP trace endpoint for direct Tempo ingestion |
|
||||
| `propagateTraceHeaderCorsUrls` | `Array<string \| RegExp>` | — | CORS patterns for W3C trace context propagation (default: `[/.*/]`) |
|
||||
|
||||
### Audit Headers
|
||||
|
||||
Every request dispatched through `BaseRemoteDataServices` automatically attaches two business audit headers:
|
||||
|
||||
| Header | Source | Purpose |
|
||||
|---|---|---|
|
||||
| `ex-module-key` | `DataServicesConfig.moduleKey` | Identifies the business module (e.g., `BOOKING`) |
|
||||
| `ex-module-action` | `RequestDescriptor.action` | Identifies the operation (e.g., `READ`, `CREATE`) |
|
||||
|
||||
These headers are extracted by the `faroAdapter` and included in all Faro `pushLog`, `pushError`, and `pushEvent` calls as top-level context — making them directly queryable in **LogQL (Loki)**.
|
||||
|
||||
### Span Safety Guarantees
|
||||
|
||||
| Guarantee | Mechanism |
|
||||
|---|---|
|
||||
| **No span leaks** | `safeEndSpan()` always closes the span and detaches the reference from config |
|
||||
| **No double-close on retry** | Span reference is deleted from config after `span.end()` |
|
||||
| **No error swallowing** | All adapter calls are wrapped in try-catch in `create-http-client.ts` |
|
||||
| **No crash on timeout** | `null`/`undefined` config guards on all `error.config` access |
|
||||
|
||||
---
|
||||
|
||||
## Data Services
|
||||
|
||||
### `CommonRemoteDataServices<E>`
|
||||
|
||||
A concrete, ready-to-use data services class that provides full CRUD and lifecycle operations. Extends `BaseRemoteDataServices<E>`.
|
||||
|
||||
```typescript
|
||||
import { CommonRemoteDataServices } from '@repo/core-api/data-services';
|
||||
import type { BaseEntity } from '@repo/core-api/data-services';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
|
||||
interface BookingEntity extends BaseEntity {
|
||||
bookingCode: string;
|
||||
customerName: string;
|
||||
status: 'pending' | 'confirmed' | 'cancelled';
|
||||
}
|
||||
|
||||
export const bookingServices = new CommonRemoteDataServices<BookingEntity>(
|
||||
apiClient,
|
||||
{
|
||||
apiUrl: '/bookings',
|
||||
moduleKey: 'BOOKING',
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Available Operations
|
||||
|
||||
| Method | HTTP | URL Template | Description |
|
||||
|---|---|---|---|
|
||||
| `getMany(config?)` | GET | `/bookings` | Fetch paginated list |
|
||||
| `getOne(id, config?)` | GET | `/bookings/:id` | Fetch single entity |
|
||||
| `create(data, config?)` | POST | `/bookings` | Create new entity |
|
||||
| `edit(id, data, config?)` | PUT | `/bookings/:id` | Update entity |
|
||||
| `delete(id, config?)` | DELETE | `/bookings/:id` | Delete entity |
|
||||
| `batchDelete(ids, config?)` | DELETE | `/bookings/batch` | Delete multiple |
|
||||
| `activate(id)` | PATCH | `/bookings/:id/activate` | Activate entity |
|
||||
| `deactivate(id)` | PATCH | `/bookings/:id/deactivate` | Deactivate entity |
|
||||
| `confirmProcessData(id)` | PATCH | `/bookings/:id/confirm-process-data` | Confirm data processing |
|
||||
| `confirmProcessTransaction(id)` | PATCH | `/bookings/:id/confirm-process-transaction` | Confirm transaction |
|
||||
| `cancelProcessTransaction(id)` | PATCH | `/bookings/:id/cancel-process-transaction` | Cancel transaction |
|
||||
| `rollbackProcessTransaction(id)` | PATCH | `/bookings/:id/rollback-process-transaction` | Rollback transaction |
|
||||
| `holdProcessTransaction(id)` | PATCH | `/bookings/:id/hold-process-transaction` | Hold transaction |
|
||||
|
||||
All batch variants (`batchActivate`, `batchDeactivate`, etc.) are also available.
|
||||
|
||||
### Escape Hatch: `customRequest<T>(config)`
|
||||
|
||||
For non-standard endpoints that don't fit the CRUD pattern:
|
||||
|
||||
```typescript
|
||||
const taxResult = await bookingServices.customRequest<TaxCalculation>({
|
||||
url: '/bookings/42/calculate-tax',
|
||||
method: 'POST',
|
||||
data: { items: [...] },
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Application Setup Guide
|
||||
|
||||
### 1. Initialize Telemetry (Entry Point)
|
||||
|
||||
```typescript
|
||||
// apps/web/src/main.tsx — MUST be the first import
|
||||
import { initTelemetry } from '@repo/core-api/observability/setup';
|
||||
|
||||
initTelemetry({
|
||||
appName: import.meta.env.VITE_APP_NAME || 'fe-monorepo-web',
|
||||
appVersion: import.meta.env.VITE_APP_VERSION || '0.0.0',
|
||||
telemetryUrl: import.meta.env.VITE_FARO_URL || '[https://telemetry.eigen.co.id/collect](https://telemetry.eigen.co.id/collect)',
|
||||
otlpTraceUrl: import.meta.env.VITE_OTLP_TRACE_URL || '[https://telemetry.eigen.co.id/v1/traces](https://telemetry.eigen.co.id/v1/traces)',
|
||||
environment: import.meta.env.VITE_ENV || 'development',
|
||||
});
|
||||
|
||||
// ... rest of React bootstrap
|
||||
```
|
||||
|
||||
### 2. Create the HTTP Client
|
||||
|
||||
```typescript
|
||||
// apps/web/src/lib/api-client.ts
|
||||
import { createHttpClient } from '@repo/core-api/http-client';
|
||||
import { faroAdapter } from '@repo/core-api/observability';
|
||||
|
||||
export const apiClient = createHttpClient({
|
||||
baseURL: import.meta.env.VITE_API_URL ?? 'http://localhost:8080/api/v1',
|
||||
timeout: 15000,
|
||||
observability: faroAdapter,
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Create a Data Service
|
||||
|
||||
```typescript
|
||||
// features/booking/data/booking.data-services.ts
|
||||
import { CommonRemoteDataServices } from '@repo/core-api/data-services';
|
||||
import type { BaseEntity } from '@repo/core-api/data-services';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
|
||||
export interface BookingEntity extends BaseEntity {
|
||||
bookingCode: string;
|
||||
customerName: string;
|
||||
status: 'pending' | 'confirmed' | 'cancelled';
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
export const bookingServices = new CommonRemoteDataServices<BookingEntity>(
|
||||
apiClient,
|
||||
{ apiUrl: '/bookings', moduleKey: 'BOOKING' },
|
||||
);
|
||||
```
|
||||
|
||||
### 4. Consume in a React Component
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react';
|
||||
import { bookingServices } from '../data/booking.data-services';
|
||||
import type { BookingEntity } from '../data/booking.data-services';
|
||||
import type { ApiResponse } from '@repo/core-api/http-client';
|
||||
import { ApiError } from '@repo/core-api/errors';
|
||||
|
||||
export default function BookingSample() {
|
||||
const [result, setResult] = useState<ApiResponse<BookingEntity[]> | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleFetch = async () => {
|
||||
try {
|
||||
const response = await bookingServices.getMany<BookingEntity[]>({
|
||||
params: { page: 1, limit: 20 },
|
||||
// Optional: Per-request telemetry escape hatch
|
||||
telemetryContext: {
|
||||
customSpanName: 'booking.list.fetch',
|
||||
tags: { feature: 'booking', page: 1 },
|
||||
pushEventOnSuccess: 'booking_list_loaded',
|
||||
},
|
||||
});
|
||||
setResult(response);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(`[${err.code}] ${err.message} (HTTP ${err.status})`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return <button onClick={handleFetch}>Fetch Bookings</button>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Per-Request Telemetry (Escape Hatch)
|
||||
|
||||
### `TelemetryContext`
|
||||
|
||||
Attach to any request via the `telemetryContext` property to push custom spans and business events:
|
||||
|
||||
```typescript
|
||||
interface TelemetryContext {
|
||||
/** Creates a custom OTel span wrapping this request (visible in Grafana Tempo). */
|
||||
customSpanName?: string;
|
||||
/** Custom tags enriching the span and Faro logs (prefixed with `custom.` on spans). */
|
||||
tags?: Record<string, string | number | boolean>;
|
||||
/** Pushes a named Faro event on success (visible in Grafana Faro dashboard). */
|
||||
pushEventOnSuccess?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Precedence
|
||||
|
||||
`telemetryContext` can be provided at two levels. The top-level `ExecuteOptions.telemetryContext` takes precedence over `config.telemetryContext`:
|
||||
|
||||
```typescript
|
||||
// Top-level (preferred)
|
||||
await bookingServices.getMany({
|
||||
telemetryContext: { customSpanName: 'booking.list.fetch' },
|
||||
});
|
||||
|
||||
// Nested in config (also works)
|
||||
await bookingServices.getMany({
|
||||
params: { page: 1 },
|
||||
telemetryContext: { customSpanName: 'booking.list.fetch' },
|
||||
});
|
||||
```
|
||||
|
||||
### What Happens at Each Stage
|
||||
|
||||
| Stage | Baseline (no telemetryContext) | With `customSpanName` |
|
||||
|---|---|---|
|
||||
| **Request Start** | Faro `pushLog` (DEBUG) with `module.key`, `module.action`, URL | + Creates OTel span with `http.method`, `http.url`, `custom.*` tags |
|
||||
| **Request Success** | — | Closes span (OK). If `pushEventOnSuccess`, pushes Faro event |
|
||||
| **Request Error** | Faro `pushError` + `pushLog` (ERROR) | + Closes span (ERROR), records exception |
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### `ApiError`
|
||||
|
||||
All non-2xx responses are normalized into structured `ApiError` instances:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await bookingServices.getOne('42');
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
err.code; // ApiErrorCode.NOT_FOUND
|
||||
err.status; // 404
|
||||
err.message; // "Booking not found"
|
||||
err.data; // Raw server response body
|
||||
err.toJSON(); // Serializable for logging
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Codes
|
||||
|
||||
| Code | HTTP Status | Description |
|
||||
|---|---|---|
|
||||
| `BAD_REQUEST` | 400 | Invalid request parameters |
|
||||
| `UNAUTHORIZED` | 401 | Missing or expired token |
|
||||
| `FORBIDDEN` | 403 | Insufficient permissions |
|
||||
| `NOT_FOUND` | 404 | Resource not found |
|
||||
| `TIMEOUT` | — | Request timed out (`ECONNABORTED`) |
|
||||
| `CANCELLED` | — | Request was cancelled (`ERR_CANCELED`) |
|
||||
| `NETWORK_ERROR` | — | No response received |
|
||||
| `SERVER_ERROR` | 500+ | Internal server error |
|
||||
|
||||
---
|
||||
|
||||
## Package Exports
|
||||
|
||||
| Import Path | Contents |
|
||||
|---|---|
|
||||
| `@repo/core-api/http-client` | `createHttpClient`, `ApiResponse`, `TelemetryContext`, Axios type re-exports |
|
||||
| `@repo/core-api/observability` | `faroAdapter`, `noopObservabilityAdapter`, `IObservabilityAdapter`, `initTelemetry`, `getFaro`, `TelemetryConfig` |
|
||||
| `@repo/core-api/observability/setup` | `initTelemetry`, `getFaro`, `TelemetryConfig` |
|
||||
| `@repo/core-api/data-services` | `BaseRemoteDataServices`, `CommonRemoteDataServices`, types, constants |
|
||||
| `@repo/core-api/errors` | `ApiError`, `ApiErrorCode` |
|
||||
Reference in New Issue
Block a user