fix: migrate webhook and API key REST endpoints to core schema (#13318)
## Problem
After migrating webhooks and API keys from workspace to core level, REST
API endpoints were still creating entities in workspace schema
(`workspace_*`) instead of core schema, causing webhooks to not fire.
## Solution
- Added dedicated REST controllers for webhooks (`/rest/webhooks`) and
API keys (`/rest/apiKeys`)
- Updated dynamic controller to block workspace-gated entities from
being processed
- Fixed OpenAPI documentation to exclude these endpoints from playground
- Ensured return formats match GraphQL resolvers exactly
## Testing
✅ All endpoints tested with provided auth token - webhooks and API keys
now correctly stored in `core` schema
This commit is contained in:
@ -17,10 +17,9 @@ import {
|
|||||||
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||||
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
|
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
|
||||||
import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage';
|
|
||||||
import { getResolverName } from 'src/engine/utils/get-resolver-name.util';
|
import { getResolverName } from 'src/engine/utils/get-resolver-name.util';
|
||||||
import { standardObjectMetadataDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects';
|
import { standardObjectMetadataDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects';
|
||||||
import { isGatedAndNotEnabled } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/is-gate-and-not-enabled.util';
|
import { shouldExcludeFromWorkspaceApi } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/should-exclude-from-workspace-api.util';
|
||||||
|
|
||||||
import { CreateManyResolverFactory } from './factories/create-many-resolver.factory';
|
import { CreateManyResolverFactory } from './factories/create-many-resolver.factory';
|
||||||
import { CreateOneResolverFactory } from './factories/create-one-resolver.factory';
|
import { CreateOneResolverFactory } from './factories/create-one-resolver.factory';
|
||||||
@ -100,27 +99,14 @@ export class WorkspaceResolverFactory {
|
|||||||
for (const objectMetadata of Object.values(objectMetadataMaps.byId).filter(
|
for (const objectMetadata of Object.values(objectMetadataMaps.byId).filter(
|
||||||
isDefined,
|
isDefined,
|
||||||
)) {
|
)) {
|
||||||
const workspaceEntity = standardObjectMetadataDefinitions.find(
|
if (
|
||||||
(entity) => {
|
shouldExcludeFromWorkspaceApi(
|
||||||
const entityMetadata = metadataArgsStorage.filterEntities(entity);
|
objectMetadata,
|
||||||
|
standardObjectMetadataDefinitions,
|
||||||
return entityMetadata?.standardId === objectMetadata.standardId;
|
workspaceFeatureFlagsMap,
|
||||||
},
|
)
|
||||||
);
|
) {
|
||||||
|
continue;
|
||||||
if (workspaceEntity) {
|
|
||||||
const entityMetadata =
|
|
||||||
metadataArgsStorage.filterEntities(workspaceEntity);
|
|
||||||
|
|
||||||
if (
|
|
||||||
isGatedAndNotEnabled(
|
|
||||||
entityMetadata?.gate,
|
|
||||||
workspaceFeatureFlagsMap,
|
|
||||||
'graphql',
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate query resolvers
|
// Generate query resolvers
|
||||||
|
|||||||
@ -21,10 +21,9 @@ import {
|
|||||||
WorkspaceMetadataCacheExceptionCode,
|
WorkspaceMetadataCacheExceptionCode,
|
||||||
} from 'src/engine/metadata-modules/workspace-metadata-cache/exceptions/workspace-metadata-cache.exception';
|
} from 'src/engine/metadata-modules/workspace-metadata-cache/exceptions/workspace-metadata-cache.exception';
|
||||||
import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service';
|
import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service';
|
||||||
import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage';
|
|
||||||
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
||||||
import { standardObjectMetadataDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects';
|
import { standardObjectMetadataDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects';
|
||||||
import { isGatedAndNotEnabled } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/is-gate-and-not-enabled.util';
|
import { shouldExcludeFromWorkspaceApi } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/should-exclude-from-workspace-api.util';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WorkspaceSchemaFactory {
|
export class WorkspaceSchemaFactory {
|
||||||
@ -86,27 +85,10 @@ export class WorkspaceSchemaFactory {
|
|||||||
indexes: objectMetadataItem.indexMetadatas,
|
indexes: objectMetadataItem.indexMetadatas,
|
||||||
}))
|
}))
|
||||||
.filter((objectMetadata) => {
|
.filter((objectMetadata) => {
|
||||||
// Find the corresponding workspace entity for this object metadata
|
return !shouldExcludeFromWorkspaceApi(
|
||||||
const workspaceEntity = standardObjectMetadataDefinitions.find(
|
objectMetadata,
|
||||||
(entity) => {
|
standardObjectMetadataDefinitions,
|
||||||
const entityMetadata = metadataArgsStorage.filterEntities(entity);
|
|
||||||
|
|
||||||
return entityMetadata?.standardId === objectMetadata.standardId;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!workspaceEntity) {
|
|
||||||
return true; // Include non-workspace entities (custom objects, etc.)
|
|
||||||
}
|
|
||||||
|
|
||||||
const entityMetadata =
|
|
||||||
metadataArgsStorage.filterEntities(workspaceEntity);
|
|
||||||
|
|
||||||
// Filter out entities that are GraphQL-gated and not enabled
|
|
||||||
return !isGatedAndNotEnabled(
|
|
||||||
entityMetadata?.gate,
|
|
||||||
workspaceFeatureFlagsMap,
|
workspaceFeatureFlagsMap,
|
||||||
'graphql',
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -12,25 +12,29 @@ import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path
|
|||||||
import { Query } from 'src/engine/api/rest/core/types/query.type';
|
import { Query } from 'src/engine/api/rest/core/types/query.type';
|
||||||
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
|
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
|
||||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||||
|
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||||
|
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
||||||
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
|
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
|
||||||
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
|
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
|
||||||
import { getObjectMetadataMapItemByNamePlural } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-plural.util';
|
import { getObjectMetadataMapItemByNamePlural } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-plural.util';
|
||||||
import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util';
|
import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util';
|
||||||
import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service';
|
import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service';
|
||||||
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
||||||
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
import { standardObjectMetadataDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects';
|
||||||
|
import { shouldExcludeFromWorkspaceApi } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/should-exclude-from-workspace-api.util';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CoreQueryBuilderFactory {
|
export class CoreQueryBuilderFactory {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly createManyQueryFactory: CreateManyQueryFactory,
|
private readonly createManyQueryFactory: CreateManyQueryFactory,
|
||||||
private readonly findDuplicatesQueryFactory: FindDuplicatesQueryFactory,
|
|
||||||
private readonly createVariablesFactory: CreateVariablesFactory,
|
private readonly createVariablesFactory: CreateVariablesFactory,
|
||||||
|
private readonly findDuplicatesQueryFactory: FindDuplicatesQueryFactory,
|
||||||
private readonly findDuplicatesVariablesFactory: FindDuplicatesVariablesFactory,
|
private readonly findDuplicatesVariablesFactory: FindDuplicatesVariablesFactory,
|
||||||
private readonly accessTokenService: AccessTokenService,
|
private readonly accessTokenService: AccessTokenService,
|
||||||
private readonly domainManagerService: DomainManagerService,
|
private readonly domainManagerService: DomainManagerService,
|
||||||
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
|
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
|
||||||
private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService,
|
private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService,
|
||||||
|
private readonly featureFlagService: FeatureFlagService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getObjectMetadata(
|
async getObjectMetadata(
|
||||||
@ -94,6 +98,22 @@ export class CoreQueryBuilderFactory {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const workspaceFeatureFlagsMap =
|
||||||
|
await this.featureFlagService.getWorkspaceFeatureFlagsMap(workspace.id);
|
||||||
|
|
||||||
|
// Check if this entity is workspace-gated and should be blocked from workspace API
|
||||||
|
if (
|
||||||
|
shouldExcludeFromWorkspaceApi(
|
||||||
|
objectMetadataItem,
|
||||||
|
standardObjectMetadataDefinitions,
|
||||||
|
workspaceFeatureFlagsMap,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`object '${parsedObject}' not found. ${parsedObject} is not available via REST API.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
objectMetadataMaps,
|
objectMetadataMaps,
|
||||||
objectMetadataMapItem: objectMetadataItem,
|
objectMetadataMapItem: objectMetadataItem,
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { CoreQueryBuilderFactory } from 'src/engine/api/rest/core/query-builder/
|
|||||||
import { coreQueryBuilderFactories } from 'src/engine/api/rest/core/query-builder/factories/factories';
|
import { coreQueryBuilderFactories } from 'src/engine/api/rest/core/query-builder/factories/factories';
|
||||||
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
||||||
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
|
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
|
||||||
|
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||||
import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module';
|
import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module';
|
||||||
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||||
|
|
||||||
@ -11,6 +12,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
|
|||||||
imports: [
|
imports: [
|
||||||
AuthModule,
|
AuthModule,
|
||||||
DomainManagerModule,
|
DomainManagerModule,
|
||||||
|
FeatureFlagModule,
|
||||||
WorkspaceCacheStorageModule,
|
WorkspaceCacheStorageModule,
|
||||||
WorkspaceMetadataCacheModule,
|
WorkspaceMetadataCacheModule,
|
||||||
],
|
],
|
||||||
|
|||||||
@ -4,11 +4,21 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
|
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||||
import { ApiKeyResolver } from 'src/engine/core-modules/api-key/api-key.resolver';
|
import { ApiKeyResolver } from 'src/engine/core-modules/api-key/api-key.resolver';
|
||||||
import { ApiKeyService } from 'src/engine/core-modules/api-key/api-key.service';
|
import { ApiKeyService } from 'src/engine/core-modules/api-key/api-key.service';
|
||||||
|
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
||||||
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
|
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
|
||||||
|
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||||
|
|
||||||
|
import { ApiKeyController } from './controllers/api-key.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([ApiKey], 'core'), JwtModule],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([ApiKey], 'core'),
|
||||||
|
JwtModule,
|
||||||
|
AuthModule,
|
||||||
|
WorkspaceCacheStorageModule,
|
||||||
|
],
|
||||||
providers: [ApiKeyService, ApiKeyResolver],
|
providers: [ApiKeyService, ApiKeyResolver],
|
||||||
|
controllers: [ApiKeyController],
|
||||||
exports: [ApiKeyService, TypeOrmModule],
|
exports: [ApiKeyService, TypeOrmModule],
|
||||||
})
|
})
|
||||||
export class ApiKeyModule {}
|
export class ApiKeyModule {}
|
||||||
|
|||||||
@ -0,0 +1,89 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
UseFilters,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
|
||||||
|
import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter';
|
||||||
|
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||||
|
import { ApiKeyService } from 'src/engine/core-modules/api-key/api-key.service';
|
||||||
|
import { CreateApiKeyDTO } from 'src/engine/core-modules/api-key/dtos/create-api-key.dto';
|
||||||
|
import { UpdateApiKeyDTO } from 'src/engine/core-modules/api-key/dtos/update-api-key.dto';
|
||||||
|
import { Workspace } 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 { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* rest/apiKeys is deprecated, use rest/metadata/apiKeys instead
|
||||||
|
* rest/apiKeys will be removed in the future
|
||||||
|
*/
|
||||||
|
@Controller(['rest/apiKeys', 'rest/metadata/apiKeys'])
|
||||||
|
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard)
|
||||||
|
@UseFilters(RestApiExceptionFilter)
|
||||||
|
export class ApiKeyController {
|
||||||
|
constructor(private readonly apiKeyService: ApiKeyService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async findAll(@AuthWorkspace() workspace: Workspace): Promise<ApiKey[]> {
|
||||||
|
return this.apiKeyService.findActiveByWorkspaceId(workspace.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
async findOne(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
): Promise<ApiKey | null> {
|
||||||
|
return this.apiKeyService.findById(id, workspace.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async create(
|
||||||
|
@Body() createApiKeyDto: CreateApiKeyDTO,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
): Promise<ApiKey> {
|
||||||
|
return this.apiKeyService.create({
|
||||||
|
name: createApiKeyDto.name,
|
||||||
|
expiresAt: new Date(createApiKeyDto.expiresAt),
|
||||||
|
revokedAt: createApiKeyDto.revokedAt
|
||||||
|
? new Date(createApiKeyDto.revokedAt)
|
||||||
|
: undefined,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
async update(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() updateApiKeyDto: UpdateApiKeyDTO,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
): Promise<ApiKey | null> {
|
||||||
|
const updateData: Partial<ApiKey> = {};
|
||||||
|
|
||||||
|
if (updateApiKeyDto.name !== undefined)
|
||||||
|
updateData.name = updateApiKeyDto.name;
|
||||||
|
if (updateApiKeyDto.expiresAt !== undefined)
|
||||||
|
updateData.expiresAt = new Date(updateApiKeyDto.expiresAt);
|
||||||
|
if (updateApiKeyDto.revokedAt !== undefined) {
|
||||||
|
updateData.revokedAt = updateApiKeyDto.revokedAt
|
||||||
|
? new Date(updateApiKeyDto.revokedAt)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.apiKeyService.update(id, workspace.id, updateData);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
async remove(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
): Promise<ApiKey | null> {
|
||||||
|
return this.apiKeyService.revoke(id, workspace.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,12 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
||||||
|
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||||
import { OpenApiController } from 'src/engine/core-modules/open-api/open-api.controller';
|
import { OpenApiController } from 'src/engine/core-modules/open-api/open-api.controller';
|
||||||
import { OpenApiService } from 'src/engine/core-modules/open-api/open-api.service';
|
import { OpenApiService } from 'src/engine/core-modules/open-api/open-api.service';
|
||||||
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
|
||||||
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
|
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ObjectMetadataModule, AuthModule],
|
imports: [ObjectMetadataModule, AuthModule, FeatureFlagModule],
|
||||||
controllers: [OpenApiController],
|
controllers: [OpenApiController],
|
||||||
providers: [OpenApiService],
|
providers: [OpenApiService],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
|
||||||
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
|
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
|
||||||
|
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||||
import { OpenApiService } from 'src/engine/core-modules/open-api/open-api.service';
|
import { OpenApiService } from 'src/engine/core-modules/open-api/open-api.service';
|
||||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||||
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||||
@ -24,11 +25,16 @@ describe('OpenApiService', () => {
|
|||||||
provide: TwentyConfigService,
|
provide: TwentyConfigService,
|
||||||
useValue: {},
|
useValue: {},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: FeatureFlagService,
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<OpenApiService>(OpenApiService);
|
service = module.get<OpenApiService>(OpenApiService);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
expect(service).toBeDefined();
|
expect(service).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { capitalize } from 'twenty-shared/utils';
|
|||||||
|
|
||||||
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
|
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
|
||||||
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
|
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
|
||||||
|
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||||
import { baseSchema } from 'src/engine/core-modules/open-api/utils/base-schema.utils';
|
import { baseSchema } from 'src/engine/core-modules/open-api/utils/base-schema.utils';
|
||||||
import {
|
import {
|
||||||
computeMetadataSchemaComponents,
|
computeMetadataSchemaComponents,
|
||||||
@ -36,9 +37,11 @@ import {
|
|||||||
getUpdateOneResponse200,
|
getUpdateOneResponse200,
|
||||||
} from 'src/engine/core-modules/open-api/utils/responses.utils';
|
} from 'src/engine/core-modules/open-api/utils/responses.utils';
|
||||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||||
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
|
||||||
import { getServerUrl } from 'src/utils/get-server-url';
|
|
||||||
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
||||||
|
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||||
|
import { standardObjectMetadataDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects';
|
||||||
|
import { shouldExcludeFromWorkspaceApi } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/should-exclude-from-workspace-api.util';
|
||||||
|
import { getServerUrl } from 'src/utils/get-server-url';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OpenApiService {
|
export class OpenApiService {
|
||||||
@ -46,6 +49,7 @@ export class OpenApiService {
|
|||||||
private readonly accessTokenService: AccessTokenService,
|
private readonly accessTokenService: AccessTokenService,
|
||||||
private readonly twentyConfigService: TwentyConfigService,
|
private readonly twentyConfigService: TwentyConfigService,
|
||||||
private readonly objectMetadataService: ObjectMetadataService,
|
private readonly objectMetadataService: ObjectMetadataService,
|
||||||
|
private readonly featureFlagService: FeatureFlagService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async generateCoreSchema(request: Request): Promise<OpenAPIV3_1.Document> {
|
async generateCoreSchema(request: Request): Promise<OpenAPIV3_1.Document> {
|
||||||
@ -57,11 +61,13 @@ export class OpenApiService {
|
|||||||
const schema = baseSchema('core', baseUrl);
|
const schema = baseSchema('core', baseUrl);
|
||||||
|
|
||||||
let objectMetadataItems;
|
let objectMetadataItems;
|
||||||
|
let workspace;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { workspace } =
|
const authResult =
|
||||||
await this.accessTokenService.validateTokenByRequest(request);
|
await this.accessTokenService.validateTokenByRequest(request);
|
||||||
|
|
||||||
|
workspace = authResult.workspace;
|
||||||
workspaceValidator.assertIsDefinedOrThrow(workspace);
|
workspaceValidator.assertIsDefinedOrThrow(workspace);
|
||||||
|
|
||||||
objectMetadataItems =
|
objectMetadataItems =
|
||||||
@ -77,7 +83,19 @@ export class OpenApiService {
|
|||||||
if (!objectMetadataItems.length) {
|
if (!objectMetadataItems.length) {
|
||||||
return schema;
|
return schema;
|
||||||
}
|
}
|
||||||
schema.paths = objectMetadataItems.reduce((paths, item) => {
|
|
||||||
|
const workspaceFeatureFlagsMap =
|
||||||
|
await this.featureFlagService.getWorkspaceFeatureFlagsMap(workspace.id);
|
||||||
|
|
||||||
|
const filteredObjectMetadataItems = objectMetadataItems.filter((item) => {
|
||||||
|
return !shouldExcludeFromWorkspaceApi(
|
||||||
|
item,
|
||||||
|
standardObjectMetadataDefinitions,
|
||||||
|
workspaceFeatureFlagsMap,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
schema.paths = filteredObjectMetadataItems.reduce((paths, item) => {
|
||||||
paths[`/${item.namePlural}`] = computeManyResultPath(item);
|
paths[`/${item.namePlural}`] = computeManyResultPath(item);
|
||||||
paths[`/batch/${item.namePlural}`] = computeBatchPath(item);
|
paths[`/batch/${item.namePlural}`] = computeBatchPath(item);
|
||||||
paths[`/${item.namePlural}/{id}`] = computeSingleResultPath(item);
|
paths[`/${item.namePlural}/{id}`] = computeSingleResultPath(item);
|
||||||
@ -120,7 +138,7 @@ export class OpenApiService {
|
|||||||
|
|
||||||
schema.components = {
|
schema.components = {
|
||||||
...schema.components, // components.securitySchemes is defined in base Schema
|
...schema.components, // components.securitySchemes is defined in base Schema
|
||||||
schemas: computeSchemaComponents(objectMetadataItems),
|
schemas: computeSchemaComponents(filteredObjectMetadataItems),
|
||||||
parameters: computeParameterComponents(),
|
parameters: computeParameterComponents(),
|
||||||
responses: {
|
responses: {
|
||||||
'400': get400ErrorResponses(),
|
'400': get400ErrorResponses(),
|
||||||
@ -128,6 +146,8 @@ export class OpenApiService {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
schema.tags = computeSchemaTags(filteredObjectMetadataItems);
|
||||||
|
|
||||||
return schema;
|
return schema;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -152,6 +172,14 @@ export class OpenApiService {
|
|||||||
nameSingular: 'field',
|
nameSingular: 'field',
|
||||||
namePlural: 'fields',
|
namePlural: 'fields',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
nameSingular: 'webhook',
|
||||||
|
namePlural: 'webhooks',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nameSingular: 'apikey',
|
||||||
|
namePlural: 'apiKeys',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
schema.paths = metadata.reduce((path, item) => {
|
schema.paths = metadata.reduce((path, item) => {
|
||||||
|
|||||||
@ -0,0 +1,78 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
UseFilters,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
|
||||||
|
import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter';
|
||||||
|
import { CreateWebhookDTO } from 'src/engine/core-modules/webhook/dtos/create-webhook.dto';
|
||||||
|
import { UpdateWebhookDTO } from 'src/engine/core-modules/webhook/dtos/update-webhook.dto';
|
||||||
|
import { Webhook } from 'src/engine/core-modules/webhook/webhook.entity';
|
||||||
|
import { WebhookService } from 'src/engine/core-modules/webhook/webhook.service';
|
||||||
|
import { Workspace } 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 { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* rest/webhooks is deprecated, use rest/metadata/webhooks instead
|
||||||
|
* rest/webhooks will be removed in the future
|
||||||
|
*/
|
||||||
|
@Controller(['rest/webhooks', 'rest/metadata/webhooks'])
|
||||||
|
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard)
|
||||||
|
@UseFilters(RestApiExceptionFilter)
|
||||||
|
export class WebhookController {
|
||||||
|
constructor(private readonly webhookService: WebhookService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async findAll(@AuthWorkspace() workspace: Workspace): Promise<Webhook[]> {
|
||||||
|
return this.webhookService.findByWorkspaceId(workspace.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
async findOne(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
): Promise<Webhook | null> {
|
||||||
|
return this.webhookService.findById(id, workspace.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async create(
|
||||||
|
@Body() createWebhookDto: CreateWebhookDTO,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
): Promise<Webhook> {
|
||||||
|
return this.webhookService.create({
|
||||||
|
targetUrl: createWebhookDto.targetUrl,
|
||||||
|
operations: createWebhookDto.operations || ['*.*'],
|
||||||
|
description: createWebhookDto.description,
|
||||||
|
secret: createWebhookDto.secret,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
async update(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() updateWebhookDto: UpdateWebhookDTO,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
): Promise<Webhook | null> {
|
||||||
|
return this.webhookService.update(id, workspace.id, updateWebhookDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
async remove(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await this.webhookService.delete(id, workspace.id);
|
||||||
|
|
||||||
|
return result !== null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,13 +1,22 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import { Webhook } from './webhook.entity';
|
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
||||||
import { WebhookResolver } from './webhook.resolver';
|
import { Webhook } from 'src/engine/core-modules/webhook/webhook.entity';
|
||||||
import { WebhookService } from './webhook.service';
|
import { WebhookResolver } from 'src/engine/core-modules/webhook/webhook.resolver';
|
||||||
|
import { WebhookService } from 'src/engine/core-modules/webhook/webhook.service';
|
||||||
|
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||||
|
|
||||||
|
import { WebhookController } from './controllers/webhook.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Webhook], 'core')],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Webhook], 'core'),
|
||||||
|
AuthModule,
|
||||||
|
WorkspaceCacheStorageModule,
|
||||||
|
],
|
||||||
providers: [WebhookService, WebhookResolver],
|
providers: [WebhookService, WebhookResolver],
|
||||||
|
controllers: [WebhookController],
|
||||||
exports: [WebhookService, TypeOrmModule],
|
exports: [WebhookService, TypeOrmModule],
|
||||||
})
|
})
|
||||||
export class WebhookModule {}
|
export class WebhookModule {}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { TypedReflect } from 'src/utils/typed-reflect';
|
|||||||
export interface WorkspaceGateOptions {
|
export interface WorkspaceGateOptions {
|
||||||
featureFlag: string;
|
featureFlag: string;
|
||||||
excludeFromDatabase?: boolean;
|
excludeFromDatabase?: boolean;
|
||||||
excludeFromGraphQL?: boolean;
|
excludeFromWorkspaceApi?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkspaceGate(options: WorkspaceGateOptions) {
|
export function WorkspaceGate(options: WorkspaceGateOptions) {
|
||||||
@ -21,7 +21,7 @@ export function WorkspaceGate(options: WorkspaceGateOptions) {
|
|||||||
const gateOptions = {
|
const gateOptions = {
|
||||||
featureFlag: options.featureFlag,
|
featureFlag: options.featureFlag,
|
||||||
excludeFromDatabase: options.excludeFromDatabase ?? true,
|
excludeFromDatabase: options.excludeFromDatabase ?? true,
|
||||||
excludeFromGraphQL: options.excludeFromGraphQL ?? true,
|
excludeFromWorkspaceApi: options.excludeFromWorkspaceApi ?? true,
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
export interface Gate {
|
export interface Gate {
|
||||||
featureFlag: string;
|
featureFlag: string;
|
||||||
excludeFromDatabase?: boolean;
|
excludeFromDatabase?: boolean;
|
||||||
excludeFromGraphQL?: boolean;
|
excludeFromWorkspaceApi?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Gate } from 'src/engine/twenty-orm/interfaces/gate.interface';
|
import { Gate } from 'src/engine/twenty-orm/interfaces/gate.interface';
|
||||||
|
|
||||||
export type GateContext = 'database' | 'graphql';
|
export type GateContext = 'database' | 'workspaceApi';
|
||||||
|
|
||||||
export const isGatedAndNotEnabled = (
|
export const isGatedAndNotEnabled = (
|
||||||
gate: Gate | undefined,
|
gate: Gate | undefined,
|
||||||
@ -19,9 +19,9 @@ export const isGatedAndNotEnabled = (
|
|||||||
return false; // Not gated for database
|
return false; // Not gated for database
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'graphql':
|
case 'workspaceApi':
|
||||||
if (gate.excludeFromGraphQL === false) {
|
if (gate.excludeFromWorkspaceApi === false) {
|
||||||
return false; // Not gated for GraphQL
|
return false; // Not gated for workspace API
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,23 @@
|
|||||||
|
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
|
||||||
|
import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage';
|
||||||
|
import { isGatedAndNotEnabled } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/is-gate-and-not-enabled.util';
|
||||||
|
|
||||||
|
export const shouldExcludeFromWorkspaceApi = (
|
||||||
|
objectMetadataItem: { standardId?: string | null | undefined },
|
||||||
|
standardObjectMetadataDefinitions: (typeof BaseWorkspaceEntity)[],
|
||||||
|
workspaceFeatureFlagsMap: Record<string, boolean>,
|
||||||
|
): boolean => {
|
||||||
|
const entityMetadata = standardObjectMetadataDefinitions
|
||||||
|
.map((entity) => metadataArgsStorage.filterEntities(entity))
|
||||||
|
.find((meta) => meta?.standardId === objectMetadataItem.standardId);
|
||||||
|
|
||||||
|
if (!entityMetadata) {
|
||||||
|
return false; // Don't exclude non-workspace entities
|
||||||
|
}
|
||||||
|
|
||||||
|
return isGatedAndNotEnabled(
|
||||||
|
entityMetadata?.gate,
|
||||||
|
workspaceFeatureFlagsMap,
|
||||||
|
'workspaceApi',
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -24,7 +24,7 @@ import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync
|
|||||||
@WorkspaceGate({
|
@WorkspaceGate({
|
||||||
featureFlag: 'IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED',
|
featureFlag: 'IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED',
|
||||||
excludeFromDatabase: false,
|
excludeFromDatabase: false,
|
||||||
excludeFromGraphQL: true,
|
excludeFromWorkspaceApi: true,
|
||||||
})
|
})
|
||||||
export class ApiKeyWorkspaceEntity extends BaseWorkspaceEntity {
|
export class ApiKeyWorkspaceEntity extends BaseWorkspaceEntity {
|
||||||
@WorkspaceField({
|
@WorkspaceField({
|
||||||
|
|||||||
@ -25,7 +25,7 @@ import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync
|
|||||||
@WorkspaceGate({
|
@WorkspaceGate({
|
||||||
featureFlag: 'IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED',
|
featureFlag: 'IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED',
|
||||||
excludeFromDatabase: false,
|
excludeFromDatabase: false,
|
||||||
excludeFromGraphQL: true,
|
excludeFromWorkspaceApi: true,
|
||||||
})
|
})
|
||||||
export class WebhookWorkspaceEntity extends BaseWorkspaceEntity {
|
export class WebhookWorkspaceEntity extends BaseWorkspaceEntity {
|
||||||
@WorkspaceField({
|
@WorkspaceField({
|
||||||
|
|||||||
Reference in New Issue
Block a user