Revert "Revert "[4/n]: migrate the RESTAPI GET /rest/* to use TwentyORM direc…" (#11349)

This commit is contained in:
martmull
2025-05-12 10:32:04 +02:00
committed by GitHub
parent 1f4d4c5265
commit 650f8f5963
50 changed files with 1532 additions and 698 deletions

View File

@ -20,4 +20,4 @@ redis-on-docker:
docker run -d --name twenty_redis -p 6379:6379 redis/redis-stack-server:latest
clickhouse-on-docker:
docker run -d --name twenty_clickhouse -p 8123:8123 -p 9000:9000 -e CLICKHOUSE_PASSWORD=devPassword clickhouse/clickhouse-server:latest
docker run -d --name twenty_clickhouse -p 8123:8123 -p 9000:9000 -e CLICKHOUSE_PASSWORD=clickhousePassword clickhouse/clickhouse-server:latest \

View File

@ -38,6 +38,7 @@ const MIGRATED_REST_METHODS = [
RequestMethod.POST,
RequestMethod.PATCH,
RequestMethod.PUT,
RequestMethod.GET,
];
@Module({

View File

@ -9,6 +9,7 @@ import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
describe('WorkspaceSchemaFactory', () => {
let service: WorkspaceSchemaFactory;
@ -49,6 +50,10 @@ describe('WorkspaceSchemaFactory', () => {
provide: FeatureFlagService,
useValue: {},
},
{
provide: TwentyConfigService,
useValue: {},
},
],
}).compile();

View File

@ -1,8 +1,8 @@
import { capitalize } from 'twenty-shared/utils';
import { WhereExpressionBuilder } from 'typeorm';
import { FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface';
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import { FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface';
import {
GraphqlQueryRunnerException,

View File

@ -4,7 +4,6 @@ import {
ObjectRecordOrderBy,
OrderByDirection,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface';
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import {
@ -18,14 +17,9 @@ import { CompositeFieldMetadataType } from 'src/engine/metadata-modules/workspac
export class GraphqlQueryOrderFieldParser {
private fieldMetadataMapByName: FieldMetadataMap;
private featureFlagsMap: FeatureFlagMap;
constructor(
fieldMetadataMapByName: FieldMetadataMap,
featureFlagsMap: FeatureFlagMap,
) {
constructor(fieldMetadataMapByName: FieldMetadataMap) {
this.fieldMetadataMapByName = fieldMetadataMapByName;
this.featureFlagsMap = featureFlagsMap;
}
parse(

View File

@ -21,6 +21,10 @@ import { FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metada
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util';
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
export class GraphqlQueryParser {
private fieldMetadataMapByName: FieldMetadataMap;
@ -47,7 +51,6 @@ export class GraphqlQueryParser {
);
this.orderFieldParser = new GraphqlQueryOrderFieldParser(
this.fieldMetadataMapByName,
featureFlagsMap,
);
}
@ -125,8 +128,9 @@ export class GraphqlQueryParser {
)?.fieldsByName;
if (!parentFields) {
throw new Error(
throw new GraphqlQueryRunnerException(
`Could not find object metadata for ${parentObjectMetadata.nameSingular}`,
GraphqlQueryRunnerExceptionCode.OBJECT_METADATA_NOT_FOUND,
);
}

View File

@ -6,6 +6,8 @@ import { GraphQLSchema, printSchema } from 'graphql';
import { gql } from 'graphql-tag';
import { isDefined } from 'twenty-shared/utils';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
@ -21,6 +23,7 @@ import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
export class WorkspaceSchemaFactory {
@ -32,6 +35,7 @@ export class WorkspaceSchemaFactory {
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService,
private readonly featureFlagService: FeatureFlagService,
private readonly twentyConfigService: TwentyConfigService,
) {}
async createGraphQLSchema(authContext: AuthContext): Promise<GraphQLSchema> {
@ -44,7 +48,10 @@ export class WorkspaceSchemaFactory {
authContext.workspace.id,
);
if (isNewRelationEnabled) {
if (
isNewRelationEnabled &&
this.twentyConfigService.get('NODE_ENV') !== NodeEnvironment.test
) {
// eslint-disable-next-line no-console
console.log(
chalk.yellow('🚧 New relation schema generation is enabled 🚧'),

View File

@ -22,6 +22,7 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@Controller('rest/*')
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard)
@UseFilters(RestApiExceptionFilter)
export class RestApiCoreController {
constructor(
private readonly restApiCoreService: RestApiCoreService,
@ -38,15 +39,12 @@ export class RestApiCoreController {
@Get()
async handleApiGet(@Req() request: Request, @Res() res: Response) {
const result = await this.restApiCoreService.get(request);
const result = await this.restApiCoreServiceV2.get(request);
res.status(200).send(cleanGraphQLResponse(result.data.data));
res.status(200).send(result);
}
@Delete()
// We should move this exception filter to RestApiCoreController class level
// when all endpoints are migrated to v2
@UseFilters(RestApiExceptionFilter)
async handleApiDelete(@Req() request: Request, @Res() res: Response) {
const result = await this.restApiCoreServiceV2.delete(request);
@ -54,7 +52,6 @@ export class RestApiCoreController {
}
@Post()
@UseFilters(RestApiExceptionFilter)
async handleApiPost(@Req() request: Request, @Res() res: Response) {
const result = await this.restApiCoreServiceV2.createOne(request);
@ -73,7 +70,6 @@ export class RestApiCoreController {
// We keep it to avoid a breaking change since it initially used PUT instead of PATCH,
// and because the PUT verb is often used as a PATCH.
@Put()
@UseFilters(RestApiExceptionFilter)
async handleApiPut(@Req() request: Request, @Res() res: Response) {
const result = await this.restApiCoreServiceV2.update(request);

View File

@ -0,0 +1,58 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Request } from 'express';
import { isDefined } from 'twenty-shared/utils';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
@Injectable()
export class RestApiCreateOneHandler extends RestApiBaseHandler {
async handle(request: Request) {
const { objectMetadataNameSingular, objectMetadata, repository } =
await this.getRepositoryAndMetadataOrFail(request);
const overriddenBody = await this.recordInputTransformerService.process({
recordInput: request.body,
objectMetadataMapItem: objectMetadata.objectMetadataMapItem,
});
const recordExists =
isDefined(overriddenBody.id) &&
(await repository.exists({
where: {
id: overriddenBody.id,
},
}));
if (recordExists) {
throw new BadRequestException('Record already exists');
}
const createdRecord = await repository.save(overriddenBody);
this.apiEventEmitterService.emitCreateEvents(
[createdRecord],
this.getAuthContextFromRequest(request),
objectMetadata.objectMetadataMapItem,
);
const records = await this.getRecord({
recordIds: [createdRecord.id],
repository,
objectMetadata,
depth: this.depthInputFactory.create(request),
});
const record = records[0];
if (!isDefined(record)) {
throw new Error('Created record not found');
}
return this.formatResult({
operation: 'create',
objectNameSingular: objectMetadataNameSingular,
data: record,
});
}
}

View File

@ -0,0 +1,40 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Request } from 'express';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils';
@Injectable()
export class RestApiDeleteOneHandler extends RestApiBaseHandler {
async handle(request: Request) {
const { id: recordId } = parseCorePath(request);
if (!recordId) {
throw new BadRequestException('Record ID not found');
}
const { objectMetadataNameSingular, objectMetadata, repository } =
await this.getRepositoryAndMetadataOrFail(request);
const recordToDelete = await repository.findOneOrFail({
where: { id: recordId },
});
await repository.delete(recordId);
this.apiEventEmitterService.emitDestroyEvents(
[recordToDelete],
this.getAuthContextFromRequest(request),
objectMetadata.objectMetadataMapItem,
);
return this.formatResult({
operation: 'delete',
objectNameSingular: objectMetadataNameSingular,
data: {
id: recordToDelete.id,
},
});
}
}

View File

@ -0,0 +1,37 @@
import { Injectable } from '@nestjs/common';
import { Request } from 'express';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
@Injectable()
export class RestApiGetManyHandler extends RestApiBaseHandler {
async handle(request: Request) {
const {
objectMetadataNameSingular,
objectMetadataNamePlural,
repository,
dataSource,
objectMetadata,
objectMetadataItemWithFieldsMaps,
} = await this.getRepositoryAndMetadataOrFail(request);
const { records, isForwardPagination, hasMoreRecords, totalCount } =
await this.findRecords({
request,
repository,
dataSource,
objectMetadata,
objectMetadataNameSingular,
objectMetadataItemWithFieldsMaps,
});
return this.formatPaginatedResult(
records,
objectMetadataNamePlural,
isForwardPagination,
hasMoreRecords,
totalCount,
);
}
}

View File

@ -0,0 +1,51 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Request } from 'express';
import { isDefined } from 'twenty-shared/utils';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils';
@Injectable()
export class RestApiGetOneHandler extends RestApiBaseHandler {
async handle(request: Request) {
const { id: recordId } = parseCorePath(request);
if (!isDefined(recordId)) {
throw new BadRequestException(
'No recordId provided in rest api get one query',
);
}
const {
objectMetadataNameSingular,
repository,
dataSource,
objectMetadata,
objectMetadataItemWithFieldsMaps,
} = await this.getRepositoryAndMetadataOrFail(request);
const { records } = await this.findRecords({
request,
recordId,
repository,
dataSource,
objectMetadata,
objectMetadataNameSingular,
objectMetadataItemWithFieldsMaps,
});
const record = records?.[0];
if (!isDefined(record)) {
throw new BadRequestException('Record not found');
}
return this.formatResult({
operation: 'findOne',
objectNameSingular: objectMetadataNameSingular,
data: record,
});
}
}

View File

@ -0,0 +1,63 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Request } from 'express';
import { isDefined } from 'twenty-shared/utils';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils';
@Injectable()
export class RestApiUpdateOneHandler extends RestApiBaseHandler {
async handle(request: Request) {
const { id: recordId } = parseCorePath(request);
if (!recordId) {
throw new BadRequestException('Record ID not found');
}
const { objectMetadataNameSingular, objectMetadata, repository } =
await this.getRepositoryAndMetadataOrFail(request);
const recordToUpdate = await repository.findOneOrFail({
where: { id: recordId },
});
const overriddenBody = await this.recordInputTransformerService.process({
recordInput: request.body,
objectMetadataMapItem: objectMetadata.objectMetadataMapItem,
});
const updatedRecord = await repository.save({
...recordToUpdate,
...overriddenBody,
});
this.apiEventEmitterService.emitUpdateEvents(
[recordToUpdate],
[updatedRecord],
Object.keys(request.body),
this.getAuthContextFromRequest(request),
objectMetadata.objectMetadataMapItem,
);
const records = await this.getRecord({
recordIds: [updatedRecord.id],
repository,
objectMetadata,
depth: this.depthInputFactory.create(request),
});
const record = records[0];
if (!isDefined(record)) {
throw new Error('Updated record not found');
}
return this.formatResult({
operation: 'update',
objectNameSingular: objectMetadataNameSingular,
data: record,
});
}
}

View File

@ -0,0 +1,416 @@
import { BadRequestException, Inject } from '@nestjs/common';
import { Request } from 'express';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { In, ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { FieldMetadataType } from 'twenty-shared/types';
import {
ObjectRecord,
OrderByDirection,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { CoreQueryBuilderFactory } from 'src/engine/api/rest/core/query-builder/core-query-builder.factory';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { GetVariablesFactory } from 'src/engine/api/rest/core/query-builder/factories/get-variables.factory';
import {
Depth,
DepthInputFactory,
MAX_DEPTH,
} from 'src/engine/api/rest/input-factories/depth-input.factory';
import { ApiEventEmitterService } from 'src/engine/api/graphql/graphql-query-runner/services/api-event-emitter.service';
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils';
import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util';
import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { WorkspaceDataSource } from 'src/engine/twenty-orm/datasource/workspace.datasource';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import { formatResult as formatGetManyData } from 'src/engine/twenty-orm/utils/format-result.util';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { QueryVariables } from 'src/engine/api/rest/core/types/query-variables.type';
export interface PageInfo {
hasNextPage?: boolean;
hasPreviousPage?: boolean;
startCursor: string | null;
endCursor: string | null;
}
interface FormatResultParams<T> {
operation: 'delete' | 'create' | 'update' | 'findOne' | 'findMany';
objectNameSingular?: string;
objectNamePlural?: string;
data: T;
pageInfo?: PageInfo;
totalCount?: number;
}
export interface FormatResult {
data: {
[operation: string]: object;
};
pageInfo?: PageInfo;
totalCount?: number;
}
export abstract class RestApiBaseHandler {
@Inject()
protected readonly recordInputTransformerService: RecordInputTransformerService;
@Inject()
protected readonly coreQueryBuilderFactory: CoreQueryBuilderFactory;
@Inject()
protected readonly twentyORMGlobalManager: TwentyORMGlobalManager;
@Inject()
protected readonly getVariablesFactory: GetVariablesFactory;
@Inject()
protected readonly depthInputFactory: DepthInputFactory;
@Inject()
protected readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService;
@Inject()
protected readonly apiEventEmitterService: ApiEventEmitterService;
protected abstract handle(request: Request): Promise<FormatResult>;
public async getRepositoryAndMetadataOrFail(request: Request) {
const { workspace, apiKey, userWorkspaceId } = request;
const { object: parsedObject } = parseCorePath(request);
const objectMetadata = await this.coreQueryBuilderFactory.getObjectMetadata(
request,
parsedObject,
);
if (!objectMetadata) {
throw new BadRequestException('Object metadata not found');
}
if (!workspace?.id) {
throw new BadRequestException('Workspace not found');
}
const dataSource =
await this.twentyORMGlobalManager.getDataSourceForWorkspace({
workspaceId: workspace.id,
shouldFailIfMetadataNotFound: false,
});
const objectMetadataNameSingular =
objectMetadata.objectMetadataMapItem.nameSingular;
const objectMetadataItemWithFieldsMaps =
getObjectMetadataMapItemByNameSingular(
objectMetadata.objectMetadataMaps,
objectMetadataNameSingular,
);
const shouldBypassPermissionChecks = !!apiKey;
const roleId =
await this.workspacePermissionsCacheService.getRoleIdFromUserWorkspaceId({
workspaceId: workspace.id,
userWorkspaceId,
});
const repository = dataSource.getRepository<ObjectRecord>(
objectMetadataNameSingular,
shouldBypassPermissionChecks,
roleId,
);
return {
objectMetadataNameSingular,
objectMetadataNamePlural: objectMetadata.objectMetadataMapItem.namePlural,
objectMetadata,
repository,
dataSource,
objectMetadataItemWithFieldsMaps,
};
}
getRelations({
objectMetadata,
depth,
}: {
objectMetadata: {
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
};
depth: Depth | undefined;
}) {
if (!isDefined(depth) || depth === 0) {
return [];
}
const relations: string[] = [];
objectMetadata.objectMetadataMapItem.fields.forEach((field) => {
if (field.type === FieldMetadataType.RELATION) {
if (
depth === MAX_DEPTH &&
isDefined(field.relationTargetObjectMetadataId)
) {
const relationTargetObjectMetadata =
objectMetadata.objectMetadataMaps.byId[
field.relationTargetObjectMetadataId
];
const depth2Relations = this.getRelations({
objectMetadata: {
objectMetadataMaps: objectMetadata.objectMetadataMaps,
objectMetadataMapItem: relationTargetObjectMetadata,
},
depth: 1,
});
depth2Relations.forEach((depth2Relation) => {
relations.push(`${field.name}.${depth2Relation}`);
});
} else {
relations.push(`${field.name}`);
}
}
});
return relations;
}
async getRecord({
recordIds,
repository,
objectMetadata,
depth,
}: {
recordIds: string[];
repository: WorkspaceRepository<ObjectLiteral>;
objectMetadata: {
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
};
depth: Depth | undefined;
}) {
const relations = this.getRelations({
objectMetadata,
depth: depth,
});
const unorderedRecords = await repository.find({
where: { id: In(recordIds) },
relations,
});
const recordMap = new Map(unorderedRecords.map((r) => [r.id, r]));
const orderedRecords = recordIds.map((id) => recordMap.get(id));
return orderedRecords;
}
public getAuthContextFromRequest(request: Request): AuthContext {
return {
user: request.user,
workspace: request.workspace,
apiKey: request.apiKey,
workspaceMemberId: request.workspaceMemberId,
userWorkspaceId: request.userWorkspaceId,
};
}
public formatResult<T>({
operation,
objectNameSingular,
objectNamePlural,
data,
pageInfo,
totalCount,
}: FormatResultParams<T>) {
let prefix: string;
if (operation === 'findOne') {
prefix = objectNameSingular || '';
} else if (operation === 'findMany') {
prefix = objectNamePlural || '';
} else {
prefix = operation + capitalize(objectNameSingular || '');
}
return {
data: {
[prefix]: data,
},
...(isDefined(pageInfo) ? { pageInfo } : {}),
...(isDefined(totalCount) ? { totalCount } : {}),
};
}
formatPaginatedResult(
finalRecords: any[],
objectMetadataNamePlural: string,
isForwardPagination: boolean,
hasMoreRecords: boolean,
totalCount: number,
) {
const hasPreviousPage = !isForwardPagination && hasMoreRecords;
return this.formatResult({
operation: 'findMany',
objectNamePlural: objectMetadataNamePlural,
data: isForwardPagination ? finalRecords : finalRecords.reverse(),
pageInfo: {
hasNextPage: isForwardPagination && hasMoreRecords,
...(hasPreviousPage ? { hasPreviousPage } : {}),
startCursor:
finalRecords.length > 0
? Buffer.from(JSON.stringify({ id: finalRecords[0].id })).toString(
'base64',
)
: null,
endCursor:
finalRecords.length > 0
? Buffer.from(
JSON.stringify({
id: finalRecords[finalRecords.length - 1].id,
}),
).toString('base64')
: null,
},
totalCount,
});
}
async findRecords({
request,
recordId,
repository,
dataSource,
objectMetadata,
objectMetadataNameSingular,
objectMetadataItemWithFieldsMaps,
}: {
request: Request;
recordId?: string;
repository: WorkspaceRepository<ObjectLiteral>;
dataSource: WorkspaceDataSource;
objectMetadata: any;
objectMetadataNameSingular: string;
objectMetadataItemWithFieldsMaps:
| ObjectMetadataItemWithFieldMaps
| undefined;
}) {
const qb = repository.createQueryBuilder(objectMetadataNameSingular);
const inputs = this.getVariablesFactory.create(
recordId,
request,
objectMetadata,
);
const fieldMetadataMapByName =
objectMetadataItemWithFieldsMaps?.fieldsByName || {};
const fieldMetadataMapByJoinColumnName =
objectMetadataItemWithFieldsMaps?.fieldsByJoinColumnName || {};
const graphqlQueryParser = new GraphqlQueryParser(
fieldMetadataMapByName,
fieldMetadataMapByJoinColumnName,
objectMetadata.objectMetadataMaps,
dataSource.featureFlagMap,
);
const filters = this.computeFilters(inputs);
let selectQueryBuilder = isDefined(filters)
? graphqlQueryParser.applyFilterToBuilder(
qb,
objectMetadataNameSingular,
filters,
)
: qb;
const totalCount = await this.getTotalCount(selectQueryBuilder);
const isForwardPagination = !inputs.endingBefore;
selectQueryBuilder = graphqlQueryParser.applyOrderToBuilder(
selectQueryBuilder,
[...(inputs.orderBy || []), { id: OrderByDirection.AscNullsFirst }],
objectMetadataNameSingular,
isForwardPagination,
);
if (inputs.first) {
selectQueryBuilder = selectQueryBuilder.limit(inputs.first);
}
if (inputs.last) {
selectQueryBuilder = selectQueryBuilder.limit(inputs.last);
}
const recordIds = await selectQueryBuilder
.select(`${objectMetadataNameSingular}.id`)
.getMany();
const records = await this.getRecord({
recordIds: recordIds.map((record) => record.id),
repository,
objectMetadata,
depth: this.depthInputFactory.create(request),
});
const hasMoreRecords = records.length < totalCount;
return {
records: formatGetManyData<ObjectLiteral[]>(
records,
objectMetadataItemWithFieldsMaps as any,
objectMetadata.objectMetadataMaps,
dataSource.featureFlagMap[FeatureFlagKey.IsNewRelationEnabled],
),
totalCount,
hasMoreRecords,
isForwardPagination,
};
}
async getTotalCount(
query: SelectQueryBuilder<ObjectLiteral>,
): Promise<number> {
const countQuery = query.clone();
return await countQuery.getCount();
}
computeFilters(inputs: QueryVariables) {
let appliedFilters = inputs.filter;
if (inputs.startingAfter) {
appliedFilters = {
and: [
appliedFilters || {},
{ id: { gt: this.parseCursor(inputs.startingAfter).id } },
],
};
}
if (inputs.endingBefore) {
appliedFilters = {
and: [
appliedFilters || {},
{ id: { lt: this.parseCursor(inputs.endingBefore).id } },
],
};
}
return appliedFilters;
}
private parseCursor = (cursor: string) => {
try {
return JSON.parse(Buffer.from(cursor ?? '', 'base64').toString());
} catch (error) {
throw new BadRequestException(`Invalid cursor: ${cursor}`);
}
};
}

View File

@ -3,17 +3,8 @@ import { BadRequestException, Injectable } from '@nestjs/common';
import { Request } from 'express';
import { CreateManyQueryFactory } from 'src/engine/api/rest/core/query-builder/factories/create-many-query.factory';
import { CreateOneQueryFactory } from 'src/engine/api/rest/core/query-builder/factories/create-one-query.factory';
import { CreateVariablesFactory } from 'src/engine/api/rest/core/query-builder/factories/create-variables.factory';
import { DeleteQueryFactory } from 'src/engine/api/rest/core/query-builder/factories/delete-query.factory';
import { DeleteVariablesFactory } from 'src/engine/api/rest/core/query-builder/factories/delete-variables.factory';
import { FindDuplicatesQueryFactory } from 'src/engine/api/rest/core/query-builder/factories/find-duplicates-query.factory';
import { FindDuplicatesVariablesFactory } from 'src/engine/api/rest/core/query-builder/factories/find-duplicates-variables.factory';
import { FindManyQueryFactory } from 'src/engine/api/rest/core/query-builder/factories/find-many-query.factory';
import { FindOneQueryFactory } from 'src/engine/api/rest/core/query-builder/factories/find-one-query.factory';
import { GetVariablesFactory } from 'src/engine/api/rest/core/query-builder/factories/get-variables.factory';
import { UpdateQueryFactory } from 'src/engine/api/rest/core/query-builder/factories/update-query.factory';
import { UpdateVariablesFactory } from 'src/engine/api/rest/core/query-builder/factories/update-variables.factory';
import { computeDepth } from 'src/engine/api/rest/core/query-builder/utils/compute-depth.utils';
import { parseCoreBatchPath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-batch-path.utils';
import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils';
@ -26,21 +17,14 @@ import { getObjectMetadataMapItemByNamePlural } from 'src/engine/metadata-module
import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util';
import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { CreateVariablesFactory } from 'src/engine/api/rest/core/query-builder/factories/create-variables.factory';
@Injectable()
export class CoreQueryBuilderFactory {
constructor(
private readonly deleteQueryFactory: DeleteQueryFactory,
private readonly createOneQueryFactory: CreateOneQueryFactory,
private readonly createManyQueryFactory: CreateManyQueryFactory,
private readonly updateQueryFactory: UpdateQueryFactory,
private readonly findOneQueryFactory: FindOneQueryFactory,
private readonly findManyQueryFactory: FindManyQueryFactory,
private readonly findDuplicatesQueryFactory: FindDuplicatesQueryFactory,
private readonly deleteVariablesFactory: DeleteVariablesFactory,
private readonly createVariablesFactory: CreateVariablesFactory,
private readonly updateVariablesFactory: UpdateVariablesFactory,
private readonly getVariablesFactory: GetVariablesFactory,
private readonly findDuplicatesVariablesFactory: FindDuplicatesVariablesFactory,
private readonly accessTokenService: AccessTokenService,
private readonly domainManagerService: DomainManagerService,
@ -113,38 +97,6 @@ export class CoreQueryBuilderFactory {
};
}
async delete(request: Request): Promise<Query> {
const { object: parsedObject } = parseCorePath(request);
const objectMetadata = await this.getObjectMetadata(request, parsedObject);
const { id } = parseCorePath(request);
if (!id) {
throw new BadRequestException(
`delete ${objectMetadata.objectMetadataMapItem.nameSingular} query invalid. Id missing. eg: /rest/${objectMetadata.objectMetadataMapItem.namePlural}/0d4389ef-ea9c-4ae8-ada1-1cddc440fb56`,
);
}
return {
query: this.deleteQueryFactory.create(
objectMetadata.objectMetadataMapItem,
),
variables: this.deleteVariablesFactory.create(id),
};
}
async createOne(request: Request): Promise<Query> {
const { object: parsedObject } = parseCorePath(request);
const objectMetadata = await this.getObjectMetadata(request, parsedObject);
const depth = computeDepth(request);
return {
query: this.createOneQueryFactory.create(objectMetadata, depth),
variables: this.createVariablesFactory.create(request),
};
}
async createMany(request: Request): Promise<Query> {
const { object: parsedObject } = parseCoreBatchPath(request);
const objectMetadata = await this.getObjectMetadata(request, parsedObject);
@ -156,42 +108,6 @@ export class CoreQueryBuilderFactory {
};
}
async update(request: Request): Promise<Query> {
const { object: parsedObject } = parseCorePath(request);
const objectMetadata = await this.getObjectMetadata(request, parsedObject);
const depth = computeDepth(request);
const { id } = parseCorePath(request);
if (!id) {
throw new BadRequestException(
`update ${objectMetadata.objectMetadataMapItem.nameSingular} query invalid. Id missing. eg: /rest/${objectMetadata.objectMetadataMapItem.namePlural}/0d4389ef-ea9c-4ae8-ada1-1cddc440fb56`,
);
}
return {
query: this.updateQueryFactory.create(objectMetadata, depth),
variables: this.updateVariablesFactory.create(id, request),
};
}
async get(request: Request): Promise<Query> {
const { object: parsedObject } = parseCorePath(request);
const objectMetadata = await this.getObjectMetadata(request, parsedObject);
const depth = computeDepth(request);
const { id } = parseCorePath(request);
return {
query: id
? this.findOneQueryFactory.create(objectMetadata, depth)
: this.findManyQueryFactory.create(objectMetadata, depth),
variables: this.getVariablesFactory.create(id, request, objectMetadata),
};
}
async findDuplicates(request: Request): Promise<Query> {
const { object: parsedObject } = parseCorePath(request);
const objectMetadata = await this.getObjectMetadata(request, parsedObject);

View File

@ -1,39 +0,0 @@
import { Injectable } from '@nestjs/common';
import { capitalize } from 'twenty-shared/utils';
import { mapFieldMetadataToGraphqlQuery } from 'src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
@Injectable()
export class CreateOneQueryFactory {
create(
objectMetadata: {
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
},
depth?: number,
): string {
const objectNameSingular = capitalize(
objectMetadata.objectMetadataMapItem.nameSingular,
);
return `
mutation Create${objectNameSingular}($data: ${objectNameSingular}CreateInput!) {
create${objectNameSingular}(data: $data) {
id
${objectMetadata.objectMetadataMapItem.fields
.map((field) =>
mapFieldMetadataToGraphqlQuery(
objectMetadata.objectMetadataMaps,
field,
depth,
),
)
.join('\n')}
}
}
`;
}
}

View File

@ -1,20 +0,0 @@
import { Injectable } from '@nestjs/common';
import { capitalize } from 'twenty-shared/utils';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
@Injectable()
export class DeleteQueryFactory {
create(objectMetadataMapItem: ObjectMetadataItemWithFieldMaps): string {
const objectNameSingular = capitalize(objectMetadataMapItem.nameSingular);
return `
mutation Delete${objectNameSingular}($id: ID!) {
delete${objectNameSingular}(id: $id) {
id
}
}
`;
}
}

View File

@ -1,12 +0,0 @@
import { Injectable } from '@nestjs/common';
import { QueryVariables } from 'src/engine/api/rest/core/types/query-variables.type';
@Injectable()
export class DeleteVariablesFactory {
create(id: string): QueryVariables {
return {
id,
};
}
}

View File

@ -1,28 +1,14 @@
import { CreateManyQueryFactory } from 'src/engine/api/rest/core/query-builder/factories/create-many-query.factory';
import { CreateOneQueryFactory } from 'src/engine/api/rest/core/query-builder/factories/create-one-query.factory';
import { CreateVariablesFactory } from 'src/engine/api/rest/core/query-builder/factories/create-variables.factory';
import { DeleteQueryFactory } from 'src/engine/api/rest/core/query-builder/factories/delete-query.factory';
import { DeleteVariablesFactory } from 'src/engine/api/rest/core/query-builder/factories/delete-variables.factory';
import { FindDuplicatesQueryFactory } from 'src/engine/api/rest/core/query-builder/factories/find-duplicates-query.factory';
import { FindDuplicatesVariablesFactory } from 'src/engine/api/rest/core/query-builder/factories/find-duplicates-variables.factory';
import { FindManyQueryFactory } from 'src/engine/api/rest/core/query-builder/factories/find-many-query.factory';
import { FindOneQueryFactory } from 'src/engine/api/rest/core/query-builder/factories/find-one-query.factory';
import { GetVariablesFactory } from 'src/engine/api/rest/core/query-builder/factories/get-variables.factory';
import { UpdateQueryFactory } from 'src/engine/api/rest/core/query-builder/factories/update-query.factory';
import { UpdateVariablesFactory } from 'src/engine/api/rest/core/query-builder/factories/update-variables.factory';
import { CreateVariablesFactory } from 'src/engine/api/rest/core/query-builder/factories/create-variables.factory';
import { inputFactories } from 'src/engine/api/rest/input-factories/factories';
export const coreQueryBuilderFactories = [
DeleteQueryFactory,
CreateOneQueryFactory,
CreateManyQueryFactory,
UpdateQueryFactory,
FindOneQueryFactory,
FindManyQueryFactory,
FindDuplicatesQueryFactory,
DeleteVariablesFactory,
CreateVariablesFactory,
UpdateVariablesFactory,
GetVariablesFactory,
FindDuplicatesVariablesFactory,
...inputFactories,

View File

@ -1,65 +0,0 @@
import { Injectable } from '@nestjs/common';
import { capitalize } from 'twenty-shared/utils';
import { mapFieldMetadataToGraphqlQuery } from 'src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
@Injectable()
export class FindManyQueryFactory {
create(
objectMetadata: {
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
},
depth?: number,
): string {
const objectNameSingular = capitalize(
objectMetadata.objectMetadataMapItem.nameSingular,
);
const objectNamePlural = objectMetadata.objectMetadataMapItem.namePlural;
return `
query FindMany${capitalize(objectNamePlural)}(
$filter: ${objectNameSingular}FilterInput,
$orderBy: [${objectNameSingular}OrderByInput],
$startingAfter: String,
$endingBefore: String,
$first: Int,
$last: Int
) {
${objectNamePlural}(
filter: $filter,
orderBy: $orderBy,
first: $first,
last: $last,
after: $startingAfter,
before: $endingBefore
) {
edges {
node {
id
${objectMetadata.objectMetadataMapItem.fields
.map((field) =>
mapFieldMetadataToGraphqlQuery(
objectMetadata.objectMetadataMaps,
field,
depth,
),
)
.join('\n')}
}
cursor
}
pageInfo {
hasNextPage
startCursor
endCursor
}
totalCount
}
}
`;
}
}

View File

@ -1,40 +0,0 @@
import { Injectable } from '@nestjs/common';
import { capitalize } from 'twenty-shared/utils';
import { mapFieldMetadataToGraphqlQuery } from 'src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
@Injectable()
export class FindOneQueryFactory {
create(
objectMetadata: {
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
},
depth?: number,
): string {
const objectNameSingular =
objectMetadata.objectMetadataMapItem.nameSingular;
return `
query FindOne${capitalize(objectNameSingular)}(
$filter: ${capitalize(objectNameSingular)}FilterInput!,
) {
${objectNameSingular}(filter: $filter) {
id
${objectMetadata.objectMetadataMapItem.fields
.map((field) =>
mapFieldMetadataToGraphqlQuery(
objectMetadata.objectMetadataMaps,
field,
depth,
),
)
.join('\n')}
}
}
`;
}
}

View File

@ -33,13 +33,15 @@ export class GetVariablesFactory {
return { filter: { id: { eq: id } } };
}
const filter = this.filterInputFactory.create(request, objectMetadata);
const limit = this.limitInputFactory.create(request);
const orderBy = this.orderByInputFactory.create(request, objectMetadata);
const endingBefore = this.endingBeforeInputFactory.create(request);
const startingAfter = this.startingAfterInputFactory.create(request);
return {
filter: this.filterInputFactory.create(request, objectMetadata),
orderBy: this.orderByInputFactory.create(request, objectMetadata),
filter,
orderBy,
first: !endingBefore ? limit : undefined,
last: endingBefore ? limit : undefined,
startingAfter,

View File

@ -1,40 +0,0 @@
import { Injectable } from '@nestjs/common';
import { capitalize } from 'twenty-shared/utils';
import { mapFieldMetadataToGraphqlQuery } from 'src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
@Injectable()
export class UpdateQueryFactory {
create(
objectMetadata: {
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
},
depth?: number,
): string {
const objectNameSingular =
objectMetadata.objectMetadataMapItem.nameSingular;
return `
mutation Update${capitalize(
objectNameSingular,
)}($id: ID!, $data: ${capitalize(objectNameSingular)}UpdateInput!) {
update${capitalize(objectNameSingular)}(id: $id, data: $data) {
id
${Object.values(objectMetadata.objectMetadataMapItem.fieldsById)
.map((field) =>
mapFieldMetadataToGraphqlQuery(
objectMetadata.objectMetadataMaps,
field,
depth,
),
)
.join('\n')}
}
}
`;
}
}

View File

@ -1,15 +0,0 @@
import { Injectable } from '@nestjs/common';
import { Request } from 'express';
import { QueryVariables } from 'src/engine/api/rest/core/types/query-variables.type';
@Injectable()
export class UpdateVariablesFactory {
create(id: string, request: Request): QueryVariables {
return {
id,
data: request.body,
};
}
}

View File

@ -1,197 +1,44 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { Request } from 'express';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { isDefined } from 'twenty-shared/utils';
import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { ApiEventEmitterService } from 'src/engine/api/graphql/graphql-query-runner/services/api-event-emitter.service';
import { CoreQueryBuilderFactory } from 'src/engine/api/rest/core/query-builder/core-query-builder.factory';
import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { RestApiDeleteOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-delete-one.handler';
import { RestApiCreateOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-create-one.handler';
import { RestApiUpdateOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-update-one.handler';
import { RestApiGetOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-get-one.handler';
import { RestApiGetManyHandler } from 'src/engine/api/rest/core/handlers/rest-api-get-many.handler';
@Injectable()
export class RestApiCoreServiceV2 {
constructor(
private readonly coreQueryBuilderFactory: CoreQueryBuilderFactory,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly recordInputTransformerService: RecordInputTransformerService,
protected readonly apiEventEmitterService: ApiEventEmitterService,
private readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
private readonly restApiDeleteOneHandler: RestApiDeleteOneHandler,
private readonly restApiCreateOneHandler: RestApiCreateOneHandler,
private readonly restApiUpdateOneHandler: RestApiUpdateOneHandler,
private readonly restApiGetOneHandler: RestApiGetOneHandler,
private readonly restApiGetManyHandler: RestApiGetManyHandler,
) {}
async delete(request: Request) {
const { id: recordId } = parseCorePath(request);
if (!recordId) {
throw new BadRequestException('Record ID not found');
}
const { objectMetadataNameSingular, objectMetadata, repository } =
await this.getRepositoryAndMetadataOrFail(request);
const recordToDelete = await repository.findOneOrFail({
where: { id: recordId },
});
await repository.delete(recordId);
this.apiEventEmitterService.emitDestroyEvents(
[recordToDelete],
this.getAuthContextFromRequest(request),
objectMetadata.objectMetadataMapItem,
);
return this.formatResult('delete', objectMetadataNameSingular, {
id: recordToDelete.id,
});
return await this.restApiDeleteOneHandler.handle(request);
}
async createOne(request: Request) {
const { objectMetadataNameSingular, objectMetadata, repository } =
await this.getRepositoryAndMetadataOrFail(request);
const overriddenBody = await this.recordInputTransformerService.process({
recordInput: request.body,
objectMetadataMapItem: objectMetadata.objectMetadataMapItem,
});
const recordExists =
isDefined(overriddenBody.id) &&
(await repository.exists({
where: {
id: overriddenBody.id,
},
}));
if (recordExists) {
throw new BadRequestException('Record already exists');
}
const createdRecord = await repository.save(overriddenBody);
this.apiEventEmitterService.emitCreateEvents(
[createdRecord],
this.getAuthContextFromRequest(request),
objectMetadata.objectMetadataMapItem,
);
return this.formatResult(
'create',
objectMetadataNameSingular,
createdRecord,
);
return await this.restApiCreateOneHandler.handle(request);
}
async update(request: Request) {
return await this.restApiUpdateOneHandler.handle(request);
}
async get(request: Request) {
const { id: recordId } = parseCorePath(request);
if (!recordId) {
throw new BadRequestException('Record ID not found');
}
const { objectMetadataNameSingular, objectMetadata, repository } =
await this.getRepositoryAndMetadataOrFail(request);
const recordToUpdate = await repository.findOneOrFail({
where: { id: recordId },
});
const overriddenBody = await this.recordInputTransformerService.process({
recordInput: request.body,
objectMetadataMapItem: objectMetadata.objectMetadataMapItem,
});
const updatedRecord = await repository.save({
...recordToUpdate,
...overriddenBody,
});
this.apiEventEmitterService.emitUpdateEvents(
[recordToUpdate],
[updatedRecord],
Object.keys(request.body),
this.getAuthContextFromRequest(request),
objectMetadata.objectMetadataMapItem,
);
return this.formatResult(
'update',
objectMetadataNameSingular,
updatedRecord,
);
}
private formatResult<T>(
operation: 'delete' | 'create' | 'update' | 'find',
objectNameSingular: string,
data: T,
) {
const result = {
data: {
[operation + capitalize(objectNameSingular)]: data,
},
};
return result;
}
private async getRepositoryAndMetadataOrFail(request: Request) {
const { workspace, apiKey, userWorkspaceId } = request;
const { object: parsedObject } = parseCorePath(request);
const objectMetadata = await this.coreQueryBuilderFactory.getObjectMetadata(
request,
parsedObject,
);
if (!objectMetadata) {
throw new BadRequestException('Object metadata not found');
}
if (!workspace?.id) {
throw new BadRequestException('Workspace not found');
}
const dataSource =
await this.twentyORMGlobalManager.getDataSourceForWorkspace({
workspaceId: workspace.id,
shouldFailIfMetadataNotFound: false,
});
const objectMetadataNameSingular =
objectMetadata.objectMetadataMapItem.nameSingular;
const shouldBypassPermissionChecks = !!apiKey;
const roleId =
await this.workspacePermissionsCacheService.getRoleIdFromUserWorkspaceId({
workspaceId: workspace.id,
userWorkspaceId,
});
const repository = dataSource.getRepository<ObjectRecord>(
objectMetadataNameSingular,
shouldBypassPermissionChecks,
roleId,
);
return {
objectMetadataNameSingular,
objectMetadata,
repository,
};
}
private getAuthContextFromRequest(request: Request): AuthContext {
return {
user: request.user,
workspace: request.workspace,
apiKey: request.apiKey,
workspaceMemberId: request.workspaceMemberId,
userWorkspaceId: request.userWorkspaceId,
};
if (isDefined(recordId)) {
return await this.restApiGetOneHandler.handle(request);
} else {
return await this.restApiGetManyHandler.handle(request);
}
}
}

View File

@ -0,0 +1,51 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { RestApiDeleteOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-delete-one.handler';
import { RestApiCreateOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-create-one.handler';
import { RestApiUpdateOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-update-one.handler';
import { RestApiGetOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-get-one.handler';
import { RestApiGetManyHandler } from 'src/engine/api/rest/core/handlers/rest-api-get-many.handler';
import { CoreQueryBuilderModule } from 'src/engine/api/rest/core/query-builder/core-query-builder.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
import { RecordTransformerModule } from 'src/engine/core-modules/record-transformer/record-transformer.module';
import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module';
import { RestApiCoreController } from 'src/engine/api/rest/core/controllers/rest-api-core.controller';
import { coreQueryBuilderFactories } from 'src/engine/api/rest/core/query-builder/factories/factories';
import { RestApiCoreBatchController } from 'src/engine/api/rest/core/controllers/rest-api-core-batch.controller';
import { RestApiCoreServiceV2 } from 'src/engine/api/rest/core/rest-api-core-v2.service';
import { RestApiCoreService } from 'src/engine/api/rest/core/rest-api-core.service';
import { RestApiService } from 'src/engine/api/rest/rest-api.service';
import { ApiEventEmitterService } from 'src/engine/api/graphql/graphql-query-runner/services/api-event-emitter.service';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
const restApiCoreResolvers = [
RestApiDeleteOneHandler,
RestApiCreateOneHandler,
RestApiUpdateOneHandler,
RestApiGetOneHandler,
RestApiGetManyHandler,
];
@Module({
imports: [
CoreQueryBuilderModule,
WorkspaceCacheStorageModule,
AuthModule,
HttpModule,
TwentyORMModule,
RecordTransformerModule,
WorkspacePermissionsCacheModule,
],
controllers: [RestApiCoreController, RestApiCoreBatchController],
providers: [
RestApiService,
RestApiCoreService,
RestApiCoreServiceV2,
ApiEventEmitterService,
...coreQueryBuilderFactories,
...restApiCoreResolvers,
],
})
export class RestApiCoreModule {}

View File

@ -15,36 +15,12 @@ export class RestApiCoreService {
private readonly restApiService: RestApiService,
) {}
async get(request: Request) {
const data = await this.coreQueryBuilderFactory.get(request);
return await this.restApiService.call(GraphqlApiType.CORE, request, data);
}
async delete(request: Request) {
const data = await this.coreQueryBuilderFactory.delete(request);
return await this.restApiService.call(GraphqlApiType.CORE, request, data);
}
async createOne(request: Request) {
const data = await this.coreQueryBuilderFactory.createOne(request);
return await this.restApiService.call(GraphqlApiType.CORE, request, data);
}
async createMany(request: Request) {
const data = await this.coreQueryBuilderFactory.createMany(request);
return await this.restApiService.call(GraphqlApiType.CORE, request, data);
}
async update(request: Request) {
const data = await this.coreQueryBuilderFactory.update(request);
return await this.restApiService.call(GraphqlApiType.CORE, request, data);
}
async findDuplicates(request: Request) {
const data = await this.coreQueryBuilderFactory.findDuplicates(request);

View File

@ -1,9 +1,11 @@
import { ObjectRecordOrderBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
export type QueryVariables = {
id?: string;
ids?: string[];
data?: object | null;
filter?: object;
orderBy?: object;
orderBy?: ObjectRecordOrderBy;
last?: number;
first?: number;
startingAfter?: string;

View File

@ -0,0 +1,32 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Request } from 'express';
export const MAX_DEPTH = 2;
export type Depth = 0 | 1 | 2;
const ALLOWED_DEPTH_VALUES: Depth[] = [0, 1, MAX_DEPTH];
@Injectable()
export class DepthInputFactory {
create(request: Request): Depth {
if (!request.query.depth) {
return 0;
}
const depth = +request.query.depth as Depth;
if (isNaN(depth) || !ALLOWED_DEPTH_VALUES.includes(depth)) {
throw new BadRequestException(
`'depth=${
request.query.depth
}' parameter invalid. Allowed values are ${ALLOWED_DEPTH_VALUES.join(
', ',
)}`,
);
}
return depth;
}
}

View File

@ -3,11 +3,13 @@ import { EndingBeforeInputFactory } from 'src/engine/api/rest/input-factories/en
import { LimitInputFactory } from 'src/engine/api/rest/input-factories/limit-input.factory';
import { OrderByInputFactory } from 'src/engine/api/rest/input-factories/order-by-input.factory';
import { FilterInputFactory } from 'src/engine/api/rest/input-factories/filter-input.factory';
import { DepthInputFactory } from 'src/engine/api/rest/input-factories/depth-input.factory';
export const inputFactories = [
StartingAfterInputFactory,
DepthInputFactory,
EndingBeforeInputFactory,
FilterInputFactory,
LimitInputFactory,
OrderByInputFactory,
FilterInputFactory,
StartingAfterInputFactory,
];

View File

@ -1,51 +1,23 @@
import { HttpModule } from '@nestjs/axios';
import { Module } from '@nestjs/common';
import { ApiEventEmitterService } from 'src/engine/api/graphql/graphql-query-runner/services/api-event-emitter.service';
import { RestApiCoreBatchController } from 'src/engine/api/rest/core/controllers/rest-api-core-batch.controller';
import { RestApiCoreController } from 'src/engine/api/rest/core/controllers/rest-api-core.controller';
import { CoreQueryBuilderModule } from 'src/engine/api/rest/core/query-builder/core-query-builder.module';
import { RestApiCoreServiceV2 } from 'src/engine/api/rest/core/rest-api-core-v2.service';
import { RestApiCoreService } from 'src/engine/api/rest/core/rest-api-core.service';
import { EndingBeforeInputFactory } from 'src/engine/api/rest/input-factories/ending-before-input.factory';
import { LimitInputFactory } from 'src/engine/api/rest/input-factories/limit-input.factory';
import { StartingAfterInputFactory } from 'src/engine/api/rest/input-factories/starting-after-input.factory';
import { MetadataQueryBuilderModule } from 'src/engine/api/rest/metadata/query-builder/metadata-query-builder.module';
import { RestApiMetadataController } from 'src/engine/api/rest/metadata/rest-api-metadata.controller';
import { RestApiMetadataService } from 'src/engine/api/rest/metadata/rest-api-metadata.service';
import { RestApiService } from 'src/engine/api/rest/rest-api.service';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { RecordTransformerModule } from 'src/engine/core-modules/record-transformer/record-transformer.module';
import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { RestApiCoreModule } from 'src/engine/api/rest/core/rest-api-core.module';
import { RestApiService } from 'src/engine/api/rest/rest-api.service';
import { RestApiMetadataController } from 'src/engine/api/rest/metadata/rest-api-metadata.controller';
@Module({
imports: [
CoreQueryBuilderModule,
MetadataQueryBuilderModule,
WorkspaceCacheStorageModule,
AuthModule,
HttpModule,
TwentyORMModule,
RecordTransformerModule,
WorkspacePermissionsCacheModule,
RestApiCoreModule,
],
controllers: [
RestApiMetadataController,
RestApiCoreBatchController,
RestApiCoreController,
],
providers: [
RestApiMetadataService,
RestApiCoreService,
RestApiCoreServiceV2,
RestApiService,
StartingAfterInputFactory,
EndingBeforeInputFactory,
LimitInputFactory,
ApiEventEmitterService,
],
exports: [RestApiMetadataService],
controllers: [RestApiMetadataController],
providers: [RestApiService, RestApiMetadataService],
})
export class RestApiModule {}

View File

@ -15,15 +15,15 @@ export class CacheStorageService {
) {}
async get<T>(key: string): Promise<T | undefined> {
return this.cache.get(`${this.namespace}:${key}`);
return this.cache.get(this.getKey(key));
}
async set<T>(key: string, value: T, ttl?: Milliseconds) {
return this.cache.set(`${this.namespace}:${key}`, value, ttl);
return this.cache.set(this.getKey(key), value, ttl);
}
async del(key: string) {
return this.cache.del(`${this.namespace}:${key}`);
return this.cache.del(this.getKey(key));
}
async setAdd(key: string, value: string[], ttl?: Milliseconds) {
@ -33,13 +33,13 @@ export class CacheStorageService {
if (this.isRedisCache()) {
await (this.cache as RedisCache).store.client.sAdd(
`${this.namespace}:${key}`,
this.getKey(key),
value,
);
if (ttl) {
await (this.cache as RedisCache).store.client.expire(
`${this.namespace}:${key}`,
this.getKey(key),
ttl / 1000,
);
}
@ -65,7 +65,7 @@ export class CacheStorageService {
async setPop(key: string, size = 1) {
if (this.isRedisCache()) {
return (this.cache as RedisCache).store.client.sPop(
`${this.namespace}:${key}`,
this.getKey(key),
size,
);
}
@ -84,7 +84,7 @@ export class CacheStorageService {
async getSetLength(key: string) {
if (this.isRedisCache()) {
return await (this.cache as RedisCache).store.client.sCard(
`${this.namespace}:${key}`,
this.getKey(key),
);
}
@ -125,4 +125,14 @@ export class CacheStorageService {
private isRedisCache() {
return (this.cache.store as any)?.name === 'redis';
}
private getKey(key: string) {
const formattedKey = `${this.namespace}:${key}`;
if (process.env.NODE_ENV === 'test') {
return `integration-tests:${formattedKey}`;
}
return formattedKey;
}
}

View File

@ -24,8 +24,8 @@ type CacheResult<T, U> = {
data: U;
};
const USER_WORKSPACE_ROLE_MAP = 'User workspace role map';
const ROLES_PERMISSIONS = 'Roles permissions';
export const USER_WORKSPACE_ROLE_MAP = 'User workspace role map';
export const ROLES_PERMISSIONS = 'Roles permissions';
@Injectable()
export class WorkspacePermissionsCacheService {

View File

@ -12,7 +12,10 @@ import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-
import { WorkspaceFeatureFlagsMapCacheService } from 'src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.service';
import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service';
import { WorkspacePermissionsCacheStorageService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache-storage.service';
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
import {
ROLES_PERMISSIONS,
WorkspacePermissionsCacheService,
} from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
import { WorkspaceDataSource } from 'src/engine/twenty-orm/datasource/workspace.datasource';
import {
TwentyORMException,
@ -229,7 +232,7 @@ export class WorkspaceDatasourceFactory {
workspaceId,
ignoreLock: true,
}),
cachedEntityName: 'Roles permissions',
cachedEntityName: ROLES_PERMISSIONS,
exceptionCode: TwentyORMExceptionCode.ROLES_PERMISSIONS_VERSION_NOT_FOUND,
});
}

View File

@ -1,16 +0,0 @@
import { PERSON_2_ID } from 'test/integration/constants/mock-person-ids.constants';
export const INITIAL_PERSON_DATA = {
id: PERSON_2_ID,
name: {
firstName: 'Testing',
lastName: 'User',
},
emails: {
primaryEmail: 'test8@user.com',
additionalEmails: ['user8@example.com'],
},
city: 'New York',
jobTitle: 'Manager',
companyId: '20202020-0713-40a5-8216-82802401d33e',
};

View File

@ -1,4 +0,0 @@
export const PERSON_1_ID = '777a8457-eb2d-40ac-a707-551b615b6987';
export const PERSON_2_ID = '777a8457-eb2d-40ac-a707-551b615b6988';
export const PERSON_3_ID = '777a8457-eb2d-40ac-a707-551b615b6989';
export const NOT_EXISTING_PERSON_ID = '777a8457-eb2d-40ac-a707-551b615b6990';

View File

@ -0,0 +1 @@
export const TEST_COMPANY_1_ID = '525c282e-030a-4a3e-90a0-d8aad0d33a93';

View File

@ -0,0 +1,6 @@
export const TEST_PERSON_1_ID = '777a8457-eb2d-40ac-a707-551b615b6980';
export const TEST_PERSON_2_ID = '777a8457-eb2d-40ac-a707-551b615b6981';
export const TEST_PERSON_3_ID = '777a8457-eb2d-40ac-a707-551b615b6982';
export const TEST_PERSON_4_ID = '777a8457-eb2d-40ac-a707-551b615b6983';
export const NOT_EXISTING_TEST_PERSON_ID =
'777a8457-eb2d-40ac-a707-551b615b6990';

View File

@ -0,0 +1 @@
export const TEST_PRIMARY_LINK_URL = 'http://test/';

View File

@ -1,8 +1,8 @@
import {
PERSON_1_ID,
PERSON_2_ID,
PERSON_3_ID,
} from 'test/integration/constants/mock-person-ids.constants';
TEST_PERSON_1_ID,
TEST_PERSON_2_ID,
TEST_PERSON_3_ID,
} from 'test/integration/constants/test-person-ids.constants';
import { PERSON_GQL_FIELDS } from 'test/integration/constants/person-gql-fields.constants';
import { createManyOperationFactory } from 'test/integration/graphql/utils/create-many-operation-factory.util';
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
@ -16,22 +16,27 @@ import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graph
import { updateManyOperationFactory } from 'test/integration/graphql/utils/update-many-operation-factory.util';
import { updateOneOperationFactory } from 'test/integration/graphql/utils/update-one-operation-factory.util';
import { generateRecordName } from 'test/integration/utils/generate-record-name';
import { deleteAllRecords } from 'test/integration/utils/delete-all-records';
describe('people resolvers (integration)', () => {
beforeAll(async () => {
await deleteAllRecords('person');
});
it('1. should create and return people', async () => {
const personCity1 = generateRecordName(PERSON_1_ID);
const personCity2 = generateRecordName(PERSON_2_ID);
const personCity1 = generateRecordName(TEST_PERSON_1_ID);
const personCity2 = generateRecordName(TEST_PERSON_2_ID);
const graphqlOperation = createManyOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
gqlFields: PERSON_GQL_FIELDS,
data: [
{
id: PERSON_1_ID,
id: TEST_PERSON_1_ID,
city: personCity1,
},
{
id: PERSON_2_ID,
id: TEST_PERSON_2_ID,
city: personCity2,
},
],
@ -57,13 +62,13 @@ describe('people resolvers (integration)', () => {
});
it('1b. should create and return one person', async () => {
const personCity3 = generateRecordName(PERSON_3_ID);
const personCity3 = generateRecordName(TEST_PERSON_3_ID);
const graphqlOperation = createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: {
id: PERSON_3_ID,
id: TEST_PERSON_3_ID,
city: personCity3,
},
});
@ -121,7 +126,7 @@ describe('people resolvers (integration)', () => {
gqlFields: PERSON_GQL_FIELDS,
filter: {
id: {
eq: PERSON_3_ID,
eq: TEST_PERSON_3_ID,
},
},
});
@ -152,7 +157,7 @@ describe('people resolvers (integration)', () => {
},
filter: {
id: {
in: [PERSON_1_ID, PERSON_2_ID],
in: [TEST_PERSON_1_ID, TEST_PERSON_2_ID],
},
},
});
@ -175,7 +180,7 @@ describe('people resolvers (integration)', () => {
data: {
city: 'New City',
},
recordId: PERSON_3_ID,
recordId: TEST_PERSON_3_ID,
});
const response = await makeGraphqlAPIRequest(graphqlOperation);
@ -225,7 +230,7 @@ describe('people resolvers (integration)', () => {
gqlFields: PERSON_GQL_FIELDS,
filter: {
id: {
in: [PERSON_1_ID, PERSON_2_ID],
in: [TEST_PERSON_1_ID, TEST_PERSON_2_ID],
},
},
});
@ -245,7 +250,7 @@ describe('people resolvers (integration)', () => {
const graphqlOperation = deleteOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
recordId: PERSON_3_ID,
recordId: TEST_PERSON_3_ID,
});
const response = await makeGraphqlAPIRequest(graphqlOperation);
@ -260,7 +265,7 @@ describe('people resolvers (integration)', () => {
gqlFields: PERSON_GQL_FIELDS,
filter: {
id: {
in: [PERSON_1_ID, PERSON_2_ID],
in: [TEST_PERSON_1_ID, TEST_PERSON_2_ID],
},
},
});
@ -276,7 +281,7 @@ describe('people resolvers (integration)', () => {
gqlFields: PERSON_GQL_FIELDS,
filter: {
id: {
eq: PERSON_3_ID,
eq: TEST_PERSON_3_ID,
},
},
});
@ -293,7 +298,7 @@ describe('people resolvers (integration)', () => {
gqlFields: PERSON_GQL_FIELDS,
filter: {
id: {
in: [PERSON_1_ID, PERSON_2_ID],
in: [TEST_PERSON_1_ID, TEST_PERSON_2_ID],
},
not: {
deletedAt: {
@ -314,7 +319,7 @@ describe('people resolvers (integration)', () => {
gqlFields: PERSON_GQL_FIELDS,
filter: {
id: {
eq: PERSON_3_ID,
eq: TEST_PERSON_3_ID,
},
not: {
deletedAt: {
@ -326,7 +331,7 @@ describe('people resolvers (integration)', () => {
const response = await makeGraphqlAPIRequest(graphqlOperation);
expect(response.body.data.person.id).toEqual(PERSON_3_ID);
expect(response.body.data.person.id).toEqual(TEST_PERSON_3_ID);
});
it('8. should destroy many people', async () => {
@ -336,7 +341,7 @@ describe('people resolvers (integration)', () => {
gqlFields: PERSON_GQL_FIELDS,
filter: {
id: {
in: [PERSON_1_ID, PERSON_2_ID],
in: [TEST_PERSON_1_ID, TEST_PERSON_2_ID],
},
},
});
@ -350,7 +355,7 @@ describe('people resolvers (integration)', () => {
const graphqlOperation = destroyOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
recordId: PERSON_3_ID,
recordId: TEST_PERSON_3_ID,
});
const destroyPeopleResponse = await makeGraphqlAPIRequest(graphqlOperation);
@ -365,7 +370,7 @@ describe('people resolvers (integration)', () => {
gqlFields: PERSON_GQL_FIELDS,
filter: {
id: {
in: [PERSON_1_ID, PERSON_2_ID],
in: [TEST_PERSON_1_ID, TEST_PERSON_2_ID],
},
not: {
deletedAt: {
@ -386,7 +391,7 @@ describe('people resolvers (integration)', () => {
gqlFields: PERSON_GQL_FIELDS,
filter: {
id: {
eq: PERSON_3_ID,
eq: TEST_PERSON_3_ID,
},
not: {
deletedAt: {

View File

@ -8,7 +8,7 @@ const ORIGIN = new URL(SERVER_URL);
ORIGIN.hostname =
process.env.IS_MULTIWORKSPACE_ENABLED === 'true'
? `acme.${ORIGIN.hostname}`
? `apple.${ORIGIN.hostname}`
: ORIGIN.hostname;
const auth = {

View File

@ -14,8 +14,6 @@ import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.
import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception';
describe('datamodel permissions', () => {
beforeAll(async () => {});
describe('fieldMetadata', () => {
let listingObjectId = '';
let testFieldId = '';

View File

@ -1,21 +1,31 @@
import { PERSON_1_ID } from 'test/integration/constants/mock-person-ids.constants';
import { TEST_PERSON_1_ID } from 'test/integration/constants/test-person-ids.constants';
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
import { deleteAllRecords } from 'test/integration/utils/delete-all-records';
import { generateRecordName } from 'test/integration/utils/generate-record-name';
import { TEST_COMPANY_1_ID } from 'test/integration/constants/test-company-ids.constants';
import { TEST_PRIMARY_LINK_URL } from 'test/integration/constants/test-primary-link-url.constant';
describe('Core REST API Create One endpoint', () => {
beforeAll(
async () =>
beforeEach(async () => {
await deleteAllRecords('person');
await makeRestAPIRequest({
method: 'delete',
path: `/people/${PERSON_1_ID}`,
}),
);
method: 'post',
path: '/companies',
body: {
id: TEST_COMPANY_1_ID,
domainName: {
primaryLinkUrl: TEST_PRIMARY_LINK_URL,
},
},
});
});
it('should create a new person', async () => {
const personCity = generateRecordName(PERSON_1_ID);
const personCity = generateRecordName(TEST_PERSON_1_ID);
const requestBody = {
id: PERSON_1_ID,
id: TEST_PERSON_1_ID,
city: personCity,
companyId: TEST_COMPANY_1_ID,
};
await makeRestAPIRequest({
@ -27,18 +37,97 @@ describe('Core REST API Create One endpoint', () => {
.expect((res) => {
const createdPerson = res.body.data.createPerson;
expect(createdPerson.id).toBe(PERSON_1_ID);
expect(createdPerson.id).toBe(TEST_PERSON_1_ID);
expect(createdPerson.city).toBe(personCity);
});
});
it('should return a BadRequestException when trying to create a person with an existing ID', async () => {
const personCity = generateRecordName(PERSON_1_ID);
it('should support depth 0 parameter', async () => {
const personCity = generateRecordName(TEST_PERSON_1_ID);
const requestBody = {
id: PERSON_1_ID,
id: TEST_PERSON_1_ID,
city: personCity,
companyId: TEST_COMPANY_1_ID,
};
await makeRestAPIRequest({
method: 'post',
path: `/people?depth=0`,
body: requestBody,
})
.expect(201)
.expect((res) => {
const createdPerson = res.body.data.createPerson;
expect(createdPerson.companyId).toBeDefined();
expect(createdPerson.company).not.toBeDefined();
});
});
it('should support depth 1 parameter', async () => {
const personCity = generateRecordName(TEST_PERSON_1_ID);
const requestBody = {
id: TEST_PERSON_1_ID,
city: personCity,
companyId: TEST_COMPANY_1_ID,
};
await makeRestAPIRequest({
method: 'post',
path: `/people?depth=1`,
body: requestBody,
})
.expect(201)
.expect((res) => {
const createdPerson = res.body.data.createPerson;
expect(createdPerson.company).toBeDefined();
expect(createdPerson.company.domainName.primaryLinkUrl).toBe(
TEST_PRIMARY_LINK_URL,
);
expect(createdPerson.company.people).not.toBeDefined();
});
});
it('should support depth 2 parameter', async () => {
const personCity = generateRecordName(TEST_PERSON_1_ID);
const requestBody = {
id: TEST_PERSON_1_ID,
city: personCity,
companyId: TEST_COMPANY_1_ID,
};
await makeRestAPIRequest({
method: 'post',
path: `/people?depth=2`,
body: requestBody,
})
.expect(201)
.expect((res) => {
const createdPerson = res.body.data.createPerson;
expect(createdPerson.company.people).toBeDefined();
const depth2Person = createdPerson.company.people.find(
(p) => p.id === createdPerson.id,
);
expect(depth2Person).toBeDefined();
});
});
it('should return a BadRequestException when trying to create a person with an existing ID', async () => {
const personCity = generateRecordName(TEST_PERSON_1_ID);
const requestBody = {
id: TEST_PERSON_1_ID,
city: personCity,
};
await makeRestAPIRequest({
method: 'post',
path: `/people`,
body: requestBody,
});
await makeRestAPIRequest({
method: 'post',
path: `/people`,

View File

@ -1,34 +1,59 @@
import {
NOT_EXISTING_PERSON_ID,
PERSON_1_ID,
} from 'test/integration/constants/mock-person-ids.constants';
NOT_EXISTING_TEST_PERSON_ID,
TEST_PERSON_1_ID,
} from 'test/integration/constants/test-person-ids.constants';
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
import { deleteAllRecords } from 'test/integration/utils/delete-all-records';
describe('Core REST API Delete One endpoint', () => {
beforeAll(
async () =>
beforeAll(async () => {
await deleteAllRecords('person');
});
beforeEach(async () => {
await makeRestAPIRequest({
method: 'post',
path: `/people`,
body: {
id: PERSON_1_ID,
id: TEST_PERSON_1_ID,
},
}),
);
});
});
it('should delete one person', async () => {
await makeRestAPIRequest({
method: 'delete',
path: `/people/${PERSON_1_ID}`,
path: `/people/${TEST_PERSON_1_ID}`,
})
.expect(200)
.expect((res) => expect(res.body.data.deletePerson.id).toBe(PERSON_1_ID));
.expect((res) =>
expect(res.body.data.deletePerson).toEqual({ id: TEST_PERSON_1_ID }),
);
});
it('should delete one person with favorite', async () => {
await makeRestAPIRequest({
method: 'post',
path: `/favorites`,
body: {
personId: TEST_PERSON_1_ID,
},
});
await makeRestAPIRequest({
method: 'delete',
path: `/people/${TEST_PERSON_1_ID}`,
})
.expect(200)
.expect((res) =>
expect(res.body.data.deletePerson).toEqual({ id: TEST_PERSON_1_ID }),
);
});
it('should return a EntityNotFoundError when trying to delete a non-existing person', async () => {
await makeRestAPIRequest({
method: 'delete',
path: `/people/${NOT_EXISTING_PERSON_ID}`,
path: `/people/${NOT_EXISTING_TEST_PERSON_ID}`,
})
.expect(400)
.expect((res) => {

View File

@ -0,0 +1,289 @@
import {
TEST_PERSON_1_ID,
TEST_PERSON_2_ID,
TEST_PERSON_3_ID,
TEST_PERSON_4_ID,
} from 'test/integration/constants/test-person-ids.constants';
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
import { generateRecordName } from 'test/integration/utils/generate-record-name';
import { TEST_COMPANY_1_ID } from 'test/integration/constants/test-company-ids.constants';
import { deleteAllRecords } from 'test/integration/utils/delete-all-records';
import { TEST_PRIMARY_LINK_URL } from 'test/integration/constants/test-primary-link-url.constant';
describe('Core REST API Find Many endpoint', () => {
const testPersonIds = [
TEST_PERSON_1_ID,
TEST_PERSON_2_ID,
TEST_PERSON_3_ID,
TEST_PERSON_4_ID,
];
const testPersonCities: Record<string, string> = {};
beforeAll(async () => {
await deleteAllRecords('person');
await makeRestAPIRequest({
method: 'post',
path: '/companies',
body: {
id: TEST_COMPANY_1_ID,
domainName: {
primaryLinkUrl: TEST_PRIMARY_LINK_URL,
},
},
});
let index = 0;
for (const personId of testPersonIds) {
const city = generateRecordName(personId);
testPersonCities[personId] = city;
await makeRestAPIRequest({
method: 'post',
path: '/people',
body: {
id: personId,
city: city,
position: index,
companyId: TEST_COMPANY_1_ID,
},
});
index++;
}
});
it('should retrieve all people with pagination metadata', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: '/people',
}).expect(200);
const people = response.body.data.people;
const pageInfo = response.body.pageInfo;
const totalCount = response.body.totalCount;
expect(people).not.toBeNull();
expect(Array.isArray(people)).toBe(true);
expect(people.length).toBeGreaterThanOrEqual(testPersonIds.length);
// Check that our test people are included in the results
for (const personId of testPersonIds) {
const person = people.find((p) => p.id === personId);
expect(person).toBeDefined();
expect(person.city).toBe(testPersonCities[personId]);
}
// Check pagination metadata
expect(pageInfo).toBeDefined();
expect(pageInfo.startCursor).toBeDefined();
expect(pageInfo.endCursor).toBeDefined();
expect(typeof totalCount).toBe('number');
expect(totalCount).toEqual(testPersonIds.length);
expect(response.body.pageInfo.hasNextPage).toBe(false);
});
it('should limit results based on the limit parameter', async () => {
const limit = testPersonIds.length - 1;
const response = await makeRestAPIRequest({
method: 'get',
path: `/people?limit=${limit}`,
}).expect(200);
const people = response.body.data.people;
expect(people).not.toBeNull();
expect(Array.isArray(people)).toBe(true);
expect(people.length).toEqual(limit);
expect(response.body.totalCount).toEqual(testPersonIds.length);
expect(response.body.pageInfo.hasNextPage).toBe(true);
});
it('should return filtered totalCount', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: `/people?filter=position[lte]:1`,
}).expect(200);
const people = response.body.data.people;
expect(people).not.toBeNull();
expect(Array.isArray(people)).toBe(true);
expect(people.length).toEqual(2);
expect(response.body.totalCount).toEqual(2);
expect(response.body.pageInfo.hasNextPage).toBe(false);
});
it('should filter results based on filter parameters', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: '/people?filter=position[lt]:2',
}).expect(200);
const filteredPeople = response.body.data.people;
expect(filteredPeople).toBeDefined();
expect(Array.isArray(filteredPeople)).toBe(true);
expect(filteredPeople.length).toBe(2);
});
it('should support cursor-based pagination with starting_after', async () => {
const initialResponse = await makeRestAPIRequest({
method: 'get',
path: '/people?limit=2',
}).expect(200);
const people = initialResponse.body.data.people;
const startCursor = initialResponse.body.pageInfo.startCursor;
expect(people).toBeDefined();
expect(people.length).toBe(2);
expect(startCursor).toBeDefined();
const nextPageResponse = await makeRestAPIRequest({
method: 'get',
path: `/people?starting_after=${startCursor}&limit=1`,
}).expect(200);
const nextPagePeople = nextPageResponse.body.data.people;
expect(nextPagePeople).toBeDefined();
expect(nextPagePeople.length).toBe(1);
expect(nextPagePeople[0].id).toBe(people[1].id);
});
it('should support cursor-based pagination with ending_before', async () => {
const initialResponse = await makeRestAPIRequest({
method: 'get',
path: '/people?limit=4',
}).expect(200);
const people = initialResponse.body.data.people;
const endCursor = initialResponse.body.pageInfo.endCursor;
expect(people).toBeDefined();
expect(people.length).toBe(4);
expect(endCursor).toBeDefined();
const nextPageResponse = await makeRestAPIRequest({
method: 'get',
path: `/people?ending_before=${endCursor}&limit=2`,
}).expect(200);
const nextPagePeople = nextPageResponse.body.data.people;
expect(nextPagePeople).toBeDefined();
expect(nextPagePeople.length).toBe(2);
expect(nextPagePeople[0].id).toBe(people[1].id);
expect(nextPagePeople[1].id).toBe(people[2].id);
});
it('should support ordering Asc of results', async () => {
const ascResponse = await makeRestAPIRequest({
method: 'get',
path: '/people?order_by=position[AscNullsLast]',
}).expect(200);
const ascPeople = ascResponse.body.data.people;
expect(ascPeople).toEqual(
[...ascPeople].sort((a, b) => a.position - b.position),
);
});
it('should support ordering Desc of results', async () => {
const descResponse = await makeRestAPIRequest({
method: 'get',
path: '/people?order_by=position[DescNullsLast]',
}).expect(200);
const descPeople = descResponse.body.data.people;
expect(descPeople).toEqual(
[...descPeople].sort((a, b) => -(a.position - b.position)),
);
});
it('should handle invalid cursor gracefully', async () => {
await makeRestAPIRequest({
method: 'get',
path: '/people?starting_after=invalid-cursor',
})
.expect(400)
.expect((res) => {
expect(res.body.error).toBe('BadRequestException');
expect(res.body.messages[0]).toContain('Invalid cursor');
});
});
it('should combine filtering, ordering, and pagination', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: '/people?filter=position[gt]:0&order_by=city[AscNullsFirst]&limit=2',
}).expect(200);
const people = response.body.data.people;
const pageInfo = response.body.pageInfo;
expect(people).toBeDefined();
expect(people.length).toBeLessThanOrEqual(2);
expect(pageInfo).toBeDefined();
expect(people).toEqual([...people].sort((a, b) => a.city - b.city));
});
it('should support depth 0 parameter', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: '/people?depth=0',
}).expect(200);
const people = response.body.data.people;
expect(people).toBeDefined();
const person = people[0];
expect(person).toBeDefined();
expect(person.companyId).toBeDefined();
expect(person.company).not.toBeDefined();
});
it('should support depth 1 parameter', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: '/people?depth=1',
}).expect(200);
const people = response.body.data.people;
const person = people[0];
expect(person.company).toBeDefined();
expect(person.company.domainName.primaryLinkUrl).toBe(
TEST_PRIMARY_LINK_URL,
);
expect(person.company.people).not.toBeDefined();
});
it('should support depth 2 parameter', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: '/people?depth=2',
}).expect(200);
const people = response.body.data.people;
const person = people[0];
expect(person.company.people).toBeDefined();
const depth2Person = person.company.people.find((p) => p.id === person.id);
expect(depth2Person).toBeDefined();
});
});

View File

@ -0,0 +1,119 @@
import {
NOT_EXISTING_TEST_PERSON_ID,
TEST_PERSON_1_ID,
} from 'test/integration/constants/test-person-ids.constants';
import { TEST_COMPANY_1_ID } from 'test/integration/constants/test-company-ids.constants';
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
import { generateRecordName } from 'test/integration/utils/generate-record-name';
import { deleteAllRecords } from 'test/integration/utils/delete-all-records';
import { TEST_PRIMARY_LINK_URL } from 'test/integration/constants/test-primary-link-url.constant';
describe('Core REST API Find One endpoint', () => {
let personCity: string;
beforeAll(async () => {
await deleteAllRecords('person');
await deleteAllRecords('company');
personCity = generateRecordName(TEST_PERSON_1_ID);
await makeRestAPIRequest({
method: 'post',
path: '/companies',
body: {
id: TEST_COMPANY_1_ID,
domainName: {
primaryLinkUrl: TEST_PRIMARY_LINK_URL,
},
},
});
await makeRestAPIRequest({
method: 'post',
path: '/people',
body: {
id: TEST_PERSON_1_ID,
city: personCity,
companyId: TEST_COMPANY_1_ID,
},
});
});
it('should retrieve a person by ID', async () => {
await makeRestAPIRequest({
method: 'get',
path: `/people/${TEST_PERSON_1_ID}`,
})
.expect(200)
.expect((res) => {
const person = res.body.data.person;
expect(person).not.toBeNull();
expect(person.id).toBe(TEST_PERSON_1_ID);
expect(person.city).toBe(personCity);
});
});
it('should return 400 error when trying to retrieve a non-existing person', async () => {
await makeRestAPIRequest({
method: 'get',
path: `/people/${NOT_EXISTING_TEST_PERSON_ID}`,
})
.expect(400)
.expect((res) => {
expect(res.body.messages[0]).toContain('Record not found');
expect(res.body.error).toBe('BadRequestException');
});
});
it('should support depth 0 parameter', async () => {
await makeRestAPIRequest({
method: 'get',
path: `/people/${TEST_PERSON_1_ID}?depth=0`,
})
.expect(200)
.expect((res) => {
const person = res.body.data.person;
expect(person).toBeDefined();
expect(person.companyId).toBeDefined();
expect(person.company).not.toBeDefined();
});
});
it('should support depth 1 parameter', async () => {
await makeRestAPIRequest({
method: 'get',
path: `/people/${TEST_PERSON_1_ID}?depth=1`,
})
.expect(200)
.expect((res) => {
const person = res.body.data.person;
expect(person.company).toBeDefined();
expect(person.company.domainName.primaryLinkUrl).toBe(
TEST_PRIMARY_LINK_URL,
);
expect(person.company.people).not.toBeDefined();
});
});
it('should support depth 2 parameter', async () => {
await makeRestAPIRequest({
method: 'get',
path: `/people/${TEST_PERSON_1_ID}?depth=2`,
})
.expect(200)
.expect((res) => {
const person = res.body.data.person;
expect(person.company.people).toBeDefined();
const depth2Person = person.company.people.find(
(p) => p.id === person.id,
);
expect(depth2Person).toBeDefined();
});
});
});

View File

@ -1,26 +1,14 @@
import {
NOT_EXISTING_PERSON_ID,
PERSON_1_ID,
} from 'test/integration/constants/mock-person-ids.constants';
NOT_EXISTING_TEST_PERSON_ID,
TEST_PERSON_1_ID,
} from 'test/integration/constants/test-person-ids.constants';
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
import { generateRecordName } from 'test/integration/utils/generate-record-name';
import { deleteAllRecords } from 'test/integration/utils/delete-all-records';
import { TEST_COMPANY_1_ID } from 'test/integration/constants/test-company-ids.constants';
import { TEST_PRIMARY_LINK_URL } from 'test/integration/constants/test-primary-link-url.constant';
describe('Core REST API Update One endpoint', () => {
beforeAll(async () => {
await makeRestAPIRequest({
method: 'delete',
path: `/people/${PERSON_1_ID}`,
});
await makeRestAPIRequest({
method: 'post',
path: `/people`,
body: {
id: PERSON_1_ID,
},
});
});
it('should update an existing person (name, emails, and city)', async () => {
const updatedData = {
name: {
firstName: 'Updated',
@ -30,19 +18,42 @@ describe('Core REST API Update One endpoint', () => {
primaryEmail: 'updated@example.com',
additionalEmails: ['extra@example.com'],
},
city: generateRecordName(PERSON_1_ID),
city: generateRecordName(TEST_PERSON_1_ID),
};
beforeAll(async () => {
await deleteAllRecords('person');
await makeRestAPIRequest({
method: 'post',
path: '/companies',
body: {
id: TEST_COMPANY_1_ID,
domainName: {
primaryLinkUrl: TEST_PRIMARY_LINK_URL,
},
},
});
await makeRestAPIRequest({
method: 'post',
path: `/people`,
body: {
id: TEST_PERSON_1_ID,
companyId: TEST_COMPANY_1_ID,
},
});
});
it('should update an existing person (name, emails, and city)', async () => {
await makeRestAPIRequest({
method: 'patch',
path: `/people/${PERSON_1_ID}`,
path: `/people/${TEST_PERSON_1_ID}`,
body: updatedData,
})
.expect(200)
.expect((res) => {
const updatedPerson = res.body.data.updatePerson;
expect(updatedPerson.id).toBe(PERSON_1_ID);
expect(updatedPerson.id).toBe(TEST_PERSON_1_ID);
expect(updatedPerson.name.firstName).toBe(updatedData.name.firstName);
expect(updatedPerson.name.lastName).toBe(updatedData.name.lastName);
expect(updatedPerson.emails.primaryEmail).toBe(
@ -54,14 +65,67 @@ describe('Core REST API Update One endpoint', () => {
expect(updatedPerson.city).toBe(updatedData.city);
expect(updatedPerson.jobTitle).toBe('');
expect(updatedPerson.companyId).toBe(null);
expect(updatedPerson.companyId).toBe(TEST_COMPANY_1_ID);
});
});
it('should support depth 0 parameter', async () => {
await makeRestAPIRequest({
method: 'patch',
path: `/people/${TEST_PERSON_1_ID}?depth=0`,
body: updatedData,
})
.expect(200)
.expect((res) => {
const updatedPerson = res.body.data.updatePerson;
expect(updatedPerson.companyId).toBeDefined();
expect(updatedPerson.company).not.toBeDefined();
});
});
it('should support depth 1 parameter', async () => {
await makeRestAPIRequest({
method: 'patch',
path: `/people/${TEST_PERSON_1_ID}?depth=1`,
body: updatedData,
})
.expect(200)
.expect((res) => {
const updatedPerson = res.body.data.updatePerson;
expect(updatedPerson.company).toBeDefined();
expect(updatedPerson.company.domainName.primaryLinkUrl).toBe(
TEST_PRIMARY_LINK_URL,
);
expect(updatedPerson.company.people).not.toBeDefined();
});
});
it('should support depth 2 parameter', async () => {
await makeRestAPIRequest({
method: 'patch',
path: `/people/${TEST_PERSON_1_ID}?depth=2`,
body: updatedData,
})
.expect(200)
.expect((res) => {
const updatedPerson = res.body.data.updatePerson;
expect(updatedPerson.company.people).toBeDefined();
const depth2Person = updatedPerson.company.people.find(
(p) => p.id === updatedPerson.id,
);
expect(depth2Person).toBeDefined();
});
});
it('should return a EntityNotFoundError when trying to update a non-existing person', async () => {
await makeRestAPIRequest({
method: 'patch',
path: `/people/${NOT_EXISTING_PERSON_ID}`,
path: `/people/${NOT_EXISTING_TEST_PERSON_ID}`,
})
.expect(400)
.expect((res) => {

View File

@ -0,0 +1,11 @@
const TEST_SCHEMA_NAME = 'workspace_1wgvd1injqtife6y4rvfbu3h5';
export const deleteAllRecords = async (objectNameSingular: string) => {
try {
await global.testDataSource.query(
`DELETE from "${TEST_SCHEMA_NAME}"."${objectNameSingular}"`,
);
} catch {
/* empty */
}
};

View File

@ -1,6 +1,8 @@
import { JestConfigWithTsJest } from 'ts-jest';
import 'tsconfig-paths/register';
import { rawDataSource } from 'src/database/typeorm/raw/raw.datasource';
import { createApp } from './create-app';
export default async (_, projectConfig: JestConfigWithTsJest) => {
@ -10,7 +12,10 @@ export default async (_, projectConfig: JestConfigWithTsJest) => {
throw new Error('No globals found in project config');
}
await rawDataSource.initialize();
await app.listen(projectConfig.globals.APP_PORT);
global.app = app;
global.testDataSource = rawDataSource;
};

View File

@ -1,5 +1,6 @@
import 'tsconfig-paths/register';
export default async () => {
global.testDataSource.destroy();
global.app.close();
};