17 KiB
Raw Blame History

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):

  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):

  • Step 1: SharesService.getMarketData — change return from { data, meta } to new 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 }:

  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


  • 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.tsexpect(response).toBeInstanceOf(ApiResponse), expect(response.meta)
  • apps/backend/src/modules/tbank/services/broker-accounts.service.spec.tsexpect(result.meta.fromCache)
  • apps/backend/src/modules/tbank/services/broker-analytics.service.spec.tsexpect(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:

// 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 ApiEnvelopePayload instead of ApiResponse.

  • Step 2: broker-accounts.service.spec.ts — update result.meta.fromCacheresult.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"