krona-front/docs/REST-API-ADAPTER.md

15 KiB
Raw Blame History

Krona Universal Business OS — REST API Adapter Specification

This document defines the architectural contract, header mappings, error translations, query serialization protocols, and the production HttpBaseRepository implementation required to transition the Krona Universal Business OS from client-side IndexedDB mock storage to a production-grade multi-tenant RESTful backend.


1. Architectural Overview

The Krona platform follows a strict Contract-First Hexagonal Architecture. Frontend components, view renderers, and business logic interact exclusively with abstract contracts defined in src/services/contracts/:

┌────────────────────────────────────────────────────────┐
│                   React UI Layer                       │
│    (DynamicCardRenderer, DynamicTable, Kanban, etc.)   │
└───────────────────────────┬────────────────────────────┘
                            │
               IRepository<T> Contract
                            │
            ┌───────────────┴───────────────┐
            ▼                               ▼
┌───────────────────────┐       ┌───────────────────────┐
│  BaseMockRepository   │       │  HttpBaseRepository   │
│  (IndexedDB Mock)     │       │  (REST API Client)    │
└───────────────────────┘       └───────────────────────┘

By decoupling storage from presentation, the transition from client-side prototype to cloud backend requires zero changes to UI components, hooks, or state managers. The application simply swaps the repository provider at runtime or build-time.


2. RequestContext & Multi-Tenancy Protocol

Every read and write operation requires a RequestContext. The context guarantees multi-tenant storage isolation, auditability, and distributed tracing.

export interface RequestContext {
  tenantId: string;       // Tenant / Workspace identifier (e.g. 'tenant_main', 'tenant_alpha')
  actorId: string;        // Authenticated user ID (e.g. 'usr_01M26B3...')
  roleIds: string[];      // Active role keys (e.g. ['owner', 'director'])
  correlationId: string;  // Monotonic ULID tracking the distributed call across microservices
}

In the frontend, RequestContext is managed globally by TenantProvider (useTenant()) and created with default fallbacks via createRequestContext().


3. HTTP Header Mapping

When HttpBaseRepository communicates with the backend, it maps the RequestContext and authentication state into standardized HTTP headers:

Context Attribute HTTP Header Description / Format Example
tenantId X-Tenant-ID The target workspace ID. Mandatory for tenant routing & RLS. tenant_alpha
actorId X-Actor-ID Authenticated actor/user ID. Used for audit logging & ACL. usr_01HX98Z...
roleIds X-Role-IDs Comma-separated active role identifiers for RBAC/ABAC. admin,operations_head
correlationId X-Correlation-ID Monotonic ULID for end-to-end distributed tracing. 01M26B3GB2WERA5TANF94KRJAM
Auth State Authorization Standard JWT bearer token for identity verification. Bearer eyJhbGci...

Security & Invariant Rules

  1. Tenant Header Enforcement: If X-Tenant-ID is missing, the API Gateway immediately rejects the request with 400 Bad Request.
  2. Cross-Tenant Verification: The backend validates that the actor in Authorization has permissions within X-Tenant-ID. Unauthorized access yields 403 Forbidden.
  3. Correlation Propagation: The backend must echo X-Correlation-ID in all response headers and forward it across all asynchronous queues and downstream microservices.

4. Query Serialization Protocol

IRepository<T>.list(options?: QueryOptions<T>) accepts pagination, search, sorting, and field-level filters. The HTTP adapter serializes these into URL query parameters according to the following specification:

4.1. Pagination

  • Format: ?page=<number>&limit=<pageSize>
  • Example: ?page=2&limit=50 (1-indexed page)

4.2. Sorting

  • Format: ?sort=<fieldName>:<asc|desc>
  • Multiple Fields: ?sort=<field1>:asc,<field2>:desc
  • Example: ?sort=updatedAt:desc
  • Format: ?search=<query>
  • Example: ?search=ООО+Вектор

4.4. Field Filtering

  • Format: ?filter[<fieldKey>]=<value>
  • Operator-based Filtering: ?filter[<fieldKey>][<operator>]=<value>
  • Supported Operators: eq, neq, gt, gte, lt, lte, in, contains
  • Example:
    GET /api/v1/deal?page=1&limit=25&sort=createdAt:desc&filter[status]=won&filter[budget][gte]=1000000
    

5. Standard HTTP Error Code Translations

The frontend contracts define domain error types (TenantAccessDeniedError, NotFoundError). HttpBaseRepository maps HTTP response status codes into typed domain errors:

