diff --git a/packages/twenty-server/src/engine/core-modules/open-api/utils/__tests__/__snapshots__/components.utils.spec.ts.snap b/packages/twenty-server/src/engine/core-modules/open-api/utils/__tests__/__snapshots__/components.utils.spec.ts.snap index afe2f727b5ec..36e0d9f0c512 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/utils/__tests__/__snapshots__/components.utils.spec.ts.snap +++ b/packages/twenty-server/src/engine/core-modules/open-api/utils/__tests__/__snapshots__/components.utils.spec.ts.snap @@ -4,6 +4,9 @@ exports[`computeSchemaComponents Float without decimals 1`] = ` { "ObjectName": { "description": undefined, + "example": { + "number2": 692.1852368365229, + }, "properties": { "number2": { "type": "number", @@ -25,6 +28,9 @@ exports[`computeSchemaComponents Float without decimals 1`] = ` }, "ObjectNameForUpdate": { "description": undefined, + "example": { + "number2": 316.2001153750569, + }, "properties": { "number2": { "type": "number", @@ -39,6 +45,9 @@ exports[`computeSchemaComponents Integer dataType with decimals 1`] = ` { "ObjectName": { "description": undefined, + "example": { + "number1": 957.9316406203515, + }, "properties": { "number1": { "type": "number", @@ -60,6 +69,9 @@ exports[`computeSchemaComponents Integer dataType with decimals 1`] = ` }, "ObjectNameForUpdate": { "description": undefined, + "example": { + "number1": 533.6321196880441, + }, "properties": { "number1": { "type": "number", @@ -74,6 +86,9 @@ exports[`computeSchemaComponents Integer with a 0 decimals 1`] = ` { "ObjectName": { "description": undefined, + "example": { + "number3": 686.8144267539021, + }, "properties": { "number3": { "type": "integer", @@ -95,6 +110,9 @@ exports[`computeSchemaComponents Integer with a 0 decimals 1`] = ` }, "ObjectNameForUpdate": { "description": undefined, + "example": { + "number3": 834.7910462254755, + }, "properties": { "number3": { "type": "integer", diff --git a/packages/twenty-server/src/engine/core-modules/open-api/utils/__tests__/components.utils.spec.ts b/packages/twenty-server/src/engine/core-modules/open-api/utils/__tests__/components.utils.spec.ts index 83268fc7747a..8fc2bb58e944 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/utils/__tests__/components.utils.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/utils/__tests__/components.utils.spec.ts @@ -1,5 +1,6 @@ import { EachTestingContext } from 'twenty-shared/testing'; import { FieldMetadataType } from 'twenty-shared/types'; +import { faker } from '@faker-js/faker'; import { NumberDataType } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface'; @@ -9,6 +10,7 @@ import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; describe('computeSchemaComponents', () => { + faker.seed(1); it('should compute schema components', () => { expect( computeSchemaComponents([ @@ -18,6 +20,36 @@ describe('computeSchemaComponents', () => { { "ObjectName": { "description": undefined, + "example": { + "fieldCurrency": { + "amountMicros": 284000000, + "currencyCode": "EUR", + }, + "fieldEmails": { + "additionalEmails": null, + "primaryEmail": "mina.gutmann9@hotmail.com", + }, + "fieldFullName": { + "firstName": "Shad", + "lastName": "Osinski", + }, + "fieldLinks": { + "additionalLinks": [], + "primaryLinkLabel": "", + "primaryLinkUrl": "https://narrow-help.net/", + }, + "fieldMultiSelect": [ + "OPTION_1", + ], + "fieldNumber": 346.2151663160047, + "fieldPhones": { + "additionalPhones": [], + "primaryPhoneCallingCode": "+33", + "primaryPhoneCountryCode": "FR", + "primaryPhoneNumber": "06 10 20 30 40", + }, + "fieldSelect": "OPTION_1", + }, "properties": { "fieldActor": { "properties": { @@ -444,6 +476,36 @@ describe('computeSchemaComponents', () => { }, "ObjectNameForUpdate": { "description": undefined, + "example": { + "fieldCurrency": { + "amountMicros": 253000000, + "currencyCode": "EUR", + }, + "fieldEmails": { + "additionalEmails": null, + "primaryEmail": "keegan_donnelly96@hotmail.com", + }, + "fieldFullName": { + "firstName": "Shad", + "lastName": "Jones", + }, + "fieldLinks": { + "additionalLinks": [], + "primaryLinkLabel": "", + "primaryLinkUrl": "https://unlawful-blowgun.biz", + }, + "fieldMultiSelect": [ + "OPTION_1", + ], + "fieldNumber": 692.6302930536448, + "fieldPhones": { + "additionalPhones": [], + "primaryPhoneCallingCode": "+33", + "primaryPhoneCountryCode": "FR", + "primaryPhoneNumber": "06 10 20 30 40", + }, + "fieldSelect": "OPTION_1", + }, "properties": { "fieldActor": { "properties": { diff --git a/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts b/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts index aaa5d58cd98e..8eb43cd604dd 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts @@ -7,6 +7,7 @@ import { NumberDataType, } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface'; import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; +import { FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface'; import { computeDepthParameters, @@ -20,6 +21,8 @@ import { import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util'; +import { camelToTitleCase } from 'src/utils/camel-to-title-case'; +import { generateRandomFieldValue } from 'src/engine/core-modules/open-api/utils/generate-random-field-value.utils'; type Property = OpenAPIV3_1.SchemaObject; @@ -27,6 +30,8 @@ type Properties = { [name: string]: Property; }; +type OpenApiExample = Record; + const isFieldAvailable = (field: FieldMetadataEntity, forResponse: boolean) => { if (forResponse) { return true; @@ -86,6 +91,47 @@ const getFieldProperties = (field: FieldMetadataEntity): Property => { } }; +const getSchemaComponentsExample = ( + item: ObjectMetadataEntity, +): OpenApiExample => { + return item.fields.reduce((node, field) => { + // If field is required + if (!field.isNullable && field.defaultValue === null) { + return { ...node, [field.name]: generateRandomFieldValue({ field }) }; + } + + switch (field.type) { + case FieldMetadataType.TEXT: { + if (field.name !== 'name') { + return node; + } + + return { + ...node, + [field.name]: `${camelToTitleCase(item.nameSingular)} name`, + }; + } + + case FieldMetadataType.EMAILS: + case FieldMetadataType.LINKS: + case FieldMetadataType.CURRENCY: + case FieldMetadataType.FULL_NAME: + case FieldMetadataType.SELECT: + case FieldMetadataType.MULTI_SELECT: + case FieldMetadataType.PHONES: { + return { + ...node, + [field.name]: generateRandomFieldValue({ field }), + }; + } + + default: { + return node; + } + } + }, {}); +}; + const getSchemaComponentsProperties = ({ item, forResponse, @@ -105,12 +151,13 @@ const getSchemaComponentsProperties = ({ isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) && field.settings?.relationType === RelationType.MANY_TO_ONE ) { - node[`${field.name}Id`] = { - type: 'string', - format: 'uuid', + return { + ...node, + [`${field.name}Id`]: { + type: 'string', + format: 'uuid', + }, }; - - return node; } if ( @@ -333,7 +380,7 @@ const getSchemaComponentsProperties = ({ } if (Object.keys(itemProperty).length) { - node[field.name] = itemProperty; + return { ...node, [field.name]: itemProperty }; } return node; @@ -379,7 +426,7 @@ const getSchemaComponentsRelationProperties = ( } if (Object.keys(itemProperty).length) { - node[field.name] = itemProperty; + return { ...node, [field.name]: itemProperty }; } return node; @@ -400,20 +447,23 @@ const getRequiredFields = (item: ObjectMetadataEntity): string[] => { const computeSchemaComponent = ({ item, - withRequiredFields, forResponse, - withRelations, + forUpdate, }: { item: ObjectMetadataEntity; - withRequiredFields: boolean; forResponse: boolean; - withRelations: boolean; + forUpdate: boolean; }): OpenAPIV3_1.SchemaObject => { - const result = { + const withRelations = forResponse && !forUpdate; + + const withRequiredFields = !forResponse && !forUpdate; + + const result: OpenAPIV3_1.SchemaObject = { type: 'object', description: item.description, properties: getSchemaComponentsProperties({ item, forResponse }), - } as OpenAPIV3_1.SchemaObject; + ...(!forResponse ? { example: getSchemaComponentsExample(item) } : {}), + }; if (withRelations) { result.properties = { @@ -442,23 +492,20 @@ export const computeSchemaComponents = ( (schemas, item) => { schemas[capitalize(item.nameSingular)] = computeSchemaComponent({ item, - withRequiredFields: true, forResponse: false, - withRelations: false, + forUpdate: false, }); schemas[capitalize(item.nameSingular) + 'ForUpdate'] = computeSchemaComponent({ item, - withRequiredFields: false, forResponse: false, - withRelations: false, + forUpdate: true, }); schemas[capitalize(item.nameSingular) + 'ForResponse'] = computeSchemaComponent({ item, - withRequiredFields: false, forResponse: true, - withRelations: true, + forUpdate: false, }); return schemas; diff --git a/packages/twenty-server/src/engine/core-modules/open-api/utils/generate-random-field-value.utils.ts b/packages/twenty-server/src/engine/core-modules/open-api/utils/generate-random-field-value.utils.ts new file mode 100644 index 000000000000..425e31edcdb5 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/open-api/utils/generate-random-field-value.utils.ts @@ -0,0 +1,144 @@ +import { FieldMetadataType } from 'twenty-shared/types'; +import { v4 } from 'uuid'; +import { faker } from '@faker-js/faker'; +import { assertUnreachable, isDefined } from 'twenty-shared/utils'; + +import { FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface'; + +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; + +export const generateRandomFieldValue = ({ + field, +}: { + field: FieldMetadataEntity; +}): FieldMetadataDefaultValue => { + switch (field.type) { + case FieldMetadataType.UUID: { + return v4(); + } + + case FieldMetadataType.TEXT: { + return faker.string.fromCharacters(field.name); + } + + case FieldMetadataType.PHONES: { + return { + primaryPhoneNumber: '06 10 20 30 40', + primaryPhoneCallingCode: '+33', + primaryPhoneCountryCode: 'FR', + additionalPhones: [], + }; + } + + case FieldMetadataType.EMAILS: { + return { + primaryEmail: faker.internet.email().toLowerCase(), + additionalEmails: null, + }; + } + + case FieldMetadataType.DATE: + case FieldMetadataType.DATE_TIME: { + return faker.date.soon(); + } + + case FieldMetadataType.BOOLEAN: { + return false; + } + + case FieldMetadataType.NUMBER: { + return faker.number.float({ min: 1, max: 1_000 }); + } + + case FieldMetadataType.NUMERIC: { + return faker.number.int({ min: 1, max: 1_000 }); + } + + case FieldMetadataType.LINKS: { + return { + primaryLinkLabel: '', + primaryLinkUrl: faker.internet.url(), + additionalLinks: [], + }; + } + + case FieldMetadataType.CURRENCY: { + return { + amountMicros: faker.number.int({ min: 100, max: 1_000 }) * 1_000_000, + currencyCode: 'EUR', + }; + } + + case FieldMetadataType.FULL_NAME: { + return { + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + }; + } + + case FieldMetadataType.RATING: { + return 'RATING_5'; + } + + case FieldMetadataType.SELECT: { + return isDefined(field.options[0].value) ? field.options[0].value : []; + } + + case FieldMetadataType.MULTI_SELECT: { + return isDefined(field.options[0].value) ? [field.options[0].value] : []; + } + + case FieldMetadataType.RELATION: { + return null; + } + + case FieldMetadataType.POSITION: { + return 1; + } + + case FieldMetadataType.ADDRESS: { + return { + addressStreet1: faker.location.streetAddress(), + addressStreet2: faker.location.secondaryAddress(), + addressCity: faker.location.city(), + addressState: faker.location.state(), + addressCountry: faker.location.country(), + addressPostcode: faker.location.zipCode(), + addressLat: faker.location.latitude(), + addressLng: faker.location.longitude(), + }; + } + + case FieldMetadataType.RAW_JSON: { + return {}; + } + + case FieldMetadataType.RICH_TEXT: + case FieldMetadataType.RICH_TEXT_V2: { + return ''; + } + + case FieldMetadataType.ACTOR: { + return { + source: 'MANUAL', + context: {}, + name: faker.person.fullName(), + workspaceMemberId: null, + }; + } + + case FieldMetadataType.ARRAY: { + return []; + } + + case FieldMetadataType.TS_VECTOR: { + throw new Error( + `We should not generate fake version for ${field.type} field`, + ); + } + + default: { + assertUnreachable(field.type, `Unsupported field type '${field.type}'`); + } + } +}; diff --git a/packages/twenty-server/src/engine/core-modules/open-api/utils/request-body.utils.ts b/packages/twenty-server/src/engine/core-modules/open-api/utils/request-body.utils.ts index 37157eda1a5e..93055f085436 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/utils/request-body.utils.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/utils/request-body.utils.ts @@ -1,3 +1,5 @@ +import { v4 } from 'uuid'; + export const getRequestBody = (name: string) => { return { description: 'body', @@ -59,8 +61,13 @@ export const getFindDuplicatesRequestBody = (name: string) => { }, ids: { type: 'array', + items: { + type: 'string', + format: 'uuid', + }, }, }, + example: { ids: [v4()] }, }, }, }, diff --git a/packages/twenty-server/src/utils/camel-to-title-case.ts b/packages/twenty-server/src/utils/camel-to-title-case.ts index de86fd9d6ba9..d1e361c72325 100644 --- a/packages/twenty-server/src/utils/camel-to-title-case.ts +++ b/packages/twenty-server/src/utils/camel-to-title-case.ts @@ -1,5 +1,7 @@ import { capitalize } from 'twenty-shared/utils'; export const camelToTitleCase = (camelCaseText: string) => - capitalize(camelCaseText) - .replace(/([A-Z])/g, ' $1') - .replace(/^./, (str) => str.toUpperCase()); + capitalize( + camelCaseText + .replace(/([A-Z])/g, ' $1') + .replace(/^./, (str) => str.toUpperCase()), + );