diff --git a/api/registry/types.ts b/api/registry/types.ts index 460be078..9ee3e862 100644 --- a/api/registry/types.ts +++ b/api/registry/types.ts @@ -54,6 +54,7 @@ export type { export {LogEventType} from '../../src/registry/common/utils/log-event/types'; export type {GatewayApi} from '../../src/registry'; export type {CheckTenant, GetServicePlan} from '../../src/registry/common/utils/tenant/types'; +export type {GetEntryResolveUserLogin} from '../../src/registry/common/utils/entry/types'; export type { CollectionConstructor, CollectionInstance, diff --git a/package-lock.json b/package-lock.json index ae84951b..2c46acac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@asteasolutions/zod-to-openapi": "^7.3.0", "@gravity-ui/expresskit": "^2.4.0", - "@gravity-ui/gateway": "^2.6.2", + "@gravity-ui/gateway": "^4.7.2", "@gravity-ui/nodekit": "^2.4.1", "@gravity-ui/postgreskit": "^2.0.0", "ajv": "^6.12.4", @@ -834,16 +834,16 @@ } }, "node_modules/@gravity-ui/gateway": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/@gravity-ui/gateway/-/gateway-2.6.2.tgz", - "integrity": "sha512-HHNAJikLt74CEH1nOTT8A6hAL4NbigB3U7wy+b/ryjYstBTHfHZNUmGHCRqHp80OhljB2IbHmIsWiUFwjBuCRA==", + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/@gravity-ui/gateway/-/gateway-4.7.2.tgz", + "integrity": "sha512-gu6mwTGe0I5EO2c41AMepl9FmJxjKA4ItxGmgAfGA62b6Ai6DMHQSLQXCTIV8KE6ddW8luSqHTFVfHLxj4qYRQ==", "license": "MIT", "dependencies": { "@grpc/grpc-js": "^1.9.9", "@grpc/proto-loader": "^0.7.8", "ajv": "^8.12.0", - "axios": "^1.3.5", - "axios-retry": "^3.4.0", + "axios": "^1.8.3", + "axios-retry": "^3.9.1", "lodash": "^4.17.21", "object-sizeof": "^2.6.5", "protobufjs": "^7.2.5", @@ -3417,9 +3417,10 @@ } }, "node_modules/axios": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", - "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", + "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", + "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -3427,9 +3428,10 @@ } }, "node_modules/axios-retry": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-3.7.0.tgz", - "integrity": "sha512-ZTnCkJbRtfScvwiRnoVskFAfvU0UG3xNcsjwTR0mawSbIJoothxn67gKsMaNAFHRXJ1RmuLhmZBzvyXi3+9WyQ==", + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-3.9.1.tgz", + "integrity": "sha512-8PJDLJv7qTTMMwdnbMvrLYuvB47M81wRtxQmEdV5w4rgbTXTt+vtPkXwajOfOdSyv/wZICJOC+/UhXH4aQ/R+w==", + "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.15.4", "is-retry-allowed": "^2.2.0" diff --git a/package.json b/package.json index 3e75e804..136920c8 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "dependencies": { "@asteasolutions/zod-to-openapi": "^7.3.0", "@gravity-ui/expresskit": "^2.4.0", - "@gravity-ui/gateway": "^2.6.2", + "@gravity-ui/gateway": "^4.7.2", "@gravity-ui/nodekit": "^2.4.1", "@gravity-ui/postgreskit": "^2.0.0", "ajv": "^6.12.4", diff --git a/src/components/error-response-presenter.ts b/src/components/error-response-presenter.ts index fb045861..11500ff2 100644 --- a/src/components/error-response-presenter.ts +++ b/src/components/error-response-presenter.ts @@ -317,6 +317,7 @@ export default (error: AppError | DBError) => { }; } + case US_ERRORS.ENTRY_AND_WORKBOOK_TENANT_MISMATCH: case US_ERRORS.TOO_MANY_COLOR_PALETTES: { return { code: 500, @@ -400,6 +401,16 @@ export default (error: AppError | DBError) => { }; } + case US_ERRORS.ACTION_TIMEOUT: { + return { + code: 504, + response: { + code, + message: 'Action timed out', + }, + }; + } + default: return { code: 500, diff --git a/src/components/features/types.ts b/src/components/features/types.ts index 5f11cb86..813ab932 100644 --- a/src/components/features/types.ts +++ b/src/components/features/types.ts @@ -6,6 +6,7 @@ export enum Feature { WorkbookIsolationEnabled = 'WorkbookIsolationEnabled', DefaultColorPaletteEnabled = 'DefaultColorPaletteEnabled', TenantsEnabled = 'TenantsEnabled', + GetEntryV2Enabled = 'GetEntryV2Enabled', } export type FeaturesConfig = { diff --git a/src/const/errors.ts b/src/const/errors.ts index acb47d37..1d3ffec7 100644 --- a/src/const/errors.ts +++ b/src/const/errors.ts @@ -50,4 +50,6 @@ export const US_ERRORS = { COLLECTION_WITH_WORKBOOK_TEMPLATE_CANT_BE_DELETED: 'COLLECTION_WITH_WORKBOOK_TEMPLATE_CANT_BE_DELETED', TENANT_ID_MISSING_IN_CONTEXT: 'TENANT_ID_MISSING_IN_CONTEXT', + ACTION_TIMEOUT: 'ACTION_TIMEOUT', + ENTRY_AND_WORKBOOK_TENANT_MISMATCH: 'ENTRY_AND_WORKBOOK_TENANT_MISMATCH', }; diff --git a/src/controllers/entries/get-entry/index.ts b/src/controllers/entries/get-entry/index.ts new file mode 100644 index 00000000..54491a10 --- /dev/null +++ b/src/controllers/entries/get-entry/index.ts @@ -0,0 +1,64 @@ +import {Request, Response} from '@gravity-ui/expresskit'; + +import {ApiTag} from '../../../components/api-docs'; +import {makeReqParser, z, zc} from '../../../components/zod'; +import {CONTENT_TYPE_JSON} from '../../../const'; +import {getEntryV2} from '../../../services/new/entry'; + +import {getEntryResult} from './response-model'; + +const requestSchema = { + params: z.object({ + entryId: z.string(), + }), + query: z.object({ + branch: z.enum(['saved', 'published']).optional(), + revId: z.string().optional(), + includePermissionsInfo: zc.stringBoolean().optional(), + includeLinks: zc.stringBoolean().optional(), + includeServicePlan: zc.stringBoolean().optional(), + includeTenantFeatures: zc.stringBoolean().optional(), + includeFavorite: zc.stringBoolean().optional(), + }), +}; + +const parseReq = makeReqParser(requestSchema); + +export const getEntryController = async (req: Request, res: Response) => { + const {query, params} = await parseReq(req); + + const result = await getEntryV2( + {ctx: req.ctx}, + { + entryId: params.entryId, + branch: query.branch, + revId: query.revId, + includePermissionsInfo: query.includePermissionsInfo, + includeLinks: query.includeLinks, + includeServicePlan: query.includeServicePlan, + includeTenantFeatures: query.includeTenantFeatures, + includeFavorite: query.includeFavorite, + }, + ); + + res.status(200).send(getEntryResult.format(req.ctx, result)); +}; + +getEntryController.api = { + summary: 'Get entry', + tags: [ApiTag.Entries], + request: { + params: requestSchema.params, + query: requestSchema.query, + }, + responses: { + 200: { + description: getEntryResult.schema.description ?? '', + content: { + [CONTENT_TYPE_JSON]: { + schema: getEntryResult.schema, + }, + }, + }, + }, +}; diff --git a/src/controllers/entries/get-entry/response-model.ts b/src/controllers/entries/get-entry/response-model.ts new file mode 100644 index 00000000..789ab15b --- /dev/null +++ b/src/controllers/entries/get-entry/response-model.ts @@ -0,0 +1,106 @@ +import {AppContext} from '@gravity-ui/nodekit'; + +import {z} from '../../../components/zod'; +import {EntryScope} from '../../../db/models/new/entry/types'; +import {GetEntryV2Result} from '../../../services/new/entry'; +import Utils from '../../../utils'; + +const schema = z + .object({ + entryId: z.string(), + scope: z.nativeEnum(EntryScope), + type: z.string(), + key: z.string().nullable(), + unversionedData: z.record(z.string(), z.unknown()).optional(), + createdBy: z.string(), + createdAt: z.string(), + updatedBy: z.string(), + updatedAt: z.string(), + savedId: z.string().nullable(), + publishedId: z.string().nullable(), + revId: z.string(), + tenantId: z.string().nullable(), + data: z.record(z.string(), z.unknown()).nullable(), + meta: z.record(z.string(), z.unknown()).nullable(), + hidden: z.boolean(), + public: z.boolean(), + workbookId: z.string().nullable(), + links: z.record(z.string(), z.unknown()).optional().nullable(), + isFavorite: z.boolean().optional(), + permissions: z + .object({ + execute: z.boolean().optional(), + read: z.boolean().optional(), + edit: z.boolean().optional(), + admin: z.boolean().optional(), + }) + .optional(), + servicePlan: z.string().optional(), + tenantFeatures: z.record(z.string(), z.unknown()).optional(), + }) + .describe('Get entry result'); + +const format = ( + ctx: AppContext, + { + entry, + revision, + includePermissionsInfo, + permissions, + includeLinks, + includeServicePlan, + servicePlan, + includeTenantFeatures, + tenantFeatures, + includeFavorite, + }: GetEntryV2Result, +): z.infer => { + const {privatePermissions, onlyPublic} = ctx.get('info'); + + let isHiddenUnversionedData = false; + if (!privatePermissions.ownedScopes.includes(entry.scope)) { + isHiddenUnversionedData = true; + } + + let isFavorite: boolean | undefined; + + if (includeFavorite && !onlyPublic) { + isFavorite = Boolean(entry.favorite); + } + + const registry = ctx.get('registry'); + const {getEntryAddFormattedFieldsHook} = registry.common.functions.get(); + const additionalFields = getEntryAddFormattedFieldsHook({ctx}); + + return { + entryId: Utils.encodeId(entry.entryId), + scope: entry.scope, + type: entry.type, + key: entry.displayKey, + unversionedData: isHiddenUnversionedData ? undefined : entry.unversionedData, + createdBy: entry.createdBy, + createdAt: entry.createdAt, + updatedBy: revision.updatedBy, + updatedAt: revision.updatedAt, + savedId: entry.savedId ? Utils.encodeId(entry.savedId) : null, + publishedId: entry.publishedId ? Utils.encodeId(entry.publishedId) : null, + revId: Utils.encodeId(revision.revId), + tenantId: entry.tenantId, + data: revision.data, + meta: revision.meta, + hidden: entry.hidden, + public: entry.public, + workbookId: entry.workbookId ? Utils.encodeId(entry.workbookId) : null, + links: includeLinks ? revision.links : undefined, + isFavorite, + permissions: includePermissionsInfo ? permissions : undefined, + servicePlan: includeServicePlan ? servicePlan : undefined, + tenantFeatures: includeTenantFeatures ? tenantFeatures : undefined, + ...additionalFields, + }; +}; + +export const getEntryResult = { + schema, + format, +}; diff --git a/src/controllers/entries/index.ts b/src/controllers/entries/index.ts index f09a49a6..776ec760 100644 --- a/src/controllers/entries/index.ts +++ b/src/controllers/entries/index.ts @@ -1,5 +1,6 @@ import {Request, Response} from '@gravity-ui/expresskit'; +import {Feature, isEnabledFeature} from '../../components/features'; import {prepareResponseAsync} from '../../components/response-presenter'; import {US_MASTER_TOKEN_HEADER} from '../../const'; import {EntryScope} from '../../db/models/new/entry/types'; @@ -32,6 +33,7 @@ import {deleteEntryController} from './delete-entry'; import {getEntriesController} from './get-entries'; import {getEntriesDataController} from './get-entries-data'; import {getEntriesMetaController} from './get-entries-meta'; +import {getEntryController as getEntryV2Controller} from './get-entry'; import {renameEntryController} from './rename-entry'; import {updateEntryController} from './update-entry'; @@ -48,25 +50,29 @@ export default { getEntriesController, getEntry: async (req: Request, res: Response) => { - const {query, params} = req; - - const result = await getEntry( - {ctx: req.ctx}, - { - entryId: params.entryId, - branch: query.branch as GetEntryArgs['branch'], - revId: query.revId as GetEntryArgs['revId'], - includePermissionsInfo: isTrueArg(query.includePermissionsInfo), - includeLinks: isTrueArg(query.includeLinks), - includeServicePlan: isTrueArg(query.includeServicePlan), - includeTenantFeatures: isTrueArg(query.includeTenantFeatures), - }, - ); - const formattedResponse = await formatGetEntryResponse(req.ctx, result); - - const {code, response} = await prepareResponseAsync({data: formattedResponse}); - - res.status(code).send(response); + if (isEnabledFeature(req.ctx, Feature.GetEntryV2Enabled)) { + await getEntryV2Controller(req, res); + } else { + const {query, params} = req; + + const result = await getEntry( + {ctx: req.ctx}, + { + entryId: params.entryId, + branch: query.branch as GetEntryArgs['branch'], + revId: query.revId as GetEntryArgs['revId'], + includePermissionsInfo: isTrueArg(query.includePermissionsInfo), + includeLinks: isTrueArg(query.includeLinks), + includeServicePlan: isTrueArg(query.includeServicePlan), + includeTenantFeatures: isTrueArg(query.includeTenantFeatures), + }, + ); + const formattedResponse = await formatGetEntryResponse(req.ctx, result); + + const {code, response} = await prepareResponseAsync({data: formattedResponse}); + + res.status(code).send(response); + } }, getEntryMeta: async (req: Request, res: Response) => { diff --git a/src/db/models/new/entry/index.ts b/src/db/models/new/entry/index.ts index 7f3930cc..4f9e0582 100644 --- a/src/db/models/new/entry/index.ts +++ b/src/db/models/new/entry/index.ts @@ -2,6 +2,7 @@ import {Model} from '../../..'; import {EntryPermissions} from '../../../../services/new/entry/types'; import {Favorite} from '../favorite'; import {RevisionModel} from '../revision'; +import {Tenant, TenantColumn} from '../tenant'; import {WorkbookModel} from '../workbook'; import {EntryScope} from './types'; @@ -101,12 +102,20 @@ export class Entry extends Model { }, favorite: { relation: Model.HasOneRelation, - modelClass: RevisionModel, + modelClass: Favorite, join: { from: `${Entry.tableName}.${EntryColumn.EntryId}`, to: `${Favorite.tableName}.entryId`, }, }, + tenant: { + relation: Model.BelongsToOneRelation, + modelClass: Tenant, + join: { + from: `${Entry.tableName}.${EntryColumn.TenantId}`, + to: `${Tenant.tableName}.${TenantColumn.TenantId}`, + }, + }, }; } @@ -138,6 +147,7 @@ export class Entry extends Model { publishedRevision?: RevisionModel; workbook?: WorkbookModel; favorite?: Favorite; + tenant?: Tenant; permissions?: EntryPermissions; isLocked?: boolean; diff --git a/src/db/models/new/favorite/index.ts b/src/db/models/new/favorite/index.ts index 0ca27fa2..cfc7926c 100644 --- a/src/db/models/new/favorite/index.ts +++ b/src/db/models/new/favorite/index.ts @@ -1,6 +1,14 @@ import {Model} from '../../..'; import {Entry} from '../entry'; +export const FavoriteColumn = { + EntryId: 'entryId', + TenantId: 'tenantId', + Login: 'login', + Alias: 'alias', + CreatedAt: 'createdAt', +} as const; + export class Favorite extends Model { static get tableName() { return 'favorites'; diff --git a/src/registry/common/functions-map.ts b/src/registry/common/functions-map.ts index fcec5a86..be994030 100644 --- a/src/registry/common/functions-map.ts +++ b/src/registry/common/functions-map.ts @@ -9,6 +9,7 @@ import type {CheckEmbedding} from './utils/embedding/types'; import type { GetEntryAddFormattedFieldsHook, GetEntryBeforeDbRequestHook, + GetEntryResolveUserLogin, IsNeedBypassEntryByKey, } from './utils/entry/types'; import type {LogEvent} from './utils/log-event/types'; @@ -23,6 +24,7 @@ export const commonFunctionsMap = { getZitadelUserRole: makeFunctionTemplate(), getEntryBeforeDbRequestHook: makeFunctionTemplate(), getEntryAddFormattedFieldsHook: makeFunctionTemplate(), + getEntryResolveUserLogin: makeFunctionTemplate(), checkEmbedding: makeFunctionTemplate(), logEvent: makeFunctionTemplate(), checkTenant: makeFunctionTemplate(), diff --git a/src/registry/common/register.ts b/src/registry/common/register.ts index fab6af69..dc76c4a9 100644 --- a/src/registry/common/register.ts +++ b/src/registry/common/register.ts @@ -12,6 +12,7 @@ import {checkEmbedding} from './utils/embedding/utils'; import { getEntryAddFormattedFieldsHook, getEntryBeforeDbRequestHook, + getEntryResolveUserLogin, isNeedBypassEntryByKey, } from './utils/entry/utils'; import {logEvent} from './utils/log-event/utils'; @@ -33,6 +34,7 @@ export const registerCommonPlugins = () => { getZitadelUserRole, getEntryBeforeDbRequestHook, getEntryAddFormattedFieldsHook, + getEntryResolveUserLogin, checkEmbedding, logEvent, checkTenant, diff --git a/src/registry/common/utils/entry/types.ts b/src/registry/common/utils/entry/types.ts index befbfc16..65800bdd 100644 --- a/src/registry/common/utils/entry/types.ts +++ b/src/registry/common/utils/entry/types.ts @@ -1,7 +1,5 @@ import type {AppContext} from '@gravity-ui/nodekit'; -import type {GetEntryResult} from '../../../../services/new/entry/get-entry'; - export type IsNeedBypassEntryByKey = (ctx: AppContext, key?: string) => boolean; export type GetEntryBeforeDbRequestHook = (args: { @@ -9,7 +7,6 @@ export type GetEntryBeforeDbRequestHook = (args: { entryId: string; }) => Promise; -export type GetEntryAddFormattedFieldsHook = (args: { - ctx: AppContext; - result: GetEntryResult; -}) => Promise>; +export type GetEntryAddFormattedFieldsHook = (args: {ctx: AppContext}) => Record; + +export type GetEntryResolveUserLogin = (args: {ctx: AppContext}) => Promise; diff --git a/src/registry/common/utils/entry/utils.ts b/src/registry/common/utils/entry/utils.ts index 7ad397f9..7b4c8e60 100644 --- a/src/registry/common/utils/entry/utils.ts +++ b/src/registry/common/utils/entry/utils.ts @@ -1,6 +1,7 @@ import type { GetEntryAddFormattedFieldsHook, GetEntryBeforeDbRequestHook, + GetEntryResolveUserLogin, IsNeedBypassEntryByKey, } from './types'; @@ -8,5 +9,6 @@ export const isNeedBypassEntryByKey: IsNeedBypassEntryByKey = () => false; export const getEntryBeforeDbRequestHook: GetEntryBeforeDbRequestHook = () => Promise.resolve(); -export const getEntryAddFormattedFieldsHook: GetEntryAddFormattedFieldsHook = () => - Promise.resolve({}); +export const getEntryAddFormattedFieldsHook: GetEntryAddFormattedFieldsHook = () => ({}); + +export const getEntryResolveUserLogin: GetEntryResolveUserLogin = () => Promise.resolve(undefined); diff --git a/src/registry/index.ts b/src/registry/index.ts index c6c6654e..bcc89f82 100644 --- a/src/registry/index.ts +++ b/src/registry/index.ts @@ -1,8 +1,9 @@ import type {ExpressKit, Request, Response} from '@gravity-ui/expresskit'; -import getGatewayControllers, { +import { ApiWithRoot, GatewayConfig, SchemasByScope, + getGatewayControllers, } from '@gravity-ui/gateway'; import type {AppContext} from '@gravity-ui/nodekit'; diff --git a/src/services/new/collection/utils/get-parents.ts b/src/services/new/collection/utils/get-parents.ts index c41482a9..d9421322 100644 --- a/src/services/new/collection/utils/get-parents.ts +++ b/src/services/new/collection/utils/get-parents.ts @@ -15,13 +15,20 @@ interface Ctx { interface GetCollectionsParentIds extends Ctx { collectionIds: Nullable[]; + queryTimeout?: number; } interface GetCollectionParentIds extends Ctx { collectionId: string; + getParentsQueryTimeout?: number; } -export const getParents = async ({ctx, trx, collectionIds}: GetCollectionsParentIds) => { +export const getParents = async ({ + ctx, + trx, + collectionIds, + queryTimeout = CollectionModel.DEFAULT_QUERY_TIMEOUT, +}: GetCollectionsParentIds) => { const {tenantId, onlyMirrored} = ctx.get('info'); const targetTrx = getReplica(trx); @@ -59,13 +66,23 @@ export const getParents = async ({ctx, trx, collectionIds}: GetCollectionsParent }) .select() .from(recursiveName) - .timeout(CollectionModel.DEFAULT_QUERY_TIMEOUT); + .timeout(queryTimeout); return result; }; -export const getParentIds = async ({ctx, trx, collectionId}: GetCollectionParentIds) => { - const parents = await getParents({ctx, trx, collectionIds: [collectionId]}); +export const getParentIds = async ({ + ctx, + trx, + collectionId, + getParentsQueryTimeout, +}: GetCollectionParentIds) => { + const parents = await getParents({ + ctx, + trx, + collectionIds: [collectionId], + queryTimeout: getParentsQueryTimeout, + }); return parents.map((item) => item.collectionId); }; diff --git a/src/services/new/entry/formatters/format-get-entry-response.ts b/src/services/new/entry/formatters/format-get-entry-response.ts index d90e3467..21984a5b 100644 --- a/src/services/new/entry/formatters/format-get-entry-response.ts +++ b/src/services/new/entry/formatters/format-get-entry-response.ts @@ -28,7 +28,7 @@ export const formatGetEntryResponse = async (ctx: CTX, result: GetEntryResult) = const {getEntryAddFormattedFieldsHook} = registry.common.functions.get(); - const additionalFields = await getEntryAddFormattedFieldsHook({ctx, result}); + const additionalFields = getEntryAddFormattedFieldsHook({ctx}); return { entryId: joinedEntryRevisionFavoriteTenant.entryId, diff --git a/src/services/new/entry/get-entry-v2/constants.ts b/src/services/new/entry/get-entry-v2/constants.ts new file mode 100644 index 00000000..b8b7e182 --- /dev/null +++ b/src/services/new/entry/get-entry-v2/constants.ts @@ -0,0 +1,57 @@ +import {Entry, EntryColumn} from '../../../../db/models/new/entry'; +import {Favorite, FavoriteColumn} from '../../../../db/models/new/favorite'; +import {RevisionModel, RevisionModelColumn} from '../../../../db/models/new/revision'; +import {Tenant, TenantColumn} from '../../../../db/models/new/tenant'; + +export const entryColumns = [ + EntryColumn.Scope, + EntryColumn.Type, + EntryColumn.Key, + EntryColumn.InnerMeta, + EntryColumn.CreatedBy, + EntryColumn.CreatedAt, + EntryColumn.IsDeleted, + EntryColumn.DeletedAt, + EntryColumn.Hidden, + EntryColumn.DisplayKey, + EntryColumn.EntryId, + EntryColumn.SavedId, + EntryColumn.PublishedId, + EntryColumn.TenantId, + EntryColumn.Name, + EntryColumn.SortName, + EntryColumn.Public, + EntryColumn.UnversionedData, + EntryColumn.WorkbookId, + EntryColumn.Mirrored, +]; + +export const selectedEntryColumns = entryColumns.map((column) => `${Entry.tableName}.${column}`); + +export const revisionColumns = [ + RevisionModelColumn.Data, + RevisionModelColumn.Meta, + RevisionModelColumn.UpdatedBy, + RevisionModelColumn.UpdatedAt, + RevisionModelColumn.RevId, + RevisionModelColumn.Links, + RevisionModelColumn.EntryId, +] as const; + +export const selectedRevisionColumns = revisionColumns.map( + (column) => `${RevisionModel.tableName}.${column}`, +); + +export const tenantColumns = [ + TenantColumn.BillingStartedAt, + TenantColumn.BillingEndedAt, + TenantColumn.Features, +] as const; + +export const selectedTenantColumns = tenantColumns.map((column) => `${Tenant.tableName}.${column}`); + +export const favoriteColumns = [FavoriteColumn.EntryId, FavoriteColumn.Login] as const; + +export const selectedFavoriteColumns = favoriteColumns.map( + (column) => `${Favorite.tableName}.${column}`, +); diff --git a/src/services/new/entry/get-entry-v2/index.ts b/src/services/new/entry/get-entry-v2/index.ts new file mode 100644 index 00000000..647ecc3e --- /dev/null +++ b/src/services/new/entry/get-entry-v2/index.ts @@ -0,0 +1,276 @@ +import {AppError} from '@gravity-ui/nodekit'; + +import {Feature, isEnabledFeature} from '../../../../components/features'; +import {US_ERRORS} from '../../../../const'; +import OldEntry from '../../../../db/models/entry'; +import {Entry, EntryColumn} from '../../../../db/models/new/entry'; +import {TenantColumn} from '../../../../db/models/new/tenant'; +import {DlsActions} from '../../../../types/models'; +import Utils, {withTimeout} from '../../../../utils'; +import {ServiceArgs} from '../../types'; +import {getReplica} from '../../utils'; +import {EntryPermissions} from '../types'; +import {checkWorkbookIsolation} from '../utils'; + +import { + selectedEntryColumns, + selectedFavoriteColumns, + selectedRevisionColumns, + selectedTenantColumns, +} from './constants'; +import type {SelectedEntry, SelectedRevision} from './types'; +import {checkWorkbookEntry} from './utils'; + +const ENTRY_QUERY_TIMEOUT = 3000; + +interface GetEntryV2Args { + entryId: string; + revId?: string; + branch?: 'saved' | 'published'; + includePermissionsInfo?: boolean; + includeLinks?: boolean; + includeServicePlan?: boolean; + includeTenantFeatures?: boolean; + includeFavorite?: boolean; +} + +export type GetEntryV2Result = { + entry: SelectedEntry; + revision: SelectedRevision; + includePermissionsInfo?: boolean; + permissions: EntryPermissions; + includeLinks?: boolean; + includeServicePlan?: boolean; + servicePlan?: string; + tenantFeatures?: Record; + includeTenantFeatures?: boolean; + includeFavorite?: boolean; +}; + +// eslint-disable-next-line complexity +export const getEntryV2 = async ( + {ctx, trx}: ServiceArgs, + args: GetEntryV2Args, +): Promise => { + const { + entryId, + revId, + branch = 'saved', + includePermissionsInfo, + includeLinks, + includeServicePlan, + includeTenantFeatures, + includeFavorite, + } = args; + + ctx.log('GET_ENTRY_V2_REQUEST', { + entryId: Utils.encodeId(entryId), + revId: Utils.encodeId(revId), + branch, + includePermissionsInfo, + includeLinks, + includeServicePlan, + includeTenantFeatures, + includeFavorite, + }); + + const registry = ctx.get('registry'); + const {DLS} = registry.common.classes.get(); + + const {isPrivateRoute, user, onlyPublic, onlyMirrored, tenantId} = ctx.get('info'); + + const {getEntryBeforeDbRequestHook, checkEmbedding, getEntryResolveUserLogin} = + registry.common.functions.get(); + + let userLoginPromise: Promise = Promise.resolve(undefined); + + if (includeFavorite) { + userLoginPromise = user.login + ? Promise.resolve(user.login) + : getEntryResolveUserLogin({ctx}); + } + + const [userLogin] = await Promise.all([ + userLoginPromise, + getEntryBeforeDbRequestHook({ctx, entryId}), + ]); + + const isEmbedding = checkEmbedding({ctx}); + + const graphRelations = ['workbook', 'tenant(tenantModifier)']; + + if (revId) { + graphRelations.push('revisions(revisionsModifier)'); + } else if (branch === 'saved') { + graphRelations.push('savedRevision(revisionModifier)'); + } else { + graphRelations.push('publishedRevision(revisionModifier)'); + } + + if (includeFavorite && userLogin) { + graphRelations.push('favorite(favoriteModifier)'); + } + + const checkEntryTenantEnabled = !isPrivateRoute && !onlyPublic && !onlyMirrored && !isEmbedding; + + const entry: SelectedEntry | undefined = await Entry.query(getReplica(trx)) + .select(selectedEntryColumns) + .where((builder) => { + builder.where({ + [`${Entry.tableName}.${EntryColumn.EntryId}`]: entryId, + [`${Entry.tableName}.${EntryColumn.IsDeleted}`]: false, + ...(checkEntryTenantEnabled + ? { + [`${Entry.tableName}.${EntryColumn.TenantId}`]: tenantId, + } + : {}), + }); + + if (onlyPublic) { + builder.andWhere({public: true}); + } + + if (onlyMirrored) { + builder.andWhere({mirrored: true}); + } + }) + .withGraphJoined(`[${graphRelations.join(', ')}]`) + .modifiers({ + tenantModifier(builder) { + builder.select(selectedTenantColumns); + }, + + revisionsModifier(builder) { + builder.select(selectedRevisionColumns).where({revId}); + }, + + revisionModifier(builder) { + builder.select(selectedRevisionColumns); + }, + + favoriteModifier(builder) { + builder.select(selectedFavoriteColumns).where({login: userLogin}); + }, + }) + .first() + .timeout(ENTRY_QUERY_TIMEOUT); + + const revision = entry?.publishedRevision ?? entry?.savedRevision ?? entry?.revisions?.[0]; + + if (!entry || !revision) { + throw new AppError(US_ERRORS.NOT_EXIST_ENTRY, { + code: US_ERRORS.NOT_EXIST_ENTRY, + }); + } + + if (!entry.tenant) { + throw new AppError(US_ERRORS.NOT_EXIST_TENANT, { + code: US_ERRORS.NOT_EXIST_TENANT, + }); + } + + const checkWorkbookIsolationEnabled = + !isPrivateRoute && + !onlyPublic && + !onlyMirrored && + !isEmbedding && + isEnabledFeature(ctx, Feature.WorkbookIsolationEnabled); + + if (checkWorkbookIsolationEnabled) { + checkWorkbookIsolation({ + ctx, + workbookId: entry.workbookId, + }); + } + + const {isNeedBypassEntryByKey, getServicePlan} = registry.common.functions.get(); + + const dlsBypassByKeyEnabled = isNeedBypassEntryByKey(ctx, entry.key as string); + + let dlsPermissions: any; // TODO: Update the type after refactoring DLS.checkPermission(...) + let iamPermissions: Optional; + + if (entry.workbookId) { + if (!entry.workbook) { + throw new AppError(US_ERRORS.WORKBOOK_NOT_EXISTS, { + code: US_ERRORS.WORKBOOK_NOT_EXISTS, + }); + } + + if (entry.tenantId !== entry.workbook.tenantId) { + throw new AppError(US_ERRORS.ENTRY_AND_WORKBOOK_TENANT_MISMATCH, { + code: US_ERRORS.ENTRY_AND_WORKBOOK_TENANT_MISMATCH, + }); + } + + const checkWorkbookEnabled = + !isPrivateRoute && !onlyPublic && !onlyMirrored && !isEmbedding; + + if (checkWorkbookEnabled) { + iamPermissions = await checkWorkbookEntry({ + ctx, + trx, + entry, + workbook: entry.workbook, + includePermissionsInfo, + }); + } + } else { + const checkPermissionEnabled = + !dlsBypassByKeyEnabled && + !isPrivateRoute && + ctx.config.dlsEnabled && + !onlyPublic && + !onlyMirrored; + + if (checkPermissionEnabled) { + dlsPermissions = await withTimeout( + DLS.checkPermission( + {ctx, trx}, + { + entryId, + action: DlsActions.Execute, + includePermissionsInfo, + }, + ), + {timeoutMs: 3000, errorMessage: 'DLS.checkPermission timeout'}, + ); + } + } + + let servicePlan: string | undefined; + if (includeServicePlan) { + servicePlan = getServicePlan(entry.tenant); + } + + let tenantFeatures: Record | undefined; + if (includeTenantFeatures) { + tenantFeatures = entry.tenant[TenantColumn.Features] || undefined; + } + + let permissions: EntryPermissions = {}; + if (includePermissionsInfo) { + permissions = OldEntry.originatePermissions({ + isPrivateRoute, + shared: onlyPublic || isEmbedding, + permissions: dlsPermissions, + iamPermissions, + ctx, + }); + } + + ctx.log('GET_ENTRY_V2_SUCCESS'); + + return { + entry, + revision, + includePermissionsInfo, + permissions, + includeLinks, + includeServicePlan, + servicePlan, + includeTenantFeatures, + tenantFeatures, + includeFavorite, + }; +}; diff --git a/src/services/new/entry/get-entry-v2/types.ts b/src/services/new/entry/get-entry-v2/types.ts new file mode 100644 index 00000000..7881286c --- /dev/null +++ b/src/services/new/entry/get-entry-v2/types.ts @@ -0,0 +1,22 @@ +import {Entry} from '../../../../db/models/new/entry'; +import {Favorite} from '../../../../db/models/new/favorite'; +import {RevisionModel} from '../../../../db/models/new/revision'; +import {Tenant} from '../../../../db/models/new/tenant'; +import {WorkbookModel} from '../../../../db/models/new/workbook'; + +import {entryColumns, favoriteColumns, revisionColumns, tenantColumns} from './constants'; + +type SelectedFavorite = Pick>; + +export type SelectedRevision = Pick>; + +type SelectedTenant = Pick>; + +export type SelectedEntry = Pick> & { + revisions?: SelectedRevision[]; + savedRevision?: SelectedRevision; + publishedRevision?: SelectedRevision; + workbook?: WorkbookModel; + favorite?: SelectedFavorite; + tenant?: SelectedTenant; +}; diff --git a/src/services/new/entry/get-entry-v2/utils.ts b/src/services/new/entry/get-entry-v2/utils.ts new file mode 100644 index 00000000..8f9b23d9 --- /dev/null +++ b/src/services/new/entry/get-entry-v2/utils.ts @@ -0,0 +1,77 @@ +import {AppContext, AppError} from '@gravity-ui/nodekit'; +import {TransactionOrKnex} from 'objection'; + +import {US_ERRORS} from '../../../../const'; +import {WorkbookModel} from '../../../../db/models/new/workbook'; +import {WorkbookPermission} from '../../../../entities/workbook'; +import {getParentIds} from '../../collection/utils'; +import {getReplica} from '../../utils'; +import {getEntryPermissionsByWorkbook} from '../../workbook/utils'; + +import {SelectedEntry} from './types'; + +const GET_PARENTS_QUERY_TIMEOUT = 3000; + +export const checkWorkbookEntry = async ({ + ctx, + trx, + entry, + workbook, + includePermissionsInfo, +}: { + ctx: AppContext; + trx?: TransactionOrKnex; + entry: SelectedEntry; + workbook: WorkbookModel; + includePermissionsInfo?: boolean; +}) => { + let parentIds: string[] = []; + + if (workbook.collectionId !== null) { + parentIds = await getParentIds({ + ctx, + trx: getReplica(trx), + collectionId: workbook.collectionId, + getParentsQueryTimeout: GET_PARENTS_QUERY_TIMEOUT, + }); + } + + const registry = ctx.get('registry'); + + const {Workbook} = registry.common.classes.get(); + + const workbookInstance = new Workbook({ + ctx, + model: workbook, + }); + + const {accessServiceEnabled} = ctx.config; + + if (accessServiceEnabled) { + if (includePermissionsInfo) { + await workbookInstance.fetchAllPermissions({parentIds}); + + if (!workbookInstance.permissions?.[WorkbookPermission.LimitedView]) { + throw new AppError(US_ERRORS.ACCESS_SERVICE_PERMISSION_DENIED, { + code: US_ERRORS.ACCESS_SERVICE_PERMISSION_DENIED, + }); + } + } else { + await workbookInstance.checkPermission({ + parentIds, + permission: WorkbookPermission.LimitedView, + }); + } + } else { + workbookInstance.enableAllPermissions(); + } + + if (includePermissionsInfo) { + return getEntryPermissionsByWorkbook({ + workbook: workbookInstance, + scope: entry.scope, + }); + } + + return undefined; +}; diff --git a/src/services/new/entry/get-entry.ts b/src/services/new/entry/get-entry.ts index fda48dcb..e7071fbc 100644 --- a/src/services/new/entry/get-entry.ts +++ b/src/services/new/entry/get-entry.ts @@ -134,115 +134,114 @@ export const getEntry = async ( trx: getReplica(trx), }); - if (joinedEntryRevisionFavoriteTenant) { - const {isNeedBypassEntryByKey, getServicePlan} = registry.common.functions.get(); + if (!joinedEntryRevisionFavoriteTenant) { + throw new AppError(US_ERRORS.NOT_EXIST_ENTRY, { + code: US_ERRORS.NOT_EXIST_ENTRY, + }); + } - const dlsBypassByKeyEnabled = isNeedBypassEntryByKey( - ctx, - joinedEntryRevisionFavoriteTenant.key as string, - ); - - let dlsPermissions: any; // TODO: Update the type after refactoring DLS.checkPermission(...) - let iamPermissions: Optional; - - if (joinedEntryRevisionFavoriteTenant.workbookId) { - const checkWorkbookEnabled = - !isPrivateRoute && !onlyPublic && !onlyMirrored && !isEmbedding; - - if (checkWorkbookEnabled) { - if (isEnabledFeature(ctx, Feature.WorkbookIsolationEnabled)) { - checkWorkbookIsolation({ - ctx, - workbookId: joinedEntryRevisionFavoriteTenant.workbookId, - }); - } - - const workbook = await getWorkbook( - {ctx, trx}, - { - workbookId: joinedEntryRevisionFavoriteTenant.workbookId, - includePermissionsInfo, - }, - ); - - if (includePermissionsInfo) { - iamPermissions = getEntryPermissionsByWorkbook({ - workbook, - scope: joinedEntryRevisionFavoriteTenant[EntryColumn.Scope], - }); - } - } - } else { - const checkPermissionEnabled = - !dlsBypassByKeyEnabled && - !isPrivateRoute && - ctx.config.dlsEnabled && - !onlyPublic && - !onlyMirrored; - - const checkEntryEnabled = - !isPrivateRoute && !onlyPublic && !onlyMirrored && !isEmbedding; - - if (checkPermissionEnabled) { - dlsPermissions = await DLS.checkPermission( - {ctx, trx}, - { - entryId, - action: DlsActions.Execute, - includePermissionsInfo, - }, - ); - } + const {isNeedBypassEntryByKey, getServicePlan} = registry.common.functions.get(); + + const dlsBypassByKeyEnabled = isNeedBypassEntryByKey( + ctx, + joinedEntryRevisionFavoriteTenant.key as string, + ); - if (checkEntryEnabled) { - if (isEnabledFeature(ctx, Feature.WorkbookIsolationEnabled)) { - checkWorkbookIsolation({ - ctx, - workbookId: null, - }); - } + let dlsPermissions: any; // TODO: Update the type after refactoring DLS.checkPermission(...) + let iamPermissions: Optional; - await checkFetchedEntry(ctx, joinedEntryRevisionFavoriteTenant, getReplica(trx)); + if (joinedEntryRevisionFavoriteTenant.workbookId) { + const checkWorkbookEnabled = + !isPrivateRoute && !onlyPublic && !onlyMirrored && !isEmbedding; + + if (checkWorkbookEnabled) { + if (isEnabledFeature(ctx, Feature.WorkbookIsolationEnabled)) { + checkWorkbookIsolation({ + ctx, + workbookId: joinedEntryRevisionFavoriteTenant.workbookId, + }); } - } - let servicePlan: string | undefined; - if (includeServicePlan) { - servicePlan = getServicePlan(joinedEntryRevisionFavoriteTenant); + const workbook = await getWorkbook( + {ctx, trx}, + { + workbookId: joinedEntryRevisionFavoriteTenant.workbookId, + includePermissionsInfo, + }, + ); + + if (includePermissionsInfo) { + iamPermissions = getEntryPermissionsByWorkbook({ + workbook, + scope: joinedEntryRevisionFavoriteTenant[EntryColumn.Scope], + }); + } + } + } else { + const checkPermissionEnabled = + !dlsBypassByKeyEnabled && + !isPrivateRoute && + ctx.config.dlsEnabled && + !onlyPublic && + !onlyMirrored; + + const checkEntryEnabled = !isPrivateRoute && !onlyPublic && !onlyMirrored && !isEmbedding; + + if (checkPermissionEnabled) { + dlsPermissions = await DLS.checkPermission( + {ctx, trx}, + { + entryId, + action: DlsActions.Execute, + includePermissionsInfo, + }, + ); } - let tenantFeatures: Record | undefined; + if (checkEntryEnabled) { + if (isEnabledFeature(ctx, Feature.WorkbookIsolationEnabled)) { + checkWorkbookIsolation({ + ctx, + workbookId: null, + }); + } - if (includeTenantFeatures) { - tenantFeatures = joinedEntryRevisionFavoriteTenant[TenantColumn.Features] || undefined; + await checkFetchedEntry(ctx, joinedEntryRevisionFavoriteTenant, getReplica(trx)); } + } - let permissions: EntryPermissions = {}; - if (includePermissionsInfo) { - permissions = OldEntry.originatePermissions({ - isPrivateRoute, - shared: onlyPublic || isEmbedding, - permissions: dlsPermissions, - iamPermissions, - ctx, - }); - } + let servicePlan: string | undefined; + if (includeServicePlan) { + servicePlan = getServicePlan(joinedEntryRevisionFavoriteTenant); + } - ctx.log('GET_ENTRY_SUCCESS'); - - return { - joinedEntryRevisionFavoriteTenant, - permissions, - includePermissionsInfo, - includeLinks, - servicePlan, - includeServicePlan, - includeTenantFeatures, - tenantFeatures, - }; - } else { - throw new AppError(US_ERRORS.NOT_EXIST_ENTRY, { - code: US_ERRORS.NOT_EXIST_ENTRY, + let tenantFeatures: Record | undefined; + + if (includeTenantFeatures) { + tenantFeatures = joinedEntryRevisionFavoriteTenant[TenantColumn.Features] || undefined; + } + + let permissions: EntryPermissions = {}; + if (includePermissionsInfo) { + permissions = OldEntry.originatePermissions({ + isPrivateRoute, + shared: onlyPublic || isEmbedding, + permissions: dlsPermissions, + iamPermissions, + ctx, }); } + + ctx.log('GET_ENTRY_SUCCESS'); + + return { + joinedEntryRevisionFavoriteTenant, + permissions, + includePermissionsInfo, + includeLinks, + servicePlan, + includeServicePlan, + includeTenantFeatures, + tenantFeatures, + }; }; diff --git a/src/services/new/entry/index.ts b/src/services/new/entry/index.ts index a88da372..5543c328 100644 --- a/src/services/new/entry/index.ts +++ b/src/services/new/entry/index.ts @@ -5,3 +5,4 @@ export * from './get-entry-meta-private'; export * from './get-entry-meta'; export * from './get-entry'; export * from './get-joined-entries-revisions-by-ids'; +export * from './get-entry-v2'; diff --git a/src/utils/index.ts b/src/utils/index.ts index 84bd28df..383235e1 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -4,3 +4,4 @@ export default Utils; export * from './user'; export * from './validation'; export * from './tenant'; +export * from './promise'; diff --git a/src/utils/promise/index.ts b/src/utils/promise/index.ts new file mode 100644 index 00000000..13f7b1ba --- /dev/null +++ b/src/utils/promise/index.ts @@ -0,0 +1 @@ +export * from './with-timeout'; diff --git a/src/utils/promise/with-timeout.ts b/src/utils/promise/with-timeout.ts new file mode 100644 index 00000000..e2c0a105 --- /dev/null +++ b/src/utils/promise/with-timeout.ts @@ -0,0 +1,20 @@ +import {AppError} from '@gravity-ui/nodekit'; + +import {US_ERRORS} from '../../const'; + +export const withTimeout = async ( + promise: Promise, + {timeoutMs, errorMessage}: {timeoutMs: number; errorMessage: string}, +): Promise => { + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject( + new AppError(errorMessage, { + code: US_ERRORS.ACTION_TIMEOUT, + }), + ); + }, timeoutMs); + }); + + return Promise.race([promise, timeoutPromise]); +};