Add rest api (#2757)
* Add a wildcard get route * Call api from api * Add a query formatter * Use headers to authenticate * Handle findMany query * Add limit, orderBy and lastCursor parameters * Add filter parameters * Remove singular object name from valid requests * Update order_by format * Add depth parameter * Make /api/objects/ID requests work * Fix filter * Add INTERNAL_SERVER_URL env variable * Remove useless comment * Change bath api url to 'rest' * Fix limit parser * Handle full filter version * Improve handle full filter version * Continue rest api * Add and(...) default behaviour on filters * Add tests * Handle 'not' conjunction for filters * Check filter query * Format values with field metadata item type * Handle nested filtering * Update parsing method * Check nested fields * Add delete query * Add create query * Rename methods * Add update query * Update get one object request * Fix error handling * Code review returns
This commit is contained in:
@ -4,6 +4,7 @@ PG_DATABASE_URL=postgres://twenty:twenty@localhost:5432/default
|
|||||||
# PG_DATABASE_URL=postgres://twenty:twenty@postgres:5432/default?connection_limit=1
|
# PG_DATABASE_URL=postgres://twenty:twenty@postgres:5432/default?connection_limit=1
|
||||||
|
|
||||||
FRONT_BASE_URL=http://localhost:3001
|
FRONT_BASE_URL=http://localhost:3001
|
||||||
|
LOCAL_SERVER_URL=http://localhost:3000
|
||||||
ACCESS_TOKEN_SECRET=replace_me_with_a_random_string_access
|
ACCESS_TOKEN_SECRET=replace_me_with_a_random_string_access
|
||||||
LOGIN_TOKEN_SECRET=replace_me_with_a_random_string_login
|
LOGIN_TOKEN_SECRET=replace_me_with_a_random_string_login
|
||||||
REFRESH_TOKEN_SECRET=replace_me_with_a_random_string_refresh
|
REFRESH_TOKEN_SECRET=replace_me_with_a_random_string_refresh
|
||||||
@ -28,4 +29,4 @@ SIGN_IN_PREFILLED=true
|
|||||||
# LOG_LEVEL=error,warn
|
# LOG_LEVEL=error,warn
|
||||||
# MESSAGE_QUEUE_TYPE=pg-boss
|
# MESSAGE_QUEUE_TYPE=pg-boss
|
||||||
# REDIS_HOST=127.0.0.1
|
# REDIS_HOST=127.0.0.1
|
||||||
# REDIS_PORT=6379
|
# REDIS_PORT=6379
|
||||||
|
|||||||
34
server/src/core/api-rest/api-rest.controller.ts
Normal file
34
server/src/core/api-rest/api-rest.controller.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { Controller, Delete, Get, Post, Put, Req } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { Request } from 'express';
|
||||||
|
|
||||||
|
import { ApiRestService } from 'src/core/api-rest/api-rest.service';
|
||||||
|
|
||||||
|
@Controller('rest/*')
|
||||||
|
export class ApiRestController {
|
||||||
|
constructor(private readonly apiRestService: ApiRestService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async handleApiGet(@Req() request: Request): Promise<object> {
|
||||||
|
const result = await this.apiRestService.get(request);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete()
|
||||||
|
async handleApiDelete(@Req() request: Request): Promise<object> {
|
||||||
|
const result = await this.apiRestService.delete(request);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async handleApiPost(@Req() request: Request): Promise<object> {
|
||||||
|
const result = await this.apiRestService.create(request);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put()
|
||||||
|
async handleApiPut(@Req() request: Request): Promise<object> {
|
||||||
|
const result = await this.apiRestService.update(request);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
server/src/core/api-rest/api-rest.module.ts
Normal file
12
server/src/core/api-rest/api-rest.module.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ApiRestController } from 'src/core/api-rest/api-rest.controller';
|
||||||
|
import { ApiRestService } from 'src/core/api-rest/api-rest.service';
|
||||||
|
import { ObjectMetadataModule } from 'src/metadata/object-metadata/object-metadata.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ObjectMetadataModule],
|
||||||
|
controllers: [ApiRestController],
|
||||||
|
providers: [ApiRestService],
|
||||||
|
})
|
||||||
|
export class ApiRestModule {}
|
||||||
148
server/src/core/api-rest/api-rest.service.spec.ts
Normal file
148
server/src/core/api-rest/api-rest.service.spec.ts
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
|
||||||
|
import { ApiRestService } from 'src/core/api-rest/api-rest.service';
|
||||||
|
import { ObjectMetadataService } from 'src/metadata/object-metadata/object-metadata.service';
|
||||||
|
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
||||||
|
|
||||||
|
describe('ApiRestService', () => {
|
||||||
|
let service: ApiRestService;
|
||||||
|
const objectMetadataItem = { fields: [{ name: 'field', type: 'NUMBER' }] };
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
ApiRestService,
|
||||||
|
{
|
||||||
|
provide: ObjectMetadataService,
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: EnvironmentService,
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<ApiRestService>(ApiRestService);
|
||||||
|
});
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
describe('checkFilterQuery', () => {
|
||||||
|
it('should check filter query', () => {
|
||||||
|
expect(() => service.checkFilterQuery('(')).toThrow();
|
||||||
|
expect(() => service.checkFilterQuery(')')).toThrow();
|
||||||
|
expect(() => service.checkFilterQuery('(()')).toThrow();
|
||||||
|
expect(() => service.checkFilterQuery('())')).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
service.checkFilterQuery(
|
||||||
|
'and(or(field[eq]:1,field[eq]:2)),field[eq]:3)',
|
||||||
|
),
|
||||||
|
).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
service.checkFilterQuery(
|
||||||
|
'and(or(field[eq]:1,field[eq]:2),field[eq]:3)',
|
||||||
|
),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('formatFieldValue', () => {
|
||||||
|
it('should format field value', () => {
|
||||||
|
expect(service.formatFieldValue('1', 'NUMBER')).toEqual(1);
|
||||||
|
expect(service.formatFieldValue(1, 'NUMBER')).toEqual(1);
|
||||||
|
expect(service.formatFieldValue('a', 'NUMBER')).toEqual(NaN);
|
||||||
|
expect(service.formatFieldValue('true', 'BOOLEAN')).toEqual(true);
|
||||||
|
expect(service.formatFieldValue('True', 'BOOLEAN')).toEqual(true);
|
||||||
|
expect(service.formatFieldValue('false', 'BOOLEAN')).toEqual(false);
|
||||||
|
expect(service.formatFieldValue('1', 'TEXT')).toEqual('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('parseFilterQueryContent', () => {
|
||||||
|
it('should parse query filter test 1', () => {
|
||||||
|
expect(service.parseFilterQueryContent('and(field[eq]:1)')).toEqual([
|
||||||
|
'field[eq]:1',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it('should parse query filter test 2', () => {
|
||||||
|
expect(
|
||||||
|
service.parseFilterQueryContent('and(field[eq]:1,field[eq]:2)'),
|
||||||
|
).toEqual(['field[eq]:1', 'field[eq]:2']);
|
||||||
|
});
|
||||||
|
it('should parse query filter test 3', () => {
|
||||||
|
expect(
|
||||||
|
service.parseFilterQueryContent(
|
||||||
|
'and(field[eq]:1,or(field[eq]:2,field[eq]:3))',
|
||||||
|
),
|
||||||
|
).toEqual(['field[eq]:1', 'or(field[eq]:2,field[eq]:3)']);
|
||||||
|
});
|
||||||
|
it('should parse query filter test 4', () => {
|
||||||
|
expect(
|
||||||
|
service.parseFilterQueryContent(
|
||||||
|
'and(field[eq]:1,or(field[eq]:2,not(field[eq]:3)),field[eq]:4,not(field[eq]:5))',
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
'field[eq]:1',
|
||||||
|
'or(field[eq]:2,not(field[eq]:3))',
|
||||||
|
'field[eq]:4',
|
||||||
|
'not(field[eq]:5)',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('parseStringFilter', () => {
|
||||||
|
it('should parse string filter test 1', () => {
|
||||||
|
expect(
|
||||||
|
service.parseStringFilter(
|
||||||
|
'and(field[eq]:1,field[eq]:2)',
|
||||||
|
objectMetadataItem,
|
||||||
|
),
|
||||||
|
).toEqual({ and: [{ field: { eq: 1 } }, { field: { eq: 2 } }] });
|
||||||
|
});
|
||||||
|
it('should parse string filter test 2', () => {
|
||||||
|
expect(
|
||||||
|
service.parseStringFilter(
|
||||||
|
'and(field[eq]:1,or(field[eq]:2,field[eq]:3))',
|
||||||
|
objectMetadataItem,
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
and: [
|
||||||
|
{ field: { eq: 1 } },
|
||||||
|
{ or: [{ field: { eq: 2 } }, { field: { eq: 3 } }] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('should parse string filter test 3', () => {
|
||||||
|
expect(
|
||||||
|
service.parseStringFilter(
|
||||||
|
'and(field[eq]:1,or(field[eq]:2,field[eq]:3,and(field[eq]:6,field[eq]:7)),or(field[eq]:4,field[eq]:5))',
|
||||||
|
objectMetadataItem,
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
and: [
|
||||||
|
{ field: { eq: 1 } },
|
||||||
|
{
|
||||||
|
or: [
|
||||||
|
{ field: { eq: 2 } },
|
||||||
|
{ field: { eq: 3 } },
|
||||||
|
{ and: [{ field: { eq: 6 } }, { field: { eq: 7 } }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ or: [{ field: { eq: 4 } }, { field: { eq: 5 } }] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('should handler not', () => {
|
||||||
|
expect(
|
||||||
|
service.parseStringFilter(
|
||||||
|
'and(field[eq]:1,not(field[eq]:2))',
|
||||||
|
objectMetadataItem,
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
and: [
|
||||||
|
{ field: { eq: 1 } },
|
||||||
|
{
|
||||||
|
not: { field: { eq: 2 } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
663
server/src/core/api-rest/api-rest.service.ts
Normal file
663
server/src/core/api-rest/api-rest.service.ts
Normal file
@ -0,0 +1,663 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import axios from 'axios';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { verify } from 'jsonwebtoken';
|
||||||
|
import { ExtractJwt } from 'passport-jwt';
|
||||||
|
|
||||||
|
import {
|
||||||
|
OrderByDirection,
|
||||||
|
RecordOrderBy,
|
||||||
|
} from 'src/workspace/workspace-query-builder/interfaces/record.interface';
|
||||||
|
|
||||||
|
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
||||||
|
import { ObjectMetadataService } from 'src/metadata/object-metadata/object-metadata.service';
|
||||||
|
import { capitalize } from 'src/utils/capitalize';
|
||||||
|
|
||||||
|
enum FILTER_COMPARATORS {
|
||||||
|
eq = 'eq',
|
||||||
|
gt = 'gt',
|
||||||
|
gte = 'gte',
|
||||||
|
lt = 'lt',
|
||||||
|
lte = 'lte',
|
||||||
|
}
|
||||||
|
const ALLOWED_DEPTH_VALUES = [1, 2];
|
||||||
|
const DEFAULT_DEPTH_VALUE = 2;
|
||||||
|
const DEFAULT_ORDER_DIRECTION = OrderByDirection.AscNullsFirst;
|
||||||
|
enum CONJUNCTIONS {
|
||||||
|
or = 'or',
|
||||||
|
and = 'and',
|
||||||
|
not = 'not',
|
||||||
|
}
|
||||||
|
const DEFAULT_FILTER_CONJUNCTION = CONJUNCTIONS.and;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ApiRestService {
|
||||||
|
constructor(
|
||||||
|
private readonly objectMetadataService: ObjectMetadataService,
|
||||||
|
private readonly environmentService: EnvironmentService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
mapFieldMetadataToGraphQLQuery(
|
||||||
|
objectMetadataItems,
|
||||||
|
field,
|
||||||
|
maxDepthForRelations = DEFAULT_DEPTH_VALUE,
|
||||||
|
): any {
|
||||||
|
if (maxDepthForRelations <= 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldType = field.type;
|
||||||
|
|
||||||
|
const fieldIsSimpleValue = [
|
||||||
|
'UUID',
|
||||||
|
'TEXT',
|
||||||
|
'PHONE',
|
||||||
|
'DATE_TIME',
|
||||||
|
'EMAIL',
|
||||||
|
'NUMBER',
|
||||||
|
'BOOLEAN',
|
||||||
|
].includes(fieldType);
|
||||||
|
|
||||||
|
if (fieldIsSimpleValue) {
|
||||||
|
return field.name;
|
||||||
|
} else if (
|
||||||
|
fieldType === 'RELATION' &&
|
||||||
|
field.toRelationMetadata?.relationType === 'ONE_TO_MANY'
|
||||||
|
) {
|
||||||
|
const relationMetadataItem = objectMetadataItems.find(
|
||||||
|
(objectMetadataItem) =>
|
||||||
|
objectMetadataItem.id ===
|
||||||
|
(field.toRelationMetadata as any)?.fromObjectMetadata?.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return `${field.name}
|
||||||
|
{
|
||||||
|
id
|
||||||
|
${(relationMetadataItem?.fields ?? [])
|
||||||
|
.filter((field) => field.type !== 'RELATION')
|
||||||
|
.map((field) =>
|
||||||
|
this.mapFieldMetadataToGraphQLQuery(
|
||||||
|
objectMetadataItems,
|
||||||
|
field,
|
||||||
|
maxDepthForRelations - 1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.join('\n')}
|
||||||
|
}`;
|
||||||
|
} else if (
|
||||||
|
fieldType === 'RELATION' &&
|
||||||
|
field.fromRelationMetadata?.relationType === 'ONE_TO_MANY'
|
||||||
|
) {
|
||||||
|
const relationMetadataItem = objectMetadataItems.find(
|
||||||
|
(objectMetadataItem) =>
|
||||||
|
objectMetadataItem.id ===
|
||||||
|
(field.fromRelationMetadata as any)?.toObjectMetadata?.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return `${field.name}
|
||||||
|
{
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
${(relationMetadataItem?.fields ?? [])
|
||||||
|
.filter((field) => field.type !== 'RELATION')
|
||||||
|
.map((field) =>
|
||||||
|
this.mapFieldMetadataToGraphQLQuery(
|
||||||
|
objectMetadataItems,
|
||||||
|
field,
|
||||||
|
maxDepthForRelations - 1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.join('\n')}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
} else if (fieldType === 'LINK') {
|
||||||
|
return `
|
||||||
|
${field.name}
|
||||||
|
{
|
||||||
|
label
|
||||||
|
url
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
} else if (fieldType === 'CURRENCY') {
|
||||||
|
return `
|
||||||
|
${field.name}
|
||||||
|
{
|
||||||
|
amountMicros
|
||||||
|
currencyCode
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
} else if (fieldType === 'FULL_NAME') {
|
||||||
|
return `
|
||||||
|
${field.name}
|
||||||
|
{
|
||||||
|
firstName
|
||||||
|
lastName
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
computeDeleteQuery(objectMetadataItem) {
|
||||||
|
return `
|
||||||
|
mutation Delete${capitalize(objectMetadataItem.nameSingular)}($id: ID!) {
|
||||||
|
delete${capitalize(objectMetadataItem.nameSingular)}(id: $id) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
computeCreateQuery(
|
||||||
|
objectMetadataItems,
|
||||||
|
objectMetadataItem,
|
||||||
|
depth = DEFAULT_DEPTH_VALUE,
|
||||||
|
) {
|
||||||
|
return `
|
||||||
|
mutation Create${capitalize(
|
||||||
|
objectMetadataItem.nameSingular,
|
||||||
|
)}($data: CompanyCreateInput!) {
|
||||||
|
create${capitalize(objectMetadataItem.nameSingular)}(data: $data) {
|
||||||
|
id
|
||||||
|
${objectMetadataItem.fields
|
||||||
|
.map((field) =>
|
||||||
|
this.mapFieldMetadataToGraphQLQuery(
|
||||||
|
objectMetadataItems,
|
||||||
|
field,
|
||||||
|
depth,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.join('\n')}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
computeUpdateQuery(
|
||||||
|
objectMetadataItems,
|
||||||
|
objectMetadataItem,
|
||||||
|
depth = DEFAULT_DEPTH_VALUE,
|
||||||
|
) {
|
||||||
|
return `
|
||||||
|
mutation Update${capitalize(
|
||||||
|
objectMetadataItem.nameSingular,
|
||||||
|
)}($id: ID!, $data: CompanyUpdateInput!) {
|
||||||
|
update${capitalize(
|
||||||
|
objectMetadataItem.nameSingular,
|
||||||
|
)}(id: $id, data: $data) {
|
||||||
|
id
|
||||||
|
${objectMetadataItem.fields
|
||||||
|
.map((field) =>
|
||||||
|
this.mapFieldMetadataToGraphQLQuery(
|
||||||
|
objectMetadataItems,
|
||||||
|
field,
|
||||||
|
depth,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.join('\n')}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
computeFindOneQuery(
|
||||||
|
objectMetadataItems,
|
||||||
|
objectMetadataItem,
|
||||||
|
depth = DEFAULT_DEPTH_VALUE,
|
||||||
|
) {
|
||||||
|
return `
|
||||||
|
query FindOne${capitalize(objectMetadataItem.nameSingular)}(
|
||||||
|
$filter: ${capitalize(objectMetadataItem.nameSingular)}FilterInput!,
|
||||||
|
) {
|
||||||
|
${objectMetadataItem.nameSingular}(filter: $filter) {
|
||||||
|
id
|
||||||
|
${objectMetadataItem.fields
|
||||||
|
.map((field) =>
|
||||||
|
this.mapFieldMetadataToGraphQLQuery(
|
||||||
|
objectMetadataItems,
|
||||||
|
field,
|
||||||
|
depth,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.join('\n')}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
computeFindManyQuery(
|
||||||
|
objectMetadataItems,
|
||||||
|
objectMetadataItem,
|
||||||
|
depth = DEFAULT_DEPTH_VALUE,
|
||||||
|
): string {
|
||||||
|
return `
|
||||||
|
query FindMany${capitalize(objectMetadataItem.namePlural)}(
|
||||||
|
$filter: ${capitalize(objectMetadataItem.nameSingular)}FilterInput,
|
||||||
|
$orderBy: ${capitalize(objectMetadataItem.nameSingular)}OrderByInput,
|
||||||
|
$lastCursor: String,
|
||||||
|
$limit: Float = 60
|
||||||
|
) {
|
||||||
|
${objectMetadataItem.namePlural}(
|
||||||
|
filter: $filter, orderBy: $orderBy, first: $limit, after: $lastCursor
|
||||||
|
) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
${objectMetadataItem.fields
|
||||||
|
.map((field) =>
|
||||||
|
this.mapFieldMetadataToGraphQLQuery(
|
||||||
|
objectMetadataItems,
|
||||||
|
field,
|
||||||
|
depth,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.join('\n')}
|
||||||
|
}
|
||||||
|
cursor
|
||||||
|
}
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
startCursor
|
||||||
|
endCursor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getObjectMetadata(request: Request) {
|
||||||
|
const workspaceId = this.extractWorkspaceId(request);
|
||||||
|
const objectMetadataItems =
|
||||||
|
await this.objectMetadataService.findManyWithinWorkspace(workspaceId);
|
||||||
|
const parsedObject = this.parseObject(request)[0];
|
||||||
|
const [objectMetadata] = objectMetadataItems.filter(
|
||||||
|
(object) => object.namePlural === parsedObject,
|
||||||
|
);
|
||||||
|
if (!objectMetadata) {
|
||||||
|
const [wrongObjectMetadata] = objectMetadataItems.filter(
|
||||||
|
(object) => object.nameSingular === parsedObject,
|
||||||
|
);
|
||||||
|
let hint = 'eg: companies';
|
||||||
|
if (wrongObjectMetadata) {
|
||||||
|
hint = `Did you mean '${wrongObjectMetadata.namePlural}'?`;
|
||||||
|
}
|
||||||
|
throw Error(`object '${parsedObject}' not found. ${hint}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
objectMetadataItems,
|
||||||
|
objectMetadataItem: objectMetadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
addDefaultConjunctionIfMissing(filterQuery) {
|
||||||
|
if (!(filterQuery.includes('(') && filterQuery.includes(')'))) {
|
||||||
|
return `${DEFAULT_FILTER_CONJUNCTION}(${filterQuery})`;
|
||||||
|
}
|
||||||
|
return filterQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkFilterQuery(filterQuery) {
|
||||||
|
const countOpenedBrackets = (filterQuery.match(/\(/g) || []).length;
|
||||||
|
const countClosedBrackets = (filterQuery.match(/\)/g) || []).length;
|
||||||
|
const diff = countOpenedBrackets - countClosedBrackets;
|
||||||
|
if (diff !== 0) {
|
||||||
|
const hint =
|
||||||
|
diff > 0
|
||||||
|
? `${diff} open bracket${diff > 1 ? 's are' : ' is'}`
|
||||||
|
: `${Math.abs(diff)} close bracket${
|
||||||
|
Math.abs(diff) > 1 ? 's are' : ' is'
|
||||||
|
}`;
|
||||||
|
throw Error(`'filter' invalid. ${hint} missing in the query`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
parseFilterQueryContent(filterQuery) {
|
||||||
|
let parenthesisCounter = 0;
|
||||||
|
const predicates: string[] = [];
|
||||||
|
let currentPredicates = '';
|
||||||
|
for (const c of filterQuery) {
|
||||||
|
if (c === '(') {
|
||||||
|
parenthesisCounter++;
|
||||||
|
if (parenthesisCounter === 1) continue;
|
||||||
|
}
|
||||||
|
if (c === ')') {
|
||||||
|
parenthesisCounter--;
|
||||||
|
if (parenthesisCounter === 0) continue;
|
||||||
|
}
|
||||||
|
if (c === ',' && parenthesisCounter === 1) {
|
||||||
|
predicates.push(currentPredicates);
|
||||||
|
currentPredicates = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (parenthesisCounter >= 1) currentPredicates += c;
|
||||||
|
}
|
||||||
|
if (currentPredicates.length) {
|
||||||
|
predicates.push(currentPredicates);
|
||||||
|
}
|
||||||
|
return predicates;
|
||||||
|
}
|
||||||
|
|
||||||
|
parseStringFilter(filterQuery, objectMetadataItem) {
|
||||||
|
const result = {};
|
||||||
|
const match = filterQuery.match(
|
||||||
|
`^(${Object.values(CONJUNCTIONS).join('|')})((.+))$`,
|
||||||
|
);
|
||||||
|
if (match) {
|
||||||
|
const conjunction = match[1];
|
||||||
|
const subResult = this.parseFilterQueryContent(filterQuery).map((elem) =>
|
||||||
|
this.parseStringFilter(elem, objectMetadataItem),
|
||||||
|
);
|
||||||
|
if (conjunction === CONJUNCTIONS.not) {
|
||||||
|
if (subResult.length > 1) {
|
||||||
|
throw Error(
|
||||||
|
`'filter' invalid. 'not' conjunction should contain only 1 condition. eg: not(field[eq]:1)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
result[conjunction] = subResult[0];
|
||||||
|
} else {
|
||||||
|
result[conjunction] = subResult;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return this.parseSimpleFilter(filterQuery, objectMetadataItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
parseSimpleFilter(filterString: string, objectMetadataItem) {
|
||||||
|
// price[lte]:100
|
||||||
|
if (
|
||||||
|
!filterString.match(
|
||||||
|
`^(.+)\\[(${Object.keys(FILTER_COMPARATORS).join('|')})\\]:(.+)$`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw Error(`'filter' invalid for '${filterString}'. eg: price[gte]:10`);
|
||||||
|
}
|
||||||
|
const [fieldAndComparator, value] = filterString.split(':');
|
||||||
|
const [field, comparator] = fieldAndComparator.replace(']', '').split('[');
|
||||||
|
if (!Object.keys(FILTER_COMPARATORS).includes(comparator)) {
|
||||||
|
throw Error(
|
||||||
|
`'filter' invalid for '${filterString}', comparator ${comparator} not in ${Object.keys(
|
||||||
|
FILTER_COMPARATORS,
|
||||||
|
).join(',')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const fields = field.split('.');
|
||||||
|
this.checkFields(objectMetadataItem, fields, 'filter');
|
||||||
|
const fieldType = this.getFieldType(objectMetadataItem, fields[0]);
|
||||||
|
const formattedValue = this.formatFieldValue(value, fieldType);
|
||||||
|
return fields.reverse().reduce(
|
||||||
|
(acc, currentValue) => {
|
||||||
|
return { [currentValue]: acc };
|
||||||
|
},
|
||||||
|
{ [comparator]: formattedValue },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
formatFieldValue(value, fieldType?) {
|
||||||
|
if (fieldType === 'NUMBER') {
|
||||||
|
return parseInt(value);
|
||||||
|
}
|
||||||
|
if (fieldType === 'BOOLEAN') {
|
||||||
|
return value.toLowerCase() === 'true';
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
parseFilter(request, objectMetadataItem) {
|
||||||
|
const parsedObjectId = this.parseObject(request)[1];
|
||||||
|
if (parsedObjectId) {
|
||||||
|
return { id: { eq: parsedObjectId } };
|
||||||
|
}
|
||||||
|
const rawFilterQuery = request.query.filter;
|
||||||
|
if (typeof rawFilterQuery !== 'string') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
this.checkFilterQuery(rawFilterQuery);
|
||||||
|
const filterQuery = this.addDefaultConjunctionIfMissing(rawFilterQuery);
|
||||||
|
return this.parseStringFilter(filterQuery, objectMetadataItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
parseOrderBy(request, objectMetadataItem) {
|
||||||
|
//?order_by=field_1[AscNullsFirst],field_2[DescNullsLast],field_3
|
||||||
|
const orderByQuery = request.query.order_by;
|
||||||
|
if (typeof orderByQuery !== 'string') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const orderByItems = orderByQuery.split(',');
|
||||||
|
const result = {};
|
||||||
|
for (const orderByItem of orderByItems) {
|
||||||
|
// orderByItem -> field_1[AscNullsFirst]
|
||||||
|
if (orderByItem.includes('[') && orderByItem.includes(']')) {
|
||||||
|
const [field, direction] = orderByItem.replace(']', '').split('[');
|
||||||
|
// field -> field_1 ; direction -> AscNullsFirst
|
||||||
|
if (!(direction in OrderByDirection)) {
|
||||||
|
throw Error(
|
||||||
|
`'order_by' direction '${direction}' invalid. Allowed values are '${Object.values(
|
||||||
|
OrderByDirection,
|
||||||
|
).join(
|
||||||
|
"', '",
|
||||||
|
)}'. eg: ?order_by=field_1[AscNullsFirst],field_2[DescNullsLast],field_3`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
result[field] = direction;
|
||||||
|
} else {
|
||||||
|
// orderByItem -> field_3
|
||||||
|
result[orderByItem] = DEFAULT_ORDER_DIRECTION;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.checkFields(objectMetadataItem, Object.keys(result), 'order_by');
|
||||||
|
return <RecordOrderBy>result;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkFields(objectMetadataItem, fieldNames, queryParam) {
|
||||||
|
for (const fieldName of fieldNames) {
|
||||||
|
if (
|
||||||
|
!objectMetadataItem.fields
|
||||||
|
.reduce(
|
||||||
|
(acc, itemField) => [
|
||||||
|
...acc,
|
||||||
|
itemField.name,
|
||||||
|
...Object.keys(itemField.targetColumnMap),
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.includes(fieldName)
|
||||||
|
) {
|
||||||
|
throw Error(
|
||||||
|
`'${queryParam}' field '${fieldName}' does not exist in '${objectMetadataItem.targetTableName}' object`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getFieldType(objectMetadataItem, fieldName) {
|
||||||
|
for (const itemField of objectMetadataItem.fields) {
|
||||||
|
if (fieldName === itemField.name) {
|
||||||
|
return itemField.type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parseLimit(request) {
|
||||||
|
const limitQuery = request.query.limit;
|
||||||
|
if (typeof limitQuery !== 'string') {
|
||||||
|
return 60;
|
||||||
|
}
|
||||||
|
const limitParsed = parseInt(limitQuery);
|
||||||
|
if (!Number.isInteger(limitParsed)) {
|
||||||
|
throw Error(`limit '${limitQuery}' is invalid. Should be an integer`);
|
||||||
|
}
|
||||||
|
return limitParsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
parseCursor(request) {
|
||||||
|
const cursorQuery = request.query.last_cursor;
|
||||||
|
if (typeof cursorQuery !== 'string') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return cursorQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
computeQueryVariables(request: Request, objectMetadataItem) {
|
||||||
|
return {
|
||||||
|
filter: this.parseFilter(request, objectMetadataItem),
|
||||||
|
orderBy: this.parseOrderBy(request, objectMetadataItem),
|
||||||
|
limit: this.parseLimit(request),
|
||||||
|
lastCursor: this.parseCursor(request),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
parseObject(request) {
|
||||||
|
const queryAction = request.path.replace('/rest/', '').split('/');
|
||||||
|
if (queryAction.length > 2) {
|
||||||
|
throw Error(
|
||||||
|
`Query path '${request.path}' invalid. Valid examples: /rest/companies/id or /rest/companies`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (queryAction.length === 1) {
|
||||||
|
return [queryAction[0], undefined];
|
||||||
|
}
|
||||||
|
return queryAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
extractWorkspaceId(request: Request) {
|
||||||
|
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request);
|
||||||
|
if (!token) {
|
||||||
|
throw Error('missing authentication token');
|
||||||
|
}
|
||||||
|
return verify(token, this.environmentService.getAccessTokenSecret())[
|
||||||
|
'workspaceId'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
computeDepth(request): number {
|
||||||
|
const depth =
|
||||||
|
typeof request.query.depth === 'string'
|
||||||
|
? parseInt(request.query.depth)
|
||||||
|
: DEFAULT_DEPTH_VALUE;
|
||||||
|
if (!ALLOWED_DEPTH_VALUES.includes(depth)) {
|
||||||
|
throw Error(
|
||||||
|
`'depth=${depth}' parameter invalid. Allowed values are ${ALLOWED_DEPTH_VALUES.join(
|
||||||
|
', ',
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return depth;
|
||||||
|
}
|
||||||
|
|
||||||
|
async callGraphql(request: Request, data) {
|
||||||
|
return await axios.post(
|
||||||
|
`${this.environmentService.getLocalServerUrl()}/graphql`,
|
||||||
|
data,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
authorization: request.headers.authorization,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(request: Request) {
|
||||||
|
try {
|
||||||
|
const objectMetadata = await this.getObjectMetadata(request);
|
||||||
|
const id = this.parseObject(request)[1];
|
||||||
|
const depth = this.computeDepth(request);
|
||||||
|
const data = {
|
||||||
|
query: id
|
||||||
|
? this.computeFindOneQuery(
|
||||||
|
objectMetadata.objectMetadataItems,
|
||||||
|
objectMetadata.objectMetadataItem,
|
||||||
|
depth,
|
||||||
|
)
|
||||||
|
: this.computeFindManyQuery(
|
||||||
|
objectMetadata.objectMetadataItems,
|
||||||
|
objectMetadata.objectMetadataItem,
|
||||||
|
depth,
|
||||||
|
),
|
||||||
|
variables: id
|
||||||
|
? { filter: { id: { eq: id } } }
|
||||||
|
: this.computeQueryVariables(
|
||||||
|
request,
|
||||||
|
objectMetadata.objectMetadataItem,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
return await this.callGraphql(request, data);
|
||||||
|
} catch (err) {
|
||||||
|
return { data: { error: `${err}` } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(request: Request) {
|
||||||
|
try {
|
||||||
|
const objectMetadata = await this.getObjectMetadata(request);
|
||||||
|
const id = this.parseObject(request)[1];
|
||||||
|
if (!id) {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
error: `delete ${objectMetadata.objectMetadataItem.nameSingular} query invalid. Id missing. eg: /rest/${objectMetadata.objectMetadataItem.namePlural}/0d4389ef-ea9c-4ae8-ada1-1cddc440fb56`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const data = {
|
||||||
|
query: this.computeDeleteQuery(objectMetadata.objectMetadataItem),
|
||||||
|
variables: {
|
||||||
|
id: this.parseObject(request)[1],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return await this.callGraphql(request, data);
|
||||||
|
} catch (err) {
|
||||||
|
return { data: { error: `${err}` } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(request: Request) {
|
||||||
|
try {
|
||||||
|
const objectMetadata = await this.getObjectMetadata(request);
|
||||||
|
const depth = this.computeDepth(request);
|
||||||
|
const data = {
|
||||||
|
query: this.computeCreateQuery(
|
||||||
|
objectMetadata.objectMetadataItems,
|
||||||
|
objectMetadata.objectMetadataItem,
|
||||||
|
depth,
|
||||||
|
),
|
||||||
|
variables: {
|
||||||
|
data: request.body,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return await this.callGraphql(request, data);
|
||||||
|
} catch (err) {
|
||||||
|
return { data: { error: `${err}` } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(request: Request) {
|
||||||
|
try {
|
||||||
|
const objectMetadata = await this.getObjectMetadata(request);
|
||||||
|
const depth = this.computeDepth(request);
|
||||||
|
const id = this.parseObject(request)[1];
|
||||||
|
if (!id) {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
error: `update ${objectMetadata.objectMetadataItem.nameSingular} query invalid. Id missing. eg: /rest/${objectMetadata.objectMetadataItem.namePlural}/0d4389ef-ea9c-4ae8-ada1-1cddc440fb56`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const data = {
|
||||||
|
query: this.computeUpdateQuery(
|
||||||
|
objectMetadata.objectMetadataItems,
|
||||||
|
objectMetadata.objectMetadataItem,
|
||||||
|
depth,
|
||||||
|
),
|
||||||
|
variables: {
|
||||||
|
id: this.parseObject(request)[1],
|
||||||
|
data: request.body,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return await this.callGraphql(request, data);
|
||||||
|
} catch (err) {
|
||||||
|
return { data: { error: `${err}` } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import { WorkspaceModule } from 'src/core/workspace/workspace.module';
|
|||||||
import { UserModule } from 'src/core/user/user.module';
|
import { UserModule } from 'src/core/user/user.module';
|
||||||
import { RefreshTokenModule } from 'src/core/refresh-token/refresh-token.module';
|
import { RefreshTokenModule } from 'src/core/refresh-token/refresh-token.module';
|
||||||
import { AuthModule } from 'src/core/auth/auth.module';
|
import { AuthModule } from 'src/core/auth/auth.module';
|
||||||
|
import { ApiRestModule } from 'src/core/api-rest/api-rest.module';
|
||||||
import { FeatureFlagModule } from 'src/core/feature-flag/feature-flag.module';
|
import { FeatureFlagModule } from 'src/core/feature-flag/feature-flag.module';
|
||||||
|
|
||||||
import { AnalyticsModule } from './analytics/analytics.module';
|
import { AnalyticsModule } from './analytics/analytics.module';
|
||||||
@ -19,6 +20,7 @@ import { ClientConfigModule } from './client-config/client-config.module';
|
|||||||
AnalyticsModule,
|
AnalyticsModule,
|
||||||
FileModule,
|
FileModule,
|
||||||
ClientConfigModule,
|
ClientConfigModule,
|
||||||
|
ApiRestModule,
|
||||||
FeatureFlagModule,
|
FeatureFlagModule,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
|
|||||||
@ -50,6 +50,10 @@ export class EnvironmentService {
|
|||||||
return this.configService.get<string>('FRONT_BASE_URL')!;
|
return this.configService.get<string>('FRONT_BASE_URL')!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getLocalServerUrl(): string {
|
||||||
|
return this.configService.get<string>('LOCAL_SERVER_URL')!;
|
||||||
|
}
|
||||||
|
|
||||||
getAccessTokenSecret(): string {
|
getAccessTokenSecret(): string {
|
||||||
return this.configService.get<string>('ACCESS_TOKEN_SECRET')!;
|
return this.configService.get<string>('ACCESS_TOKEN_SECRET')!;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -59,6 +59,10 @@ export class EnvironmentVariables {
|
|||||||
@IsUrl({ require_tld: false })
|
@IsUrl({ require_tld: false })
|
||||||
FRONT_BASE_URL: string;
|
FRONT_BASE_URL: string;
|
||||||
|
|
||||||
|
// Server internal URL
|
||||||
|
@IsUrl({ require_tld: false })
|
||||||
|
LOCAL_SERVER_URL: string;
|
||||||
|
|
||||||
// Json Web Token
|
// Json Web Token
|
||||||
@IsString()
|
@IsString()
|
||||||
ACCESS_TOKEN_SECRET: string;
|
ACCESS_TOKEN_SECRET: string;
|
||||||
|
|||||||
3
server/src/utils/capitalize.ts
Normal file
3
server/src/utils/capitalize.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export const capitalize = (string: string) => {
|
||||||
|
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user