API Integration

HTTP client (Axios)

FT Configs UI uses a single Axios instance with:

  • base URL resolution at runtime,

  • withCredentials: true for cookie-based auth,

  • locale propagation via Accept-Language header,

  • read-only enforcement for VIEWER role,

  • CSRF token fetching from /csrf for unsafe methods,

  • refresh-token retry on 401 via /auth/refresh-token.

Implementation entry points:

  • Axios instance + interceptors: src/lib/axios.ts

  • Base URL resolution: src/lib/api-url.ts

  • Runtime config loader (Zod-validated): src/lib/runtime-config.ts

  • Permission / read-only helpers: src/lib/permissions.ts

  • Common auth APIs: src/lib/api.ts

Server-side state (caching, dedup, refetch) is handled by TanStack Query on top of apiService. Axios owns the HTTP transport layer only. See State & Data Flow.

Axios instance configuration

// src/lib/axios.ts
const axiosInstance = axios.create({
  baseURL: resolveApiUrl(),
  headers: { 'Content-Type': 'application/json' },
  withCredentials: true,
});

export const apiService = {
  get:    <T>(url: string, params?: unknown, config?: AxiosRequestConfig) => ...,
  post:   <T>(url: string, data?: unknown, config?: AxiosRequestConfig) => ...,
  put:    <T>(url: string, data?: unknown, config?: AxiosRequestConfig) => ...,
  patch:  <T>(url: string, data?: unknown, config?: AxiosRequestConfig) => ...,
  delete: <T>(url: string, config?: AxiosRequestConfig) => ...,
};

Every helper merges withCredentials: true so cookie-based session and CSRF cookies are always sent.

Base URL resolution

The base URL is resolved at runtime — the same build artifact targets any backend environment without recompilation.

Resolution chain (src/lib/api-url.tsresolveApiUrl()):

  1. getRuntimeConfig().API_URL — read from window.RUNTIME_CONFIG, validated against a Zod schema in src/lib/runtime-config.ts. Populated by /runtime-config.js, which is loaded synchronously by index.html before the app bundle. In production it is generated at container startup by docker/ops/entrypoint.sh with a fixed API_URL of /configs-service.

  2. Fallback /configs-service — used only if runtime config is missing or fails Zod validation (e.g. in tests). A relative path works because in development the Vite dev server proxies /configs-service to http://localhost:8082 (see vite.config.ts), and in production nginx proxies the same prefix to BACKEND_URL.

Build-time constants (import.meta.env.VITE_APP_VERSION, import.meta.env.VITE_APP_BUILD) are injected by Vite’s define from version.json and are used for version display, not for API URL resolution.

There are no NEXT_PUBLIC_* environment variables in this project. Build-time configuration uses import.meta.env.VITE_*; runtime configuration uses window.RUNTIME_CONFIG.

Helper: buildActuatorHealthUrl(apiBase) normalizes <base>/actuator/health for readiness probes.

Request interceptor

The request interceptor runs in this order:

  1. Re-sync config.baseURL via syncApiUrl() — picks up any runtime config changes.

  2. Ensure withCredentials: true.

  3. Read locale from document.cookie and set Accept-Language.

  4. Read-only check: shouldBlockRequestForReadOnly(method, url) rejects the request immediately if the current user is VIEWER, the method is unsafe, and the URL is not in the allowlist (see Read-only request blocking).

  5. CSRF fetch for unsafe methods: on first unsafe call, GET /csrf with X-Requested-With: XMLHttpRequest; parallel calls share csrfPromise (dedup); the token is cached in csrfTokenCache and attached as X-XSRF-TOKEN + X-Requested-With headers on the outgoing request.

CSRF protection flow

All unsafe HTTP methods (POST, PUT, PATCH, DELETE) require a CSRF token.

csrf-flow

Key implementation details:

  • Token is fetched lazily before the first unsafe request

  • Token is extracted from response headers (X-XSRF-TOKEN), response body, or cookie (XSRF-TOKEN)

  • Token is cached in memory and reused until invalidated

  • Concurrent requests share the same token fetch promise to avoid race conditions

