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:
+267
@@ -0,0 +1,267 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Req,
|
||||
UseFilters,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
|
||||
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 {
|
||||
paginateByIdCursor,
|
||||
type RestCursorPageInfo,
|
||||
} from 'src/engine/api/rest/metadata/utils/paginate-by-id-cursor.util';
|
||||
import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
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';
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { fromFieldMetadataEntityToFieldMetadataDto } from 'src/engine/metadata-modules/field-metadata/utils/from-field-metadata-entity-to-field-metadata-dto.util';
|
||||
import { fromFlatObjectMetadataToObjectMetadataDto } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-flat-object-metadata-to-object-metadata-dto.util';
|
||||
import { CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
|
||||
import { type ObjectMetadataWithFieldsDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata-with-fields.dto';
|
||||
import { UpdateObjectPayload } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
|
||||
import { ObjectMetadataRestApiExceptionFilter } from 'src/engine/metadata-modules/object-metadata/filters/object-metadata-rest-api-exception.filter';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import {
|
||||
ObjectMetadataException,
|
||||
ObjectMetadataExceptionCode,
|
||||
} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
|
||||
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||
import { fromObjectMetadataEntityToObjectMetadataDto } from 'src/engine/metadata-modules/object-metadata/utils/from-object-metadata-entity-to-object-metadata-dto.util';
|
||||
import {
|
||||
toLegacyObjectMetadataCreateResponse,
|
||||
toLegacyObjectMetadataDeleteResponse,
|
||||
toLegacyObjectMetadataFindOneResponse,
|
||||
toLegacyObjectMetadataListResponse,
|
||||
toLegacyObjectMetadataUpdateResponse,
|
||||
} from 'src/engine/metadata-modules/object-metadata/utils/to-legacy-object-metadata-response.util';
|
||||
|
||||
@Controller('rest/metadata/objects')
|
||||
@UseGuards(
|
||||
JwtAuthGuard,
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.DATA_MODEL),
|
||||
)
|
||||
@UseFilters(ObjectMetadataRestApiExceptionFilter)
|
||||
@UsePipes(new ValidationPipe())
|
||||
export class ObjectMetadataController {
|
||||
constructor(
|
||||
@InjectRepository(ObjectMetadataEntity)
|
||||
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
|
||||
@InjectRepository(FieldMetadataEntity)
|
||||
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
|
||||
private readonly objectMetadataService: ObjectMetadataService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async findMany(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
) {
|
||||
const { items, pageInfo, totalCount } = await paginateByIdCursor({
|
||||
repository: this.objectMetadataRepository,
|
||||
workspaceId,
|
||||
limit: parseLimitRestRequest(request),
|
||||
startingAfter: parseStartingAfterRestRequest(request),
|
||||
endingBefore: parseEndingBeforeRestRequest(request),
|
||||
});
|
||||
|
||||
const fields = await this.findFieldsForObjectIds(
|
||||
workspaceId,
|
||||
items.map((object) => object.id),
|
||||
);
|
||||
|
||||
const data = items.map((object) =>
|
||||
this.toObjectWithFieldsDto(object, fields.get(object.id) ?? []),
|
||||
);
|
||||
|
||||
const result: {
|
||||
data: ObjectMetadataWithFieldsDTO[];
|
||||
pageInfo: RestCursorPageInfo;
|
||||
totalCount: number;
|
||||
} = { data, pageInfo, totalCount };
|
||||
|
||||
return (await this.isNewMetadataFormat(workspaceId))
|
||||
? result
|
||||
: toLegacyObjectMetadataListResponse(result);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(
|
||||
@Param('id', new ParseUUIDPipe()) id: string,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
) {
|
||||
const object = await this.objectMetadataRepository.findOne({
|
||||
where: { id, workspaceId },
|
||||
});
|
||||
|
||||
if (!object) {
|
||||
throw new ObjectMetadataException(
|
||||
'Object metadata not found',
|
||||
ObjectMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const fields = await this.fieldMetadataRepository.find({
|
||||
where: { objectMetadataId: object.id, workspaceId },
|
||||
});
|
||||
|
||||
const result = this.toObjectWithFieldsDto(object, fields);
|
||||
|
||||
return (await this.isNewMetadataFormat(workspaceId))
|
||||
? result
|
||||
: toLegacyObjectMetadataFindOneResponse(result);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async createOne(
|
||||
@Body() input: CreateObjectInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
) {
|
||||
const flatObject = await this.objectMetadataService.createOneObject({
|
||||
createObjectInput: input,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const fields = await this.fieldMetadataRepository.find({
|
||||
where: { objectMetadataId: flatObject.id, workspaceId },
|
||||
});
|
||||
|
||||
const result: ObjectMetadataWithFieldsDTO = {
|
||||
...fromFlatObjectMetadataToObjectMetadataDto(flatObject),
|
||||
fields: fields.map(fromFieldMetadataEntityToFieldMetadataDto),
|
||||
};
|
||||
|
||||
return (await this.isNewMetadataFormat(workspaceId))
|
||||
? result
|
||||
: toLegacyObjectMetadataCreateResponse(result);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async updateOnePatch(
|
||||
@Param('id', new ParseUUIDPipe()) id: string,
|
||||
@Body() update: UpdateObjectPayload,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
) {
|
||||
return this.handleUpdate({ id, update, workspaceId });
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async updateOnePut(
|
||||
@Param('id', new ParseUUIDPipe()) id: string,
|
||||
@Body() update: UpdateObjectPayload,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
) {
|
||||
return this.handleUpdate({ id, update, workspaceId });
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async deleteOne(
|
||||
@Param('id', new ParseUUIDPipe()) id: string,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
) {
|
||||
const flatObject = await this.objectMetadataService.deleteOneObject({
|
||||
deleteObjectInput: { id },
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const result = fromFlatObjectMetadataToObjectMetadataDto(flatObject);
|
||||
|
||||
return (await this.isNewMetadataFormat(workspaceId))
|
||||
? result
|
||||
: toLegacyObjectMetadataDeleteResponse(result);
|
||||
}
|
||||
|
||||
private async handleUpdate({
|
||||
id,
|
||||
update,
|
||||
workspaceId,
|
||||
}: {
|
||||
id: string;
|
||||
update: UpdateObjectPayload;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
const flatObject = await this.objectMetadataService.updateOneObject({
|
||||
updateObjectInput: { id, update },
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const fields = await this.fieldMetadataRepository.find({
|
||||
where: { objectMetadataId: flatObject.id, workspaceId },
|
||||
});
|
||||
|
||||
const result: ObjectMetadataWithFieldsDTO = {
|
||||
...fromFlatObjectMetadataToObjectMetadataDto(flatObject),
|
||||
fields: fields.map(fromFieldMetadataEntityToFieldMetadataDto),
|
||||
};
|
||||
|
||||
return (await this.isNewMetadataFormat(workspaceId))
|
||||
? result
|
||||
: toLegacyObjectMetadataUpdateResponse(result);
|
||||
}
|
||||
|
||||
private async isNewMetadataFormat(workspaceId: string): Promise<boolean> {
|
||||
return this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_REST_METADATA_API_NEW_FORMAT_DIRECT,
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
private async findFieldsForObjectIds(
|
||||
workspaceId: string,
|
||||
objectIds: string[],
|
||||
): Promise<Map<string, FieldMetadataEntity[]>> {
|
||||
const grouped = new Map<string, FieldMetadataEntity[]>();
|
||||
|
||||
if (objectIds.length === 0) {
|
||||
return grouped;
|
||||
}
|
||||
|
||||
const fields = await this.fieldMetadataRepository.find({
|
||||
where: { workspaceId, objectMetadataId: In(objectIds) },
|
||||
});
|
||||
|
||||
for (const field of fields) {
|
||||
const list = grouped.get(field.objectMetadataId);
|
||||
|
||||
if (list) {
|
||||
list.push(field);
|
||||
} else {
|
||||
grouped.set(field.objectMetadataId, [field]);
|
||||
}
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
private toObjectWithFieldsDto(
|
||||
object: ObjectMetadataEntity,
|
||||
fields: FieldMetadataEntity[],
|
||||
): ObjectMetadataWithFieldsDTO {
|
||||
return {
|
||||
...fromObjectMetadataEntityToObjectMetadataDto(object),
|
||||
fields: fields.map(fromFieldMetadataEntityToFieldMetadataDto),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user