[4/n]: migrate the RESTAPI GET /rest/* to use TwentyORM directly (#10372)
# This PR - Addressing #3644 - Migrates the `findOne` and the `findMany` Rest API to use TwentyORM directly - Adds integration tests to the migrated methods --------- Co-authored-by: prastoin <paul@twenty.com> Co-authored-by: martmull <martmull@hotmail.fr>
This commit is contained in:
@ -37,6 +37,7 @@ const MIGRATED_REST_METHODS = [
|
|||||||
RequestMethod.POST,
|
RequestMethod.POST,
|
||||||
RequestMethod.PATCH,
|
RequestMethod.PATCH,
|
||||||
RequestMethod.PUT,
|
RequestMethod.PUT,
|
||||||
|
RequestMethod.GET,
|
||||||
];
|
];
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import {
|
|||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
|
||||||
import { ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
import { ObjectRecordFilter } 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 { FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metadata-map';
|
import { FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metadata-map';
|
||||||
|
|
||||||
@ -16,14 +15,10 @@ export class GraphqlQueryFilterConditionParser {
|
|||||||
private fieldMetadataMapByName: FieldMetadataMap;
|
private fieldMetadataMapByName: FieldMetadataMap;
|
||||||
private queryFilterFieldParser: GraphqlQueryFilterFieldParser;
|
private queryFilterFieldParser: GraphqlQueryFilterFieldParser;
|
||||||
|
|
||||||
constructor(
|
constructor(fieldMetadataMapByName: FieldMetadataMap) {
|
||||||
fieldMetadataMapByName: FieldMetadataMap,
|
|
||||||
featureFlagsMap: FeatureFlagMap,
|
|
||||||
) {
|
|
||||||
this.fieldMetadataMapByName = fieldMetadataMapByName;
|
this.fieldMetadataMapByName = fieldMetadataMapByName;
|
||||||
this.queryFilterFieldParser = new GraphqlQueryFilterFieldParser(
|
this.queryFilterFieldParser = new GraphqlQueryFilterFieldParser(
|
||||||
this.fieldMetadataMapByName,
|
this.fieldMetadataMapByName,
|
||||||
featureFlagsMap,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { WhereExpressionBuilder } from 'typeorm';
|
import { WhereExpressionBuilder } from 'typeorm';
|
||||||
import { capitalize } from 'twenty-shared/utils';
|
import { capitalize } from 'twenty-shared/utils';
|
||||||
|
|
||||||
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 { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -18,14 +17,9 @@ const ARRAY_OPERATORS = ['in', 'contains', 'notContains'];
|
|||||||
|
|
||||||
export class GraphqlQueryFilterFieldParser {
|
export class GraphqlQueryFilterFieldParser {
|
||||||
private fieldMetadataMapByName: FieldMetadataMap;
|
private fieldMetadataMapByName: FieldMetadataMap;
|
||||||
private featureFlagsMap: FeatureFlagMap;
|
|
||||||
|
|
||||||
constructor(
|
constructor(fieldMetadataMapByName: FieldMetadataMap) {
|
||||||
fieldMetadataMapByName: FieldMetadataMap,
|
|
||||||
featureFlagsMap: FeatureFlagMap,
|
|
||||||
) {
|
|
||||||
this.fieldMetadataMapByName = fieldMetadataMapByName;
|
this.fieldMetadataMapByName = fieldMetadataMapByName;
|
||||||
this.featureFlagsMap = featureFlagsMap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public parse(
|
public parse(
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import {
|
|||||||
ObjectRecordOrderBy,
|
ObjectRecordOrderBy,
|
||||||
OrderByDirection,
|
OrderByDirection,
|
||||||
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
} 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 { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -18,14 +17,9 @@ import { CompositeFieldMetadataType } from 'src/engine/metadata-modules/workspac
|
|||||||
|
|
||||||
export class GraphqlQueryOrderFieldParser {
|
export class GraphqlQueryOrderFieldParser {
|
||||||
private fieldMetadataMapByName: FieldMetadataMap;
|
private fieldMetadataMapByName: FieldMetadataMap;
|
||||||
private featureFlagsMap: FeatureFlagMap;
|
|
||||||
|
|
||||||
constructor(
|
constructor(fieldMetadataMapByName: FieldMetadataMap) {
|
||||||
fieldMetadataMapByName: FieldMetadataMap,
|
|
||||||
featureFlagsMap: FeatureFlagMap,
|
|
||||||
) {
|
|
||||||
this.fieldMetadataMapByName = fieldMetadataMapByName;
|
this.fieldMetadataMapByName = fieldMetadataMapByName;
|
||||||
this.featureFlagsMap = featureFlagsMap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
parse(
|
parse(
|
||||||
|
|||||||
@ -39,11 +39,9 @@ export class GraphqlQueryParser {
|
|||||||
this.featureFlagsMap = featureFlagsMap;
|
this.featureFlagsMap = featureFlagsMap;
|
||||||
this.filterConditionParser = new GraphqlQueryFilterConditionParser(
|
this.filterConditionParser = new GraphqlQueryFilterConditionParser(
|
||||||
this.fieldMetadataMapByName,
|
this.fieldMetadataMapByName,
|
||||||
featureFlagsMap,
|
|
||||||
);
|
);
|
||||||
this.orderFieldParser = new GraphqlQueryOrderFieldParser(
|
this.orderFieldParser = new GraphqlQueryOrderFieldParser(
|
||||||
this.fieldMetadataMapByName,
|
this.fieldMetadataMapByName,
|
||||||
featureFlagsMap,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
|||||||
|
|
||||||
@Controller('rest/*')
|
@Controller('rest/*')
|
||||||
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard)
|
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard)
|
||||||
|
@UseFilters(RestApiExceptionFilter)
|
||||||
export class RestApiCoreController {
|
export class RestApiCoreController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly restApiCoreService: RestApiCoreService,
|
private readonly restApiCoreService: RestApiCoreService,
|
||||||
@ -29,7 +30,6 @@ export class RestApiCoreController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('/duplicates')
|
@Post('/duplicates')
|
||||||
@UseFilters(RestApiExceptionFilter)
|
|
||||||
async handleApiFindDuplicates(@Req() request: Request, @Res() res: Response) {
|
async handleApiFindDuplicates(@Req() request: Request, @Res() res: Response) {
|
||||||
const result = await this.restApiCoreService.findDuplicates(request);
|
const result = await this.restApiCoreService.findDuplicates(request);
|
||||||
|
|
||||||
@ -37,17 +37,13 @@ export class RestApiCoreController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseFilters(RestApiExceptionFilter)
|
|
||||||
async handleApiGet(@Req() request: Request, @Res() res: Response) {
|
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()
|
@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) {
|
async handleApiDelete(@Req() request: Request, @Res() res: Response) {
|
||||||
const result = await this.restApiCoreServiceV2.delete(request);
|
const result = await this.restApiCoreServiceV2.delete(request);
|
||||||
|
|
||||||
@ -55,7 +51,6 @@ export class RestApiCoreController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseFilters(RestApiExceptionFilter)
|
|
||||||
async handleApiPost(@Req() request: Request, @Res() res: Response) {
|
async handleApiPost(@Req() request: Request, @Res() res: Response) {
|
||||||
const result = await this.restApiCoreServiceV2.createOne(request);
|
const result = await this.restApiCoreServiceV2.createOne(request);
|
||||||
|
|
||||||
@ -74,7 +69,6 @@ export class RestApiCoreController {
|
|||||||
// We keep it to avoid a breaking change since it initially used PUT instead of PATCH,
|
// 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.
|
// and because the PUT verb is often used as a PATCH.
|
||||||
@Put()
|
@Put()
|
||||||
@UseFilters(RestApiExceptionFilter)
|
|
||||||
async handleApiPut(@Req() request: Request, @Res() res: Response) {
|
async handleApiPut(@Req() request: Request, @Res() res: Response) {
|
||||||
const result = await this.restApiCoreServiceV2.update(request);
|
const result = await this.restApiCoreServiceV2.update(request);
|
||||||
|
|
||||||
|
|||||||
@ -2,16 +2,52 @@ import { BadRequestException, Injectable } from '@nestjs/common';
|
|||||||
|
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import { capitalize } from 'twenty-shared/utils';
|
import { capitalize } from 'twenty-shared/utils';
|
||||||
|
import { ObjectLiteral, OrderByCondition, SelectQueryBuilder } from 'typeorm';
|
||||||
|
|
||||||
|
import { GraphqlQueryFilterConditionParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-condition.parser';
|
||||||
|
import { GraphqlQueryOrderFieldParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser';
|
||||||
import { CoreQueryBuilderFactory } from 'src/engine/api/rest/core/query-builder/core-query-builder.factory';
|
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 { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils';
|
||||||
|
import { FieldValue } from 'src/engine/api/rest/core/types/field-value.type';
|
||||||
|
import { EndingBeforeInputFactory } from 'src/engine/api/rest/input-factories/ending-before-input.factory';
|
||||||
|
import { FilterInputFactory } from 'src/engine/api/rest/input-factories/filter-input.factory';
|
||||||
|
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 { StartingAfterInputFactory } from 'src/engine/api/rest/input-factories/starting-after-input.factory';
|
||||||
|
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||||
|
import { FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metadata-map';
|
||||||
|
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
|
||||||
|
import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util';
|
||||||
|
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
|
||||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||||
|
import { formatResult as formatGetManyData } from 'src/engine/twenty-orm/utils/format-result.util';
|
||||||
|
|
||||||
|
interface FindManyMeta {
|
||||||
|
hasNextPage: boolean;
|
||||||
|
hasPreviousPage: boolean;
|
||||||
|
startCursor: string | null;
|
||||||
|
endCursor: string | null;
|
||||||
|
totalCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormatResultParams<T> {
|
||||||
|
operation: 'delete' | 'create' | 'update' | 'findOne' | 'findMany';
|
||||||
|
objectNameSingular?: string;
|
||||||
|
objectNamePlural?: string;
|
||||||
|
data: T;
|
||||||
|
meta?: FindManyMeta;
|
||||||
|
}
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RestApiCoreServiceV2 {
|
export class RestApiCoreServiceV2 {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly coreQueryBuilderFactory: CoreQueryBuilderFactory,
|
private readonly coreQueryBuilderFactory: CoreQueryBuilderFactory,
|
||||||
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||||
|
private readonly limitInputFactory: LimitInputFactory,
|
||||||
|
private readonly filterInputFactory: FilterInputFactory,
|
||||||
|
private readonly featureFlagService: FeatureFlagService,
|
||||||
|
private readonly orderByInputFactory: OrderByInputFactory,
|
||||||
|
private readonly startingAfterInputFactory: StartingAfterInputFactory,
|
||||||
|
private readonly endingBeforeInputFactory: EndingBeforeInputFactory,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async delete(request: Request) {
|
async delete(request: Request) {
|
||||||
@ -29,8 +65,12 @@ export class RestApiCoreServiceV2 {
|
|||||||
|
|
||||||
await repository.delete(recordId);
|
await repository.delete(recordId);
|
||||||
|
|
||||||
return this.formatResult('delete', objectMetadataNameSingular, {
|
return this.formatResult({
|
||||||
id: recordToDelete.id,
|
operation: 'delete',
|
||||||
|
objectNameSingular: objectMetadataNameSingular,
|
||||||
|
data: {
|
||||||
|
id: recordToDelete.id,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -41,11 +81,11 @@ export class RestApiCoreServiceV2 {
|
|||||||
await this.getRepositoryAndMetadataOrFail(request);
|
await this.getRepositoryAndMetadataOrFail(request);
|
||||||
const createdRecord = await repository.save(body);
|
const createdRecord = await repository.save(body);
|
||||||
|
|
||||||
return this.formatResult(
|
return this.formatResult({
|
||||||
'create',
|
operation: 'create',
|
||||||
objectMetadataNameSingular,
|
objectNameSingular: objectMetadataNameSingular,
|
||||||
createdRecord,
|
data: createdRecord,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(request: Request) {
|
async update(request: Request) {
|
||||||
@ -67,24 +107,310 @@ export class RestApiCoreServiceV2 {
|
|||||||
...request.body,
|
...request.body,
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.formatResult(
|
return this.formatResult({
|
||||||
'update',
|
operation: 'update',
|
||||||
|
objectNameSingular: objectMetadataNameSingular,
|
||||||
|
data: updatedRecord,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(request: Request) {
|
||||||
|
const { id: recordId } = parseCorePath(request);
|
||||||
|
const {
|
||||||
objectMetadataNameSingular,
|
objectMetadataNameSingular,
|
||||||
updatedRecord,
|
repository,
|
||||||
|
objectMetadata,
|
||||||
|
objectMetadataItemWithFieldsMaps,
|
||||||
|
} = await this.getRepositoryAndMetadataOrFail(request);
|
||||||
|
|
||||||
|
if (recordId) {
|
||||||
|
return await this.findOne(
|
||||||
|
repository,
|
||||||
|
recordId,
|
||||||
|
objectMetadataNameSingular,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return await this.findMany(
|
||||||
|
request,
|
||||||
|
repository,
|
||||||
|
objectMetadata,
|
||||||
|
objectMetadataNameSingular,
|
||||||
|
objectMetadataItemWithFieldsMaps,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findOne(
|
||||||
|
repository: any,
|
||||||
|
recordId: string,
|
||||||
|
objectMetadataNameSingular: string,
|
||||||
|
) {
|
||||||
|
const record = await repository.findOne({
|
||||||
|
where: { id: recordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.formatResult({
|
||||||
|
operation: 'findOne',
|
||||||
|
objectNameSingular: objectMetadataNameSingular,
|
||||||
|
data: record,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findMany(
|
||||||
|
request: Request,
|
||||||
|
repository: WorkspaceRepository<ObjectLiteral>,
|
||||||
|
objectMetadata: any,
|
||||||
|
objectMetadataNameSingular: string,
|
||||||
|
objectMetadataItemWithFieldsMaps:
|
||||||
|
| ObjectMetadataItemWithFieldMaps
|
||||||
|
| undefined,
|
||||||
|
) {
|
||||||
|
// Get input parameters
|
||||||
|
const inputs = this.getPaginationInputs(request, objectMetadata);
|
||||||
|
|
||||||
|
// Create query builder
|
||||||
|
const qb = repository.createQueryBuilder(objectMetadataNameSingular);
|
||||||
|
|
||||||
|
// Apply filters with cursor
|
||||||
|
const { finalQuery } = await this.applyFiltersWithCursor(
|
||||||
|
qb,
|
||||||
|
objectMetadataNameSingular,
|
||||||
|
objectMetadataItemWithFieldsMaps,
|
||||||
|
inputs,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const totalCount = await this.getTotalCount(finalQuery);
|
||||||
|
|
||||||
|
// Get records with pagination
|
||||||
|
const { finalRecords, hasMoreRecords } =
|
||||||
|
await this.getRecordsWithPagination(
|
||||||
|
finalQuery,
|
||||||
|
objectMetadataNameSingular,
|
||||||
|
objectMetadataItemWithFieldsMaps,
|
||||||
|
inputs,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Format and return result
|
||||||
|
return this.formatPaginatedResult(
|
||||||
|
finalRecords,
|
||||||
|
objectMetadataNameSingular,
|
||||||
|
objectMetadataItemWithFieldsMaps,
|
||||||
|
objectMetadata,
|
||||||
|
inputs.isForwardPagination,
|
||||||
|
hasMoreRecords,
|
||||||
|
totalCount,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatResult<T>(
|
private getPaginationInputs(request: Request, objectMetadata: any) {
|
||||||
operation: 'delete' | 'create' | 'update' | 'find',
|
const limit = this.limitInputFactory.create(request);
|
||||||
objectNameSingular: string,
|
const filter = this.filterInputFactory.create(request, objectMetadata);
|
||||||
data: T,
|
const orderBy = this.orderByInputFactory.create(request, objectMetadata);
|
||||||
|
const endingBefore = this.endingBeforeInputFactory.create(request);
|
||||||
|
const startingAfter = this.startingAfterInputFactory.create(request);
|
||||||
|
const isForwardPagination = !endingBefore;
|
||||||
|
|
||||||
|
return {
|
||||||
|
limit,
|
||||||
|
filter,
|
||||||
|
orderBy,
|
||||||
|
endingBefore,
|
||||||
|
startingAfter,
|
||||||
|
isForwardPagination,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyFiltersWithCursor(
|
||||||
|
qb: SelectQueryBuilder<ObjectLiteral>,
|
||||||
|
objectMetadataNameSingular: string,
|
||||||
|
objectMetadataItemWithFieldsMaps:
|
||||||
|
| ObjectMetadataItemWithFieldMaps
|
||||||
|
| undefined,
|
||||||
|
inputs: {
|
||||||
|
filter: Record<string, FieldValue>;
|
||||||
|
orderBy: any;
|
||||||
|
startingAfter: string | undefined;
|
||||||
|
endingBefore: string | undefined;
|
||||||
|
isForwardPagination: boolean;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
|
const fieldMetadataMapByName =
|
||||||
|
objectMetadataItemWithFieldsMaps?.fieldsByName || {};
|
||||||
|
|
||||||
|
let appliedFilters = inputs.filter;
|
||||||
|
|
||||||
|
// Handle cursor-based filtering
|
||||||
|
if (inputs.startingAfter || inputs.endingBefore) {
|
||||||
|
const cursor = inputs.startingAfter || inputs.endingBefore;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cursorData = JSON.parse(
|
||||||
|
Buffer.from(cursor ?? '', 'base64').toString(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// We always include ID in the ordering to ensure consistent pagination results
|
||||||
|
// Even if two records have identical values for the user-specified sort fields, their IDs ensures a deterministic order
|
||||||
|
const orderByWithIdCondition = [
|
||||||
|
...(inputs.orderBy || []),
|
||||||
|
{ id: 'ASC' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const cursorFilter = await this.computeCursorFilter(
|
||||||
|
cursorData,
|
||||||
|
orderByWithIdCondition,
|
||||||
|
fieldMetadataMapByName,
|
||||||
|
inputs.isForwardPagination,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Combine cursor filter with any user-provided filters
|
||||||
|
appliedFilters = inputs.filter
|
||||||
|
? { and: [inputs.filter, cursorFilter] }
|
||||||
|
: cursorFilter;
|
||||||
|
} catch (error) {
|
||||||
|
throw new BadRequestException(`Invalid cursor: ${cursor}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply filters to query builder
|
||||||
|
const finalQuery = new GraphqlQueryFilterConditionParser(
|
||||||
|
fieldMetadataMapByName,
|
||||||
|
).parse(qb, objectMetadataNameSingular, appliedFilters);
|
||||||
|
|
||||||
|
return { finalQuery, appliedFilters };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getTotalCount(
|
||||||
|
query: SelectQueryBuilder<ObjectLiteral>,
|
||||||
|
): Promise<number> {
|
||||||
|
// Clone the query to avoid modifying the original query that will fetch records
|
||||||
|
const countQuery = query.clone();
|
||||||
|
|
||||||
|
return await countQuery.getCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getRecordsWithPagination(
|
||||||
|
query: SelectQueryBuilder<ObjectLiteral>,
|
||||||
|
objectMetadataNameSingular: string,
|
||||||
|
objectMetadataItemWithFieldsMaps:
|
||||||
|
| ObjectMetadataItemWithFieldMaps
|
||||||
|
| undefined,
|
||||||
|
inputs: {
|
||||||
|
orderBy: any;
|
||||||
|
limit: number;
|
||||||
|
isForwardPagination: boolean;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const fieldMetadataMapByName =
|
||||||
|
objectMetadataItemWithFieldsMaps?.fieldsByName || {};
|
||||||
|
|
||||||
|
// Get parsed order by
|
||||||
|
const parsedOrderBy = new GraphqlQueryOrderFieldParser(
|
||||||
|
fieldMetadataMapByName,
|
||||||
|
).parse(inputs.orderBy, objectMetadataNameSingular);
|
||||||
|
|
||||||
|
// For backward pagination (endingBefore), we need to reverse the sort order
|
||||||
|
const finalOrderBy = inputs.isForwardPagination
|
||||||
|
? parsedOrderBy
|
||||||
|
: Object.entries(parsedOrderBy).reduce((acc, [key, direction]) => {
|
||||||
|
acc[key] = direction === 'ASC' ? 'DESC' : 'ASC';
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// Fetch one extra record beyond the requested limit
|
||||||
|
// We'll remove it from the results before returning to the client
|
||||||
|
const records = await query
|
||||||
|
.orderBy(finalOrderBy as OrderByCondition)
|
||||||
|
.take(inputs.limit + 1)
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
// If we got more records than the limit, it means there are more pages
|
||||||
|
const hasMoreRecords = records.length > inputs.limit;
|
||||||
|
|
||||||
|
// Remove the extra record if we fetched more than requested
|
||||||
|
if (hasMoreRecords) {
|
||||||
|
records.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// For backward pagination, we reversed the order to get the correct records
|
||||||
|
// Now we need to reverse them back to maintain the expected order for the client
|
||||||
|
const finalRecords = !inputs.isForwardPagination
|
||||||
|
? records.reverse()
|
||||||
|
: records;
|
||||||
|
|
||||||
|
return { finalRecords, hasMoreRecords };
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatPaginatedResult(
|
||||||
|
finalRecords: any[],
|
||||||
|
objectMetadataNameSingular: string,
|
||||||
|
objectMetadataItemWithFieldsMaps: any,
|
||||||
|
objectMetadata: any,
|
||||||
|
isForwardPagination: boolean,
|
||||||
|
hasMoreRecords: boolean,
|
||||||
|
totalCount: number,
|
||||||
|
) {
|
||||||
|
return this.formatResult({
|
||||||
|
operation: 'findMany',
|
||||||
|
objectNamePlural: objectMetadataNameSingular,
|
||||||
|
data: formatGetManyData(
|
||||||
|
finalRecords,
|
||||||
|
objectMetadataItemWithFieldsMaps as any,
|
||||||
|
objectMetadata.objectMetadataMaps,
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
hasNextPage: isForwardPagination && hasMoreRecords,
|
||||||
|
hasPreviousPage: !isForwardPagination && hasMoreRecords,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatResult<T>({
|
||||||
|
operation,
|
||||||
|
objectNameSingular,
|
||||||
|
objectNamePlural,
|
||||||
|
data,
|
||||||
|
meta,
|
||||||
|
}: FormatResultParams<T>) {
|
||||||
|
let prefix: string;
|
||||||
|
|
||||||
|
if (operation === 'findOne') {
|
||||||
|
prefix = objectNameSingular || '';
|
||||||
|
} else if (operation === 'findMany') {
|
||||||
|
prefix = objectNamePlural || '';
|
||||||
|
} else {
|
||||||
|
prefix = operation + capitalize(objectNameSingular || '');
|
||||||
|
}
|
||||||
const result = {
|
const result = {
|
||||||
data: {
|
data: {
|
||||||
[operation + capitalize(objectNameSingular)]: data,
|
[prefix]: data,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (meta) {
|
||||||
|
const { totalCount, ...rest } = meta;
|
||||||
|
|
||||||
|
(result.data as any).pageInfo = { ...rest };
|
||||||
|
|
||||||
|
(result.data as any).totalCount = totalCount;
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,12 +433,36 @@ export class RestApiCoreServiceV2 {
|
|||||||
|
|
||||||
const objectMetadataNameSingular =
|
const objectMetadataNameSingular =
|
||||||
objectMetadata.objectMetadataMapItem.nameSingular;
|
objectMetadata.objectMetadataMapItem.nameSingular;
|
||||||
|
|
||||||
|
const objectMetadataItemWithFieldsMaps =
|
||||||
|
getObjectMetadataMapItemByNameSingular(
|
||||||
|
objectMetadata.objectMetadataMaps,
|
||||||
|
objectMetadataNameSingular,
|
||||||
|
);
|
||||||
|
|
||||||
const repository =
|
const repository =
|
||||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||||
workspace.id,
|
workspace.id,
|
||||||
objectMetadataNameSingular,
|
objectMetadataNameSingular,
|
||||||
);
|
);
|
||||||
|
|
||||||
return { objectMetadataNameSingular, repository };
|
return {
|
||||||
|
objectMetadataNameSingular,
|
||||||
|
objectMetadata,
|
||||||
|
repository,
|
||||||
|
objectMetadataItemWithFieldsMaps,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper method to compute cursor filter
|
||||||
|
private async computeCursorFilter(
|
||||||
|
cursorData: Record<string, any>,
|
||||||
|
orderByWithIdCondition: any[],
|
||||||
|
fieldMetadataMapByName: FieldMetadataMap,
|
||||||
|
isForwardPagination: boolean,
|
||||||
|
): Promise<any> {
|
||||||
|
return {
|
||||||
|
id: isForwardPagination ? { gt: cursorData.id } : { lt: cursorData.id },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,10 @@ import { objectMetadataItemMock } from 'src/engine/api/__mocks__/object-metadata
|
|||||||
import { OrderByInputFactory } from 'src/engine/api/rest/input-factories/order-by-input.factory';
|
import { OrderByInputFactory } from 'src/engine/api/rest/input-factories/order-by-input.factory';
|
||||||
|
|
||||||
describe('OrderByInputFactory', () => {
|
describe('OrderByInputFactory', () => {
|
||||||
const objectMetadata = { objectMetadataItem: objectMetadataItemMock };
|
const objectMetadata = {
|
||||||
|
objectMetadataItem: objectMetadataItemMock,
|
||||||
|
objectMetadataMapItem: objectMetadataItemMock,
|
||||||
|
};
|
||||||
|
|
||||||
let service: OrderByInputFactory;
|
let service: OrderByInputFactory;
|
||||||
|
|
||||||
|
|||||||
@ -72,7 +72,7 @@ export class OrderByInputFactory {
|
|||||||
result = [...result, ...resultFields];
|
result = [...result, ...resultFields];
|
||||||
}
|
}
|
||||||
|
|
||||||
checkArrayFields(objectMetadata.objectMetadataItem, result);
|
checkArrayFields(objectMetadata.objectMetadataMapItem, result);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { HttpModule } from '@nestjs/axios';
|
import { HttpModule } from '@nestjs/axios';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import { RestApiCoreBatchController } from 'src/engine/api/rest/core/controllers/rest-api-core-batch.controller';
|
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 { RestApiCoreController } from 'src/engine/api/rest/core/controllers/rest-api-core.controller';
|
||||||
@ -7,13 +8,18 @@ import { CoreQueryBuilderModule } from 'src/engine/api/rest/core/query-builder/c
|
|||||||
import { RestApiCoreServiceV2 } from 'src/engine/api/rest/core/rest-api-core-v2.service';
|
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 { 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 { EndingBeforeInputFactory } from 'src/engine/api/rest/input-factories/ending-before-input.factory';
|
||||||
|
import { FilterInputFactory } from 'src/engine/api/rest/input-factories/filter-input.factory';
|
||||||
import { LimitInputFactory } from 'src/engine/api/rest/input-factories/limit-input.factory';
|
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 { StartingAfterInputFactory } from 'src/engine/api/rest/input-factories/starting-after-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 { 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 { RestApiMetadataController } from 'src/engine/api/rest/metadata/rest-api-metadata.controller';
|
||||||
import { RestApiMetadataService } from 'src/engine/api/rest/metadata/rest-api-metadata.service';
|
import { RestApiMetadataService } from 'src/engine/api/rest/metadata/rest-api-metadata.service';
|
||||||
import { RestApiService } from 'src/engine/api/rest/rest-api.service';
|
import { RestApiService } from 'src/engine/api/rest/rest-api.service';
|
||||||
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
||||||
|
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||||
|
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||||
|
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||||
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
|
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
|
||||||
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||||
|
|
||||||
@ -25,6 +31,8 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
|
|||||||
AuthModule,
|
AuthModule,
|
||||||
HttpModule,
|
HttpModule,
|
||||||
TwentyORMModule,
|
TwentyORMModule,
|
||||||
|
TypeOrmModule.forFeature([FeatureFlag], 'core'),
|
||||||
|
FeatureFlagModule,
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
RestApiMetadataController,
|
RestApiMetadataController,
|
||||||
@ -36,9 +44,12 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
|
|||||||
RestApiCoreService,
|
RestApiCoreService,
|
||||||
RestApiCoreServiceV2,
|
RestApiCoreServiceV2,
|
||||||
RestApiService,
|
RestApiService,
|
||||||
|
FeatureFlagService,
|
||||||
StartingAfterInputFactory,
|
StartingAfterInputFactory,
|
||||||
EndingBeforeInputFactory,
|
EndingBeforeInputFactory,
|
||||||
LimitInputFactory,
|
LimitInputFactory,
|
||||||
|
FilterInputFactory,
|
||||||
|
OrderByInputFactory,
|
||||||
],
|
],
|
||||||
exports: [RestApiMetadataService],
|
exports: [RestApiMetadataService],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -0,0 +1,252 @@
|
|||||||
|
import {
|
||||||
|
PERSON_1_ID,
|
||||||
|
PERSON_2_ID,
|
||||||
|
PERSON_3_ID,
|
||||||
|
} from 'test/integration/constants/mock-person-ids.constants';
|
||||||
|
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
|
||||||
|
import { generateRecordName } from 'test/integration/utils/generate-record-name';
|
||||||
|
|
||||||
|
describe.skip('Core REST API Find Many endpoint', () => {
|
||||||
|
const testPersonIds = [PERSON_1_ID, PERSON_2_ID, PERSON_3_ID];
|
||||||
|
const testPersonCities: Record<string, string> = {};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Create test people with different cities for testing
|
||||||
|
for (const personId of testPersonIds) {
|
||||||
|
const city = generateRecordName(personId);
|
||||||
|
|
||||||
|
testPersonCities[personId] = city;
|
||||||
|
|
||||||
|
await makeRestAPIRequest({
|
||||||
|
method: 'post',
|
||||||
|
path: '/people',
|
||||||
|
body: {
|
||||||
|
id: personId,
|
||||||
|
city: city,
|
||||||
|
// Add different jobTitles to test filtering
|
||||||
|
jobTitle: `Job ${personId.slice(0, 4)}`,
|
||||||
|
},
|
||||||
|
}).expect(201);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Clean up test people
|
||||||
|
for (const personId of testPersonIds) {
|
||||||
|
await makeRestAPIRequest({
|
||||||
|
method: 'delete',
|
||||||
|
path: `/people/${personId}`,
|
||||||
|
}).expect(200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('5.a. 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.data.pageInfo;
|
||||||
|
const totalCount = response.body.data.totalCount;
|
||||||
|
|
||||||
|
expect(people).toBeDefined();
|
||||||
|
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).toBeGreaterThanOrEqual(testPersonIds.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('5.b. should limit results based on the limit parameter', async () => {
|
||||||
|
const limit = 2;
|
||||||
|
const response = await makeRestAPIRequest({
|
||||||
|
method: 'get',
|
||||||
|
path: `/people?limit=${limit}`,
|
||||||
|
}).expect(200);
|
||||||
|
|
||||||
|
const people = response.body.data.people;
|
||||||
|
|
||||||
|
expect(people).toBeDefined();
|
||||||
|
expect(Array.isArray(people)).toBe(true);
|
||||||
|
expect(people.length).toBeLessThanOrEqual(limit);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('5.c. should filter results based on filter parameters', async () => {
|
||||||
|
// Filter by jobTitle starting with "Job"
|
||||||
|
const response = await makeRestAPIRequest({
|
||||||
|
method: 'get',
|
||||||
|
path: '/people?filter[jobTitle][contains]=Job',
|
||||||
|
}).expect(200);
|
||||||
|
|
||||||
|
const people = response.body.data.people;
|
||||||
|
|
||||||
|
expect(people).toBeDefined();
|
||||||
|
expect(Array.isArray(people)).toBe(true);
|
||||||
|
expect(people.length).toBeGreaterThanOrEqual(testPersonIds.length);
|
||||||
|
|
||||||
|
// All returned people should have jobTitle containing "Job"
|
||||||
|
for (const person of people) {
|
||||||
|
expect(person.jobTitle).toContain('Job');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('5.d. should support cursor-based pagination with startingAfter', async () => {
|
||||||
|
// First, get initial results to get a cursor
|
||||||
|
const initialResponse = await makeRestAPIRequest({
|
||||||
|
method: 'get',
|
||||||
|
path: '/people?limit=1',
|
||||||
|
}).expect(200);
|
||||||
|
|
||||||
|
const firstPerson = initialResponse.body.data.people[0];
|
||||||
|
const endCursor = initialResponse.body.data.pageInfo.endCursor;
|
||||||
|
|
||||||
|
expect(firstPerson).toBeDefined();
|
||||||
|
expect(endCursor).toBeDefined();
|
||||||
|
|
||||||
|
// Now use the cursor to get the next page
|
||||||
|
const nextPageResponse = await makeRestAPIRequest({
|
||||||
|
method: 'get',
|
||||||
|
path: `/people?startingAfter=${endCursor}&limit=1`,
|
||||||
|
}).expect(200);
|
||||||
|
|
||||||
|
const nextPagePeople = nextPageResponse.body.data.people;
|
||||||
|
|
||||||
|
expect(nextPagePeople).toBeDefined();
|
||||||
|
expect(nextPagePeople.length).toBe(1);
|
||||||
|
expect(nextPagePeople[0].id).not.toBe(firstPerson.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('5.e. should support cursor-based pagination with endingBefore', async () => {
|
||||||
|
// First, get results to get a cursor from the second page
|
||||||
|
const initialResponse = await makeRestAPIRequest({
|
||||||
|
method: 'get',
|
||||||
|
path: '/people?limit=2',
|
||||||
|
}).expect(200);
|
||||||
|
|
||||||
|
const people = initialResponse.body.data.people;
|
||||||
|
const startCursor = initialResponse.body.data.pageInfo.startCursor;
|
||||||
|
|
||||||
|
expect(people.length).toBe(2);
|
||||||
|
expect(startCursor).toBeDefined();
|
||||||
|
|
||||||
|
// Now use the cursor to get the previous page (which should be empty in this case)
|
||||||
|
const prevPageResponse = await makeRestAPIRequest({
|
||||||
|
method: 'get',
|
||||||
|
path: `/people?endingBefore=${startCursor}&limit=1`,
|
||||||
|
}).expect(200);
|
||||||
|
|
||||||
|
const prevPagePeople = prevPageResponse.body.data.people;
|
||||||
|
const pageInfo = prevPageResponse.body.data.pageInfo;
|
||||||
|
|
||||||
|
// Since we're at the beginning, there might not be previous results
|
||||||
|
expect(prevPagePeople).toBeDefined();
|
||||||
|
expect(pageInfo.hasPreviousPage).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('5.f. should support ordering of results', async () => {
|
||||||
|
// Order by city in ascending order
|
||||||
|
const ascResponse = await makeRestAPIRequest({
|
||||||
|
method: 'get',
|
||||||
|
path: '/people?orderBy[city]=ASC',
|
||||||
|
}).expect(200);
|
||||||
|
|
||||||
|
const ascPeople = ascResponse.body.data.people;
|
||||||
|
|
||||||
|
// Check that cities are in ascending order
|
||||||
|
for (let i = 1; i < ascPeople.length; i++) {
|
||||||
|
if (ascPeople[i - 1].city && ascPeople[i].city) {
|
||||||
|
expect(ascPeople[i - 1].city <= ascPeople[i].city).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order by city in descending order
|
||||||
|
const descResponse = await makeRestAPIRequest({
|
||||||
|
method: 'get',
|
||||||
|
path: '/people?orderBy[city]=DESC',
|
||||||
|
}).expect(200);
|
||||||
|
|
||||||
|
const descPeople = descResponse.body.data.people;
|
||||||
|
|
||||||
|
// Check that cities are in descending order
|
||||||
|
for (let i = 1; i < descPeople.length; i++) {
|
||||||
|
if (descPeople[i - 1].city && descPeople[i].city) {
|
||||||
|
expect(descPeople[i - 1].city >= descPeople[i].city).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('5.g. should return an UnauthorizedException when no token is provided', async () => {
|
||||||
|
await makeRestAPIRequest({
|
||||||
|
method: 'get',
|
||||||
|
path: '/people',
|
||||||
|
headers: { authorization: '' },
|
||||||
|
})
|
||||||
|
.expect(401)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body.error).toBe('UNAUTHENTICATED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('5.h. should return an UnauthorizedException when an invalid token is provided', async () => {
|
||||||
|
await makeRestAPIRequest({
|
||||||
|
method: 'get',
|
||||||
|
path: '/people',
|
||||||
|
headers: { authorization: 'Bearer invalid-token' },
|
||||||
|
})
|
||||||
|
.expect(401)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body.error).toBe('UNAUTHENTICATED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('5.i. should handle invalid cursor gracefully', async () => {
|
||||||
|
await makeRestAPIRequest({
|
||||||
|
method: 'get',
|
||||||
|
path: '/people?startingAfter=invalid-cursor',
|
||||||
|
})
|
||||||
|
.expect(400)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body.error).toBe('BadRequestException');
|
||||||
|
expect(res.body.messages[0]).toContain('Invalid cursor');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('5.j. should combine filtering, ordering, and pagination', async () => {
|
||||||
|
const response = await makeRestAPIRequest({
|
||||||
|
method: 'get',
|
||||||
|
path: '/people?filter[jobTitle][contains]=Job&orderBy[city]=ASC&limit=2',
|
||||||
|
}).expect(200);
|
||||||
|
|
||||||
|
const people = response.body.data.people;
|
||||||
|
const pageInfo = response.body.data.pageInfo;
|
||||||
|
|
||||||
|
expect(people).toBeDefined();
|
||||||
|
expect(people.length).toBeLessThanOrEqual(2);
|
||||||
|
expect(pageInfo).toBeDefined();
|
||||||
|
|
||||||
|
// Check that all returned people match the filter
|
||||||
|
for (const person of people) {
|
||||||
|
expect(person.jobTitle).toContain('Job');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that cities are in ascending order
|
||||||
|
for (let i = 1; i < people.length; i++) {
|
||||||
|
if (people[i - 1].city && people[i].city) {
|
||||||
|
expect(people[i - 1].city <= people[i].city).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
import {
|
||||||
|
FAKE_PERSON_ID,
|
||||||
|
PERSON_1_ID,
|
||||||
|
} from 'test/integration/constants/mock-person-ids.constants';
|
||||||
|
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
|
||||||
|
import { generateRecordName } from 'test/integration/utils/generate-record-name';
|
||||||
|
|
||||||
|
describe.skip('Core REST API Find One endpoint', () => {
|
||||||
|
let personCity: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
personCity = generateRecordName(PERSON_1_ID);
|
||||||
|
|
||||||
|
// Create a test person to retrieve
|
||||||
|
await makeRestAPIRequest({
|
||||||
|
method: 'post',
|
||||||
|
path: '/people',
|
||||||
|
body: {
|
||||||
|
id: PERSON_1_ID,
|
||||||
|
city: personCity,
|
||||||
|
},
|
||||||
|
}).expect(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Clean up the test person
|
||||||
|
await makeRestAPIRequest({
|
||||||
|
method: 'delete',
|
||||||
|
path: `/people/${PERSON_1_ID}`,
|
||||||
|
}).expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('4.a. should retrieve a person by ID', async () => {
|
||||||
|
const response = await makeRestAPIRequest({
|
||||||
|
method: 'get',
|
||||||
|
path: `/people/${PERSON_1_ID}`,
|
||||||
|
}).expect(200);
|
||||||
|
|
||||||
|
const person = response.body.data.person;
|
||||||
|
|
||||||
|
expect(person).toBeDefined();
|
||||||
|
expect(person.id).toBe(PERSON_1_ID);
|
||||||
|
expect(person.city).toBe(personCity);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('4.b. should return null when trying to retrieve a non-existing person', async () => {
|
||||||
|
const response = await makeRestAPIRequest({
|
||||||
|
method: 'get',
|
||||||
|
path: `/people/${FAKE_PERSON_ID}`,
|
||||||
|
}).expect(200);
|
||||||
|
|
||||||
|
const person = response.body.data.person;
|
||||||
|
|
||||||
|
expect(person).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('4.c. should return an UnauthorizedException when no token is provided', async () => {
|
||||||
|
await makeRestAPIRequest({
|
||||||
|
method: 'get',
|
||||||
|
path: `/people/${PERSON_1_ID}`,
|
||||||
|
headers: { authorization: '' },
|
||||||
|
})
|
||||||
|
.expect(401)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body.error).toBe('UNAUTHENTICATED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('4.d. should return an UnauthorizedException when an invalid token is provided', async () => {
|
||||||
|
await makeRestAPIRequest({
|
||||||
|
method: 'get',
|
||||||
|
path: `/people/${PERSON_1_ID}`,
|
||||||
|
headers: { authorization: 'Bearer invalid-token' },
|
||||||
|
})
|
||||||
|
.expect(401)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body.error).toBe('UNAUTHENTICATED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('4.e. should return an UnauthorizedException when an expired token is provided', async () => {
|
||||||
|
await makeRestAPIRequest({
|
||||||
|
method: 'get',
|
||||||
|
path: `/people/${PERSON_1_ID}`,
|
||||||
|
headers: { authorization: `Bearer ${EXPIRED_ACCESS_TOKEN}` },
|
||||||
|
})
|
||||||
|
.expect(401)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body.error).toBe('UNAUTHENTICATED');
|
||||||
|
expect(res.body.messages[0]).toBe('Token has expired.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user