Files
twenty/packages/twenty-server/test/integration/rest/utils/metadata-rest-api.util.ts
T
27fd124c2e 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>
2026-05-13 12:31:16 +00:00

150 lines
4.3 KiB
TypeScript

import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { deleteOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/delete-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { FieldMetadataType } from 'twenty-shared/types';
export const NON_EXISTENT_UUID = '00000000-0000-4000-8000-000000000000';
export const uniqueSuffix = (): string =>
Math.random().toString(36).slice(2, 10);
export type CreateTestObjectInput = {
nameSingular: string;
namePlural: string;
labelSingular: string;
labelPlural: string;
icon: string;
isLabelSyncedWithName: boolean;
};
export const buildUniqueObjectInput = (): CreateTestObjectInput => {
const suffix = uniqueSuffix();
return {
nameSingular: `restTest${suffix}`,
namePlural: `restTest${suffix}s`,
labelSingular: `Rest Test ${suffix}`,
labelPlural: `Rest Tests ${suffix}`,
icon: 'IconTestPipe',
isLabelSyncedWithName: false,
};
};
export const createTestObjectViaGraphql = async (
overrides?: Partial<CreateTestObjectInput>,
): Promise<{ id: string; input: CreateTestObjectInput }> => {
const input = { ...buildUniqueObjectInput(), ...overrides };
const { data, errors } = await createOneObjectMetadata({ input });
if (!data?.createOneObject?.id) {
throw new Error(
`Failed to create test object: ${JSON.stringify(errors ?? data)}`,
);
}
return { id: data.createOneObject.id, input };
};
export const cleanupTestObject = async (id: string): Promise<void> => {
try {
await updateOneObjectMetadata({
expectToFail: false,
input: { idToUpdate: id, updatePayload: { isActive: false } },
});
await deleteOneObjectMetadata({
input: { idToDelete: id },
expectToFail: false,
});
} catch {}
};
export type CreateTestFieldInput = {
objectMetadataId: string;
type: FieldMetadataType;
name: string;
label: string;
isLabelSyncedWithName: boolean;
};
export const buildUniqueFieldInput = (
objectMetadataId: string,
): CreateTestFieldInput => {
const suffix = uniqueSuffix();
return {
objectMetadataId,
type: FieldMetadataType.TEXT,
name: `restTestField${suffix}`,
label: `Rest Test Field ${suffix}`,
isLabelSyncedWithName: false,
};
};
export const createTestFieldViaGraphql = async (
objectMetadataId: string,
overrides?: Partial<CreateTestFieldInput>,
): Promise<{ id: string; input: CreateTestFieldInput }> => {
const input = { ...buildUniqueFieldInput(objectMetadataId), ...overrides };
const { data, errors } = await createOneFieldMetadata({ input });
if (!data?.createOneField?.id) {
throw new Error(
`Failed to create test field: ${JSON.stringify(errors ?? data)}`,
);
}
return { id: data.createOneField.id, input };
};
export const cleanupTestField = async (id: string): Promise<void> => {
try {
await deleteOneFieldMetadata({
input: { idToDelete: id },
expectToFail: false,
});
} catch {
// best-effort cleanup
}
};
export type MetadataListPageInfo = {
hasNextPage: boolean;
startCursor: string | null;
endCursor: string | null;
};
export const extractMetadataListPayload = <T>(
body: Record<string, unknown>,
pluralKey: 'objects' | 'fields',
): { items: T[]; pageInfo: MetadataListPageInfo; totalCount: number } => {
if (Array.isArray(body.data)) {
return {
items: body.data as T[],
pageInfo: body.pageInfo as MetadataListPageInfo,
totalCount: body.totalCount as number,
};
}
const data = body.data as Record<string, unknown>;
return {
items: (data[pluralKey] ?? []) as T[],
pageInfo: body.pageInfo as MetadataListPageInfo,
totalCount: body.totalCount as number,
};
};
export const extractMetadataItemPayload = <T>(
body: Record<string, unknown>,
legacyKey: string,
): T => {
const data = body.data as Record<string, unknown> | undefined;
if (data && legacyKey in data) {
return data[legacyKey] as T;
}
return body as T;
};