Files
trackgo-fe/packages/core-api/README.md
T

17 KiB

← Back to Root

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

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:

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.

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:

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>.

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:

const taxResult = await bookingServices.customRequest<TaxCalculation>({
  url: '/bookings/42/calculate-tax',
  method: 'POST',
  data: { items: [...] },
});

Application Setup Guide

1. Initialize Telemetry (Entry Point)

// 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

// 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

// 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

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:

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:

// 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:

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