Status Code Backend Condition Frontend Domain Error UI Handling Strategy
401 Unauthorized Missing or expired JWT token AuthenticationRequiredError Clears token, redirects to /login with return URL.
403 Forbidden Cross-tenant access attempt or insufficient roles TenantAccessDeniedError Displays access-denied banner; prevents destructive UI actions.
404 Not Found Record not found in current tenant NotFoundError / null Returns null on getById; surfaces 404 page for route views.
409 Conflict Optimistic locking violation (version mismatch) ConcurrencyConflictError Prompts user with "Data changed by another user" reload dialog.
422 Unprocessable Payload validation failed against field metadata ValidationError Maps backend field errors directly to React Hook Form field state.
5xx / Network Gateway timeout or server failure ServiceUnavailableError TanStack Query automatically retries with exponential backoff.

6. Concrete Implementation: HttpBaseRepository<T>

Below is the production-ready TypeScript implementation of HttpBaseRepository<T>, implementing IRepository<T>:

import {
  IRepository,
  QueryOptions,
  PagedResult,
  QueryFilter,
} from '../contracts/repository';
import {
  RequestContext,
  TenantAccessDeniedError,
  NotFoundError,
  createRequestContext,
} from '../contracts/context';
import { BaseEntity } from '../../entities/types';
import { ulid } from '../../shared/lib/ulid';

export class HttpBaseRepository<T extends BaseEntity> implements IRepository<T> {
  protected baseUrl: string;

  constructor(
    protected entityName: string,
    baseUrl = import.meta.env.VITE_API_BASE_URL || '/api/v1'
  ) {
    this.baseUrl = `${baseUrl.replace(/\/$/, '')}/${entityName}`;
  }

  protected resolveContext(context?: RequestContext): RequestContext {
    return context || createRequestContext();
  }

  protected getAuthToken(): string | null {
    return typeof window !== 'undefined' ? localStorage.getItem('krona_auth_token') : null;
  }

  protected buildHeaders(context: RequestContext): HeadersInit {
    const headers: Record<string, string> = {
      'Content-Type': 'application/json',
      'Accept': 'application/json',
      'X-Tenant-ID': context.tenantId,
      'X-Actor-ID': context.actorId,
      'X-Role-IDs': context.roleIds.join(','),
      'X-Correlation-ID': context.correlationId || ulid(),
    };

    const token = this.getAuthToken();
    if (token) {
      headers['Authorization'] = `Bearer ${token}`;
    }

    return headers;
  }

  protected async handleError(response: Response, context: RequestContext): Promise<never> {
    const status = response.status;
    let payload: { message?: string; details?: unknown } = {};

    try {
      payload = await response.json();
    } catch {
      payload = { message: response.statusText };
    }

    const message = payload.message || response.statusText;

    switch (status) {
      case 401:
        if (typeof window !== 'undefined') {
          window.dispatchEvent(new CustomEvent('krona:auth-expired'));
        }
        throw new Error(`[401 Unauthorized]: ${message}`);

      case 403:
        throw new TenantAccessDeniedError(
          `[403 Forbidden]: Отказано в доступе к «${this.entityName}» в тенанте «${context.tenantId}»: ${message}`
        );

      case 404:
        throw new NotFoundError(
          `[404 Not Found]: Запись «${this.entityName}» не найдена: ${message}`
        );

      case 409:
        throw new Error(
          `[409 Conflict]: Конфликт параллельного обновления записи «${this.entityName}»: ${message}`
        );

      case 422:
        throw new Error(
          `[422 Validation Error]: Ошибка валидации структуры записи «${this.entityName}»: ${message}`
        );

      default:
        throw new Error(`[${status} HTTP Error]: ${message}`);
    }
  }

  protected serializeQueryParams(options?: QueryOptions<T>): string {
    if (!options) return '';
    const params = new URLSearchParams();

    if (options.page) params.set('page', String(options.page));
    if (options.pageSize) params.set('limit', String(options.pageSize));
    if (options.search) params.set('search', options.search);

    if (options.sort) {
      params.set('sort', `${String(options.sort.field)}:${options.sort.order}`);
    }

    if (options.filter) {
      for (const [key, value] of Object.entries(options.filter)) {
        if (value !== undefined && value !== null && value !== '') {
          if (typeof value === 'object' && !Array.isArray(value)) {
            for (const [op, opVal] of Object.entries(value as Record<string, unknown>)) {
              params.set(`filter[${key}][${op}]`, String(opVal));
            }
          } else {
            params.set(`filter[${key}]`, String(value));
          }
        }
      }
    }

    const queryString = params.toString();
    return queryString ? `?${queryString}` : '';
  }

