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

388 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.
```typescript
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`
### 4.3. Global Text Search
- **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>`:
```typescript
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:
```typescript
// 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
- [x] Abstract contract `IRepository<T>` accepts optional `RequestContext`.
- [x] Standard HTTP headers configured (`X-Tenant-ID`, `X-Actor-ID`, `X-Role-IDs`, `X-Correlation-ID`).
- [x] HTTP status code translation to domain errors (`TenantAccessDeniedError`, `NotFoundError`).
- [x] URL query serialization for nested filters, sorting, search, and pagination.
- [x] Zero changes required in UI components upon swapping the provider.