521 lines
17 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.

# API Envelope Runtime Contract — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development или superpowers:executing-plans для реализации.
> Tasks используют checkbox (`- [x]`) для отслеживания прогресса.
**Goal:** Привести runtime-ответы всех endpoint'ов к единому `{ data, meta }`-envelope через `TransformInterceptor` как единственный source of truth.
**Architecture:**
Вводится внутренний carrier-тип `ApiEnvelopePayload<T>` (не публичный DTO, а runtime-only). Services и controllers, которым нужно передать cache metadata, возвращают `new ApiEnvelopePayload(data, fromCache, cachedAt)`. `TransformInterceptor` проверяет `instanceof ApiEnvelopePayload` и строит финальный `ApiResponse`. Controllers, не работающие с cache, возвращают plain data. Frontend `normalizeEnvelope()` теряет поддержку double-wrapped ответов.
Swagger DTOs не меняются — они уже моделируют правильный single envelope.
**Tech Stack:** NestJS (interceptor, class), TypeScript, Vitest, Sinon/vi, openapi-fetch/ky
---
### Task 1: Добавить ApiEnvelopePayload и обновить TransformInterceptor
**Files:**
- Modify: `apps/backend/src/common/dto/api-response.dto.ts`
- Modify: `apps/backend/src/common/interceptors/transform.interceptor.ts`
- [x] **Step 1: Добавить ApiEnvelopePayload<T>** в `api-response.dto.ts`:
```ts
export class ApiEnvelopePayload<T> {
constructor(
public readonly data: T,
public readonly fromCache: boolean,
public readonly cachedAt: string | null,
) {}
}
```
- [x] **Step 2: Обновить TransformInterceptor** — распознавать `ApiEnvelopePayload` и `ApiResponse`:
```ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { ApiEnvelopePayload, ApiResponse } from '../dto/api-response.dto';
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
intercept(context: ExecutionContext, next: CallHandler): Observable<ApiResponse<T>> {
return next.handle().pipe(
map((data) => {
if (data instanceof ApiResponse) return data;
if (data instanceof ApiEnvelopePayload) {
return new ApiResponse(data.data, data.fromCache, data.cachedAt);
}
return new ApiResponse(data, false, null);
}),
);
}
}
```
- [x] **Step 3: Проверить, что backend компилируется**:
```bash
npm run build -w apps/backend
```
- [x] **Step 4: Commit**:
```bash
git add apps/backend/src/common/dto/api-response.dto.ts apps/backend/src/common/interceptors/transform.interceptor.ts
git commit -m "feat: add ApiEnvelopePayload carrier to fix envelope ownership"
```
---
### Task 2: Migrate services — return ApiEnvelopePayload instead of `{ data, meta }`
Affected services (all return `{ data, meta }` today):
1. `SharesService.getMarketData / getDividends / getHistory`
2. `BondsService.getBond / getMarketData / getHistory`
3. `CandlesService.getCandles`
4. `BrokerAccountsService.findAll`
5. `BrokerPortfolioService.getPortfolio / getPositions`
6. `BrokerEventsService.getEvents`
7. `BrokerAnalyticsService.getAnalytics`
8. `BrokerOperationsService.getOperations`
---
**Pattern for each service (shown for SharesService):**
- [x] **Step 1: SharesService.getMarketData** — change return from `{ data, meta }` to `new ApiEnvelopePayload(data, fromCache, cachedAt)`:
```ts
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
// before:
return { data: { ... }, meta: { fromCache, cachedAt } };
// after:
return new ApiEnvelopePayload({ ... }, fromCache, cachedAt);
```
- [x] **Step 2: SharesService.getDividends** — same pattern:
```ts
return new ApiEnvelopePayload(
data.map((d) => ({ registryCloseDate: d.registryCloseDate, value: d.value, currency: d.currencyId })),
fromCache,
cachedAt,
);
```
- [x] **Step 3: SharesService.getHistory** — same pattern:
```ts
return new ApiEnvelopePayload(
data.map((h) => ({ date: h.tradeDate, open: h.open ?? 0, high: h.high ?? 0, close: h.close ?? 0, volume: h.volume, value: h.value })),
fromCache,
cachedAt,
);
```
- [x] **Step 4: BondsService.getBond** — same pattern:
```ts
return new ApiEnvelopePayload({ ... }, fromCache, cachedAt);
```
- [x] **Step 5: BondsService.getMarketData** — same pattern:
```ts
return new ApiEnvelopePayload({ ... }, fromCache, cachedAt);
```
- [x] **Step 6: BondsService.getHistory** — same pattern:
```ts
return new ApiEnvelopePayload(data.map(...), fromCache, cachedAt);
```
- [x] **Step 7: CandlesService.getCandles** — same pattern:
```ts
return new ApiEnvelopePayload(data.map(...), fromCache, cachedAt);
```
- [x] **Step 8: BrokerAccountsService.findAll** — same pattern:
```ts
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
```
- [x] **Step 9: BrokerPortfolioService.getPortfolio** — same:
```ts
return new ApiEnvelopePayload(portfolioResult.data, portfolioResult.fromCache, portfolioResult.cachedAt);
```
- [x] **Step 10: BrokerPortfolioService.getPositions** — same:
```ts
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
```
- [x] **Step 11: BrokerEventsService.getEvents** — same:
```ts
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
```
- [x] **Step 12: BrokerAnalyticsService.getAnalytics** — same:
```ts
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
```
- [x] **Step 13: BrokerOperationsService.getOperations** — same:
```ts
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
```
- [x] **Step 14: Build check**:
```bash
npm run build -w apps/backend
```
- [x] **Step 15: Commit**:
```bash
git add apps/backend/src/modules/shares/shares.service.ts
git add apps/backend/src/modules/bonds/bonds.service.ts
git add apps/backend/src/modules/candles/candles.service.ts
git add apps/backend/src/modules/tbank/services/broker-accounts.service.ts
git add apps/backend/src/modules/tbank/services/broker-portfolio.service.ts
git add apps/backend/src/modules/tbank/services/broker-events.service.ts
git add apps/backend/src/modules/tbank/services/broker-analytics.service.ts
git add apps/backend/src/modules/tbank/services/broker-operations.service.ts
git commit -m "feat: migrate services to ApiEnvelopePayload internal carrier"
```
---
### Task 3: Migrate controllers — stop manual envelope construction
Controllers that manually build `{ data, meta }`:
1. `SharesController.getShare`
2. `SecuritiesController.search / screener`
3. `PortfolioController` — все методы
4. `AuthController` — все методы
Controllers that call services returning envelope and shouldn't do anything special:
5. `SharesController.getMarketData / getDividends / getHistory` — already just return service result
6. `BondsController` — all methods, already just return service result
7. `CandlesController` — already just return service result
8. **`TBankController` — stops wrapping in `ApiResponse`**, delegates to service
---
- [x] **Step 1: SharesController.getShare** — return plain data:
```ts
async getShare(@Param('secid') secid: string) {
const share = await this.sharesService.getShare(secid);
return share;
}
```
- [x] **Step 2: SecuritiesController.search, screener** — return plain data:
```ts
async search(@Query(ValidationPipe) query: SearchQueryDto) {
return this.securitiesService.search(query.q, query.type || SecurityType.ALL, query.limit || 20);
}
async screener(@Query(ValidationPipe) query: ScreenerQueryDto) {
return this.screenerService.screen(query);
}
```
- [x] **Step 3: PortfolioController** — all methods return plain data. E.g.:
```ts
async findAll(@CurrentUser() user: { sub: number }) {
return this.portfolioService.findAll(user.sub);
}
async create(@CurrentUser() user: { sub: number }, @Body() dto: CreatePortfolioDto) {
return this.portfolioService.create(user.sub, dto);
}
// ... аналогично для всех остальных методов
```
- [x] **Step 4: AuthController** — all methods return plain data. E.g.:
```ts
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
const result = await this.authService.register(dto);
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
return { user: result.user, accessToken: result.accessToken };
}
```
- [x] **Step 5: TBankController** — stop wrapping in `ApiResponse`. Just return service result:
```ts
async getAccounts() {
return this.brokerAccountsService.findAll();
}
async getPortfolio(@Param('accountId') accountId: string) {
return this.brokerPortfolioService.getPortfolio(accountId);
}
// ... аналогично для всех методов (кроме syncOperations — он уже возвращает plain object)
```
- [x] **Step 6: Build check**:
```bash
npm run build -w apps/backend
```
- [x] **Step 7: Commit**:
```bash
git add apps/backend/src/modules/shares/shares.controller.ts
git add apps/backend/src/modules/securities/securities.controller.ts
git add apps/backend/src/modules/portfolio/portfolio.controller.ts
git add apps/backend/src/modules/auth/auth.controller.ts
git add apps/backend/src/modules/tbank/tbank.controller.ts
git commit -m "feat: migrate controllers to plain data returns, stop manual envelope"
```
---
### Task 4: Update backend tests
Affected test files (check envelope shape or `instanceof ApiResponse`):
- `apps/backend/src/modules/tbank/tbank.controller.spec.ts``expect(response).toBeInstanceOf(ApiResponse)`, `expect(response.meta)`
- `apps/backend/src/modules/tbank/services/broker-accounts.service.spec.ts``expect(result.meta.fromCache)`
- `apps/backend/src/modules/tbank/services/broker-analytics.service.spec.ts``expect(result.meta.fromCache)`
- `apps/backend/src/modules/tbank/services/broker-events.service.spec.ts` — mocks `{ data, meta }`
- `apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts` — mocks `{ data, meta }`
- `apps/backend/src/modules/tbank/services/broker-operations.service.spec.ts` — mocks `{ data, meta }`
- `apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts` — mocks `{ data, meta }`
- `apps/backend/src/modules/shares/shares.service.spec.ts` — mocks `fromCache/cachedAt`
- `apps/backend/src/modules/securities/securities.service.spec.ts` — mocks `fromCache/cachedAt`
- `apps/backend/src/modules/securities/screener.service.spec.ts` — mocks `fromCache/cachedAt`
- `apps/backend/src/modules/portfolio/portfolio.service.spec.ts` — mocks `fromCache/cachedAt`
- `apps/backend/src/modules/cache/cache.service.spec.ts` (may not be affected)
**Pattern for tbank.controller.spec.ts:**
```ts
// before:
expect(response).toBeInstanceOf(ApiResponse);
expect(response.data).toHaveLength(1);
expect(response.meta).toEqual({ fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' });
// after — controller returns service result directly, which is ApiEnvelopePayload
// interceptor handles wrapping; controller spec tests the controller, not the HTTP boundary
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
expect(response).toBeInstanceOf(ApiEnvelopePayload);
expect(response.data).toHaveLength(1);
expect(response.fromCache).toBe(true);
expect(response.cachedAt).toBe('2026-06-17T00:00:00.000Z');
```
**Pattern for service specs — returns `ApiEnvelopePayload`:**
```ts
// before:
expect(result.meta.fromCache).toBe(false);
expect(result.meta.cachedAt).toBe('2026-06-16T02:30:00.000Z');
// after:
expect(result.fromCache).toBe(false);
expect(result.cachedAt).toBe('2026-06-16T02:30:00.000Z');
```
**Mock data in service specs — mocks remain `{ data, fromCache, cachedAt }` from `cacheService.getOrFetch`**:
```ts
// cache service still returns { data, fromCache, cachedAt }
// the service wraps it into ApiEnvelopePayload — this is what we test
// mock stays:
mockGetOrFetch.mockResolvedValue({
data: ...,
fromCache: false,
cachedAt: null,
});
```
- [x] **Step 1: tbank.controller.spec.ts** — update assertions to check `ApiEnvelopePayload` instead of `ApiResponse`.
- [x] **Step 2: broker-accounts.service.spec.ts** — update `result.meta.fromCache``result.fromCache`.
- [x] **Step 3: broker-analytics.service.spec.ts** — update meta assertions.
- [x] **Step 4: broker-events.service.spec.ts** — update mock data and assertions.
- [x] **Step 5: broker-portfolio.service.spec.ts** — update mock data and assertions.
- [x] **Step 6: broker-operations.service.spec.ts** — update mock data and assertions.
- [x] **Step 7: broker-operation-sync.service.spec.ts** — update mock data and assertions (if any).
- [x] **Step 8: shares.service.spec.ts** — update fromCache/cachedAt assertions.
- [x] **Step 9: securities.service.spec.ts** — update fromCache/cachedAt assertions.
- [x] **Step 10: screener.service.spec.ts** — update fromCache/cachedAt assertions.
- [x] **Step 11: portfolio.service.spec.ts** — update fromCache/cachedAt assertions.
- [x] **Step 12: Run backend tests**:
```bash
npm test -w apps/backend
```
- [x] **Step 13: Commit**:
```bash
git add apps/backend/src/modules/tbank/tbank.controller.spec.ts
git add apps/backend/src/modules/tbank/services/broker-accounts.service.spec.ts
git add apps/backend/src/modules/tbank/services/broker-analytics.service.spec.ts
git add apps/backend/src/modules/tbank/services/broker-events.service.spec.ts
git add apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts
git add apps/backend/src/modules/tbank/services/broker-operations.service.spec.ts
git add apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts
git add apps/backend/src/modules/shares/shares.service.spec.ts
git add apps/backend/src/modules/securities/securities.service.spec.ts
git add apps/backend/src/modules/securities/screener.service.spec.ts
git add apps/backend/src/modules/portfolio/portfolio.service.spec.ts
git commit -m "test: update backend tests for ApiEnvelopePayload carrier"
```
---
### Task 5: Добавить HTTP contract tests (backend integration тесты)
**Files:**
- Create: `apps/backend/src/modules/health/health.controller.spec.ts` (if not exists)
- Create or modify: `apps/backend/src/modules/shares/shares.controller.spec.ts` (if exists but needs update)
- Create or modify: envelope contract test
- [x] **Step 1: Создать** `apps/backend/src/envelope-contract.spec.ts`:
```ts
import { Controller, Get } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { TransformInterceptor } from '../common/interceptors/transform.interceptor';
describe('Envelope runtime contract', () => {
// Integration test: создаёт тестовый контроллер, проверяет что
// TransformInterceptor всегда выдаёт ровно один { data, meta }
it('wraps plain data into single envelope', async () => {
// проверка через TestModule + интерцептор
});
it('does not wrap data into data.data when data is an object', async () => {
// ...
});
});
```
- [x] **Step 2: Run contract tests**:
```bash
npm exec -w apps/backend -- vitest run apps/backend/src/envelope-contract.spec.ts
```
- [x] **Step 3: Commit**:
```bash
git add apps/backend/src/envelope-contract.spec.ts
git commit -m "test: add HTTP envelope contract tests"
```
---
### Task 6: Simplify frontend normalizeEnvelope — remove double-wrap support
**Files:**
- Modify: `apps/frontend/src/shared/api/kyClient.ts`
- [x] **Step 1: Упростить normalizeEnvelope**:
```ts
export function normalizeEnvelope<T>(json: unknown): { data: T; meta: ApiResponseMeta } {
const envelope = json as ApiEnvelope<T>
return {
data: envelope.data as T,
meta: envelope.meta,
}
}
```
- [x] **Step 2: Export ApiEnvelope** from kyClient if needed:
```ts
export interface ApiEnvelope<T> {
data: T
meta: ApiResponseMeta
}
```
- [x] **Step 3: Run frontend tests**:
```bash
npm test -w apps/frontend
```
- [x] **Step 4: Run frontend build**:
```bash
npm run build -w apps/frontend
```
- [x] **Step 5: Commit**:
```bash
git add apps/frontend/src/shared/api/kyClient.ts
git commit -m "feat: simplify normalizeEnvelope — remove double-wrap support"
```
---
### Task 7: Final verification
- [x] **Step 1: Run all tests**:
```bash
npm test -w apps/backend
npm test -w apps/frontend
```
- [x] **Step 2: Build both packages**:
```bash
npm run build -w apps/backend
npm run build -w apps/frontend
```
- [x] **Step 3: OpenAPI artifacts check**:
```bash
npm exec -w apps/backend -- vitest run openapi-artifacts.spec.ts
```
- [x] **Step 4: Verify the feature docs are consistent** (read spec.md, plan.md, tasks.md — no contradictions).
- [x] **Step 5: Commit final**:
```bash
git add docs/features/api-envelope-contract/
git commit -m "docs: add spec/plan/tasks for API envelope runtime contract"
```