15 KiB
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
- Tenant Header Enforcement: If
X-Tenant-IDis missing, the API Gateway immediately rejects the request with400 Bad Request. - Cross-Tenant Verification: The backend validates that the actor in
Authorizationhas permissions withinX-Tenant-ID. Unauthorized access yields403 Forbidden. - Correlation Propagation: The backend must echo
X-Correlation-IDin 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>:
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 optionalRequestContext. - 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.