# 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` (не публичный 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** в `api-response.dto.ts`: ```ts export class ApiEnvelopePayload { 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 implements NestInterceptor> { intercept(context: ExecutionContext, next: CallHandler): Observable> { 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(json: unknown): { data: T; meta: ApiResponseMeta } { const envelope = json as ApiEnvelope return { data: envelope.data as T, meta: envelope.meta, } } ``` - [x] **Step 2: Export ApiEnvelope** from kyClient if needed: ```ts export interface ApiEnvelope { 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" ```