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
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { t } from '@lingui/core/macro';
import { isDefined } from 'class-validator';
import { FieldMetadataType } from 'twenty-shared/types';
@@ -10,8 +11,8 @@ import {
FieldMetadataExceptionCode,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { ValidateOneFieldMetadataArgs } from 'src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-validator.service';
import { FailedFlatFieldMetadataValidationExceptions } from 'src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type';
import { type FlatFieldMetadataTypeValidator } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-type-validator.type';
import { FlatFieldMetadataValidationError } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-validation-error.type';
import { isEnumValidateOneFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-enum-validate-one-field-metadata-args.util';
import { validateEnumSelectFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-enum-flat-field-metadata.util';
import { validateRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-relation-flat-field-metadata.util';
@@ -60,10 +61,11 @@ export class FlatFieldMetadataTypeValidatorService {
if (!isMorphRelationEnabled) {
return [
new FieldMetadataException(
'Morph relation feature is disabled',
FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED,
),
{
code: FieldMetadataExceptionCode.UNCOVERED_FIELD_METADATA_TYPE_VALIDATION,
message: 'Morph relation feature flag is disabled',
userFriendlyMessage: t`Morph relation fields are disabled for your workspace`,
},
];
}
@@ -72,7 +74,7 @@ export class FlatFieldMetadataTypeValidatorService {
MULTI_SELECT: (args) => {
if (!isEnumValidateOneFieldMetadata(args)) {
throw new FieldMetadataException(
'Should never occur, invaliad enum field metadata type',
'Should never occur, invalid enum field metadata type',
FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR,
);
}
@@ -94,7 +96,7 @@ export class FlatFieldMetadataTypeValidatorService {
RATING: (args) => {
if (!isEnumValidateOneFieldMetadata(args)) {
throw new FieldMetadataException(
'Should never occur, invaliad enum field metadata type',
'Should never occur, invalid enum field metadata type',
FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR,
);
}
@@ -114,7 +116,7 @@ export class FlatFieldMetadataTypeValidatorService {
SELECT: (args) => {
if (!isEnumValidateOneFieldMetadata(args)) {
throw new FieldMetadataException(
'Should never occur, invaliad enum field metadata type',
'Should never occur, invalid enum field metadata type',
FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR,
);
}
@@ -140,7 +142,7 @@ export class FlatFieldMetadataTypeValidatorService {
workspaceId,
otherFlatObjectMetadataMapsToValidate,
}: ValidateOneFieldMetadataArgs<T>): Promise<
FailedFlatFieldMetadataValidationExceptions[]
FlatFieldMetadataValidationError[]
> {
const fieldMetadataTypeValidator =
this.FIELD_METADATA_TYPE_VALIDATOR_HASHMAP[
@@ -149,10 +151,12 @@ export class FlatFieldMetadataTypeValidatorService {
if (!isDefined(fieldMetadataTypeValidator)) {
return [
new FieldMetadataException(
'Unsupported field metadata type',
FieldMetadataExceptionCode.UNCOVERED_FIELD_METADATA_TYPE_VALIDATION,
),
{
code: FieldMetadataExceptionCode.UNCOVERED_FIELD_METADATA_TYPE_VALIDATION,
message: `Unsupported field metadata type ${flatFieldMetadataToValidate.type}`,
value: flatFieldMetadataToValidate.type,
userFriendlyMessage: t`Unsupported field metadata type ${flatFieldMetadataToValidate.type}`,
},
];
}
@@ -4,13 +4,10 @@ import { t } from '@lingui/core/macro';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
FieldMetadataException,
FieldMetadataExceptionCode,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { FLAT_FIELD_METADATA_RELATION_PROPERTIES_TO_COMPARE } from 'src/engine/metadata-modules/flat-field-metadata/constants/flat-field-metadata-relation-properties-to-compare.constant';
import { FlatFieldMetadataTypeValidatorService } from 'src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-type-validator.service';
import { type 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 { FlatFieldMetadataRelationPropertiesToCompare } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-relation-properties-to-compare.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { compareTwoFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/compare-two-flat-field-metadata.util';
@@ -21,14 +18,6 @@ import { validateFlatFieldMetadataNameAvailability } from 'src/engine/metadata-m
import { validateFlatFieldMetadataName } from 'src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-flat-field-metadata-name.util';
import { type FlatObjectMetadataMaps } from 'src/engine/metadata-modules/flat-object-metadata-maps/types/flat-object-metadata-maps.type';
import { fromFlatObjectMetadataWithFlatFieldMapsToFlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-flat-object-metadata-with-flat-field-maps-to-flat-object-metadatas.util';
import {
ObjectMetadataException,
ObjectMetadataExceptionCode,
} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
import {
InvalidMetadataException,
InvalidMetadataExceptionCode,
} from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception';
import { isStandardMetadata } from 'src/engine/metadata-modules/utils/is-standard-metadata.util';
export type ValidateOneFieldMetadataArgs<
@@ -50,34 +39,41 @@ export class FlatFieldMetadataValidatorService {
existingFlatObjectMetadataMaps,
flatFieldMetadataToValidate: updatedFlatFieldMetadata,
workspaceId,
}: ValidateOneFieldMetadataArgs<T>): Promise<
FailedFlatFieldMetadataValidationExceptions[]
> {
const errors: FailedFlatFieldMetadataValidationExceptions[] = [];
}: ValidateOneFieldMetadataArgs<T>): Promise<FailedFlatFieldMetadataValidation> {
const validationResult: FailedFlatFieldMetadataValidation = {
type: 'update_field',
errors: [],
fieldMinimalInformation: {
id: updatedFlatFieldMetadata.id,
name: updatedFlatFieldMetadata.name,
objectMetadataId: updatedFlatFieldMetadata.objectMetadataId,
},
};
const flatObjectMetadataWithFlatFieldMaps =
existingFlatObjectMetadataMaps.byId[
updatedFlatFieldMetadata.objectMetadataId
];
if (!isDefined(flatObjectMetadataWithFlatFieldMaps)) {
return [
new FieldMetadataException(
'field metadata to update object metadata not found',
FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
),
];
validationResult.errors.push({
code: FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
message: 'field metadata to update object metadata not found',
userFriendlyMessage: t`Object related to field to update not found`,
});
return validationResult;
}
if (
!isDefined(
flatObjectMetadataWithFlatFieldMaps.labelIdentifierFieldMetadataId,
)
) {
errors.push(
new FieldMetadataException(
'Label identifier field metadata id does not exist',
FieldMetadataExceptionCode.LABEL_IDENTIFIER_FIELD_METADATA_ID_NOT_FOUND,
),
);
validationResult.errors.push({
code: FieldMetadataExceptionCode.LABEL_IDENTIFIER_FIELD_METADATA_ID_NOT_FOUND,
message: 'Label identifier field metadata id does not exist',
userFriendlyMessage: t`Object related to updated field does not have a label identifier`,
});
}
const existingFlatFieldMetadataToUpdate =
@@ -86,14 +82,13 @@ export class FlatFieldMetadataValidatorService {
];
if (!isDefined(existingFlatFieldMetadataToUpdate)) {
errors.push(
new FieldMetadataException(
'field metadata to update not found',
FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND,
),
);
validationResult.errors.push({
code: FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND,
message: 'field metadata to update not found',
userFriendlyMessage: t`Field to update not found`,
});
return errors;
return validationResult;
}
const updates = compareTwoFlatFieldMetadata({
@@ -121,12 +116,11 @@ export class FlatFieldMetadataValidatorService {
);
if (relationNonEditableUpdatedProperties.length > 0) {
errors.push(
new FieldMetadataException(
`Forbidden updated properties for relation field metadata: ${relationNonEditableUpdatedProperties.join(', ')}`,
FieldMetadataExceptionCode.FIELD_MUTATION_NOT_ALLOWED,
),
);
validationResult.errors.push({
code: FieldMetadataExceptionCode.FIELD_MUTATION_NOT_ALLOWED,
message: `Forbidden updated properties for relation field metadata: ${relationNonEditableUpdatedProperties.join(', ')}`,
userFriendlyMessage: t`Forbidden updated properties for relation field metadata`,
});
}
}
@@ -136,31 +130,24 @@ export class FlatFieldMetadataValidatorService {
);
if (updates.some((update) => update.property === 'name')) {
errors.push(
validationResult.errors.push(
...validateFlatFieldMetadataName(updatedFlatFieldMetadata.name),
);
const nameAvailabilityValidationError =
validateFlatFieldMetadataNameAvailability({
...validateFlatFieldMetadataNameAvailability({
name: updatedFlatFieldMetadata.name,
flatObjectMetadata: flatObjectMetadata,
});
if (isDefined(nameAvailabilityValidationError)) {
errors.push(nameAvailabilityValidationError);
}
}),
);
}
if (
updatedFlatFieldMetadata.isLabelSyncedWithName &&
!isFlatFieldMetadataNameSyncedWithLabel(updatedFlatFieldMetadata)
) {
errors.push(
new InvalidMetadataException(
t`Name is not synced with label.`,
InvalidMetadataExceptionCode.NAME_NOT_SYNCED_WITH_LABEL,
),
);
validationResult.errors.push({
code: FieldMetadataExceptionCode.FIELD_MUTATION_NOT_ALLOWED,
message: `Name is not synced with label.`,
userFriendlyMessage: t`Updated field name is not synced with label`,
});
}
const fieldMetadataTypeValidationErrors =
@@ -173,10 +160,10 @@ export class FlatFieldMetadataValidatorService {
);
if (fieldMetadataTypeValidationErrors.length > 0) {
errors.push(...fieldMetadataTypeValidationErrors);
validationResult.errors.push(...fieldMetadataTypeValidationErrors);
}
return errors;
return validationResult;
}
validateFlatFieldMetadataDeletion({
@@ -185,8 +172,16 @@ export class FlatFieldMetadataValidatorService {
}: {
flatFieldMetadataToDelete: FlatFieldMetadata;
existingFlatObjectMetadataMaps: FlatObjectMetadataMaps;
}): FailedFlatFieldMetadataValidationExceptions[] {
const errors: FailedFlatFieldMetadataValidationExceptions[] = [];
}): FailedFlatFieldMetadataValidation {
const validationResult: FailedFlatFieldMetadataValidation = {
type: 'delete_field',
errors: [],
fieldMinimalInformation: {
id: flatFieldMetadataToDelete.id,
name: flatFieldMetadataToDelete.name,
objectMetadataId: flatFieldMetadataToDelete.objectMetadataId,
},
};
const flatObjectMetadataWithFieldMaps =
existingFlatObjectMetadataMaps.byId[
@@ -194,26 +189,22 @@ export class FlatFieldMetadataValidatorService {
];
if (!isDefined(flatObjectMetadataWithFieldMaps)) {
errors.push(
new FieldMetadataException(
'field to delete object metadata not found',
FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
),
);
validationResult.errors.push({
code: FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
message: 'field to delete object metadata not found',
userFriendlyMessage: t`Object related to field to delete not found`,
});
} else {
if (
flatObjectMetadataWithFieldMaps.labelIdentifierFieldMetadataId ===
flatFieldMetadataToDelete.id
) {
errors.push(
new FieldMetadataException(
validationResult.errors.push({
code: FieldMetadataExceptionCode.FIELD_MUTATION_NOT_ALLOWED,
message:
'Cannot delete, please update the label identifier field first',
FieldMetadataExceptionCode.FIELD_MUTATION_NOT_ALLOWED,
{
userFriendlyMessage: t`Cannot delete, please update the label identifier field first`,
},
),
);
userFriendlyMessage: t`Cannot delete, please update the label identifier field first`,
});
}
}
@@ -229,27 +220,25 @@ export class FlatFieldMetadataValidatorService {
isStandardMetadata(flatFieldMetadataToDelete) &&
!isRelationFieldAndRelationTargetObjectMetadataHasBeenDeleted
) {
errors.push(
new FieldMetadataException(
"Standard Fields can't be deleted",
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
),
);
validationResult.errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: "Standard Fields can't be deleted",
userFriendlyMessage: t`Standard fields cannot be deleted`,
});
}
if (
flatFieldMetadataToDelete.isActive &&
!isRelationFieldAndRelationTargetObjectMetadataHasBeenDeleted
) {
errors.push(
new FieldMetadataException(
"Active fields can't be deleted",
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
),
);
validationResult.errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: "Active fields can't be deleted",
userFriendlyMessage: t`Active fields cannot be deleted`,
});
}
return errors;
return validationResult;
}
async validateFlatFieldMetadataCreation<
@@ -259,10 +248,16 @@ export class FlatFieldMetadataValidatorService {
flatFieldMetadataToValidate,
otherFlatObjectMetadataMapsToValidate,
workspaceId,
}: ValidateOneFieldMetadataArgs<T>): Promise<
FailedFlatFieldMetadataValidationExceptions[]
> {
const errors: FailedFlatFieldMetadataValidationExceptions[] = [];
}: ValidateOneFieldMetadataArgs<T>): Promise<FailedFlatFieldMetadataValidation> {
const validationResult: FailedFlatFieldMetadataValidation = {
errors: [],
fieldMinimalInformation: {
id: flatFieldMetadataToValidate.id,
name: flatFieldMetadataToValidate.name,
objectMetadataId: flatFieldMetadataToValidate.objectMetadataId,
},
type: 'create_field',
};
const parentFlatObjectMetadata =
otherFlatObjectMetadataMapsToValidate?.byId[
@@ -273,65 +268,59 @@ export class FlatFieldMetadataValidatorService {
];
if (!isDefined(parentFlatObjectMetadata)) {
errors.push(
new FieldMetadataException(
isDefined(otherFlatObjectMetadataMapsToValidate)
? 'Object metadata not found in both existing and about to be created object metadatas'
: 'Object metadata not found',
FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
),
);
validationResult.errors.push({
code: FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
message: isDefined(otherFlatObjectMetadataMapsToValidate)
? 'Object metadata not found in both existing and about to be created object metadatas'
: 'Object metadata not found',
userFriendlyMessage: t`Field to create related object not found`,
});
} else {
if (
isDefined(
parentFlatObjectMetadata.fieldsById[flatFieldMetadataToValidate.id],
)
) {
errors.push(
new ObjectMetadataException(
'Field with same id already exists in object',
ObjectMetadataExceptionCode.OBJECT_MUTATION_NOT_ALLOWED,
),
);
validationResult.errors.push({
code: FieldMetadataExceptionCode.FIELD_ALREADY_EXISTS,
message: 'Field with same id already exists in object',
userFriendlyMessage: t`Field already exists`,
});
}
if (parentFlatObjectMetadata.isRemote === true) {
errors.push(
new ObjectMetadataException(
'Remote objects are read-only',
ObjectMetadataExceptionCode.OBJECT_MUTATION_NOT_ALLOWED,
),
);
validationResult.errors.push({
code: FieldMetadataExceptionCode.FIELD_MUTATION_NOT_ALLOWED,
message: 'Remote objects are read-only',
userFriendlyMessage: t`Remote objects are not production ready yet`,
});
}
const failedNameAvailabilityValidation =
validateFlatFieldMetadataNameAvailability({
validationResult.errors.push(
...validateFlatFieldMetadataNameAvailability({
name: flatFieldMetadataToValidate.name,
flatObjectMetadata: parentFlatObjectMetadata,
});
if (isDefined(failedNameAvailabilityValidation)) {
errors.push(failedNameAvailabilityValidation);
}
}),
);
}
if (
flatFieldMetadataToValidate.isLabelSyncedWithName &&
!isFlatFieldMetadataNameSyncedWithLabel(flatFieldMetadataToValidate)
) {
errors.push(
new InvalidMetadataException(
t`Name is not synced with label.`,
InvalidMetadataExceptionCode.NAME_NOT_SYNCED_WITH_LABEL,
),
);
validationResult.errors.push({
code: FieldMetadataExceptionCode.NAME_NOT_SYNCED_WITH_LABEL,
message: `Name is not synced with label`,
userFriendlyMessage: t`Field name is not synced with field label`,
value: flatFieldMetadataToValidate.label,
});
}
errors.push(
validationResult.errors.push(
...validateFlatFieldMetadataName(flatFieldMetadataToValidate.name),
);
errors.push(
validationResult.errors.push(
...(await this.flatFieldMetadataTypeValidatorService.validateFlatFieldMetadataTypeSpecificities(
{
existingFlatObjectMetadataMaps,
@@ -342,6 +331,6 @@ export class FlatFieldMetadataValidatorService {
)),
);
return errors;
return validationResult;
}
}