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,3 +1,4 @@
import { t } from '@lingui/core/macro';
import { FieldMetadataType } from 'twenty-shared/types';
import {
assertUnreachable,
@@ -10,10 +11,7 @@ import { type FieldMetadataOptions } from 'src/engine/metadata-modules/field-met
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { type CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
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 { generateRatingOptions } from 'src/engine/metadata-modules/field-metadata/utils/generate-rating-optionts.util';
import { type FieldInputTranspilationResult } from 'src/engine/metadata-modules/flat-field-metadata/types/field-input-transpilation-result.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
@@ -37,10 +35,10 @@ export const fromCreateFieldInputToFlatFieldMetadatasToCreate = async ({
if (rawCreateFieldInput.isRemoteCreation) {
return {
status: 'fail',
error: new FieldMetadataException(
"Remote fields aren't supported",
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
),
error: {
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: "Remote fields aren't supported",
},
};
}
const createFieldInput =
@@ -54,14 +52,11 @@ export const fromCreateFieldInputToFlatFieldMetadatasToCreate = async ({
if (!isDefined(parentFlatObjectMetadata)) {
return {
status: 'fail',
error: new FieldMetadataException(
'Provided object metadata id does not exist',
FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
{
userFriendlyMessage:
'Created field metadata, parent object metadata not found',
},
),
error: {
code: FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
message: 'Provided object metadata id does not exist',
userFriendlyMessage: t`Created field metadata, parent object metadata not found`,
},
};
}
@@ -74,7 +69,6 @@ export const fromCreateFieldInputToFlatFieldMetadatasToCreate = async ({
switch (createFieldInput.type) {
case FieldMetadataType.MORPH_RELATION: {
// TODO prastoin
throw new UserInputError(
'Morph relation feature is not migrated to workspace migration v2 yet',
);
@@ -128,10 +122,10 @@ export const fromCreateFieldInputToFlatFieldMetadatasToCreate = async ({
case FieldMetadataType.TS_VECTOR: {
return {
status: 'fail',
error: new FieldMetadataException(
'TS Vector is not supported for field creation',
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
),
error: {
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: 'TS Vector is not supported for field creation',
},
};
}
case FieldMetadataType.UUID:
@@ -1,3 +1,4 @@
import { t } from '@lingui/core/macro';
import { FieldMetadataType } from 'twenty-shared/types';
import {
isDefined,
@@ -61,10 +62,12 @@ export const fromRelationCreateFieldInputToFlatFieldMetadata = async ({
if (!isDefined(rawCreationPayload)) {
return {
status: 'fail',
error: new FieldMetadataException(
`Relation creation payload is required`,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
),
error: {
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: `Relation creation payload is required`,
userFriendlyMessage: t`Relation creation payload is required`,
value: rawCreationPayload,
},
};
}
@@ -80,7 +83,12 @@ export const fromRelationCreateFieldInputToFlatFieldMetadata = async ({
if (error instanceof FieldMetadataException) {
return {
status: 'fail',
error,
error: {
code: FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED,
message: `Relation creation payload is invalid ${JSON.stringify(relationCreationPayload)}`,
userFriendlyMessage: t`Invalid relation creation payload`,
value: relationCreationPayload,
},
};
} else {
throw error;
@@ -95,10 +103,12 @@ export const fromRelationCreateFieldInputToFlatFieldMetadata = async ({
if (!isDefined(targetParentFlatObjectMetadata)) {
return {
status: 'fail',
error: new FieldMetadataException(
`Object metadata relation target not found for relation creation payload`,
FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED,
),
error: {
code: FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED,
message: `Object metadata relation target not found for relation creation payload`,
userFriendlyMessage: t`Object targeted by field to create not found`,
value: relationCreationPayload,
},
};
}
@@ -1,15 +1,12 @@
import { t } from '@lingui/core/macro';
import {
extractAndSanitizeObjectStringFields,
isDefined,
trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties,
} from 'twenty-shared/utils';
import { FIELD_METADATA_STANDARD_OVERRIDES_PROPERTIES } from 'src/engine/metadata-modules/field-metadata/constants/field-metadata-standard-overrides-properties.constant';
import { type UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
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 { type FieldMetadataStandardOverridesProperties } from 'src/engine/metadata-modules/field-metadata/types/field-metadata-standard-overrides-properties.type';
import { FLAT_FIELD_METADATA_PROPERTIES_TO_COMPARE } from 'src/engine/metadata-modules/flat-field-metadata/constants/flat-field-metadata-properties-to-compare.constant';
import { type FieldInputTranspilationResult } from 'src/engine/metadata-modules/flat-field-metadata/types/field-input-transpilation-result.type';
@@ -18,10 +15,6 @@ import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-m
import {} from 'src/engine/metadata-modules/flat-field-metadata/utils/compare-two-flat-field-metadata.util';
import { type FlatObjectMetadataMaps } from 'src/engine/metadata-modules/flat-object-metadata-maps/types/flat-object-metadata-maps.type';
import { findFlatFieldMetadataInFlatObjectMetadataMapsWithOnlyFieldId } from 'src/engine/metadata-modules/flat-object-metadata-maps/utils/find-flat-field-metadata-in-flat-object-metadata-maps-with-field-id-only.util';
import {
ObjectMetadataException,
ObjectMetadataExceptionCode,
} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
import { isStandardMetadata } from 'src/engine/metadata-modules/utils/is-standard-metadata.util';
const fieldMetadataEditableProperties =
@@ -43,10 +36,10 @@ export const fromUpdateFieldInputToFlatFieldMetadata = ({
updateFieldInput: rawUpdateFieldInput,
}: FromUpdateFieldInputToFlatFieldMetadataArgs): FieldInputTranspilationResult<FlatFieldMetadata> => {
const updateFieldInputInformalProperties =
trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties(
rawUpdateFieldInput,
['objectMetadataId', 'id'],
);
extractAndSanitizeObjectStringFields(rawUpdateFieldInput, [
'objectMetadataId',
'id',
]);
const updatedEditableFieldProperties = extractAndSanitizeObjectStringFields(
rawUpdateFieldInput,
fieldMetadataEditableProperties,
@@ -61,10 +54,11 @@ export const fromUpdateFieldInputToFlatFieldMetadata = ({
if (!isDefined(relatedFlatFieldMetadata)) {
return {
status: 'fail',
error: new FieldMetadataException(
'Field metadata to update not found',
FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND,
),
error: {
code: FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND,
message: 'Field metadata to update not found',
userFriendlyMessage: t`Field metadata to update not found`,
},
};
}
@@ -76,20 +70,21 @@ export const fromUpdateFieldInputToFlatFieldMetadata = ({
if (!isDefined(flatObjectMetadataWithFlatFieldMaps)) {
return {
status: 'fail',
error: new FieldMetadataException(
'Field metadata to update object metadata not found',
FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND,
),
error: {
code: FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND,
message: 'Field metadata to update object metadata not found',
userFriendlyMessage: t`Field metadata to update object metadata not found`,
},
};
}
if (flatObjectMetadataWithFlatFieldMaps.isRemote) {
return {
status: 'fail',
error: new ObjectMetadataException(
'Remote objects are read-only',
ObjectMetadataExceptionCode.OBJECT_MUTATION_NOT_ALLOWED,
),
error: {
code: FieldMetadataExceptionCode.FIELD_MUTATION_NOT_ALLOWED,
message: 'Remote objects are read-only',
},
};
}
@@ -105,10 +100,11 @@ export const fromUpdateFieldInputToFlatFieldMetadata = ({
if (invalidUpdatedProperties.length > 0) {
return {
status: 'fail',
error: new FieldMetadataException(
`Cannot edit standard field metadata properties: ${invalidUpdatedProperties.join(', ')}`,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
),
error: {
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: `Cannot update standard field metadata properties: ${invalidUpdatedProperties.join(', ')}`,
userFriendlyMessage: t`Cannot update standard field properties: ${invalidUpdatedProperties.join(', ')}`,
},
};
}
@@ -1,23 +0,0 @@
import {
FieldMetadataException,
FieldMetadataExceptionCode,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { type FailedFlatFieldMetadataValidationExceptions } from 'src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type';
import { type FlatMetadataValidator } from 'src/engine/metadata-modules/types/flat-metadata-validator.type';
export const runFlatFieldMetadataValidator = <T>(
elementToValidate: T,
{ message, validator }: FlatMetadataValidator<T>,
): FailedFlatFieldMetadataValidationExceptions | undefined => {
const isInvalid = validator(elementToValidate);
if (isInvalid) {
return new FieldMetadataException(
message,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
{
userFriendlyMessage: message,
},
);
}
};
@@ -1,15 +1,25 @@
import { isDefined } from 'twenty-shared/utils';
import { type FailedFlatFieldMetadataValidationExceptions } from 'src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type';
import { runFlatFieldMetadataValidator } from 'src/engine/metadata-modules/flat-field-metadata/utils/run-flat-field-metadata-validator.util';
import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { type FlatFieldMetadataValidationError } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-validation-error.type';
import { type FlatMetadataValidator } from 'src/engine/metadata-modules/types/flat-metadata-validator.type';
export const runFlatFieldMetadataValidators = <T>(
elementToValidate: T,
validators: FlatMetadataValidator<T>[],
): FailedFlatFieldMetadataValidationExceptions[] =>
validators
.map((validator) =>
runFlatFieldMetadataValidator(elementToValidate, validator),
)
.filter(isDefined);
export const runFlatFieldMetadataValidators = <T>({
elementToValidate,
validators,
}: {
elementToValidate: T;
validators: FlatMetadataValidator<T>[];
}): FlatFieldMetadataValidationError[] => {
return validators.flatMap(({ validator, message }) => {
const isInvalid = validator(elementToValidate);
if (isInvalid) {
return {
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message,
value: elementToValidate,
};
}
return [];
});
};
@@ -1,9 +1,10 @@
import { MultipleMetadataValidationErrors } from 'src/engine/core-modules/error/multiple-metadata-validation-errors';
import {
type FieldInputTranspilationResult,
type SuccessfulFieldInputTranspilation,
} from 'src/engine/metadata-modules/flat-field-metadata/types/field-input-transpilation-result.type';
import { WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2';
// This could be improved by still running the build and validate with available valid inputs
type ThrowOnFieldInputTranspilationsErrorArgs = <T>(
inputTranspilationResults: FieldInputTranspilationResult<T>[],
errorLabel: string,
@@ -19,8 +20,18 @@ export const throwOnFieldInputTranspilationsError: ThrowOnFieldInputTranspilatio
);
if (failedInputTranspilationErrors.length > 0) {
throw new MultipleMetadataValidationErrors(
failedInputTranspilationErrors,
// We should create a dedicated exceptions instead of hacking through the WorkspaceMigrationBuilderExceptionV2
throw new WorkspaceMigrationBuilderExceptionV2(
{
errors: [
{
errors: failedInputTranspilationErrors,
type: 'create_field',
fieldMinimalInformation: {},
},
],
status: 'fail',
},
errorLabel,
);
}