From 5fa56e00e80d14f1d089cdcd61f11385d49558a3 Mon Sep 17 00:00:00 2001 From: Dmitry Nechay Date: Mon, 27 Jul 2026 15:48:22 +0300 Subject: [PATCH] fix: accept unknown manifest as is --- .../src/common/interceptors/snake-case.ts | 44 +++++++++++++++++-- .../server/src/modules/job/job.controller.ts | 2 + 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/packages/apps/job-launcher/server/src/common/interceptors/snake-case.ts b/packages/apps/job-launcher/server/src/common/interceptors/snake-case.ts index 5d87024276..9bcab61124 100644 --- a/packages/apps/job-launcher/server/src/common/interceptors/snake-case.ts +++ b/packages/apps/job-launcher/server/src/common/interceptors/snake-case.ts @@ -5,6 +5,7 @@ import { NestInterceptor, StreamableFile, } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { @@ -12,16 +13,49 @@ import { transformKeysFromSnakeToCamel, } from '../utils/case-converter'; +export const SkipSnakeCaseTransform = Reflector.createDecorator< + | { + // Either skip specified top-level body props or skip it entirely + body?: string[] | boolean; + query?: boolean; + response?: boolean; + } + | undefined +>({ + key: 'skipSnakeCaseTransform', +}); + @Injectable() export class SnakeCaseInterceptor implements NestInterceptor { + constructor(private reflector: Reflector) {} + intercept(context: ExecutionContext, next: CallHandler): Observable { const request = context.switchToHttp().getRequest(); - if (request.body) { - request.body = transformKeysFromSnakeToCamel(request.body); + const skipTransformOptions = this.reflector.getAllAndOverride( + SkipSnakeCaseTransform, + [context.getHandler(), context.getClass()], + ) || { body: false, query: false, response: false }; + + const shouldSkipBody = skipTransformOptions.body === true; + if (request.body && !shouldSkipBody) { + const transformed = transformKeysFromSnakeToCamel(request.body); + + const skipBodyProps = Array.isArray(skipTransformOptions.body) + ? skipTransformOptions.body + : []; + for (const prop of skipBodyProps) { + if (prop in request.body) { + // @ts-expect-error - 'transformed' is same type as 'request.body' + transformed[prop] = request.body[prop]; + } + } + + request.body = transformed; } - if (request.query) { + const shouldSkipQuery = skipTransformOptions.query === true; + if (request.query && !shouldSkipQuery) { const transformedQuery = transformKeysFromSnakeToCamel(request.query); Object.defineProperty(request, 'query', { value: transformedQuery, @@ -33,9 +67,11 @@ export class SnakeCaseInterceptor implements NestInterceptor { return next.handle().pipe( map((data) => { - if (data instanceof StreamableFile) { + const shouldSkipResponse = skipTransformOptions.query === true; + if (data instanceof StreamableFile || shouldSkipResponse) { return data; } + return transformKeysFromCamelToSnake(data); }), ); diff --git a/packages/apps/job-launcher/server/src/modules/job/job.controller.ts b/packages/apps/job-launcher/server/src/modules/job/job.controller.ts index 1fda2dbe6b..cb9ee1b5c9 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.controller.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.controller.ts @@ -36,6 +36,7 @@ import { JobUnknownManifestDto, } from './job.dto'; import { JobService } from './job.service'; +import { SkipSnakeCaseTransform } from '../../common/interceptors/snake-case'; @ApiBearerAuth() @UseGuards(JwtAuthGuard) @@ -135,6 +136,7 @@ export class JobController { 'Endpoint to create a job using a manifest JSON body without validating its format.', }) @ApiBody({ type: JobUnknownManifestDto }) + @SkipSnakeCaseTransform({ body: ['manifest'] }) @ApiResponse({ status: 201, description: 'ID of the created job.',