Fail slow + structured validator error response scoped by object and fields (#13980)

# Introduction
Example of returned response: ( snapshot copy don't mind jest expect any
values )
```json
{
  "extensions": {
    "code": "BAD_USER_INPUT",
    "errors": {
      "fieldMetadata": [
        {
          "errors": [
            {
              "code": "INVALID_FIELD_INPUT",
              "message": "Name is too long",
              "userFriendlyMessage": "Name is too long",
              "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
            },
          ],
          "id": Any<String>,
          "name": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
          "objectMetadataId": Any<String>,
          "operation": "create_field",
        },
        {
          "errors": [
            {
              "code": "INVALID_FIELD_INPUT",
              "message": "Name is too long",
              "userFriendlyMessage": "Name is too long",
              "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
            },
          ],
          "id": Any<String>,
          "name": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
          "objectMetadataId": Any<String>,
          "operation": "create_field",
        },
        {
          "errors": [
            {
              "code": "INVALID_FIELD_INPUT",
              "message": "Name is too long",
              "userFriendlyMessage": "Name is too long",
              "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
            },
          ],
          "id": Any<String>,
          "name": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
          "objectMetadataId": Any<String>,
          "operation": "create_field",
        },
        {
          "errors": [
            {
              "code": "INVALID_FIELD_INPUT",
              "message": "Name is too long",
              "userFriendlyMessage": "Name is too long",
              "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
            },
          ],
          "id": Any<String>,
          "name": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
          "objectMetadataId": Any<String>,
          "operation": "create_field",
        },
        {
          "errors": [
            {
              "code": "INVALID_FIELD_INPUT",
              "message": "Name is too long",
              "userFriendlyMessage": "Name is too long",
              "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
            },
          ],
          "id": Any<String>,
          "name": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
          "objectMetadataId": Any<String>,
          "operation": "create_field",
        },
      ],
      "objectMetadata": [
        {
          "errors": [
            {
              "code": "INVALID_OBJECT_INPUT",
              "message": "Name is too long",
              "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
            },
          ],
          "fields": [],
          "id": Any<String>,
          "namePlural": "listingas",
          "nameSingular": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
          "operation": "create_object",
        },
      ],
    },
    "message": "Validation failed for 1 object(s) and 5 field(s)",
    "summary": {
      "invalidFields": 5,
      "invalidObjects": 1,
      "totalErrors": 6,
    },
    "userFriendlyMessage": "Validation failed for 1 object(s) and 5 field(s)",
  },
  "message": "Multiple validation errors occurred while creating object",
  "name": "GraphQLError",
}
```

## Note
We should put in place integrations tests on REST API too, in order to
ensure we receive the same response error
This commit is contained in:
Paul Rastoin
2025-08-20 19:33:15 +02:00
committed by GitHub
parent 4752970f79
commit e99c67d6b5
53 changed files with 2589 additions and 810 deletions
@@ -0,0 +1,11 @@
import { type FailedWorkspaceMigrationBuildResult } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/services/workspace-migration-builder-v2.service';
export class WorkspaceMigrationBuilderExceptionV2 extends Error {
constructor(
public readonly failedWorkspaceMigrationBuildResult: FailedWorkspaceMigrationBuildResult,
message = 'Workspace migration builder failed',
) {
super(message);
this.name = 'WorkspaceMigrationBuilderExceptionV2';
}
}
@@ -0,0 +1,29 @@
import { type FieldMetadataMinimalInformation } from 'src/engine/metadata-modules/flat-field-metadata/types/field-metadata-minimal-information.type';
import { type FlatFieldMetadataValidationError } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-validation-error.type';
import { type FlatObjectMetadataValidationError } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata-validation-error.type';
import { type ObjectMetadataMinimalInformation } from 'src/engine/metadata-modules/flat-object-metadata/types/object-metadata-minimal-information.type';
import { type WorkspaceMigrationFieldActionTypeV2 } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/workspace-migration-field-action-v2';
import { type WorkspaceMigrationObjectActionTypeV2 } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/workspace-migration-object-action-v2';
export type ValidationErrorFieldResponse =
Partial<FieldMetadataMinimalInformation> & {
operation: WorkspaceMigrationFieldActionTypeV2;
errors: FlatFieldMetadataValidationError[];
};
export type ValidationErrorObjectResponse =
Partial<ObjectMetadataMinimalInformation> & {
operation: WorkspaceMigrationObjectActionTypeV2;
errors: FlatObjectMetadataValidationError[];
fields: ValidationErrorFieldResponse[];
};
export type ValidationErrorResponse = {
summary: {
totalErrors: number;
invalidFields: number;
invalidObjects: number;
};
errors: {
objectMetadata: ValidationErrorObjectResponse[];
fieldMetadata: ValidationErrorFieldResponse[];
};
};
@@ -0,0 +1,18 @@
import { type FailedFlatFieldMetadataValidation } from 'src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type';
import { type ValidationErrorFieldResponse } from 'src/engine/workspace-manager/workspace-migration-v2/interceptors/types/validate-error-response.type';
export const fromFailedFlatFieldMetadataValidationToValidationErrorFieldResponse =
(
failedFlatFieldMetadataValidation: FailedFlatFieldMetadataValidation,
): ValidationErrorFieldResponse => {
const { id, name, objectMetadataId } =
failedFlatFieldMetadataValidation.fieldMinimalInformation;
return {
operation: failedFlatFieldMetadataValidation.type,
id,
name,
errors: failedFlatFieldMetadataValidation.errors,
objectMetadataId,
};
};
@@ -0,0 +1,24 @@
import { type FailedFlatObjectMetadataValidation } from 'src/engine/metadata-modules/flat-object-metadata/types/failed-flat-object-metadata-validation.type';
import { type ValidationErrorObjectResponse } from 'src/engine/workspace-manager/workspace-migration-v2/interceptors/types/validate-error-response.type';
import { fromFailedFlatFieldMetadataValidationToValidationErrorFieldResponse } from 'src/engine/workspace-manager/workspace-migration-v2/interceptors/utils/from-failed-flat-field-metadata-validation-to-validation-error-field-response.util';
export const fromFailedFlatObjectMetadataValidationToValidationErrorObjectResponse =
(
failedFlatObjectMetadataValidation: FailedFlatObjectMetadataValidation,
): ValidationErrorObjectResponse => {
const { id, namePlural, nameSingular } =
failedFlatObjectMetadataValidation.objectMinimalInformation;
const fields = failedFlatObjectMetadataValidation.fieldLevelErrors.map(
fromFailedFlatFieldMetadataValidationToValidationErrorFieldResponse,
);
const objectResponseError: ValidationErrorObjectResponse = {
operation: failedFlatObjectMetadataValidation.type,
fields,
id,
namePlural,
nameSingular,
errors: failedFlatObjectMetadataValidation.objectLevelErrors,
};
return objectResponseError;
};
@@ -0,0 +1,65 @@
import { isFailedFlatObjectMetadataValidation } from 'src/engine/metadata-modules/flat-object-metadata/utils/is-failed-flat-object-metadata-validation.util';
import { type WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2';
import { type ValidationErrorResponse } from 'src/engine/workspace-manager/workspace-migration-v2/interceptors/types/validate-error-response.type';
import { fromFailedFlatFieldMetadataValidationToValidationErrorFieldResponse } from 'src/engine/workspace-manager/workspace-migration-v2/interceptors/utils/from-failed-flat-field-metadata-validation-to-validation-error-field-response.util';
import { fromFailedFlatObjectMetadataValidationToValidationErrorObjectResponse } from 'src/engine/workspace-manager/workspace-migration-v2/interceptors/utils/from-failed-flat-object-metadata-validation-to-validation-error-object-response.util';
export const fromWorkspaceMigrationBuilderExceptionToValidationResponseError = (
workspaceMigrationBuilderException: WorkspaceMigrationBuilderExceptionV2,
) => {
const emptyResponseError: ValidationErrorResponse = {
summary: {
invalidFields: 0,
invalidObjects: 0,
totalErrors: 0,
},
errors: {
fieldMetadata: [],
objectMetadata: [],
},
};
return workspaceMigrationBuilderException.failedWorkspaceMigrationBuildResult.errors.reduce<ValidationErrorResponse>(
({ errors, summary }, failedValidationError) => {
if (isFailedFlatObjectMetadataValidation(failedValidationError)) {
const errorObjectResponse =
fromFailedFlatObjectMetadataValidationToValidationErrorObjectResponse(
failedValidationError,
);
return {
summary: {
...summary,
invalidFields:
summary.invalidFields + errorObjectResponse.fields.length,
invalidObjects: ++summary.invalidObjects,
totalErrors:
++summary.totalErrors + errorObjectResponse.fields.length,
},
errors: {
...errors,
objectMetadata: [...errors.objectMetadata, errorObjectResponse],
},
};
}
const fieldResponseError =
fromFailedFlatFieldMetadataValidationToValidationErrorFieldResponse(
failedValidationError,
);
return {
summary: {
...summary,
invalidFields: ++summary.invalidFields,
totalErrors: ++summary.totalErrors,
},
errors: {
...errors,
fieldMetadata: [...errors.fieldMetadata, fieldResponseError],
},
};
},
emptyResponseError,
);
};
@@ -0,0 +1,42 @@
import {
type CallHandler,
type ExecutionContext,
type NestInterceptor,
} from '@nestjs/common';
import { t } from '@lingui/core/macro';
import { catchError, type Observable } from 'rxjs';
import {
BaseGraphQLError,
ErrorCode,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2';
import { fromWorkspaceMigrationBuilderExceptionToValidationResponseError } from 'src/engine/workspace-manager/workspace-migration-v2/interceptors/utils/from-workspace-migration-builder-exception-to-validation-response-error.util';
export class WorkspaceMigrationBuilderExceptionV2Interceptor
implements NestInterceptor
{
intercept(_: ExecutionContext, next: CallHandler): Observable<unknown> {
return next.handle().pipe(
catchError((error) => {
if (!(error instanceof WorkspaceMigrationBuilderExceptionV2)) {
throw error;
}
const { errors, summary } =
fromWorkspaceMigrationBuilderExceptionToValidationResponseError(
error,
);
throw new BaseGraphQLError(error.message, ErrorCode.BAD_USER_INPUT, {
code: 'METADATA_VALIDATION_ERROR',
errors,
summary,
message: `Validation failed for ${summary.invalidObjects} object(s) and ${summary.invalidFields} field(s)`,
userFriendlyMessage: t`Validation failed for ${summary.invalidObjects} object(s) and ${summary.invalidFields} field(s)`,
});
}),
);
}
}
@@ -1,7 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { MultipleMetadataValidationErrors } from 'src/engine/core-modules/error/multiple-metadata-validation-errors';
import {
FailedWorkspaceMigrationBuildResult,
WorkspaceMigrationBuildArgs,
WorkspaceMigrationBuilderV2Service,
} from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/services/workspace-migration-builder-v2.service';
@@ -22,10 +22,9 @@ export class WorkspaceMigrationValidateBuildAndRunService {
private readonly workspaceMigrationRunnerV2Service: WorkspaceMigrationRunnerV2Service,
) {}
public async validateBuildAndRunWorkspaceMigration({
errorMessage,
...builderArgs
}: WorkspaceMigrationBuildArgs & { errorMessage: string }) {
public async validateBuildAndRunWorkspaceMigration(
builderArgs: WorkspaceMigrationBuildArgs,
): Promise<FailedWorkspaceMigrationBuildResult | undefined> {
const validateAndBuildResult = await this.workspaceMigrationBuilderV2Service
.validateAndBuild(builderArgs)
.catch((error) => {
@@ -37,10 +36,7 @@ export class WorkspaceMigrationValidateBuildAndRunService {
});
if (validateAndBuildResult.status === 'fail') {
throw new MultipleMetadataValidationErrors(
validateAndBuildResult.errors,
errorMessage,
);
return validateAndBuildResult;
}
await this.workspaceMigrationRunnerV2Service
@@ -2,9 +2,9 @@ import { Injectable } from '@nestjs/common';
import { type FromTo } from 'twenty-shared/types';
import { FailedFlatFieldMetadataValidationExceptions } from 'src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type';
import { FailedFlatFieldMetadataValidation } from 'src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type';
import { type FlatObjectMetadataMaps } from 'src/engine/metadata-modules/flat-object-metadata-maps/types/flat-object-metadata-maps.type';
import { FailedFlatObjectMetadataValidationExceptions } from 'src/engine/metadata-modules/flat-object-metadata/types/failed-flat-object-metadata-validation.type';
import { FailedFlatObjectMetadataValidation } from 'src/engine/metadata-modules/flat-object-metadata/types/failed-flat-object-metadata-validation.type';
import { fromFlatObjectMetadataMapsToFlatObjectMetadatas } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-flat-object-metadata-maps-to-flat-object-metadatas.util';
import { deletedCreatedUpdatedMatrixDispatcher } from 'src/engine/workspace-manager/workspace-migration-v2/utils/deleted-created-updated-matrix-dispatcher.util';
import { WorkspaceMigrationV2FieldActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/services/workspace-migration-v2-field-actions-builder.service';
@@ -20,6 +20,19 @@ export type WorkspaceMigrationV2BuilderOptions = {
isSystemBuild: boolean;
};
type SuccessfulWorkspaceMigrationBuildResult = {
status: 'success';
workspaceMigration: WorkspaceMigrationV2;
};
export type FailedWorkspaceMigrationBuildResult = {
status: 'fail';
errors: (
| FailedFlatObjectMetadataValidation
| FailedFlatFieldMetadataValidation
)[];
};
export type WorkspaceMigrationBuildArgs = {
workspaceId: string;
buildOptions: WorkspaceMigrationV2BuilderOptions;
@@ -37,17 +50,8 @@ export class WorkspaceMigrationBuilderV2Service {
workspaceId,
buildOptions,
}: WorkspaceMigrationBuildArgs): Promise<
| {
status: 'fail';
errors: (
| FailedFlatObjectMetadataValidationExceptions
| FailedFlatFieldMetadataValidationExceptions
)[];
}
| {
status: 'success';
workspaceMigration: WorkspaceMigrationV2;
}
| SuccessfulWorkspaceMigrationBuildResult
| FailedWorkspaceMigrationBuildResult
> {
const fromFlatObjectMetadatas =
fromFlatObjectMetadataMapsToFlatObjectMetadatas(
@@ -93,8 +93,8 @@ export class WorkspaceMigrationV2FieldActionsBuilderService {
},
);
if (validationErrors.length > 0) {
validateAndBuildResult.failed.push(...validationErrors);
if (validationErrors.errors.length > 0) {
validateAndBuildResult.failed.push(validationErrors);
continue;
}
@@ -142,8 +142,8 @@ export class WorkspaceMigrationV2FieldActionsBuilderService {
},
);
if (validationErrors.length > 0) {
validateAndBuildResult.failed.push(...validationErrors);
if (validationErrors.errors.length > 0) {
validateAndBuildResult.failed.push(validationErrors);
continue;
}
@@ -174,8 +174,8 @@ export class WorkspaceMigrationV2FieldActionsBuilderService {
},
);
if (validationErrors.length > 0) {
validateAndBuildResult.failed.push(...validationErrors);
if (validationErrors.errors.length > 0) {
validateAndBuildResult.failed.push(validationErrors);
continue;
}
@@ -72,8 +72,11 @@ export class WorkspaceMigrationV2ObjectActionsBuilderService {
},
);
if (validationErrors.length > 0) {
validateAndBuildResult.failed.push(...validationErrors);
if (
validationErrors.fieldLevelErrors.length > 0 ||
validationErrors.objectLevelErrors.length > 0
) {
validateAndBuildResult.failed.push(validationErrors);
continue;
}
@@ -113,8 +116,11 @@ export class WorkspaceMigrationV2ObjectActionsBuilderService {
},
);
if (validationErrors.length > 0) {
validateAndBuildResult.failed.push(...validationErrors);
if (
validationErrors.fieldLevelErrors.length > 0 ||
validationErrors.objectLevelErrors.length > 0
) {
validateAndBuildResult.failed.push(validationErrors);
continue;
}
@@ -154,8 +160,11 @@ export class WorkspaceMigrationV2ObjectActionsBuilderService {
},
);
if (validationErrors.length > 0) {
validateAndBuildResult.failed.push(...validationErrors);
if (
validationErrors.fieldLevelErrors.length > 0 ||
validationErrors.objectLevelErrors.length > 0
) {
validateAndBuildResult.failed.push(validationErrors);
continue;
}
@@ -1,14 +1,14 @@
import { type FailedFlatFieldMetadataValidationExceptions } from 'src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type';
import { type FailedFlatFieldMetadataValidation } from 'src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type';
import { type FlatObjectMetadataMaps } from 'src/engine/metadata-modules/flat-object-metadata-maps/types/flat-object-metadata-maps.type';
import { type FailedFlatObjectMetadataValidationExceptions } from 'src/engine/metadata-modules/flat-object-metadata/types/failed-flat-object-metadata-validation.type';
import { type FailedFlatObjectMetadataValidation } from 'src/engine/metadata-modules/flat-object-metadata/types/failed-flat-object-metadata-validation.type';
import { type WorkspaceMigrationActionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/workspace-migration-action-common-v2';
export type ValidateAndBuildMetadataResult<
T extends WorkspaceMigrationActionV2,
> = {
failed: (
| FailedFlatFieldMetadataValidationExceptions
| FailedFlatObjectMetadataValidationExceptions
| FailedFlatFieldMetadataValidation
| FailedFlatObjectMetadataValidation
)[];
created: T[];
deleted: T[];
@@ -35,3 +35,9 @@ export type WorkspaceMigrationObjectActionV2 =
export type WorkspaceMigrationObjectActionTypeV2 =
WorkspaceMigrationObjectActionV2['type'];
export const WORKSPACE_MIGRATION_OBJECT_ACTION_TYPES = [
'create_object',
'delete_object',
'update_object',
] as const satisfies WorkspaceMigrationObjectActionTypeV2[];