  async getById(id: string, context?: RequestContext): Promise<T | null> {
    const ctx = this.resolveContext(context);
    const response = await fetch(`${this.baseUrl}/${encodeURIComponent(id)}`, {
      method: 'GET',
      headers: this.buildHeaders(ctx),
    });

    if (response.status === 404) {
      return null;
    }

    if (!response.ok) {
      await this.handleError(response, ctx);
    }

    return (await response.json()) as T;
  }

  async list(options?: QueryOptions<T>, context?: RequestContext): Promise<PagedResult<T>> {
    const ctx = this.resolveContext(context);
    const query = this.serializeQueryParams(options);
    const response = await fetch(`${this.baseUrl}${query}`, {
      method: 'GET',
      headers: this.buildHeaders(ctx),
    });

    if (!response.ok) {
      await this.handleError(response, ctx);
    }

    return (await response.json()) as PagedResult<T>;
  }

  async create(data: Omit<T, 'id' | 'audit'>, context?: RequestContext): Promise<T> {
    const ctx = this.resolveContext(context);
    const response = await fetch(this.baseUrl, {
      method: 'POST',
      headers: this.buildHeaders(ctx),
      body: JSON.stringify(data),
    });

    if (!response.ok) {
      await this.handleError(response, ctx);
    }

    return (await response.json()) as T;
  }

  async update(id: string, data: Partial<T>, context?: RequestContext): Promise<T> {
    const ctx = this.resolveContext(context);
    const response = await fetch(`${this.baseUrl}/${encodeURIComponent(id)}`, {
      method: 'PATCH',
      headers: this.buildHeaders(ctx),
      body: JSON.stringify(data),
    });

    if (!response.ok) {
      await this.handleError(response, ctx);
    }

    return (await response.json()) as T;
  }

  async delete(id: string, context?: RequestContext): Promise<boolean> {
    const ctx = this.resolveContext(context);
    const response = await fetch(`${this.baseUrl}/${encodeURIComponent(id)}`, {
      method: 'DELETE',
      headers: this.buildHeaders(ctx),
    });

    if (response.status === 404) {
      return false;
    }

    if (!response.ok) {
      await this.handleError(response, ctx);
    }

    return true;
  }

  async count(filter?: QueryFilter, context?: RequestContext): Promise<number> {
    const ctx = this.resolveContext(context);
    const query = this.serializeQueryParams({ filter, pageSize: 1 });
    const response = await fetch(`${this.baseUrl}/count${query}`, {
      method: 'GET',
      headers: this.buildHeaders(ctx),
    });

    if (!response.ok) {
      await this.handleError(response, ctx);
    }

    const data = (await response.json()) as { count: number };
    return data.count;
  }

  async batchUpdate(
    items: { id: string; data: Partial<T> }[],
    context?: RequestContext
  ): Promise<T[]> {
    const ctx = this.resolveContext(context);
    const response = await fetch(`${this.baseUrl}/batch`, {
      method: 'PATCH',
      headers: this.buildHeaders(ctx),
      body: JSON.stringify({ items }),
    });

    if (!response.ok) {
      await this.handleError(response, ctx);
    }

    return (await response.json()) as T[];
  }
}

7. Factory Pattern for Client-Side Provider Switching

To switch cleanly between BaseMockRepository and HttpBaseRepository without affecting consuming modules, use the createRepository factory:

// src/services/repositoryFactory.ts
import { IRepository } from './contracts/repository';
import { BaseEntity } from '../entities/types';
import { BaseMockRepository } from './mock/baseRepository';
import { HttpBaseRepository } from './http/httpBaseRepository';

const DATA_PROVIDER = import.meta.env.VITE_DATA_PROVIDER || 'mock';

export function createRepository<T extends BaseEntity>(entityName: string): IRepository<T> {
  if (DATA_PROVIDER === 'http') {
    return new HttpBaseRepository<T>(entityName);
  }
  return new BaseMockRepository<T>(entityName);
}

8. Summary Checklist for Backend Integration

  • Abstract contract IRepository<T> accepts optional RequestContext.
  • Standard HTTP headers configured (X-Tenant-ID, X-Actor-ID, X-Role-IDs, X-Correlation-ID).
  • HTTP status code translation to domain errors (TenantAccessDeniedError, NotFoundError).
  • URL query serialization for nested filters, sorting, search, and pagination.
  • Zero changes required in UI components upon swapping the provider.