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:
@@ -1756,6 +1756,7 @@ enum FeatureFlagKey {
|
||||
IS_JUNCTION_RELATIONS_ENABLED
|
||||
IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED
|
||||
IS_BILLING_V2_ENABLED
|
||||
IS_REST_METADATA_API_NEW_FORMAT_DIRECT
|
||||
}
|
||||
|
||||
type WorkspaceUrls {
|
||||
|
||||
@@ -1389,7 +1389,7 @@ export interface FeatureFlag {
|
||||
__typename: 'FeatureFlag'
|
||||
}
|
||||
|
||||
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' | 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAILING_DOMAIN_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' | 'IS_BILLING_V2_ENABLED'
|
||||
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' | 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAILING_DOMAIN_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' | 'IS_BILLING_V2_ENABLED' | 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT'
|
||||
|
||||
export interface WorkspaceUrls {
|
||||
customUrl?: Scalars['String']
|
||||
@@ -8765,7 +8765,8 @@ export const enumFeatureFlagKey = {
|
||||
IS_EMAIL_GROUP_ENABLED: 'IS_EMAIL_GROUP_ENABLED' as const,
|
||||
IS_JUNCTION_RELATIONS_ENABLED: 'IS_JUNCTION_RELATIONS_ENABLED' as const,
|
||||
IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED: 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' as const,
|
||||
IS_BILLING_V2_ENABLED: 'IS_BILLING_V2_ENABLED' as const
|
||||
IS_BILLING_V2_ENABLED: 'IS_BILLING_V2_ENABLED' as const,
|
||||
IS_REST_METADATA_API_NEW_FORMAT_DIRECT: 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' as const
|
||||
}
|
||||
|
||||
export const enumIdentityProviderType = {
|
||||
|
||||
@@ -279,6 +279,7 @@ export enum FeatureFlagKey {
|
||||
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
|
||||
IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED = 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED',
|
||||
IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED = 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED',
|
||||
IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT',
|
||||
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED'
|
||||
}
|
||||
|
||||
|
||||
@@ -1650,6 +1650,7 @@ export enum FeatureFlagKey {
|
||||
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
|
||||
IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED = 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED',
|
||||
IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED = 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED',
|
||||
IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT',
|
||||
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED'
|
||||
}
|
||||
|
||||
|
||||
-34
@@ -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}
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
-27
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
-36
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
-32
@@ -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}
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
-38
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
-15
@@ -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,
|
||||
];
|
||||
-34
@@ -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}
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
-116
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
-12
@@ -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 {}
|
||||
-39
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
-90
@@ -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;
|
||||
}
|
||||
};
|
||||
-9
@@ -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('*')
|
||||
);
|
||||
};
|
||||
-48
@@ -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;
|
||||
};
|
||||
-10
@@ -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;
|
||||
+80
@@ -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 {}
|
||||
|
||||
-165
@@ -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;
|
||||
};
|
||||
@@ -332,7 +332,7 @@ export class OpenApiService {
|
||||
{ $ref: '#/components/parameters/endingBefore' },
|
||||
],
|
||||
responses: {
|
||||
'200': getFindManyResponse200(item, true),
|
||||
'200': getFindManyResponse200(item),
|
||||
'400': { $ref: '#/components/responses/400' },
|
||||
'401': { $ref: '#/components/responses/401' },
|
||||
},
|
||||
@@ -343,7 +343,7 @@ export class OpenApiService {
|
||||
operationId: `createOne${capitalize(item.nameSingular)}`,
|
||||
requestBody: getRequestBody(capitalize(item.nameSingular)),
|
||||
responses: {
|
||||
'200': getCreateOneResponse201(item, true),
|
||||
'201': getCreateOneResponse201(item, true),
|
||||
'400': { $ref: '#/components/responses/400' },
|
||||
'401': { $ref: '#/components/responses/401' },
|
||||
},
|
||||
|
||||
@@ -366,19 +366,9 @@ export const computeMetadataSchemaComponents = (
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
updatedAt: { type: 'string', format: 'date-time' },
|
||||
fields: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
edges: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
node: {
|
||||
type: 'array',
|
||||
items: {
|
||||
$ref: '#/components/schemas/FieldForResponse',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
type: 'array',
|
||||
items: {
|
||||
$ref: '#/components/schemas/FieldForResponse',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4,7 +4,6 @@ import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object
|
||||
|
||||
export const getFindManyResponse200 = (
|
||||
item: Pick<FlatObjectMetadata, 'nameSingular' | 'namePlural'>,
|
||||
fromMetadata = false,
|
||||
) => {
|
||||
const schemaRef = `#/components/schemas/${capitalize(
|
||||
item.nameSingular,
|
||||
@@ -42,11 +41,9 @@ export const getFindManyResponse200 = (
|
||||
},
|
||||
},
|
||||
},
|
||||
...(!fromMetadata && {
|
||||
totalCount: {
|
||||
type: 'integer',
|
||||
},
|
||||
}),
|
||||
totalCount: {
|
||||
type: 'integer',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Req,
|
||||
UseFilters,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { 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 { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
|
||||
import { type FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
|
||||
import { UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import {
|
||||
FieldMetadataException,
|
||||
FieldMetadataExceptionCode,
|
||||
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
|
||||
import { FieldMetadataRestApiExceptionFilter } from 'src/engine/metadata-modules/field-metadata/filters/field-metadata-rest-api-exception.filter';
|
||||
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service';
|
||||
import { fromFieldMetadataEntityToFieldMetadataDto } from 'src/engine/metadata-modules/field-metadata/utils/from-field-metadata-entity-to-field-metadata-dto.util';
|
||||
import {
|
||||
toLegacyFieldMetadataCreateResponse,
|
||||
toLegacyFieldMetadataDeleteResponse,
|
||||
toLegacyFieldMetadataFindOneResponse,
|
||||
toLegacyFieldMetadataListResponse,
|
||||
toLegacyFieldMetadataUpdateResponse,
|
||||
} from 'src/engine/metadata-modules/field-metadata/utils/to-legacy-field-metadata-response.util';
|
||||
import { fromFlatFieldMetadataToFieldMetadataDto } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-flat-field-metadata-to-field-metadata-dto.util';
|
||||
|
||||
@Controller('rest/metadata/fields')
|
||||
@UseGuards(
|
||||
JwtAuthGuard,
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.DATA_MODEL),
|
||||
)
|
||||
@UseFilters(FieldMetadataRestApiExceptionFilter)
|
||||
@UsePipes(new ValidationPipe())
|
||||
export class FieldMetadataController {
|
||||
constructor(
|
||||
@InjectRepository(FieldMetadataEntity)
|
||||
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
|
||||
private readonly fieldMetadataService: FieldMetadataService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async findMany(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
) {
|
||||
const { items, pageInfo, totalCount } = await paginateByIdCursor({
|
||||
repository: this.fieldMetadataRepository,
|
||||
workspaceId,
|
||||
limit: parseLimitRestRequest(request),
|
||||
startingAfter: parseStartingAfterRestRequest(request),
|
||||
endingBefore: parseEndingBeforeRestRequest(request),
|
||||
});
|
||||
|
||||
const result: {
|
||||
data: FieldMetadataDTO[];
|
||||
pageInfo: RestCursorPageInfo;
|
||||
totalCount: number;
|
||||
} = {
|
||||
data: items.map(fromFieldMetadataEntityToFieldMetadataDto),
|
||||
pageInfo,
|
||||
totalCount,
|
||||
};
|
||||
|
||||
return (await this.isNewMetadataFormat(workspaceId))
|
||||
? result
|
||||
: toLegacyFieldMetadataListResponse(result);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(
|
||||
@Param('id', new ParseUUIDPipe()) id: string,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
) {
|
||||
const field = await this.fieldMetadataRepository.findOne({
|
||||
where: { id, workspaceId },
|
||||
});
|
||||
|
||||
if (!field) {
|
||||
throw new FieldMetadataException(
|
||||
'Field metadata not found',
|
||||
FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const result = fromFieldMetadataEntityToFieldMetadataDto(field);
|
||||
|
||||
return (await this.isNewMetadataFormat(workspaceId))
|
||||
? result
|
||||
: toLegacyFieldMetadataFindOneResponse(result);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async createOne(
|
||||
@Body() input: CreateFieldInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
) {
|
||||
const flatField = await this.fieldMetadataService.createOneField({
|
||||
createFieldInput: input,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const result = fromFlatFieldMetadataToFieldMetadataDto(flatField);
|
||||
|
||||
return (await this.isNewMetadataFormat(workspaceId))
|
||||
? result
|
||||
: toLegacyFieldMetadataCreateResponse(result);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async updateOnePatch(
|
||||
@Param('id', new ParseUUIDPipe()) id: string,
|
||||
@Body() update: UpdateFieldInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
) {
|
||||
return this.handleUpdate({ id, update, workspaceId });
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async updateOnePut(
|
||||
@Param('id', new ParseUUIDPipe()) id: string,
|
||||
@Body() update: UpdateFieldInput,
|
||||
@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 flatField = await this.fieldMetadataService.deleteOneField({
|
||||
deleteOneFieldInput: { id },
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const result = fromFlatFieldMetadataToFieldMetadataDto(flatField);
|
||||
|
||||
return (await this.isNewMetadataFormat(workspaceId))
|
||||
? result
|
||||
: toLegacyFieldMetadataDeleteResponse(result);
|
||||
}
|
||||
|
||||
private async handleUpdate({
|
||||
id,
|
||||
update,
|
||||
workspaceId,
|
||||
}: {
|
||||
id: string;
|
||||
update: UpdateFieldInput;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
const flatField = await this.fieldMetadataService.updateOneField({
|
||||
updateFieldInput: { ...update, id },
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const result = fromFlatFieldMetadataToFieldMetadataDto(flatField);
|
||||
|
||||
return (await this.isNewMetadataFormat(workspaceId))
|
||||
? result
|
||||
: toLegacyFieldMetadataUpdateResponse(result);
|
||||
}
|
||||
|
||||
private async isNewMetadataFormat(workspaceId: string): Promise<boolean> {
|
||||
return this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_REST_METADATA_API_NEW_FORMAT_DIRECT,
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
}
|
||||
+6
@@ -10,8 +10,10 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
||||
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||
import { ActorModule } from 'src/engine/core-modules/actor/actor.module';
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { FieldMetadataController } from 'src/engine/metadata-modules/field-metadata/controllers/field-metadata.controller';
|
||||
import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
|
||||
import { FieldMetadataResolver } from 'src/engine/metadata-modules/field-metadata/field-metadata.resolver';
|
||||
import { FieldMetadataGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/field-metadata/interceptors/field-metadata-graphql-api-exception.interceptor';
|
||||
@@ -39,6 +41,9 @@ import { UpdateFieldInput } from './dtos/update-field.input';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TokenModule,
|
||||
WorkspaceCacheStorageModule,
|
||||
FeatureFlagModule,
|
||||
NestjsQueryGraphQLModule.forFeature({
|
||||
imports: [
|
||||
NestjsQueryTypeOrmModule.forFeature([
|
||||
@@ -88,6 +93,7 @@ import { UpdateFieldInput } from './dtos/update-field.input';
|
||||
],
|
||||
}),
|
||||
],
|
||||
controllers: [FieldMetadataController],
|
||||
providers: [
|
||||
FieldMetadataService,
|
||||
FieldMetadataResolver,
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
type ArgumentsHost,
|
||||
Catch,
|
||||
type ExceptionFilter,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { type Response } from 'express';
|
||||
import { SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
|
||||
import { RestInputRequestParserException } from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception';
|
||||
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
|
||||
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
import { FieldMetadataException } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
|
||||
import { fieldMetadataExceptionCodeToHttpStatus } from 'src/engine/metadata-modules/field-metadata/utils/field-metadata-exception-code-to-http-status.util';
|
||||
import { FlatEntityMapsException } from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception';
|
||||
import { flatEntityMapsExceptionCodeToHttpStatus } from 'src/engine/metadata-modules/flat-entity/utils/flat-entity-maps-exception-code-to-http-status.util';
|
||||
import { InvalidMetadataException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception';
|
||||
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
|
||||
import { workspaceMigrationBuilderRestApiExceptionHandler } from 'src/engine/workspace-manager/workspace-migration/interceptors/utils/workspace-migration-builder-rest-api-exception-handler.util';
|
||||
import { type CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
type CaughtException =
|
||||
| FieldMetadataException
|
||||
| InvalidMetadataException
|
||||
| WorkspaceMigrationBuilderException
|
||||
| RestInputRequestParserException
|
||||
| FlatEntityMapsException;
|
||||
|
||||
@Injectable()
|
||||
@Catch(
|
||||
FieldMetadataException,
|
||||
InvalidMetadataException,
|
||||
WorkspaceMigrationBuilderException,
|
||||
RestInputRequestParserException,
|
||||
FlatEntityMapsException,
|
||||
)
|
||||
export class FieldMetadataRestApiExceptionFilter implements ExceptionFilter {
|
||||
constructor(
|
||||
private readonly httpExceptionHandlerService: HttpExceptionHandlerService,
|
||||
private readonly i18nService: I18nService,
|
||||
) {}
|
||||
|
||||
catch(exception: CaughtException, host: ArgumentsHost) {
|
||||
const response = host.switchToHttp().getResponse<Response>();
|
||||
|
||||
if (exception instanceof WorkspaceMigrationBuilderException) {
|
||||
return workspaceMigrationBuilderRestApiExceptionHandler({
|
||||
exception,
|
||||
response,
|
||||
i18n: this.i18nService.getI18nInstance(SOURCE_LOCALE),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
exception instanceof InvalidMetadataException ||
|
||||
exception instanceof RestInputRequestParserException
|
||||
) {
|
||||
return this.httpExceptionHandlerService.handleError(
|
||||
exception as CustomException,
|
||||
response,
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if (exception instanceof FlatEntityMapsException) {
|
||||
return this.httpExceptionHandlerService.handleError(
|
||||
exception as CustomException,
|
||||
response,
|
||||
flatEntityMapsExceptionCodeToHttpStatus(exception.code),
|
||||
);
|
||||
}
|
||||
|
||||
if (exception instanceof FieldMetadataException) {
|
||||
return this.httpExceptionHandlerService.handleError(
|
||||
exception as CustomException,
|
||||
response,
|
||||
fieldMetadataExceptionCodeToHttpStatus(exception.code),
|
||||
);
|
||||
}
|
||||
|
||||
return this.httpExceptionHandlerService.handleError(
|
||||
exception,
|
||||
response,
|
||||
500,
|
||||
);
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
|
||||
|
||||
export const fieldMetadataExceptionCodeToHttpStatus = (
|
||||
code: keyof typeof FieldMetadataExceptionCode,
|
||||
): number => {
|
||||
switch (code) {
|
||||
case FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND:
|
||||
return 404;
|
||||
case FieldMetadataExceptionCode.FIELD_ALREADY_EXISTS:
|
||||
return 409;
|
||||
case FieldMetadataExceptionCode.FIELD_MUTATION_NOT_ALLOWED:
|
||||
return 403;
|
||||
case FieldMetadataExceptionCode.INVALID_FIELD_INPUT:
|
||||
case FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND:
|
||||
case FieldMetadataExceptionCode.APPLICATION_NOT_FOUND:
|
||||
case FieldMetadataExceptionCode.FIELD_METADATA_RELATION_NOT_ENABLED:
|
||||
case FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED:
|
||||
case FieldMetadataExceptionCode.UNCOVERED_FIELD_METADATA_TYPE_VALIDATION:
|
||||
case FieldMetadataExceptionCode.LABEL_IDENTIFIER_FIELD_METADATA_ID_NOT_FOUND:
|
||||
case FieldMetadataExceptionCode.RESERVED_KEYWORD:
|
||||
case FieldMetadataExceptionCode.NOT_AVAILABLE:
|
||||
case FieldMetadataExceptionCode.NAME_NOT_SYNCED_WITH_LABEL:
|
||||
return 400;
|
||||
case FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR:
|
||||
return 500;
|
||||
default:
|
||||
return assertUnreachable(code);
|
||||
}
|
||||
};
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { type FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
|
||||
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
|
||||
export const fromFieldMetadataEntityToFieldMetadataDto = (
|
||||
entity: FieldMetadataEntity,
|
||||
): FieldMetadataDTO => ({
|
||||
id: entity.id,
|
||||
universalIdentifier: entity.universalIdentifier,
|
||||
applicationId: entity.applicationId,
|
||||
type: entity.type,
|
||||
name: entity.name,
|
||||
label: entity.label,
|
||||
description: entity.description ?? undefined,
|
||||
icon: entity.icon ?? undefined,
|
||||
standardOverrides: entity.standardOverrides ?? undefined,
|
||||
isCustom: entity.isCustom,
|
||||
isActive: entity.isActive,
|
||||
isSystem: entity.isSystem,
|
||||
isUIReadOnly: entity.isUIReadOnly,
|
||||
isNullable: entity.isNullable ?? false,
|
||||
isUnique: entity.isUnique ?? false,
|
||||
defaultValue: entity.defaultValue ?? undefined,
|
||||
options: entity.options ?? undefined,
|
||||
settings: entity.settings ?? undefined,
|
||||
workspaceId: entity.workspaceId,
|
||||
objectMetadataId: entity.objectMetadataId,
|
||||
isLabelSyncedWithName: entity.isLabelSyncedWithName,
|
||||
morphId: entity.morphId ?? undefined,
|
||||
createdAt: entity.createdAt,
|
||||
updatedAt: entity.updatedAt,
|
||||
});
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { type RestCursorPageInfo } from 'src/engine/api/rest/metadata/utils/paginate-by-id-cursor.util';
|
||||
import { type FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
|
||||
|
||||
export const toLegacyFieldMetadataListResponse = ({
|
||||
data,
|
||||
pageInfo,
|
||||
totalCount,
|
||||
}: {
|
||||
data: FieldMetadataDTO[];
|
||||
pageInfo: RestCursorPageInfo;
|
||||
totalCount: number;
|
||||
}) => ({
|
||||
data: { fields: data },
|
||||
pageInfo,
|
||||
totalCount,
|
||||
});
|
||||
|
||||
export const toLegacyFieldMetadataFindOneResponse = (
|
||||
field: FieldMetadataDTO,
|
||||
) => ({ data: { field } });
|
||||
|
||||
export const toLegacyFieldMetadataCreateResponse = (
|
||||
field: FieldMetadataDTO,
|
||||
) => ({ data: { createOneField: field } });
|
||||
|
||||
export const toLegacyFieldMetadataUpdateResponse = (
|
||||
field: FieldMetadataDTO,
|
||||
) => ({ data: { updateOneField: field } });
|
||||
|
||||
export const toLegacyFieldMetadataDeleteResponse = (
|
||||
field: FieldMetadataDTO,
|
||||
) => ({ data: { deleteOneField: field } });
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import { FlatEntityMapsExceptionCode } from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception';
|
||||
|
||||
export const flatEntityMapsExceptionCodeToHttpStatus = (
|
||||
code: keyof typeof FlatEntityMapsExceptionCode,
|
||||
): number => {
|
||||
switch (code) {
|
||||
case FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND:
|
||||
return 404;
|
||||
case FlatEntityMapsExceptionCode.ENTITY_ALREADY_EXISTS:
|
||||
return 409;
|
||||
case FlatEntityMapsExceptionCode.RELATION_UNIVERSAL_IDENTIFIER_NOT_FOUND:
|
||||
case FlatEntityMapsExceptionCode.ENTITY_MALFORMED:
|
||||
case FlatEntityMapsExceptionCode.INTERNAL_SERVER_ERROR:
|
||||
return 500;
|
||||
default:
|
||||
return assertUnreachable(code);
|
||||
}
|
||||
};
|
||||
+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),
|
||||
};
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { type FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
|
||||
import { type ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
|
||||
|
||||
export type ObjectMetadataWithFieldsDTO = ObjectMetadataDTO & {
|
||||
fields: FieldMetadataDTO[];
|
||||
};
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
type ArgumentsHost,
|
||||
Catch,
|
||||
type ExceptionFilter,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { type Response } from 'express';
|
||||
import { SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
|
||||
import { RestInputRequestParserException } from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception';
|
||||
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
|
||||
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
import { FlatEntityMapsException } from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception';
|
||||
import { flatEntityMapsExceptionCodeToHttpStatus } from 'src/engine/metadata-modules/flat-entity/utils/flat-entity-maps-exception-code-to-http-status.util';
|
||||
import { ObjectMetadataException } from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
|
||||
import { objectMetadataExceptionCodeToHttpStatus } from 'src/engine/metadata-modules/object-metadata/utils/object-metadata-exception-code-to-http-status.util';
|
||||
import { InvalidMetadataException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception';
|
||||
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
|
||||
import { workspaceMigrationBuilderRestApiExceptionHandler } from 'src/engine/workspace-manager/workspace-migration/interceptors/utils/workspace-migration-builder-rest-api-exception-handler.util';
|
||||
import { type CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
type CaughtException =
|
||||
| ObjectMetadataException
|
||||
| InvalidMetadataException
|
||||
| WorkspaceMigrationBuilderException
|
||||
| RestInputRequestParserException
|
||||
| FlatEntityMapsException;
|
||||
|
||||
@Injectable()
|
||||
@Catch(
|
||||
ObjectMetadataException,
|
||||
InvalidMetadataException,
|
||||
WorkspaceMigrationBuilderException,
|
||||
RestInputRequestParserException,
|
||||
FlatEntityMapsException,
|
||||
)
|
||||
export class ObjectMetadataRestApiExceptionFilter implements ExceptionFilter {
|
||||
constructor(
|
||||
private readonly httpExceptionHandlerService: HttpExceptionHandlerService,
|
||||
private readonly i18nService: I18nService,
|
||||
) {}
|
||||
|
||||
catch(exception: CaughtException, host: ArgumentsHost) {
|
||||
const response = host.switchToHttp().getResponse<Response>();
|
||||
|
||||
if (exception instanceof WorkspaceMigrationBuilderException) {
|
||||
return workspaceMigrationBuilderRestApiExceptionHandler({
|
||||
exception,
|
||||
response,
|
||||
i18n: this.i18nService.getI18nInstance(SOURCE_LOCALE),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
exception instanceof InvalidMetadataException ||
|
||||
exception instanceof RestInputRequestParserException
|
||||
) {
|
||||
return this.httpExceptionHandlerService.handleError(
|
||||
exception as CustomException,
|
||||
response,
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if (exception instanceof FlatEntityMapsException) {
|
||||
return this.httpExceptionHandlerService.handleError(
|
||||
exception as CustomException,
|
||||
response,
|
||||
flatEntityMapsExceptionCodeToHttpStatus(exception.code),
|
||||
);
|
||||
}
|
||||
|
||||
if (exception instanceof ObjectMetadataException) {
|
||||
return this.httpExceptionHandlerService.handleError(
|
||||
exception as CustomException,
|
||||
response,
|
||||
objectMetadataExceptionCodeToHttpStatus(exception.code),
|
||||
);
|
||||
}
|
||||
|
||||
return this.httpExceptionHandlerService.handleError(
|
||||
exception,
|
||||
response,
|
||||
500,
|
||||
);
|
||||
}
|
||||
}
|
||||
+6
@@ -10,6 +10,7 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
||||
|
||||
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
@@ -17,6 +18,7 @@ import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
|
||||
import { FlatFieldMetadataTypeValidatorService } from 'src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-type-validator.service';
|
||||
import { IndexMetadataModule } from 'src/engine/metadata-modules/index-metadata/index-metadata.module';
|
||||
import { ObjectMetadataController } from 'src/engine/metadata-modules/object-metadata/controllers/object-metadata.controller';
|
||||
import { CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
|
||||
import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
|
||||
import { UpdateObjectPayload } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
|
||||
@@ -40,6 +42,9 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TokenModule,
|
||||
WorkspaceCacheStorageModule,
|
||||
FeatureFlagModule,
|
||||
NestjsQueryGraphQLModule.forFeature({
|
||||
imports: [
|
||||
TypeORMModule,
|
||||
@@ -89,6 +94,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
|
||||
],
|
||||
}),
|
||||
],
|
||||
controllers: [ObjectMetadataController],
|
||||
providers: [
|
||||
ObjectMetadataService,
|
||||
ObjectMetadataResolver,
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { type ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
|
||||
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
|
||||
export const fromObjectMetadataEntityToObjectMetadataDto = (
|
||||
entity: ObjectMetadataEntity,
|
||||
): ObjectMetadataDTO => ({
|
||||
id: entity.id,
|
||||
universalIdentifier: entity.universalIdentifier,
|
||||
applicationId: entity.applicationId,
|
||||
nameSingular: entity.nameSingular,
|
||||
namePlural: entity.namePlural,
|
||||
labelSingular: entity.labelSingular,
|
||||
labelPlural: entity.labelPlural,
|
||||
description: entity.description ?? undefined,
|
||||
icon: entity.icon ?? undefined,
|
||||
color: entity.color ?? undefined,
|
||||
shortcut: entity.shortcut ?? undefined,
|
||||
standardOverrides: entity.standardOverrides ?? undefined,
|
||||
isCustom: entity.isCustom,
|
||||
isRemote: entity.isRemote,
|
||||
isActive: entity.isActive,
|
||||
isSystem: entity.isSystem,
|
||||
isUIReadOnly: entity.isUIReadOnly,
|
||||
isSearchable: entity.isSearchable,
|
||||
isLabelSyncedWithName: entity.isLabelSyncedWithName,
|
||||
workspaceId: entity.workspaceId,
|
||||
labelIdentifierFieldMetadataId:
|
||||
entity.labelIdentifierFieldMetadataId ?? undefined,
|
||||
imageIdentifierFieldMetadataId:
|
||||
entity.imageIdentifierFieldMetadataId ?? undefined,
|
||||
duplicateCriteria: entity.duplicateCriteria ?? undefined,
|
||||
createdAt: entity.createdAt,
|
||||
updatedAt: entity.updatedAt,
|
||||
});
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import { ObjectMetadataExceptionCode } from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
|
||||
|
||||
export const objectMetadataExceptionCodeToHttpStatus = (
|
||||
code: ObjectMetadataExceptionCode,
|
||||
): number => {
|
||||
switch (code) {
|
||||
case ObjectMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND:
|
||||
return 404;
|
||||
case ObjectMetadataExceptionCode.OBJECT_ALREADY_EXISTS:
|
||||
return 409;
|
||||
case ObjectMetadataExceptionCode.OBJECT_MUTATION_NOT_ALLOWED:
|
||||
case ObjectMetadataExceptionCode.NAME_CONFLICT:
|
||||
return 403;
|
||||
case ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT:
|
||||
case ObjectMetadataExceptionCode.MISSING_SYSTEM_FIELD:
|
||||
case ObjectMetadataExceptionCode.INVALID_SYSTEM_FIELD:
|
||||
case ObjectMetadataExceptionCode.MISSING_CUSTOM_OBJECT_DEFAULT_LABEL_IDENTIFIER_FIELD:
|
||||
case ObjectMetadataExceptionCode.APPLICATION_NOT_FOUND:
|
||||
return 400;
|
||||
case ObjectMetadataExceptionCode.INTERNAL_SERVER_ERROR:
|
||||
case ObjectMetadataExceptionCode.INVALID_ORM_OUTPUT:
|
||||
return 500;
|
||||
default:
|
||||
return assertUnreachable(code);
|
||||
}
|
||||
};
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { type RestCursorPageInfo } from 'src/engine/api/rest/metadata/utils/paginate-by-id-cursor.util';
|
||||
import { type ObjectMetadataWithFieldsDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata-with-fields.dto';
|
||||
import { type ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
|
||||
|
||||
export const toLegacyObjectMetadataListResponse = ({
|
||||
data,
|
||||
pageInfo,
|
||||
totalCount,
|
||||
}: {
|
||||
data: ObjectMetadataWithFieldsDTO[];
|
||||
pageInfo: RestCursorPageInfo;
|
||||
totalCount: number;
|
||||
}) => ({
|
||||
data: { objects: data },
|
||||
pageInfo,
|
||||
totalCount,
|
||||
});
|
||||
|
||||
export const toLegacyObjectMetadataFindOneResponse = (
|
||||
object: ObjectMetadataWithFieldsDTO,
|
||||
) => ({ data: { object } });
|
||||
|
||||
export const toLegacyObjectMetadataCreateResponse = (
|
||||
object: ObjectMetadataWithFieldsDTO,
|
||||
) => ({ data: { createOneObject: object } });
|
||||
|
||||
export const toLegacyObjectMetadataUpdateResponse = (
|
||||
object: ObjectMetadataWithFieldsDTO,
|
||||
) => ({ data: { updateOneObject: object } });
|
||||
|
||||
export const toLegacyObjectMetadataDeleteResponse = (
|
||||
object: ObjectMetadataDTO,
|
||||
) => ({ data: { deleteOneObject: object } });
|
||||
+1
@@ -240,6 +240,7 @@ describe('WorkspaceEntityManager', () => {
|
||||
IS_EMAIL_GROUP_ENABLED: false,
|
||||
IS_JUNCTION_RELATIONS_ENABLED: false,
|
||||
IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED: false,
|
||||
IS_REST_METADATA_API_NEW_FORMAT_DIRECT: false,
|
||||
[FeatureFlagKey.IS_BILLING_V2_ENABLED]: false,
|
||||
},
|
||||
userWorkspaceRoleMap: {},
|
||||
|
||||
+1
@@ -3,5 +3,6 @@ import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
export const DEFAULT_FEATURE_FLAGS = [
|
||||
FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED,
|
||||
FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED,
|
||||
FeatureFlagKey.IS_REST_METADATA_API_NEW_FORMAT_DIRECT,
|
||||
FeatureFlagKey.IS_BILLING_V2_ENABLED,
|
||||
] as const satisfies FeatureFlagKey[];
|
||||
|
||||
+456
@@ -0,0 +1,456 @@
|
||||
import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
|
||||
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
|
||||
import {
|
||||
cleanupTestField,
|
||||
cleanupTestObject,
|
||||
createTestFieldViaGraphql,
|
||||
createTestObjectViaGraphql,
|
||||
extractMetadataItemPayload,
|
||||
extractMetadataListPayload,
|
||||
NON_EXISTENT_UUID,
|
||||
uniqueSuffix,
|
||||
} from 'test/integration/rest/utils/metadata-rest-api.util';
|
||||
import {
|
||||
assertRestApiErrorNotFoundResponse,
|
||||
assertRestApiErrorResponse,
|
||||
assertRestApiSuccessfulResponse,
|
||||
} from 'test/integration/rest/utils/rest-test-assertions.util';
|
||||
import { FeatureFlagKey, FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
type FieldShape = {
|
||||
id: string;
|
||||
name?: string;
|
||||
label?: string;
|
||||
objectMetadataId?: string;
|
||||
};
|
||||
|
||||
describe.each([
|
||||
['new format', true],
|
||||
['legacy format', false],
|
||||
] as const)('Field Metadata REST API (%s)', (_shapeLabel, isNewFormat) => {
|
||||
let parentObjectId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await updateFeatureFlag({
|
||||
featureFlag: FeatureFlagKey.IS_REST_METADATA_API_NEW_FORMAT_DIRECT,
|
||||
value: isNewFormat,
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
const { id } = await createTestObjectViaGraphql();
|
||||
|
||||
parentObjectId = id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestObject(parentObjectId);
|
||||
await updateFeatureFlag({
|
||||
featureFlag: FeatureFlagKey.IS_REST_METADATA_API_NEW_FORMAT_DIRECT,
|
||||
value: false,
|
||||
expectToFail: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /metadata/fields', () => {
|
||||
const seededIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const { id } = await createTestFieldViaGraphql(parentObjectId);
|
||||
|
||||
seededIds.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all(seededIds.map(cleanupTestField));
|
||||
seededIds.length = 0;
|
||||
});
|
||||
|
||||
it('returns the expected envelope shape', async () => {
|
||||
const response = await makeRestAPIRequest({
|
||||
method: 'get',
|
||||
path: '/metadata/fields',
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiSuccessfulResponse(response);
|
||||
if (isNewFormat) {
|
||||
expect(response.body).not.toHaveProperty('data.fields');
|
||||
expect(Array.isArray(response.body.data)).toBe(true);
|
||||
} else {
|
||||
expect(Array.isArray(response.body.data?.fields)).toBe(true);
|
||||
}
|
||||
expect(response.body).toHaveProperty('pageInfo.hasNextPage');
|
||||
expect(response.body).toHaveProperty('pageInfo.startCursor');
|
||||
expect(response.body).toHaveProperty('pageInfo.endCursor');
|
||||
expect(typeof response.body.totalCount).toBe('number');
|
||||
expect(response.body.totalCount).toBeGreaterThanOrEqual(seededIds.length);
|
||||
});
|
||||
|
||||
it('respects limit and surfaces hasNextPage', async () => {
|
||||
const response = await makeRestAPIRequest({
|
||||
method: 'get',
|
||||
path: '/metadata/fields?limit=1',
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiSuccessfulResponse(response);
|
||||
const { items, pageInfo } = extractMetadataListPayload<FieldShape>(
|
||||
response.body,
|
||||
'fields',
|
||||
);
|
||||
|
||||
expect(items.length).toBe(1);
|
||||
expect(pageInfo.hasNextPage).toBe(true);
|
||||
});
|
||||
|
||||
it('paginates forward with starting_after without overlap', async () => {
|
||||
const firstPage = await makeRestAPIRequest({
|
||||
method: 'get',
|
||||
path: '/metadata/fields?limit=2',
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
const firstPayload = extractMetadataListPayload<FieldShape>(
|
||||
firstPage.body,
|
||||
'fields',
|
||||
);
|
||||
|
||||
const secondPage = await makeRestAPIRequest({
|
||||
method: 'get',
|
||||
path: `/metadata/fields?limit=2&starting_after=${firstPayload.pageInfo.endCursor}`,
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiSuccessfulResponse(secondPage);
|
||||
const secondPayload = extractMetadataListPayload<FieldShape>(
|
||||
secondPage.body,
|
||||
'fields',
|
||||
);
|
||||
|
||||
const firstIds = firstPayload.items.map((f) => f.id);
|
||||
const secondIds = secondPayload.items.map((f) => f.id);
|
||||
|
||||
expect(firstIds.some((id) => secondIds.includes(id))).toBe(false);
|
||||
});
|
||||
|
||||
it('paginates backward with ending_before', async () => {
|
||||
const firstPage = await makeRestAPIRequest({
|
||||
method: 'get',
|
||||
path: '/metadata/fields?limit=2',
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
const firstPayload = extractMetadataListPayload<FieldShape>(
|
||||
firstPage.body,
|
||||
'fields',
|
||||
);
|
||||
|
||||
const secondPage = await makeRestAPIRequest({
|
||||
method: 'get',
|
||||
path: `/metadata/fields?limit=2&starting_after=${firstPayload.pageInfo.endCursor}`,
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
const secondPayload = extractMetadataListPayload<FieldShape>(
|
||||
secondPage.body,
|
||||
'fields',
|
||||
);
|
||||
|
||||
const backPage = await makeRestAPIRequest({
|
||||
method: 'get',
|
||||
path: `/metadata/fields?limit=2&ending_before=${secondPayload.pageInfo.startCursor}`,
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiSuccessfulResponse(backPage);
|
||||
const backPayload = extractMetadataListPayload<FieldShape>(
|
||||
backPage.body,
|
||||
'fields',
|
||||
);
|
||||
|
||||
const firstIds = firstPayload.items.map((f) => f.id);
|
||||
const backIds = backPayload.items.map((f) => f.id);
|
||||
|
||||
expect(backIds.every((id) => firstIds.includes(id))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects combining starting_after and ending_before with 400', async () => {
|
||||
const response = await makeRestAPIRequest({
|
||||
method: 'get',
|
||||
path: `/metadata/fields?starting_after=${NON_EXISTENT_UUID}&ending_before=${NON_EXISTENT_UUID}`,
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiErrorResponse(response, 400);
|
||||
});
|
||||
|
||||
it('keeps totalCount stable across pages', async () => {
|
||||
const firstPage = await makeRestAPIRequest({
|
||||
method: 'get',
|
||||
path: '/metadata/fields?limit=2',
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
const firstPayload = extractMetadataListPayload<FieldShape>(
|
||||
firstPage.body,
|
||||
'fields',
|
||||
);
|
||||
|
||||
const secondPage = await makeRestAPIRequest({
|
||||
method: 'get',
|
||||
path: `/metadata/fields?limit=2&starting_after=${firstPayload.pageInfo.endCursor}`,
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
expect(firstPage.body.totalCount).toBe(secondPage.body.totalCount);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /metadata/fields/:id', () => {
|
||||
let testFieldId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { id } = await createTestFieldViaGraphql(parentObjectId);
|
||||
|
||||
testFieldId = id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestField(testFieldId);
|
||||
});
|
||||
|
||||
it('returns the field with expected envelope', async () => {
|
||||
const response = await makeRestAPIRequest({
|
||||
method: 'get',
|
||||
path: `/metadata/fields/${testFieldId}`,
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiSuccessfulResponse(response);
|
||||
const field = extractMetadataItemPayload<FieldShape>(
|
||||
response.body,
|
||||
'field',
|
||||
);
|
||||
|
||||
expect(field.id).toBe(testFieldId);
|
||||
expect(field.objectMetadataId).toBe(parentObjectId);
|
||||
if (isNewFormat) {
|
||||
expect(response.body).not.toHaveProperty('data.field');
|
||||
} else {
|
||||
expect(response.body).toHaveProperty('data.field');
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 400 on a malformed UUID', async () => {
|
||||
const response = await makeRestAPIRequest({
|
||||
method: 'get',
|
||||
path: `/metadata/fields/not-a-uuid`,
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiErrorResponse(response, 400);
|
||||
});
|
||||
|
||||
it('returns 404 for an unknown id', async () => {
|
||||
const response = await makeRestAPIRequest({
|
||||
method: 'get',
|
||||
path: `/metadata/fields/${NON_EXISTENT_UUID}`,
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiErrorNotFoundResponse(response);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /metadata/fields', () => {
|
||||
it('creates a field and returns 201', async () => {
|
||||
const suffix = uniqueSuffix();
|
||||
const input = {
|
||||
objectMetadataId: parentObjectId,
|
||||
type: FieldMetadataType.TEXT,
|
||||
name: `postField${suffix}`,
|
||||
label: `Post Field ${suffix}`,
|
||||
isLabelSyncedWithName: false,
|
||||
};
|
||||
|
||||
const response = await makeRestAPIRequest({
|
||||
method: 'post',
|
||||
path: '/metadata/fields',
|
||||
body: input,
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
const field = extractMetadataItemPayload<FieldShape>(
|
||||
response.body,
|
||||
'createOneField',
|
||||
);
|
||||
|
||||
try {
|
||||
assertRestApiSuccessfulResponse(response, 201);
|
||||
expect(field.id).toBeDefined();
|
||||
expect(field.name).toBe(input.name);
|
||||
expect(field.objectMetadataId).toBe(parentObjectId);
|
||||
if (isNewFormat) {
|
||||
expect(response.body).not.toHaveProperty('data.createOneField');
|
||||
} else {
|
||||
expect(response.body).toHaveProperty('data.createOneField');
|
||||
}
|
||||
} finally {
|
||||
if (field.id) {
|
||||
await cleanupTestField(field.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 400 on duplicate name within an object', async () => {
|
||||
const { id, input } = await createTestFieldViaGraphql(parentObjectId);
|
||||
|
||||
try {
|
||||
const response = await makeRestAPIRequest({
|
||||
method: 'post',
|
||||
path: '/metadata/fields',
|
||||
body: input,
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiErrorResponse(response, 400);
|
||||
} finally {
|
||||
await cleanupTestField(id);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 400 on invalid input', async () => {
|
||||
const response = await makeRestAPIRequest({
|
||||
method: 'post',
|
||||
path: '/metadata/fields',
|
||||
body: { name: '' },
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiErrorResponse(response, 400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /metadata/fields/:id', () => {
|
||||
let testFieldId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const { id } = await createTestFieldViaGraphql(parentObjectId);
|
||||
|
||||
testFieldId = id;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTestField(testFieldId);
|
||||
});
|
||||
|
||||
it('updates and returns the field', async () => {
|
||||
const newLabel = `Updated Field ${uniqueSuffix()}`;
|
||||
|
||||
const response = await makeRestAPIRequest({
|
||||
method: 'patch',
|
||||
path: `/metadata/fields/${testFieldId}`,
|
||||
body: { label: newLabel },
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiSuccessfulResponse(response);
|
||||
const field = extractMetadataItemPayload<FieldShape>(
|
||||
response.body,
|
||||
'updateOneField',
|
||||
);
|
||||
|
||||
expect(field.id).toBe(testFieldId);
|
||||
expect(field.label).toBe(newLabel);
|
||||
if (isNewFormat) {
|
||||
expect(response.body).not.toHaveProperty('data.updateOneField');
|
||||
} else {
|
||||
expect(response.body).toHaveProperty('data.updateOneField');
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 400 for an unknown id', async () => {
|
||||
const response = await makeRestAPIRequest({
|
||||
method: 'patch',
|
||||
path: `/metadata/fields/${NON_EXISTENT_UUID}`,
|
||||
body: { label: 'Whatever' },
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiErrorResponse(response, 400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /metadata/fields/:id', () => {
|
||||
it('behaves equivalently to PATCH', async () => {
|
||||
const { id } = await createTestFieldViaGraphql(parentObjectId);
|
||||
|
||||
try {
|
||||
const newLabel = `PutField ${uniqueSuffix()}`;
|
||||
|
||||
const response = await makeRestAPIRequest({
|
||||
method: 'put',
|
||||
path: `/metadata/fields/${id}`,
|
||||
body: { label: newLabel },
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiSuccessfulResponse(response);
|
||||
const field = extractMetadataItemPayload<FieldShape>(
|
||||
response.body,
|
||||
'updateOneField',
|
||||
);
|
||||
|
||||
expect(field.id).toBe(id);
|
||||
expect(field.label).toBe(newLabel);
|
||||
} finally {
|
||||
await cleanupTestField(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /metadata/fields/:id', () => {
|
||||
it('deletes the field and returns the deleted resource', async () => {
|
||||
const { id } = await createTestFieldViaGraphql(parentObjectId);
|
||||
|
||||
const response = await makeRestAPIRequest({
|
||||
method: 'delete',
|
||||
path: `/metadata/fields/${id}`,
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiSuccessfulResponse(response);
|
||||
const deleted = extractMetadataItemPayload<FieldShape>(
|
||||
response.body,
|
||||
'deleteOneField',
|
||||
);
|
||||
|
||||
expect(deleted.id).toBe(id);
|
||||
if (isNewFormat) {
|
||||
expect(response.body).not.toHaveProperty('data.deleteOneField');
|
||||
} else {
|
||||
expect(response.body).toHaveProperty('data.deleteOneField');
|
||||
}
|
||||
|
||||
const getResponse = await makeRestAPIRequest({
|
||||
method: 'get',
|
||||
path: `/metadata/fields/${id}`,
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiErrorNotFoundResponse(getResponse);
|
||||
});
|
||||
|
||||
it('returns 404 for an unknown id', async () => {
|
||||
const response = await makeRestAPIRequest({
|
||||
method: 'delete',
|
||||
path: `/metadata/fields/${NON_EXISTENT_UUID}`,
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiErrorNotFoundResponse(response);
|
||||
});
|
||||
});
|
||||
});
|
||||
+498
@@ -0,0 +1,498 @@
|
||||
import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
|
||||
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
|
||||
import {
|
||||
cleanupTestObject,
|
||||
createTestObjectViaGraphql,
|
||||
extractMetadataItemPayload,
|
||||
extractMetadataListPayload,
|
||||
NON_EXISTENT_UUID,
|
||||
uniqueSuffix,
|
||||
} from 'test/integration/rest/utils/metadata-rest-api.util';
|
||||
import {
|
||||
assertRestApiErrorNotFoundResponse,
|
||||
assertRestApiErrorResponse,
|
||||
assertRestApiSuccessfulResponse,
|
||||
} from 'test/integration/rest/utils/rest-test-assertions.util';
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
|
||||
type ObjectShape = { id: string; fields: unknown[]; labelSingular?: string };
|
||||
|
||||
describe.each([
|
||||
['new format', true],
|
||||
['legacy format', false],
|
||||
] as const)('Object Metadata REST API (%s)', (_shapeLabel, isNewFormat) => {
|
||||
beforeAll(async () => {
|
||||
await updateFeatureFlag({
|
||||
featureFlag: FeatureFlagKey.IS_REST_METADATA_API_NEW_FORMAT_DIRECT,
|
||||
value: isNewFormat,
|
||||
expectToFail: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await updateFeatureFlag({
|
||||
featureFlag: FeatureFlagKey.IS_REST_METADATA_API_NEW_FORMAT_DIRECT,
|
||||
value: false,
|
||||
expectToFail: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /metadata/objects', () => {
|
||||
const seededIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const { id } = await createTestObjectViaGraphql();
|
||||
|
||||
seededIds.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all(seededIds.map(cleanupTestObject));
|
||||
seededIds.length = 0;
|
||||
});
|
||||
|
||||
it('returns the expected envelope shape', async () => {
|
||||
const response = await makeRestAPIRequest({
|
||||
method: 'get',
|
||||
path: '/metadata/objects',
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiSuccessfulResponse(response);
|
||||
if (isNewFormat) {
|
||||
expect(response.body).not.toHaveProperty('data.objects');
|
||||
expect(Array.isArray(response.body.data)).toBe(true);
|
||||
} else {
|
||||
expect(Array.isArray(response.body.data?.objects)).toBe(true);
|
||||
}
|
||||
expect(response.body).toHaveProperty('pageInfo.hasNextPage');
|
||||
expect(response.body).toHaveProperty('pageInfo.startCursor');
|
||||
expect(response.body).toHaveProperty('pageInfo.endCursor');
|
||||
expect(typeof response.body.totalCount).toBe('number');
|
||||
expect(response.body.totalCount).toBeGreaterThanOrEqual(seededIds.length);
|
||||
});
|
||||
|
||||
it('inlines fields[] on each object', async () => {
|
||||
const response = await makeRestAPIRequest({
|
||||
method: 'get',
|
||||
path: '/metadata/objects?limit=5',
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiSuccessfulResponse(response);
|
||||
const { items } = extractMetadataListPayload<ObjectShape>(
|
||||
response.body,
|
||||
'objects',
|
||||
);
|
||||
|
||||
for (const object of items) {
|
||||
expect(Array.isArray(object.fields)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('respects limit and surfaces hasNextPage', async () => {
|
||||
const response = await makeRestAPIRequest({
|
||||
method: 'get',
|
||||
path: '/metadata/objects?limit=1',
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiSuccessfulResponse(response);
|
||||
const { items, pageInfo } = extractMetadataListPayload<ObjectShape>(
|
||||
response.body,
|
||||
'objects',
|
||||
);
|
||||
|
||||
expect(items.length).toBe(1);
|
||||
expect(pageInfo.hasNextPage).toBe(true);
|
||||
expect(pageInfo.startCursor).toBe(items[0].id);
|
||||
expect(pageInfo.endCursor).toBe(items[0].id);
|
||||
});
|
||||
|
||||
it('paginates forward with starting_after without overlap', async () => {
|
||||
const firstPage = await makeRestAPIRequest({
|
||||
method: 'get',
|
||||
path: '/metadata/objects?limit=2',
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiSuccessfulResponse(firstPage);
|
||||
const firstPayload = extractMetadataListPayload<ObjectShape>(
|
||||
firstPage.body,
|
||||
'objects',
|
||||
);
|
||||
|
||||
const secondPage = await makeRestAPIRequest({
|
||||
method: 'get',
|
||||
path: `/metadata/objects?limit=2&starting_after=${firstPayload.pageInfo.endCursor}`,
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiSuccessfulResponse(secondPage);
|
||||
const secondPayload = extractMetadataListPayload<ObjectShape>(
|
||||
secondPage.body,
|
||||
'objects',
|
||||
);
|
||||
|
||||
const firstIds = firstPayload.items.map((o) => o.id);
|
||||
const secondIds = secondPayload.items.map((o) => o.id);
|
||||
|
||||
expect(firstIds.some((id) => secondIds.includes(id))).toBe(false);
|
||||
});
|
||||
|
||||
it('paginates backward with ending_before', async () => {
|
||||
const firstPage = await makeRestAPIRequest({
|
||||
method: 'get',
|
||||
path: '/metadata/objects?limit=2',
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
const firstPayload = extractMetadataListPayload<ObjectShape>(
|
||||
firstPage.body,
|
||||
'objects',
|
||||
);
|
||||
|
||||
const secondPage = await makeRestAPIRequest({
|
||||
method: 'get',
|
||||
path: `/metadata/objects?limit=2&starting_after=${firstPayload.pageInfo.endCursor}`,
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
const secondPayload = extractMetadataListPayload<ObjectShape>(
|
||||
secondPage.body,
|
||||
'objects',
|
||||
);
|
||||
|
||||
const backPage = await makeRestAPIRequest({
|
||||
method: 'get',
|
||||
path: `/metadata/objects?limit=2&ending_before=${secondPayload.pageInfo.startCursor}`,
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiSuccessfulResponse(backPage);
|
||||
const backPayload = extractMetadataListPayload<ObjectShape>(
|
||||
backPage.body,
|
||||
'objects',
|
||||
);
|
||||
|
||||
const firstIds = firstPayload.items.map((o) => o.id);
|
||||
const backIds = backPayload.items.map((o) => o.id);
|
||||
|
||||
expect(backIds.every((id) => firstIds.includes(id))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects combining starting_after and ending_before with 400', async () => {
|
||||
const response = await makeRestAPIRequest({
|
||||
method: 'get',
|
||||
path: `/metadata/objects?starting_after=${NON_EXISTENT_UUID}&ending_before=${NON_EXISTENT_UUID}`,
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiErrorResponse(response, 400);
|
||||
});
|
||||
|
||||
it('keeps totalCount stable across pages', async () => {
|
||||
const firstPage = await makeRestAPIRequest({
|
||||
method: 'get',
|
||||
path: '/metadata/objects?limit=2',
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
const firstPayload = extractMetadataListPayload<ObjectShape>(
|
||||
firstPage.body,
|
||||
'objects',
|
||||
);
|
||||
|
||||
const secondPage = await makeRestAPIRequest({
|
||||
method: 'get',
|
||||
path: `/metadata/objects?limit=2&starting_after=${firstPayload.pageInfo.endCursor}`,
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiSuccessfulResponse(firstPage);
|
||||
assertRestApiSuccessfulResponse(secondPage);
|
||||
expect(firstPage.body.totalCount).toBe(secondPage.body.totalCount);
|
||||
});
|
||||
|
||||
it('reports hasNextPage=false when the page covers all results', async () => {
|
||||
const response = await makeRestAPIRequest({
|
||||
method: 'get',
|
||||
path: '/metadata/objects?limit=200',
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiSuccessfulResponse(response);
|
||||
const { items, pageInfo } = extractMetadataListPayload<ObjectShape>(
|
||||
response.body,
|
||||
'objects',
|
||||
);
|
||||
|
||||
expect(items.length).toBe(response.body.totalCount);
|
||||
expect(pageInfo.hasNextPage).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /metadata/objects/:id', () => {
|
||||
let testObjectId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { id } = await createTestObjectViaGraphql();
|
||||
|
||||
testObjectId = id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestObject(testObjectId);
|
||||
});
|
||||
|
||||
it('returns the object with fields[] populated', async () => {
|
||||
const response = await makeRestAPIRequest({
|
||||
method: 'get',
|
||||
path: `/metadata/objects/${testObjectId}`,
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiSuccessfulResponse(response);
|
||||
const object = extractMetadataItemPayload<ObjectShape>(
|
||||
response.body,
|
||||
'object',
|
||||
);
|
||||
|
||||
expect(object.id).toBe(testObjectId);
|
||||
expect(Array.isArray(object.fields)).toBe(true);
|
||||
expect(object.fields.length).toBeGreaterThan(0);
|
||||
if (isNewFormat) {
|
||||
expect(response.body).not.toHaveProperty('data.object');
|
||||
} else {
|
||||
expect(response.body).toHaveProperty('data.object');
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 400 on a malformed UUID', async () => {
|
||||
const response = await makeRestAPIRequest({
|
||||
method: 'get',
|
||||
path: `/metadata/objects/not-a-uuid`,
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiErrorResponse(response, 400);
|
||||
});
|
||||
|
||||
it('returns 404 for an unknown id', async () => {
|
||||
const response = await makeRestAPIRequest({
|
||||
method: 'get',
|
||||
path: `/metadata/objects/${NON_EXISTENT_UUID}`,
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiErrorNotFoundResponse(response);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /metadata/objects', () => {
|
||||
it('creates an object and returns 201 with default fields[]', async () => {
|
||||
const suffix = uniqueSuffix();
|
||||
const input = {
|
||||
nameSingular: `postObj${suffix}`,
|
||||
namePlural: `postObj${suffix}s`,
|
||||
labelSingular: `Post Obj ${suffix}`,
|
||||
labelPlural: `Post Objs ${suffix}`,
|
||||
icon: 'IconTestPipe',
|
||||
isLabelSyncedWithName: false,
|
||||
};
|
||||
|
||||
const response = await makeRestAPIRequest({
|
||||
method: 'post',
|
||||
path: '/metadata/objects',
|
||||
body: input,
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
const object = extractMetadataItemPayload<ObjectShape>(
|
||||
response.body,
|
||||
'createOneObject',
|
||||
);
|
||||
|
||||
try {
|
||||
assertRestApiSuccessfulResponse(response, 201);
|
||||
expect(object.id).toBeDefined();
|
||||
expect(
|
||||
(object as ObjectShape & { nameSingular: string }).nameSingular,
|
||||
).toBe(input.nameSingular);
|
||||
expect(Array.isArray(object.fields)).toBe(true);
|
||||
expect(object.fields.length).toBeGreaterThan(0);
|
||||
if (isNewFormat) {
|
||||
expect(response.body).not.toHaveProperty('data.createOneObject');
|
||||
} else {
|
||||
expect(response.body).toHaveProperty('data.createOneObject');
|
||||
}
|
||||
} finally {
|
||||
if (object.id) {
|
||||
await cleanupTestObject(object.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 400 on duplicate nameSingular', async () => {
|
||||
const { id, input } = await createTestObjectViaGraphql();
|
||||
|
||||
try {
|
||||
const response = await makeRestAPIRequest({
|
||||
method: 'post',
|
||||
path: '/metadata/objects',
|
||||
body: input,
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiErrorResponse(response, 400);
|
||||
} finally {
|
||||
await cleanupTestObject(id);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 400 on invalid input', async () => {
|
||||
const response = await makeRestAPIRequest({
|
||||
method: 'post',
|
||||
path: '/metadata/objects',
|
||||
body: { nameSingular: '' },
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiErrorResponse(response, 400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /metadata/objects/:id', () => {
|
||||
let testObjectId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const { id } = await createTestObjectViaGraphql();
|
||||
|
||||
testObjectId = id;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTestObject(testObjectId);
|
||||
});
|
||||
|
||||
it('updates and returns the object with fields[]', async () => {
|
||||
const newLabel = `Updated ${uniqueSuffix()}`;
|
||||
|
||||
const response = await makeRestAPIRequest({
|
||||
method: 'patch',
|
||||
path: `/metadata/objects/${testObjectId}`,
|
||||
body: { labelSingular: newLabel },
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiSuccessfulResponse(response);
|
||||
const object = extractMetadataItemPayload<ObjectShape>(
|
||||
response.body,
|
||||
'updateOneObject',
|
||||
);
|
||||
|
||||
expect(object.id).toBe(testObjectId);
|
||||
expect(object.labelSingular).toBe(newLabel);
|
||||
expect(Array.isArray(object.fields)).toBe(true);
|
||||
if (isNewFormat) {
|
||||
expect(response.body).not.toHaveProperty('data.updateOneObject');
|
||||
} else {
|
||||
expect(response.body).toHaveProperty('data.updateOneObject');
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 404 for an unknown id', async () => {
|
||||
const response = await makeRestAPIRequest({
|
||||
method: 'patch',
|
||||
path: `/metadata/objects/${NON_EXISTENT_UUID}`,
|
||||
body: { labelSingular: 'Whatever' },
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiErrorNotFoundResponse(response);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /metadata/objects/:id', () => {
|
||||
it('behaves equivalently to PATCH', async () => {
|
||||
const { id } = await createTestObjectViaGraphql();
|
||||
|
||||
try {
|
||||
const newLabel = `PutUpdate ${uniqueSuffix()}`;
|
||||
|
||||
const response = await makeRestAPIRequest({
|
||||
method: 'put',
|
||||
path: `/metadata/objects/${id}`,
|
||||
body: { labelSingular: newLabel },
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiSuccessfulResponse(response);
|
||||
const object = extractMetadataItemPayload<ObjectShape>(
|
||||
response.body,
|
||||
'updateOneObject',
|
||||
);
|
||||
|
||||
expect(object.id).toBe(id);
|
||||
expect(object.labelSingular).toBe(newLabel);
|
||||
expect(Array.isArray(object.fields)).toBe(true);
|
||||
} finally {
|
||||
await cleanupTestObject(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /metadata/objects/:id', () => {
|
||||
it('deletes the object and returns the deleted resource', async () => {
|
||||
const { id } = await createTestObjectViaGraphql();
|
||||
|
||||
const patchResponse = await makeRestAPIRequest({
|
||||
method: 'patch',
|
||||
path: `/metadata/objects/${id}`,
|
||||
body: { isActive: false },
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiSuccessfulResponse(patchResponse);
|
||||
|
||||
const deleteResponse = await makeRestAPIRequest({
|
||||
method: 'delete',
|
||||
path: `/metadata/objects/${id}`,
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiSuccessfulResponse(deleteResponse);
|
||||
const deleted = extractMetadataItemPayload<{ id: string }>(
|
||||
deleteResponse.body,
|
||||
'deleteOneObject',
|
||||
);
|
||||
|
||||
expect(deleted.id).toBe(id);
|
||||
if (isNewFormat) {
|
||||
expect(deleteResponse.body).not.toHaveProperty('data.deleteOneObject');
|
||||
} else {
|
||||
expect(deleteResponse.body).toHaveProperty('data.deleteOneObject');
|
||||
}
|
||||
|
||||
const getResponse = await makeRestAPIRequest({
|
||||
method: 'get',
|
||||
path: `/metadata/objects/${id}`,
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiErrorNotFoundResponse(getResponse);
|
||||
});
|
||||
|
||||
it('returns 404 for an unknown id', async () => {
|
||||
const response = await makeRestAPIRequest({
|
||||
method: 'delete',
|
||||
path: `/metadata/objects/${NON_EXISTENT_UUID}`,
|
||||
bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
});
|
||||
|
||||
assertRestApiErrorNotFoundResponse(response);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
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;
|
||||
};
|
||||
@@ -9,4 +9,5 @@ export enum FeatureFlagKey {
|
||||
IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED',
|
||||
IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED = 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED',
|
||||
IS_BILLING_V2_ENABLED = 'IS_BILLING_V2_ENABLED',
|
||||
IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user