Implementation: fetchCsrfToken() in src/lib/axios.ts.

Token refresh on 401

When the backend returns 401 Unauthorized, the client attempts to refresh the session.

token-refresh-flow

Key implementation details:

  • Only the first 401 triggers refresh; subsequent 401s redirect to login

  • Refresh is skipped for auth endpoints (e.g., /auth/login) to avoid loops

  • Original request is automatically retried after successful refresh

  • Failed refresh redirects to /login immediately

Implementation: response interceptor in src/lib/axios.ts.

Domain clients

Feature modules define typed API wrappers under src/api/<module>/ (for example src/api/acs/fcc-config.ts, src/api/angular/tab-view.ts, src/api/settings/users.ts). They consume DTOs from src/lib/definitions.ts.

Patterns to follow:

  • Prefer apiService.get/post/put/delete over creating new Axios instances.

  • For imports:

    • validate and normalize payloads in the UI layer (often via Zod),

    • call /import/dry-run first, then /import on confirmation.

  • For exports:

    • request blobs when the backend returns files (responseType: "blob"),

    • download via downloadFile (src/lib/api.ts).

Example: Basic CRUD operations

// src/api/acs/fcc-config.ts
import {apiService} from '@/lib/axios';
import type {FccConfigEntryDto, FccConfigSnapshotDto} from '@/lib/definitions';

export const acsFccConfigApi = {
  // GET snapshot
  getSnapshot: async (): Promise<FccConfigSnapshotDto> => {
    const response = await apiService.get<FccConfigSnapshotDto>('/acs/fcc-config');
    return response.data;
  },

  // PUT bulk update
  updateEntries: async (entries: FccConfigEntryDto[]): Promise<FccConfigSnapshotDto> => {
    const response = await apiService.put<FccConfigSnapshotDto>('/acs/fcc-config', entries);
    return response.data;
  },

  // DELETE single entry
  deleteEntry: async (key: string): Promise<void> => {
    await apiService.delete(`/acs/fcc-config/${encodeURIComponent(key)}`);
  },
};

Example: Export with blob response

