388 lines
15 KiB
Markdown
388 lines
15 KiB
Markdown
# 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.
|