521 lines
17 KiB
Markdown
521 lines
17 KiB
Markdown
# 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"
|
||
```
|