// Export configuration as file
export const exportConfiguration = async (): Promise<{blob: Blob; filename: string}> => {
  const response = await apiService.get<Blob>('/acs/fcc-config/export', undefined, {
    responseType: 'blob',
  });

  const contentDisposition = response.headers['content-disposition'];
  const filenameMatch = contentDisposition?.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
  const filename = filenameMatch?.[1]?.replace(/['"]/g, '') || 'fcc.properties';

  return {
    blob: response.data,
    filename,
  };
};

Example: Import with dry-run

// Import workflow: dry-run → confirm → apply
export const importWithDryRun = async (
  file: File,
  allowWarnings: boolean = false
): Promise<ImportResultDto> => {
  const formData = new FormData();
  formData.append('file', file);

  // Step 1: Dry-run (validation only)
  const dryRunResponse = await apiService.post<ImportResultDto>(
    '/acs/fcc-config/import/dry-run',
    formData,
    {
      headers: {'Content-Type': 'multipart/form-data'},
    }
  );

  if (dryRunResponse.data.ok && dryRunResponse.data.errors.length === 0) {
    // Step 2: Apply import
    const importResponse = await apiService.post<ImportResultDto>(
      `/acs/fcc-config/import?allowWarnings=${allowWarnings}`,
      formData,
      {
        headers: {'Content-Type': 'multipart/form-data'},
      }
    );
    return importResponse.data;
  }

  return dryRunResponse.data;
};

Error handling strategies

The UI uses a unified error handling approach via useErrorHandler() hook.

Error classification

Errors are classified into types for appropriate handling:

  • server — HTTP errors from backend (400, 401, 404, 500, etc.)

  • network — Network failures, timeouts, CORS issues

  • validation — Client-side validation errors (Zod, form validation)

  • frontend — Application logic errors

  • unknown — Unclassified errors

Implementation: classifyError() in src/lib/error-utils.ts.

Error extraction and normalization

// Unified error extraction
import {extractUnifiedError} from '@/lib/error-utils';
import {useI18n} from '@/hooks/use-i18n';

const {t} = useI18n();

try {
  await apiService.post('/acs/fcc-config', data);
} catch (error) {
  const unified = extractUnifiedError(error, t);

  console.error('Error details:', {
    type: unified.type,        // 'server' | 'network' | 'validation' | ...
    message: unified.message,   // Localized message
    description: unified.description, // Additional context
    code: unified.code,        // HTTP status or error code
  });
}

Standard error handling pattern

import {useErrorHandler} from '@/hooks/use-error-handler';
import {useToast} from '@/hooks/use-toast';

const {handleError} = useErrorHandler();
const {toast} = useToast();

try {
  const result = await apiService.post('/acs/fcc-config', data);

  toast({
    title: t('success'),
    description: t('acsFccConfig.notifications.saveSuccess'),
    variant: 'success',
  });

  return result.data;
} catch (error) {
  handleError(error, {
    title: t('acsFccConfig.notifications.saveError'),
  });
  throw error; // Re-throw if caller needs to handle
}

Frontend error creation

For application logic errors, use predefined error factories:

import {FrontendErrors} from '@/lib/error-utils';

// Validation error
if (!isValid(value)) {
  throw FrontendErrors.validation(
    'Value must be a positive integer',
    'threshold',
    value
  );
}

// Permission error
if (isReadOnly) {
  throw FrontendErrors.permission(
    'Read-only access: editing is disabled',
    'write',
    'configuration'
  );
}

// Missing data error
if (!requiredField) {
  throw FrontendErrors.missingData(
    'Client type must be selected',
    'clientType'
  );
}

Implementation: FrontendErrors factories in src/lib/error-utils.ts.

Retry policies

Automatic retry (401 only)

The client automatically retries failed requests only for 401 Unauthorized responses:

  • First 401 → attempt token refresh → retry original request

  • Second 401 (after refresh failed) → redirect to login

  • Other status codes (400, 403, 404, 500) → no retry, fail immediately

Implementation: axiosInstance.interceptors.response in src/lib/axios.ts.

Manual retry (user-initiated)

For network errors or temporary failures, users must manually retry:

  • UI shows error notification with "Retry" button

  • Retry button re-executes the failed operation

  • No automatic exponential backoff (simplicity over complexity)

Example:

const [error, setError] = useState<string | null>(null);

const loadData = async () => {
  setError(null);
  setLoading(true);
  try {
    const data = await apiService.get('/acs/dashboard');
    setSnapshot(data.data);
  } catch (err) {
    handleError(err, {title: t('dashboard.fetchError')});
    setError(t('dashboard.fetchError'));
  } finally {
    setLoading(false);
  }
};

// Retry on user action
const handleRetry = () => {
  void loadData();
};

No retry scenarios

The following errors never trigger automatic retry:

  • Client errors (400, 422) — indicates invalid request data

  • Forbidden (403) — insufficient permissions

  • Not found (404) — resource doesn’t exist

  • Conflict (409) — data conflict (e.g., duplicate key)

  • Server errors (500, 502, 503) — backend failures (user must manually retry)

Rationale: Retrying these errors wastes resources and provides poor UX. Users should fix the issue or be notified of permanent failures.

Read-only request blocking

For VIEWER role users, the Axios request interceptor blocks unsafe methods before they leave the browser. This is one of several layers enforcing read-only mode (the others are AuthProvider.isReadOnly consumed by UI components, and the backend itself).

// Request interceptor (src/lib/axios.ts)
axiosInstance.interceptors.request.use(async (config) => {
  const method = config.method;

  if (shouldBlockRequestForReadOnly(method, config.url ?? null)) {
    return Promise.reject(createReadOnlyError());
  }

  // ... continue with locale header, CSRF fetch, etc.
});

UNSAFE_METHODS: POST, PUT, PATCH, DELETE (from src/lib/permissions.ts).

Allowed unsafe prefixes:

  • /auth/login

  • /auth/first-login

  • /auth/register

  • /auth/change-password

  • /auth/logout

  • /auth/refresh-token

  • /auth/me/locale

  • /service-api/import/dry-run (read-only preview)

The role is tracked in module-level state in src/lib/permissions.ts:

  • setCurrentUserRole(role) — called by AuthProvider on session load / login / logout.

  • isReadOnlyRole(role) — returns role === 'VIEWER'.

  • shouldBlockRequestForReadOnly(method, url) — combined check used by the interceptor.

Implementation: shouldBlockRequestForReadOnly() in src/lib/permissions.ts.

Best practices

Do’s

  • ✅ Use apiService.get/post/put/delete for all API calls

  • ✅ Handle errors with useErrorHandler() hook

  • ✅ Show user-friendly notifications via useToast()

  • ✅ Validate request payloads with Zod before sending

  • ✅ Use dry-run endpoints for imports before applying changes

  • ✅ Check isReadOnly flag before showing write actions in UI

  • ✅ Cancel in-flight requests on component unmount (AbortController)

Don’ts

  • ❌ Don’t create new Axios instances (bypasses interceptors)

  • ❌ Don’t retry non-401 errors automatically

  • ❌ Don’t send sensitive data in URL query params (use request body)

  • ❌ Don’t ignore CSRF errors (indicates misconfiguration)

  • ❌ Don’t bypass read-only checks in UI (backend still enforces)

  • ❌ Don’t assume baseURL is static (resolved at runtime)

Troubleshooting

CSRF token errors (403 Forbidden)

Symptoms: All write requests fail with 403

Diagnosis:

  1. Check browser devtools → Network tab → look for /csrf call

  2. Verify X-XSRF-TOKEN header is present on POST/PUT/DELETE requests

  3. Check if XSRF-TOKEN cookie is set

Solutions:

  • Ensure withCredentials: true is set on all requests

  • Verify backend CSRF configuration allows UI origin

  • Clear browser cookies and retry

Token refresh loop (infinite 401s)

Symptoms: Browser rapidly calls /auth/refresh-token repeatedly

Diagnosis:

  • Check if /auth/refresh-token itself returns 401

  • Look for _retry: true flag in request logs

Solutions:

  • Verify backend session/token configuration

  • Check if refresh token has expired

  • Clear browser storage and re-login

Base URL resolution fails (404 or CORS)

Symptoms: All API calls fail with 404 or CORS errors

Diagnosis:

  1. Open browser devtools console and evaluate window.RUNTIME_CONFIG — it must contain a non-empty API_URL string.

  2. Verify /runtime-config.js is served (Network tab) and loaded before the app bundle (it is a blocking <script> in index.html).

  3. Confirm Docker entrypoint generated the file: docker exec <container> cat /usr/share/nginx/html/runtime-config.js.

  4. Check the nginx /configs-service/ location proxies to the expected BACKEND_URL.

Solutions:

  • Ensure the runtime config script is loaded: view-source: on the page and search for RUNTIME_CONFIG.

  • Inspect entrypoint logs: docker logs <container> — look for the generated runtime-config.js line.

  • If getRuntimeConfig() throws "Invalid runtime configuration: …", the injected object failed Zod validation — fix the generated runtime-config.js (its API_URL).

  • In dev mode, verify vite.config.ts proxy targets a reachable backend (default http://localhost:8082).

  • Check backend CORS configuration allows the UI origin (dev uses http://localhost:9002).

Read-only blocking not working

Symptoms: VIEWER users can trigger write actions

Diagnosis:

  • Check currentUserRole in src/lib/permissions.ts

  • Verify setCurrentUserRole() is called on login/session load

  • Check if endpoint is in allowedUnsafePrefixes allowlist

Solutions:

  • Ensure AuthProvider calls setCurrentUserRole() after session check

  • Remove endpoint from allowlist if blocking is required

  • Verify UI disables write buttons for read-only users