Dedicated REST controllers for object & field metadata (#20364)

## Summary
- Replace the dynamic `RestApiMetadataController` (which parsed
`/rest/metadata/*path` and proxied to internal GraphQL) with two
dedicated controllers: `ObjectMetadataController` and
  `FieldMetadataController`.
- Drop the GraphQL hop: reads hit Postgres directly via TypeORM
repositories; writes call the existing
`{create,update,delete}One{Object,Field}` service methods.
- Introduce a new clean response shape behind a workspace feature flag
(`IS_REST_METADATA_API_NEW_FORMAT_DIRECT`) — see grace period below.
- Update the OpenAPI spec so the REST playground reflects the (default)
legacy shape during the grace period.

  ## Why
The legacy metadata controller was over-complex: it routed every method
through a path parser, a set of GraphQL query-builder factories, an
internal GraphQL call, and a
`cleanGraphQLResponse` post-processor. Operation names from GraphQL
(`createOneObject`, `updateOneField`, …) leaked straight into REST
responses. The internal-GraphQL hop also gave us
nothing on metadata reads — pagination, filtering, and serialization all
happen against the same Postgres tables either way.

  ## Feature flag & grace period
  `IS_REST_METADATA_API_NEW_FORMAT_DIRECT` (workspace-scoped):
- **Existing workspaces:** flag absent → resolves to `false` → **legacy
response shape** (no behavior change).
- **Newly created workspaces:** flag seeded to `true` via
`DEFAULT_FEATURE_FLAGS` → **new response shape** from day one.
- **Toggle:** support-assisted (no frontend); customers contact us to
opt into the new shape early.
- **Removal:** the flag, the legacy adapter utils
(`to-legacy-{object,field}-metadata-response.util.ts`), and the
parametrized test wrapper get deleted after the grace window. New shape
becomes the only shape; OpenAPI flips to new shape; POST loses the
conditional and reverts to a declarative response.

  ## Response shapes

| Operation | Legacy (flag OFF, default for existing) | New (flag ON) |
|-----------|-----------------------------------------|---------------|
| `GET /rest/metadata/objects` | `{ data: { objects: [...] }, pageInfo,
totalCount }` | `{ data: [...], pageInfo, totalCount }` |
| `GET /rest/metadata/objects/:id` | `{ data: { object: {...} } }` | `{
... }` |
| `POST /rest/metadata/objects` | `201 { data: { createOneObject: {...}
} }` | `201 { ... }` |
| `PATCH/PUT /rest/metadata/objects/:id` | `{ data: { updateOneObject:
{...} } }` | `{ ... }` |
| `DELETE /rest/metadata/objects/:id` | `{ data: { deleteOneObject: {
... } } }` | `{ ... }` |

Same matrix for `/rest/metadata/fields`. Cursor params
(`starting_after`, `ending_before`, `limit`) and `totalCount` are
preserved across both shapes. POST returns `201` in both (old
  controller already did — the doc on main saying `200` was wrong).

  ## Implementation notes
- Reads go straight to Postgres with TypeORM cursor pagination
(`paginateByIdCursor` util, mutually-exclusive `starting_after` /
`ending_before`). No cache on this path — caching +
  filterable pagination didn't combine cleanly.
- Object endpoints inline `fields[]` via a single follow-up `WHERE
objectMetadataId IN (...)` query.
- Controllers read the flag via `FeatureFlagService.isFeatureEnabled`
and conditionally pass the result through a legacy-shape adapter util
before returning.
- Per-domain REST exception filters
(`{Object,Field}MetadataRestApiExceptionFilter`); the `exceptionCode →
httpStatus` switch is extracted to a util so it can be merged with the
  existing GraphQL handler later.
- New controllers live inside the metadata domain modules
(`metadata-modules/{object,field}-metadata/controllers/`) to match
existing precedent (view-field, view, page-layout, …).
- Removes: `RestApiMetadataController`, `RestApiMetadataService`,
`metadata/query-builder/`, `clean-graphql-response.utils.ts`.
- Integration tests are parametrized over both flag values via
`describe.each` — both shapes are asserted in CI.
- OpenAPI fixes inherited from the migration (kept as-is): documents
flat `fields: [...]` rather than the obsolete `{edges:{node:[...]}}`
wrapping; always emits `totalCount`; POST
status `201`. These match what customers actually receive on both
shapes.

Note: Next goal is to implement something similar for graphql and remove
nestjs-query dependency for those 2 entities, then generalise it.
Note2: We have the same issue with Core Rest API such as
```json
{
  "data": {
    "createCompany": {
      "id": "123e4567-e89b-12d3-a456-426614174000",
      "createdAt": "2026-05-07T12:14:52.769Z",
      "updatedAt": "2026-05-07T12:14:52.769Z",
      "deletedAt": "2026-05-07T12:14:52.769Z",
   ...
```
with "createCompany" here which is odd compared to REST standards (FYI
@etiennejouan @charlesBochet)

## Before (Without feature flag)
<img width="1346" height="712" alt="Screenshot 2026-05-12 at 20 50 38"
src="https://github.com/user-attachments/assets/316ce225-1045-4aac-97a9-60fd537eb1ec"
/>
<img width="1378" height="729" alt="Screenshot 2026-05-12 at 20 52 24"
src="https://github.com/user-attachments/assets/a621ab6f-e4f8-44d5-817c-1efd25d33c30"
/>

## After (With feature flag)
<img width="1376" height="728" alt="Screenshot 2026-05-12 at 20 50 46"
src="https://github.com/user-attachments/assets/2424d9c5-e4ed-497c-8e5c-6b54d78675e4"
/>
<img width="1375" height="727" alt="Screenshot 2026-05-12 at 20 51 47"
src="https://github.com/user-attachments/assets/101d957f-38ed-45d9-ab7b-f4f4eb983397"
/>

---------

Co-authored-by: prastoin <paul@twenty.com>
This commit is contained in:
Weiko
2026-05-13 14:31:16 +02:00
committed by GitHub
parent bbc55193f5
commit 27fd124c2e
49 changed files with 2074 additions and 1004 deletions
@@ -1,34 +0,0 @@
import { Injectable } from '@nestjs/common';
import { capitalize } from 'twenty-shared/utils';
import { fetchMetadataFields } from 'src/engine/api/rest/metadata/query-builder/utils/fetch-metadata-fields.utils';
import {
type ObjectName,
type Singular,
} from 'src/engine/api/rest/metadata/types/metadata-entity.type';
import { type Selectors } from 'src/engine/api/rest/metadata/types/metadata-query.type';
@Injectable()
export class CreateMetadataQueryFactory {
create(
objectNameSingular: Singular<ObjectName>,
objectNamePlural: ObjectName,
selectors: Selectors,
): string {
const objectNameCapitalized = capitalize(objectNameSingular);
const fields = fetchMetadataFields(objectNamePlural, selectors);
return `
mutation CreateOne${objectNameCapitalized}($input: CreateOne${objectNameCapitalized}${
objectNameSingular === 'field' ? 'Metadata' : ''
}Input!) {
createOne${objectNameCapitalized}(input: $input) {
id
${fields}
}
}
`;
}
}
@@ -1,27 +0,0 @@
import { Injectable } from '@nestjs/common';
import { capitalize } from 'twenty-shared/utils';
import {
type ObjectName,
type Singular,
} from 'src/engine/api/rest/metadata/types/metadata-entity.type';
@Injectable()
export class DeleteMetadataQueryFactory {
create(objectNameSingular: Singular<ObjectName>): string {
const objectNameCapitalized = capitalize(objectNameSingular);
const formattedObjectName =
objectNameCapitalized === 'RelationMetadata'
? 'Relation'
: objectNameCapitalized;
return `
mutation Delete${objectNameCapitalized}($input: DeleteOne${formattedObjectName}Input!) {
deleteOne${formattedObjectName}(input: $input) {
id
}
}
`;
}
}
@@ -1,36 +0,0 @@
import { Injectable } from '@nestjs/common';
import { capitalize } from 'twenty-shared/utils';
import { fetchMetadataFields } from 'src/engine/api/rest/metadata/query-builder/utils/fetch-metadata-fields.utils';
import { type ObjectName } from 'src/engine/api/rest/metadata/types/metadata-entity.type';
import { type Selectors } from 'src/engine/api/rest/metadata/types/metadata-query.type';
@Injectable()
export class FindManyMetadataQueryFactory {
create(objectNamePlural: ObjectName, selectors: Selectors): string {
const fields = fetchMetadataFields(objectNamePlural, selectors);
return `
query FindMany${capitalize(objectNamePlural)}(
$paging: CursorPaging!
) {
${objectNamePlural}(
paging: $paging
) {
edges {
node {
id
${fields}
}
}
pageInfo {
hasNextPage
startCursor
endCursor
}
}
}
`;
}
}
@@ -1,32 +0,0 @@
import { Injectable } from '@nestjs/common';
import { capitalize } from 'twenty-shared/utils';
import { fetchMetadataFields } from 'src/engine/api/rest/metadata/query-builder/utils/fetch-metadata-fields.utils';
import {
type ObjectName,
type Singular,
} from 'src/engine/api/rest/metadata/types/metadata-entity.type';
import { type Selectors } from 'src/engine/api/rest/metadata/types/metadata-query.type';
@Injectable()
export class FindOneMetadataQueryFactory {
create(
objectNameSingular: Singular<ObjectName>,
objectNamePlural: ObjectName,
selectors: Selectors,
): string {
const fields = fetchMetadataFields(objectNamePlural, selectors);
return `
query FindOne${capitalize(objectNameSingular)}(
$id: UUID!,
) {
${objectNameSingular}(id: $id) {
id
${fields}
}
}
`;
}
}
@@ -1,38 +0,0 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { parseEndingBeforeRestRequest } from 'src/engine/api/rest/input-request-parsers/ending-before-parser-utils/parse-ending-before-rest-request.util';
import { parseLimitRestRequest } from 'src/engine/api/rest/input-request-parsers/limit-parser-utils/parse-limit-rest-request.util';
import { parseStartingAfterRestRequest } from 'src/engine/api/rest/input-request-parsers/starting-after-parser-utils/parse-starting-after-rest-request.util';
import { MetadataQueryVariables } from 'src/engine/api/rest/metadata/types/metadata-query-variables.type';
import { RequestContext } from 'src/engine/api/rest/types/RequestContext';
@Injectable()
export class GetMetadataVariablesFactory {
create(
id: string | undefined,
requestContext: RequestContext,
): MetadataQueryVariables {
if (id) {
return { id };
}
const limit = parseLimitRestRequest(requestContext, 1000);
const before = parseEndingBeforeRestRequest(requestContext);
const after = parseStartingAfterRestRequest(requestContext);
if (before && after) {
throw new BadRequestException(
`Only one of 'endingBefore' and 'startingAfter' may be provided`,
);
}
return {
paging: {
first: !before ? limit : undefined,
last: before ? limit : undefined,
after,
before,
},
};
}
}
@@ -1,15 +0,0 @@
import { CreateMetadataQueryFactory } from 'src/engine/api/rest/metadata/query-builder/factories/create-metadata-query.factory';
import { DeleteMetadataQueryFactory } from 'src/engine/api/rest/metadata/query-builder/factories/delete-metadata-query.factory';
import { FindManyMetadataQueryFactory } from 'src/engine/api/rest/metadata/query-builder/factories/find-many-metadata-query.factory';
import { FindOneMetadataQueryFactory } from 'src/engine/api/rest/metadata/query-builder/factories/find-one-metadata-query.factory';
import { GetMetadataVariablesFactory } from 'src/engine/api/rest/metadata/query-builder/factories/get-metadata-variables.factory';
import { UpdateMetadataQueryFactory } from 'src/engine/api/rest/metadata/query-builder/factories/update-metadata-query.factory';
export const metadataQueryBuilderFactories = [
FindOneMetadataQueryFactory,
FindManyMetadataQueryFactory,
CreateMetadataQueryFactory,
DeleteMetadataQueryFactory,
UpdateMetadataQueryFactory,
GetMetadataVariablesFactory,
];
@@ -1,34 +0,0 @@
import { Injectable } from '@nestjs/common';
import { capitalize } from 'twenty-shared/utils';
import { fetchMetadataFields } from 'src/engine/api/rest/metadata/query-builder/utils/fetch-metadata-fields.utils';
import {
type ObjectName,
type Singular,
} from 'src/engine/api/rest/metadata/types/metadata-entity.type';
import { type Selectors } from 'src/engine/api/rest/metadata/types/metadata-query.type';
@Injectable()
export class UpdateMetadataQueryFactory {
create(
objectNameSingular: Singular<ObjectName>,
objectNamePlural: ObjectName,
selectors: Selectors,
): string {
const objectNameCapitalized = capitalize(objectNameSingular);
const fields = fetchMetadataFields(objectNamePlural, selectors);
return `
mutation Update${objectNameCapitalized}($input: UpdateOne${objectNameCapitalized}${
objectNameSingular === 'field' ? 'Metadata' : ''
}Input!) {
updateOne${objectNameCapitalized}(input: $input) {
id
${fields}
}
}
`;
}
}
@@ -1,116 +0,0 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { GetMetadataVariablesFactory } from 'src/engine/api/rest/metadata/query-builder/factories/get-metadata-variables.factory';
import { FindOneMetadataQueryFactory } from 'src/engine/api/rest/metadata/query-builder/factories/find-one-metadata-query.factory';
import { FindManyMetadataQueryFactory } from 'src/engine/api/rest/metadata/query-builder/factories/find-many-metadata-query.factory';
import { parseMetadataPath } from 'src/engine/api/rest/metadata/query-builder/utils/parse-metadata-path.utils';
import { CreateMetadataQueryFactory } from 'src/engine/api/rest/metadata/query-builder/factories/create-metadata-query.factory';
import { UpdateMetadataQueryFactory } from 'src/engine/api/rest/metadata/query-builder/factories/update-metadata-query.factory';
import { DeleteMetadataQueryFactory } from 'src/engine/api/rest/metadata/query-builder/factories/delete-metadata-query.factory';
import {
type MetadataQuery,
type Selectors,
} from 'src/engine/api/rest/metadata/types/metadata-query.type';
import { type RequestContext } from 'src/engine/api/rest/types/RequestContext';
@Injectable()
export class MetadataQueryBuilderFactory {
constructor(
private readonly findOneQueryFactory: FindOneMetadataQueryFactory,
private readonly findManyQueryFactory: FindManyMetadataQueryFactory,
private readonly createQueryFactory: CreateMetadataQueryFactory,
private readonly updateQueryFactory: UpdateMetadataQueryFactory,
private readonly deleteQueryFactory: DeleteMetadataQueryFactory,
private readonly getMetadataVariablesFactory: GetMetadataVariablesFactory,
) {}
async get(
request: RequestContext,
selectors?: Selectors,
): Promise<MetadataQuery> {
const { id, objectNameSingular, objectNamePlural } = parseMetadataPath(
request.path,
);
return {
query: id
? this.findOneQueryFactory.create(
objectNameSingular,
objectNamePlural,
selectors,
)
: this.findManyQueryFactory.create(objectNamePlural, selectors),
variables: this.getMetadataVariablesFactory.create(id, request),
};
}
async create(
{ path, body }: Pick<RequestContext, 'path' | 'body'>,
selectors?: Selectors,
): Promise<MetadataQuery> {
const { objectNameSingular, objectNamePlural } = parseMetadataPath(path);
return {
query: this.createQueryFactory.create(
objectNameSingular,
objectNamePlural,
selectors,
),
variables: {
input: {
[objectNameSingular]: body,
},
},
};
}
async update(
request: Pick<RequestContext, 'path' | 'body'>,
selectors?: Selectors,
): Promise<MetadataQuery> {
const { objectNameSingular, objectNamePlural, id } = parseMetadataPath(
request.path,
);
if (!id) {
throw new BadRequestException(
`update ${objectNameSingular} query invalid. Id missing. eg: /rest/metadata/${objectNameSingular}/0d4389ef-ea9c-4ae8-ada1-1cddc440fb56`,
);
}
return {
query: this.updateQueryFactory.create(
objectNameSingular,
objectNamePlural,
selectors,
),
variables: {
input: {
update: request.body,
id,
},
},
};
}
async delete(
request: Pick<RequestContext, 'path' | 'body'>,
): Promise<MetadataQuery> {
const { objectNameSingular, id } = parseMetadataPath(request.path);
if (!id) {
throw new BadRequestException(
`delete ${objectNameSingular} query invalid. Id missing. eg: /rest/metadata/${objectNameSingular}/0d4389ef-ea9c-4ae8-ada1-1cddc440fb56`,
);
}
return {
query: this.deleteQueryFactory.create(objectNameSingular),
variables: {
input: {
id,
},
},
};
}
}
@@ -1,12 +0,0 @@
import { Module } from '@nestjs/common';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { MetadataQueryBuilderFactory } from 'src/engine/api/rest/metadata/query-builder/metadata-query-builder.factory';
import { metadataQueryBuilderFactories } from 'src/engine/api/rest/metadata/query-builder/factories/metadata-factories';
@Module({
imports: [AuthModule],
providers: [...metadataQueryBuilderFactories, MetadataQueryBuilderFactory],
exports: [MetadataQueryBuilderFactory],
})
export class MetadataQueryBuilderModule {}
@@ -1,39 +0,0 @@
import { parseMetadataPath } from 'src/engine/api/rest/metadata/query-builder/utils/parse-metadata-path.utils';
describe('parseMetadataPath', () => {
it('should parse object from request path with uuid', () => {
const request: any = { path: '/rest/metadata/fields/uuid' };
expect(parseMetadataPath(request.path)).toEqual({
objectNameSingular: 'field',
objectNamePlural: 'fields',
id: 'uuid',
});
});
it('should parse object from request path', () => {
const request: any = { path: '/rest/metadata/fields' };
expect(parseMetadataPath(request.path)).toEqual({
objectNameSingular: 'field',
objectNamePlural: 'fields',
id: undefined,
});
});
it('should throw for wrong request path', () => {
const request: any = { path: '/rest/metadata/INVALID' };
expect(() => parseMetadataPath(request.path)).toThrow(
'Query path \'/rest/metadata/INVALID\' invalid. Metadata path "INVALID" does not exist. Valid examples: /rest/metadata/fields or /rest/metadata/objects',
);
});
it('should throw for wrong request path', () => {
const request: any = { path: '/rest/metadata/fields/uuid/toto' };
expect(() => parseMetadataPath(request.path)).toThrow(
"Query path '/rest/metadata/fields/uuid/toto' invalid. Valid examples: /rest/metadata/fields or /rest/metadata/objects/id",
);
});
});
@@ -1,90 +0,0 @@
import { hasSelectAllFieldsSelector } from 'src/engine/api/rest/metadata/query-builder/utils/has-select-all-fields-selector.util';
import { type Selectors } from 'src/engine/api/rest/metadata/types/metadata-query.type';
export const fetchMetadataFields = (
objectNamePlural: string,
selector: Selectors,
) => {
const defaultFields = `
id
type
name
label
description
icon
isCustom
isActive
isSystem
isNullable
createdAt
updatedAt
defaultValue
options
relation {
type
targetObjectMetadata {
id
nameSingular
namePlural
}
targetFieldMetadata {
id
name
}
sourceObjectMetadata {
id
nameSingular
namePlural
}
sourceFieldMetadata {
id
name
}
}
`;
const fieldsSelection = hasSelectAllFieldsSelector(selector)
? defaultFields
: (selector?.fields?.join('\n') ?? defaultFields);
switch (objectNamePlural) {
case 'objects': {
const objectsSelection =
selector?.objects?.join('\n') ??
`
nameSingular
namePlural
labelSingular
labelPlural
description
icon
isCustom
isActive
isSystem
createdAt
updatedAt
labelIdentifierFieldMetadataId
imageIdentifierFieldMetadataId
`;
const fieldsPart = selector?.fields
? `
fields(paging: { first: 1000 }) {
edges {
node {
${fieldsSelection}
}
}
}
`
: '';
return `
${objectsSelection}
${fieldsPart}
`;
}
case 'fields':
return fieldsSelection;
}
};
@@ -1,9 +0,0 @@
import { type Selectors } from 'src/engine/api/rest/metadata/types/metadata-query.type';
export const hasSelectAllFieldsSelector = (selector: Selectors) => {
return (
selector?.fields?.length &&
selector?.fields.length === 1 &&
selector?.fields.includes('*')
);
};
@@ -1,48 +0,0 @@
import { BadRequestException } from '@nestjs/common';
import {
type ObjectName,
type ObjectNameSingularAndPlural,
} from 'src/engine/api/rest/metadata/types/metadata-entity.type';
const getObjectNames = (
objectName: ObjectName,
): ObjectNameSingularAndPlural => {
return {
objectNameSingular: objectName.substring(
0,
objectName.length - 1,
) as ObjectNameSingularAndPlural['objectNameSingular'],
objectNamePlural: objectName,
};
};
export const parseMetadataPath = (
path: string,
): ObjectNameSingularAndPlural => {
const queryAction = path.replace('/rest/metadata/', '').split('/');
if (queryAction.length >= 3 || queryAction.length === 0) {
throw new BadRequestException(
`Query path '${path}' invalid. Valid examples: /rest/metadata/fields or /rest/metadata/objects/id`,
);
}
if (!['fields', 'objects'].includes(queryAction[0])) {
throw new BadRequestException(
`Query path '${path}' invalid. Metadata path "${queryAction[0]}" does not exist. Valid examples: /rest/metadata/fields or /rest/metadata/objects`,
);
}
const hasId = queryAction.length === 2;
const { objectNameSingular, objectNamePlural } = getObjectNames(
queryAction[0] as ObjectName,
);
return {
objectNameSingular,
objectNamePlural,
...(hasId ? { id: queryAction[1] } : {}),
};
};
@@ -1,70 +0,0 @@
import {
Controller,
Delete,
Get,
Patch,
Post,
Put,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { PermissionFlagType } from 'twenty-shared/constants';
import { RestApiMetadataService } from 'src/engine/api/rest/metadata/rest-api-metadata.service';
import { cleanGraphQLResponse } from 'src/engine/api/rest/utils/clean-graphql-response.utils';
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@Controller('rest/metadata/*path')
@UseGuards(
JwtAuthGuard,
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.DATA_MODEL),
)
export class RestApiMetadataController {
constructor(
private readonly restApiMetadataService: RestApiMetadataService,
) {}
@Get()
async handleApiGet(@Req() request: Request, @Res() res: Response) {
const result = await this.restApiMetadataService.get(request);
res.status(200).send(cleanGraphQLResponse(result.data.data));
}
@Delete()
async handleApiDelete(@Req() request: Request, @Res() res: Response) {
const result = await this.restApiMetadataService.delete(request);
res.status(200).send(cleanGraphQLResponse(result.data.data));
}
@Post()
async handleApiPost(@Req() request: Request, @Res() res: Response) {
const result = await this.restApiMetadataService.create(request);
res.status(201).send(cleanGraphQLResponse(result.data.data));
}
@Patch()
async handleApiPatch(@Req() request: Request, @Res() res: Response) {
const result = await this.restApiMetadataService.update(request);
res.status(200).send(cleanGraphQLResponse(result.data.data));
}
// This endpoint is not documented in the OpenAPI schema.
// 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()
async handleApiPut(@Req() request: Request, @Res() res: Response) {
const result = await this.restApiMetadataService.update(request);
res.status(200).send(cleanGraphQLResponse(result.data.data));
}
}
@@ -1,91 +0,0 @@
import { Injectable } from '@nestjs/common';
import { type Request } from 'express';
import { MetadataQueryBuilderFactory } from 'src/engine/api/rest/metadata/query-builder/metadata-query-builder.factory';
import {
GraphqlApiType,
RestApiService,
} from 'src/engine/api/rest/rest-api.service';
import { type RequestContext } from 'src/engine/api/rest/types/RequestContext';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { getServerUrl } from 'src/utils/get-server-url';
@Injectable()
export class RestApiMetadataService {
constructor(
private readonly accessTokenService: AccessTokenService,
private readonly metadataQueryBuilderFactory: MetadataQueryBuilderFactory,
private readonly restApiService: RestApiService,
private readonly twentyConfigService: TwentyConfigService,
) {}
async get(request: Request) {
await this.accessTokenService.validateTokenByRequest(request);
const requestContext = this.getRequestContext(request);
const data = await this.metadataQueryBuilderFactory.get(requestContext, {
fields: ['*'],
});
return await this.restApiService.call(
GraphqlApiType.METADATA,
requestContext,
data,
);
}
async create(request: Request) {
await this.accessTokenService.validateTokenByRequest(request);
const requestContext = this.getRequestContext(request);
const data = await this.metadataQueryBuilderFactory.create(requestContext, {
fields: ['*'],
});
return await this.restApiService.call(
GraphqlApiType.METADATA,
requestContext,
data,
);
}
async update(request: Request) {
await this.accessTokenService.validateTokenByRequest(request);
const requestContext = this.getRequestContext(request);
const data = await this.metadataQueryBuilderFactory.update(requestContext, {
fields: ['*'],
});
return await this.restApiService.call(
GraphqlApiType.METADATA,
requestContext,
data,
);
}
async delete(request: Request) {
await this.accessTokenService.validateTokenByRequest(request);
const requestContext = this.getRequestContext(request);
const data = await this.metadataQueryBuilderFactory.delete(requestContext);
return await this.restApiService.call(
GraphqlApiType.METADATA,
requestContext,
data,
);
}
private getRequestContext(request: Request): RequestContext {
const baseUrl = getServerUrl({
serverUrlEnv: this.twentyConfigService.get('SERVER_URL'),
serverUrlFallback: `${request.protocol}://${request.get('host')}`,
});
return {
body: request.body,
baseUrl: baseUrl,
path: request.url,
headers: request.headers,
};
}
}
@@ -1,11 +0,0 @@
export type Singular<S extends string> = S extends `${infer Stem}s` ? Stem : S;
export type ObjectName = 'fields' | 'objects';
export type ObjectNameSingularAndPlural<
ObjectNameGeneric extends ObjectName = ObjectName,
> = {
objectNameSingular: Singular<ObjectNameGeneric>;
objectNamePlural: ObjectNameGeneric;
id?: string;
};
@@ -1,10 +0,0 @@
export type MetadataQueryVariables = {
id?: string;
input?: object;
paging?: {
first?: number;
last?: number;
after?: string;
before?: string;
};
};
@@ -1,10 +0,0 @@
import { type MetadataQueryVariables } from 'src/engine/api/rest/metadata/types/metadata-query-variables.type';
export type MetadataQuery = {
query: string;
variables: MetadataQueryVariables;
};
export type Selectors =
| { fields?: Array<string>; objects?: Array<string> }
| undefined;
@@ -0,0 +1,80 @@
import { BadRequestException } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import {
type FindManyOptions,
type FindOptionsWhere,
LessThan,
MoreThan,
type Repository,
} from 'typeorm';
export type RestCursorPageInfo = {
hasNextPage: boolean;
startCursor: string | null;
endCursor: string | null;
};
export const paginateByIdCursor = async <
T extends { id: string; workspaceId: string },
>({
repository,
workspaceId,
where,
limit,
startingAfter,
endingBefore,
}: {
repository: Repository<T>;
workspaceId: string;
where?: FindOptionsWhere<T>;
limit: number;
startingAfter?: string;
endingBefore?: string;
}): Promise<{
items: T[];
pageInfo: RestCursorPageInfo;
totalCount: number;
}> => {
if (isDefined(startingAfter) && isDefined(endingBefore)) {
throw new BadRequestException(
`'starting_after' and 'ending_before' cannot be used together.`,
);
}
const isBackward = isDefined(endingBefore);
const idCondition = isBackward
? { id: MoreThan(endingBefore) }
: isDefined(startingAfter)
? { id: LessThan(startingAfter) }
: {};
const baseWhere = { ...where, workspaceId } as FindOptionsWhere<T>;
const [rows, totalCount] = await Promise.all([
repository.find({
where: { ...baseWhere, ...idCondition },
order: { id: isBackward ? 'ASC' : 'DESC' },
take: limit + 1,
} as FindManyOptions<T>),
repository.count({ where: baseWhere }),
]);
const hasMore = rows.length > limit;
const items = hasMore ? rows.slice(0, limit) : rows;
if (isBackward) {
items.reverse();
}
return {
items,
pageInfo: {
hasNextPage: hasMore,
startCursor: items[0]?.id ?? null,
endCursor: items[items.length - 1]?.id ?? null,
},
totalCount,
};
};
@@ -1,26 +1,8 @@
import { Module } from '@nestjs/common';
import { MetadataQueryBuilderModule } from 'src/engine/api/rest/metadata/query-builder/metadata-query-builder.module';
import { RestApiMetadataService } from 'src/engine/api/rest/metadata/rest-api-metadata.service';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.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';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@Module({
imports: [
MetadataQueryBuilderModule,
WorkspaceCacheStorageModule,
AuthModule,
RestApiCoreModule,
PermissionsModule,
SecureHttpClientModule,
],
controllers: [RestApiMetadataController],
providers: [RestApiService, RestApiMetadataService],
exports: [RestApiMetadataService, RestApiService],
imports: [RestApiCoreModule],
})
export class RestApiModule {}
@@ -1,165 +0,0 @@
import { cleanGraphQLResponse } from 'src/engine/api/rest/utils/clean-graphql-response.utils';
describe('cleanGraphQLResponse', () => {
it('should remove edges/node from results', () => {
const data = {
companies: {
edges: [
{
node: { id: 'id', createdAt: '2023-01-01' },
},
],
},
};
const expectedResult = {
data: {
companies: [{ id: 'id', createdAt: '2023-01-01' }],
},
};
expect(cleanGraphQLResponse(data)).toEqual(expectedResult);
});
it('should remove nested edges/node from results', () => {
const data = {
companies: {
totalCount: 14,
pageInfo: {
hasNextPage: true,
startCursor:
'WyIwMDliYjNkYy1hNGEyLTRiNWUtYTZmYi1iMTFiMmFlMGI1MmIiXQ==',
endCursor: 'WyIyMDIwMjAyMC0wNzEzLTQwYTUtODIxNi04MjgwMjQwMWQzM2UiXQ==',
},
edges: [
{
node: {
id: 'id',
createdAt: '2023-01-01',
people: {
edges: [{ node: { id: 'id1' } }, { node: { id: 'id2' } }],
},
},
},
],
},
};
const expectedResult = {
data: {
companies: [
{
id: 'id',
createdAt: '2023-01-01',
people: [{ id: 'id1' }, { id: 'id2' }],
},
],
},
totalCount: 14,
pageInfo: {
hasNextPage: true,
startCursor: 'WyIwMDliYjNkYy1hNGEyLTRiNWUtYTZmYi1iMTFiMmFlMGI1MmIiXQ==',
endCursor: 'WyIyMDIwMjAyMC0wNzEzLTQwYTUtODIxNi04MjgwMjQwMWQzM2UiXQ==',
},
};
expect(cleanGraphQLResponse(data)).toEqual(expectedResult);
});
it('should not format when no list returned', () => {
const data = { company: { id: 'id' } };
const expectedResult = {
data: {
company: { id: 'id' },
},
};
expect(cleanGraphQLResponse(data)).toEqual(expectedResult);
});
it('should remove nested edges/node from results if data key is an array', () => {
const data = {
companyDuplicates: [
{
totalCount: 14,
pageInfo: {
hasNextPage: true,
startCursor:
'WyIwMDliYjNkYy1hNGEyLTRiNWUtYTZmYi1iMTFiMmFlMGI1MmIiXQ==',
endCursor:
'WyIyMDIwMjAyMC0wNzEzLTQwYTUtODIxNi04MjgwMjQwMWQzM2UiXQ==',
},
edges: [
{
node: {
id: 'id',
createdAt: '2023-01-01',
people: {
edges: [{ node: { id: 'id1' } }, { node: { id: 'id2' } }],
},
},
},
],
},
{
totalCount: 14,
pageInfo: {
hasNextPage: true,
startCursor:
'WyIwMDliYjNkYy1hNGEyLTRiNWUtYTZmYi1iMTFiMmFlMGI1MmIiXQ==',
endCursor:
'WyIyMDIwMjAyMC0wNzEzLTQwYTUtODIxNi04MjgwMjQwMWQzM2UiXQ==',
},
edges: [
{
node: {
id: 'id',
createdAt: '2023-01-01',
people: {
edges: [{ node: { id: 'id1' } }, { node: { id: 'id2' } }],
},
},
},
],
},
],
};
const expectedResult = {
data: [
{
totalCount: 14,
pageInfo: {
hasNextPage: true,
startCursor:
'WyIwMDliYjNkYy1hNGEyLTRiNWUtYTZmYi1iMTFiMmFlMGI1MmIiXQ==',
endCursor:
'WyIyMDIwMjAyMC0wNzEzLTQwYTUtODIxNi04MjgwMjQwMWQzM2UiXQ==',
},
companyDuplicates: [
{
id: 'id',
createdAt: '2023-01-01',
people: [{ id: 'id1' }, { id: 'id2' }],
},
],
},
{
totalCount: 14,
pageInfo: {
hasNextPage: true,
startCursor:
'WyIwMDliYjNkYy1hNGEyLTRiNWUtYTZmYi1iMTFiMmFlMGI1MmIiXQ==',
endCursor:
'WyIyMDIwMjAyMC0wNzEzLTQwYTUtODIxNi04MjgwMjQwMWQzM2UiXQ==',
},
companyDuplicates: [
{
id: 'id',
createdAt: '2023-01-01',
people: [{ id: 'id1' }, { id: 'id2' }],
},
],
},
],
};
expect(cleanGraphQLResponse(data)).toEqual(expectedResult);
});
});
@@ -1,75 +0,0 @@
// oxlint-disable-next-line @typescripttypescript/no-explicit-any
export const cleanGraphQLResponse = (input: any) => {
if (!input) return null;
const output = { data: {} }; // Initialize the output with a data key at the top level
// oxlint-disable-next-line @typescripttypescript/no-explicit-any
const isObject = (obj: any) => {
return obj !== null && typeof obj === 'object' && !Array.isArray(obj);
};
// oxlint-disable-next-line @typescripttypescript/no-explicit-any
const cleanObject = (obj: any) => {
const cleanedObj = {};
Object.keys(obj).forEach((key) => {
if (isObject(obj[key])) {
if (obj[key].edges) {
// Handle edges by mapping over them and applying cleanObject to each node
// @ts-expect-error legacy noImplicitAny
cleanedObj[key] = obj[key].edges.map((edge) =>
cleanObject(edge.node),
);
} else {
// Recursively clean nested objects
// @ts-expect-error legacy noImplicitAny
cleanedObj[key] = cleanObject(obj[key]);
}
} else {
// Directly assign non-object properties
// @ts-expect-error legacy noImplicitAny
cleanedObj[key] = obj[key];
}
});
return cleanedObj;
};
Object.keys(input).forEach((key) => {
if (isObject(input[key]) && input[key].edges) {
// Handle collections with edges, ensuring data is placed under the data key
// @ts-expect-error legacy noImplicitAny
output.data[key] = input[key].edges.map((edge) => cleanObject(edge.node));
// Move pageInfo and totalCount to the top level
if (input[key].pageInfo) {
// @ts-expect-error legacy noImplicitAny
output['pageInfo'] = input[key].pageInfo;
}
if (input[key].totalCount) {
// @ts-expect-error legacy noImplicitAny
output['totalCount'] = input[key].totalCount;
}
} else if (isObject(input[key])) {
// Recursively clean and assign nested objects under the data key
// @ts-expect-error legacy noImplicitAny
output.data[key] = cleanObject(input[key]);
} else if (Array.isArray(input[key])) {
const itemsWithEdges = input[key].filter((item) => item.edges);
const cleanedObjArray = itemsWithEdges.map(({ edges, ...item }) => {
return {
...item,
// @ts-expect-error legacy noImplicitAny
[key]: edges.map((edge) => cleanObject(edge.node)),
};
});
output.data = cleanedObjArray;
} else {
// Assign all other properties directly under the data key
// @ts-expect-error legacy noImplicitAny
output.data[key] = input[key];
}
});
return output;
};