From f0143144134556a64f1bdaffbca05a47b67f2c6a Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 28 Oct 2026 12:49:59 +0300 Subject: [PATCH] refactor: publish typed problem detail codes (WealthLedger/backend#117) --- src/integrations/moex/moex.client.ts | 2 +- src/integrations/ncr/ncr-ratings.client.ts | 2 +- .../investments/income/income-period.ts | 2 +- .../upcoming-events.service.ts | 2 +- src/openapi.ts | 29 +++++++++++ test/openapi-contract.e2e-spec.ts | 49 +++++++++++++++++++ 6 files changed, 82 insertions(+), 4 deletions(-) diff --git a/src/integrations/moex/moex.client.ts b/src/integrations/moex/moex.client.ts index 61f1f0e..df037e0 100644 --- a/src/integrations/moex/moex.client.ts +++ b/src/integrations/moex/moex.client.ts @@ -1,7 +1,7 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { constants as httpStatus } from 'node:http2'; -enum MoexErrorCode { Unavailable = 'MOEX_UNAVAILABLE' } +export enum MoexErrorCode { Unavailable = 'MOEX_UNAVAILABLE' } const MIN_PRICE_PERCENT_EXCLUSIVE = 0; const FIRST_PAGE_OFFSET = 0; diff --git a/src/integrations/ncr/ncr-ratings.client.ts b/src/integrations/ncr/ncr-ratings.client.ts index a5f110e..140545a 100644 --- a/src/integrations/ncr/ncr-ratings.client.ts +++ b/src/integrations/ncr/ncr-ratings.client.ts @@ -4,7 +4,7 @@ import { LRUCache } from 'lru-cache'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time'; -enum NcrErrorCode { Unavailable = 'NCR_UNAVAILABLE' } +export enum NcrErrorCode { Unavailable = 'NCR_UNAVAILABLE' } const EXPECTED_REGISTRY_TABLES = 1; const RATING_COLUMN = 2; diff --git a/src/modules/investments/income/income-period.ts b/src/modules/investments/income/income-period.ts index cf4a298..43d8bcf 100644 --- a/src/modules/investments/income/income-period.ts +++ b/src/modules/investments/income/income-period.ts @@ -8,7 +8,7 @@ import { utcDate, } from '../investment-calendar'; -enum IncomeErrorCode { InvalidPeriod = 'INVALID_INCOME_PERIOD' } +export enum IncomeErrorCode { InvalidPeriod = 'INVALID_INCOME_PERIOD' } const MONDAY_WEEKDAY_INDEX = 1; const INCLUSIVE_DAY_OFFSET = 1; diff --git a/src/modules/investments/upcoming-events/upcoming-events.service.ts b/src/modules/investments/upcoming-events/upcoming-events.service.ts index caefc95..e36d9ae 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.service.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.service.ts @@ -32,7 +32,7 @@ import { UpcomingEventsQueryDto, } from './upcoming-events.dto'; -enum EventsErrorCode { InvalidPeriod = 'INVALID_EVENTS_PERIOD' } +export enum EventsErrorCode { InvalidPeriod = 'INVALID_EVENTS_PERIOD' } const FIRST_BATCH_OFFSET = 0; diff --git a/src/openapi.ts b/src/openapi.ts index 3ea3fda..0857d4d 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -1,12 +1,38 @@ import { INestApplication } from '@nestjs/common'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { HttpErrorCode } from './common/error-code'; +import { MoexErrorCode } from './integrations/moex/moex.client'; +import { NcrErrorCode } from './integrations/ncr/ncr-ratings.client'; +import { TbankErrorCode } from './integrations/tbank/tbank-error-code'; +import { AuthErrorCode } from './modules/auth/application/auth-error-code'; +import { BankErrorCode } from './modules/banking/bank-cards/application/bank-card-input'; +import { IncomeErrorCode } from './modules/investments/income/income-period'; +import { InstrumentErrorCode } from './modules/investments/instruments/domain/instrument-error-code'; +import { OperationErrorCode } from './modules/investments/operations/domain/operation-error-code'; +import { EventsErrorCode } from './modules/investments/upcoming-events/upcoming-events.service'; + export const API_PREFIX = 'api/v1'; // Nest Swagger cannot express additionalProperties on a class, so closed request // bodies are restored here after generation; the runtime allowlist stays in the controller. const CLOSED_SCHEMAS = ['StockScreenerCriteriaDto']; +// Every code HttpExceptionFilter can emit. Assembled at the composition root so +// common/ProblemDetailsDto does not depend on modules. +export const PROBLEM_CODES = [ + ...Object.values(HttpErrorCode), + ...Object.values(AuthErrorCode), + ...Object.values(OperationErrorCode), + ...Object.values(InstrumentErrorCode), + ...Object.values(TbankErrorCode), + ...Object.values(BankErrorCode), + ...Object.values(MoexErrorCode), + ...Object.values(NcrErrorCode), + ...Object.values(EventsErrorCode), + ...Object.values(IncomeErrorCode), +]; + export function createOpenApiDocument(app: INestApplication) { const document = SwaggerModule.createDocument( app, @@ -18,5 +44,8 @@ export function createOpenApiDocument(app: INestApplication) { schema.additionalProperties = false; } } + const problem = schemas.ProblemDetailsDto; + const problemCode = problem && 'properties' in problem ? problem.properties?.code : undefined; + if (problemCode && !('$ref' in problemCode)) problemCode.enum = PROBLEM_CODES; return document; } diff --git a/test/openapi-contract.e2e-spec.ts b/test/openapi-contract.e2e-spec.ts index f9eef12..816eb0f 100644 --- a/test/openapi-contract.e2e-spec.ts +++ b/test/openapi-contract.e2e-spec.ts @@ -121,6 +121,55 @@ describe('OpenAPI contract (integration)', () => { expect(analytics.properties).not.toHaveProperty('yearComparison'); }); + it('publishes every emitted problem detail code as a named enum', () => { + const schemas = createOpenApiDocument(app).components?.schemas; + const expectedCodes = [ + 'BAD_REQUEST', + 'INTERNAL_SERVER_ERROR', + 'NOT_FOUND', + 'UNAUTHORIZED', + 'UNSUPPORTED_MEDIA_TYPE', + 'VALIDATION_ERROR', + 'EMAIL_ALREADY_REGISTERED', + 'INVALID_CREDENTIALS', + 'INVALID_OPERATIONS_CURSOR', + 'BOND_HISTORY_START_UNAVAILABLE', + 'BOND_TICKER_AMBIGUOUS', + 'STOCK_HISTORY_START_UNAVAILABLE', + 'STOCK_TICKER_AMBIGUOUS', + 'INVALID_TBANK_TOKEN', + 'TBANK_NOT_CONNECTED', + 'TBANK_UNAVAILABLE', + 'BANK_CARD_ARCHIVED', + 'BANK_DEPENDENT_REFUNDS', + 'BANK_DUPLICATE_CATEGORY', + 'BANK_IMMUTABLE_OPERATION', + 'BANK_INTENT_CONFLICT', + 'BANK_INVALID_DATE', + 'BANK_INVALID_INPUT', + 'BANK_INVALID_MONEY', + 'BANK_INVALID_REFUND', + 'BANK_NOT_FOUND', + 'BANK_OPENING_DATE_CONFLICT', + 'BANK_OPERATION_DELETED', + 'BANK_REFUND_LIMIT', + 'BANK_VERSION_CONFLICT', + 'BANK_WRITE_CONFLICT', + 'MOEX_UNAVAILABLE', + 'NCR_UNAVAILABLE', + 'INVALID_EVENTS_PERIOD', + 'INVALID_INCOME_PERIOD', + ]; + + const problem = schemas?.ProblemDetailsDto; + assert(problem && !('$ref' in problem)); + const codeSchema = problem.properties?.code; + assert(codeSchema && !('$ref' in codeSchema)); + expect(codeSchema.type).toBe('string'); + expect(codeSchema.enum).toEqual(expect.arrayContaining(expectedCodes)); + expect(codeSchema.enum).toHaveLength(expectedCodes.length); + }); + it('generates the runtime contract without server startup or deployment configuration', async () => { const schemas = createOpenApiDocument(app).components?.schemas; expect(schemas?.StockScreenerCriteriaDto).toMatchObject({ -- 2.54.0