17 KiB
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 -
Step 1: Добавить ApiEnvelopePayload в
api-response.dto.ts:
export class ApiEnvelopePayload<T> {
constructor(
public readonly data: T,
public readonly fromCache: boolean,
public readonly cachedAt: string | null,
) {}
}
- Step 2: Обновить TransformInterceptor — распознавать
ApiEnvelopePayloadиApiResponse:
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);
}),
);
}
}
- Step 3: Проверить, что backend компилируется:
npm run build -w apps/backend
- Step 4: Commit:
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):
SharesService.getMarketData / getDividends / getHistoryBondsService.getBond / getMarketData / getHistoryCandlesService.getCandlesBrokerAccountsService.findAllBrokerPortfolioService.getPortfolio / getPositionsBrokerEventsService.getEventsBrokerAnalyticsService.getAnalyticsBrokerOperationsService.getOperations
Pattern for each service (shown for SharesService):
- Step 1: SharesService.getMarketData — change return from
{ data, meta }tonew ApiEnvelopePayload(data, fromCache, cachedAt):
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
// before:
return { data: { ... }, meta: { fromCache, cachedAt } };
// after:
return new ApiEnvelopePayload({ ... }, fromCache, cachedAt);
- Step 2: SharesService.getDividends — same pattern:
return new ApiEnvelopePayload(
data.map((d) => ({ registryCloseDate: d.registryCloseDate, value: d.value, currency: d.currencyId })),
fromCache,
cachedAt,
);
- Step 3: SharesService.getHistory — same pattern:
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,
);
- Step 4: BondsService.getBond — same pattern:
return new ApiEnvelopePayload({ ... }, fromCache, cachedAt);
- Step 5: BondsService.getMarketData — same pattern:
return new ApiEnvelopePayload({ ... }, fromCache, cachedAt);
- Step 6: BondsService.getHistory — same pattern:
return new ApiEnvelopePayload(data.map(...), fromCache, cachedAt);
- Step 7: CandlesService.getCandles — same pattern:
return new ApiEnvelopePayload(data.map(...), fromCache, cachedAt);
- Step 8: BrokerAccountsService.findAll — same pattern:
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
- Step 9: BrokerPortfolioService.getPortfolio — same:
return new ApiEnvelopePayload(portfolioResult.data, portfolioResult.fromCache, portfolioResult.cachedAt);
- Step 10: BrokerPortfolioService.getPositions — same:
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
- Step 11: BrokerEventsService.getEvents — same:
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
- Step 12: BrokerAnalyticsService.getAnalytics — same:
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
- Step 13: BrokerOperationsService.getOperations — same:
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
- Step 14: Build check:
npm run build -w apps/backend
- Step 15: Commit:
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 }:
SharesController.getShareSecuritiesController.search / screenerPortfolioController— все методы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
- Step 1: SharesController.getShare — return plain data:
async getShare(@Param('secid') secid: string) {
const share = await this.sharesService.getShare(secid);
return share;
}
- Step 2: SecuritiesController.search, screener — return plain data:
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);
}
- Step 3: PortfolioController — all methods return plain data. E.g.:
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);
}
// ... аналогично для всех остальных методов
- Step 4: AuthController — all methods return plain data. E.g.:
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 };
}
- Step 5: TBankController — stop wrapping in
ApiResponse. Just return service result:
async getAccounts() {
return this.brokerAccountsService.findAll();
}
async getPortfolio(@Param('accountId') accountId: string) {
return this.brokerPortfolioService.getPortfolio(accountId);
}
// ... аналогично для всех методов (кроме syncOperations — он уже возвращает plain object)
- Step 6: Build check:
npm run build -w apps/backend
- Step 7: Commit:
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— mocksfromCache/cachedAtapps/backend/src/modules/securities/securities.service.spec.ts— mocksfromCache/cachedAtapps/backend/src/modules/securities/screener.service.spec.ts— mocksfromCache/cachedAtapps/backend/src/modules/portfolio/portfolio.service.spec.ts— mocksfromCache/cachedAtapps/backend/src/modules/cache/cache.service.spec.ts(may not be affected)
Pattern for tbank.controller.spec.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:
// 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:
// 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,
});
-
Step 1: tbank.controller.spec.ts — update assertions to check
ApiEnvelopePayloadinstead ofApiResponse. -
Step 2: broker-accounts.service.spec.ts — update
result.meta.fromCache→result.fromCache. -
Step 3: broker-analytics.service.spec.ts — update meta assertions.
-
Step 4: broker-events.service.spec.ts — update mock data and assertions.
-
Step 5: broker-portfolio.service.spec.ts — update mock data and assertions.
-
Step 6: broker-operations.service.spec.ts — update mock data and assertions.
-
Step 7: broker-operation-sync.service.spec.ts — update mock data and assertions (if any).
-
Step 8: shares.service.spec.ts — update fromCache/cachedAt assertions.
-
Step 9: securities.service.spec.ts — update fromCache/cachedAt assertions.
-
Step 10: screener.service.spec.ts — update fromCache/cachedAt assertions.
-
Step 11: portfolio.service.spec.ts — update fromCache/cachedAt assertions.
-
Step 12: Run backend tests:
npm test -w apps/backend
- Step 13: Commit:
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
-
Step 1: Создать
apps/backend/src/envelope-contract.spec.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 () => {
// ...
});
});
- Step 2: Run contract tests:
npm exec -w apps/backend -- vitest run apps/backend/src/envelope-contract.spec.ts
- Step 3: Commit:
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 -
Step 1: Упростить normalizeEnvelope:
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,
}
}
- Step 2: Export ApiEnvelope from kyClient if needed:
export interface ApiEnvelope<T> {
data: T
meta: ApiResponseMeta
}
- Step 3: Run frontend tests:
npm test -w apps/frontend
- Step 4: Run frontend build:
npm run build -w apps/frontend
- Step 5: Commit:
git add apps/frontend/src/shared/api/kyClient.ts
git commit -m "feat: simplify normalizeEnvelope — remove double-wrap support"
Task 7: Final verification
- Step 1: Run all tests:
npm test -w apps/backend
npm test -w apps/frontend
- Step 2: Build both packages:
npm run build -w apps/backend
npm run build -w apps/frontend
- Step 3: OpenAPI artifacts check:
npm exec -w apps/backend -- vitest run openapi-artifacts.spec.ts
-
Step 4: Verify the feature docs are consistent (read spec.md, plan.md, tasks.md — no contradictions).
-
Step 5: Commit final:
git add docs/features/api-envelope-contract/
git commit -m "docs: add spec/plan/tasks for API envelope runtime contract"