diff --git a/api/server/app-env.ts b/api/server/app-env.ts index 05f8e1cac8..93826c6aa6 100644 --- a/api/server/app-env.ts +++ b/api/server/app-env.ts @@ -4,4 +4,5 @@ export { isDatalensMode, isFullMode, isApiMode, + isPublicApiMode, } from '../../src/server/app-env'; diff --git a/api/server/components.ts b/api/server/components.ts index 9245b7fb52..be1b3abf61 100644 --- a/api/server/components.ts +++ b/api/server/components.ts @@ -19,3 +19,12 @@ export { } from '../../src/server/components/charts-engine'; export {renderHTML} from '../../src/server/components/charts-engine/components/markdown'; + +export {initPublicApiSwagger} from '../../src/server/components/public-api'; + +export {PUBLIC_API_PROXY_MAP, PUBLIC_API_ROUTE} from '../../src/server/components/public-api'; +export type { + PublicApiRpcMap, + PublicApiConfig, + PublicApiSecuritySchemes, +} from '../../src/server/components/public-api/types'; diff --git a/api/server/constants.ts b/api/server/constants.ts index cb1832e2ea..bf082fe8a6 100644 --- a/api/server/constants.ts +++ b/api/server/constants.ts @@ -1,2 +1,3 @@ export {SERVICE_NAME_DATALENS} from '../../src/server/constants'; export {IPV6_AXIOS_OPTIONS} from '../../src/server/constants/axios'; +export {PUBLIC_API_ORG_ID_HEADER} from '../../src/server/constants/public-api'; diff --git a/api/server/controllers.ts b/api/server/controllers.ts index a439bbc601..ad2e10e0ac 100644 --- a/api/server/controllers.ts +++ b/api/server/controllers.ts @@ -1,2 +1,3 @@ export {ping} from '../../src/server/controllers/ping'; export {chartsController} from '../../src/server/components/charts-engine/controllers/charts'; +export {createPublicApiController} from '../../src/server/controllers'; diff --git a/package-lock.json b/package-lock.json index 5e76e71721..2fd711f3a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "license": "Apache-2.0", "dependencies": { + "@asteasolutions/zod-to-openapi": "^7.3.4", "@braintree/sanitize-url": "^6.0.0", "@datalens-tech/ui-sandbox-modules": "^0.36.0", "@datalens-tech/xlsx": "^0.20.1", @@ -73,7 +74,9 @@ "request-ip": "^3.3.0", "request-promise-native": "^1.0.9", "set-cookie-parser": "^2.7.1", - "workerpool": "^9.1.1" + "swagger-ui-express": "^5.0.1", + "workerpool": "^9.1.1", + "zod": "^3.25.64" }, "devDependencies": { "@floating-ui/react": "^0.27.13", @@ -136,6 +139,7 @@ "@types/request-ip": "^0.0.41", "@types/request-promise-native": "^1.0.21", "@types/set-cookie-parser": "^2.4.10", + "@types/swagger-ui-express": "^4.1.8", "@types/uuid": "^9.0.8", "@types/webpack-env": "^1.16.0", "bem-cn-lite": "^4.0.0", @@ -235,6 +239,18 @@ "node": ">=6.0.0" } }, + "node_modules/@asteasolutions/zod-to-openapi": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-7.3.4.tgz", + "integrity": "sha512-/2rThQ5zPi9OzVwes6U7lK1+Yvug0iXu25olp7S0XsYmOqnyMfxH7gdSQjn/+DSOHRg7wnotwGJSyL+fBKdnEA==", + "license": "MIT", + "dependencies": { + "openapi3-ts": "^4.1.2" + }, + "peerDependencies": { + "zod": "^3.20.2" + } + }, "node_modules/@aws-crypto/crc32": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", @@ -9334,6 +9350,13 @@ } } }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", @@ -12605,6 +12628,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/swagger-ui-express": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz", + "integrity": "sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/serve-static": "*" + } + }, "node_modules/@types/tapable": { "version": "2.2.7", "resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-2.2.7.tgz", @@ -25287,16 +25321,6 @@ "node": ">=8" } }, - "node_modules/lint-staged/node_modules/yaml": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.1.tgz", - "integrity": "sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 14" - } - }, "node_modules/loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", @@ -26643,6 +26667,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi3-ts": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.5.0.tgz", + "integrity": "sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==", + "license": "MIT", + "dependencies": { + "yaml": "^2.8.0" + } + }, "node_modules/opener": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", @@ -31834,6 +31867,30 @@ "url": "https://opencollective.com/svgo" } }, + "node_modules/swagger-ui-dist": { + "version": "5.28.1", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.28.1.tgz", + "integrity": "sha512-IvPrtNi8MvjiuDgoSmPYgg27Lvu38fnLD1OSd8Y103xXsPAqezVNnNeHnVCZ/d+CMXJblflGaIyHxAYIF3O71w==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/swc-loader": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/swc-loader/-/swc-loader-0.2.6.tgz", @@ -33726,6 +33783,18 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -33805,6 +33874,15 @@ "toposort": "^2.0.2", "type-fest": "^2.19.0" } + }, + "node_modules/zod": { + "version": "3.25.64", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.64.tgz", + "integrity": "sha512-hbP9FpSZf7pkS7hRVUrOjhwKJNyampPgtXKc3AN6DsWtoHsg2Sb4SQaS4Tcay380zSwd2VPo9G9180emBACp5g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 0527149dac..42ca799cec 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "author": "DataLens Team ", "license": "Apache-2.0", "dependencies": { + "@asteasolutions/zod-to-openapi": "^7.3.4", "@braintree/sanitize-url": "^6.0.0", "@datalens-tech/ui-sandbox-modules": "^0.36.0", "@datalens-tech/xlsx": "^0.20.1", @@ -118,7 +119,9 @@ "request-ip": "^3.3.0", "request-promise-native": "^1.0.9", "set-cookie-parser": "^2.7.1", - "workerpool": "^9.1.1" + "swagger-ui-express": "^5.0.1", + "workerpool": "^9.1.1", + "zod": "^3.25.64" }, "devDependencies": { "@floating-ui/react": "^0.27.13", @@ -181,6 +184,7 @@ "@types/request-ip": "^0.0.41", "@types/request-promise-native": "^1.0.21", "@types/set-cookie-parser": "^2.4.10", + "@types/swagger-ui-express": "^4.1.8", "@types/uuid": "^9.0.8", "@types/webpack-env": "^1.16.0", "bem-cn-lite": "^4.0.0", diff --git a/src/server/app-env.ts b/src/server/app-env.ts index 741cf23d52..bd7cd29492 100644 --- a/src/server/app-env.ts +++ b/src/server/app-env.ts @@ -10,4 +10,6 @@ export const isFullMode = mode === AppMode.Full; export const isDatalensMode = mode === AppMode.Datalens; export const isChartsMode = mode === AppMode.Charts; export const isApiMode = mode === AppMode.Api; +export const isPublicApiMode = mode === AppMode.PublicApi; + export const isOpensourceInstallation = appInstallation === AppInstallation.Opensource; diff --git a/src/server/components/api-docs/constants.ts b/src/server/components/api-docs/constants.ts new file mode 100644 index 0000000000..6b1a01cad6 --- /dev/null +++ b/src/server/components/api-docs/constants.ts @@ -0,0 +1 @@ +export const CONTENT_TYPE_JSON = 'application/json'; diff --git a/src/server/components/api-docs/index.ts b/src/server/components/api-docs/index.ts new file mode 100644 index 0000000000..a50caf907f --- /dev/null +++ b/src/server/components/api-docs/index.ts @@ -0,0 +1 @@ +export type {SecuritySchemeObject} from './types'; diff --git a/src/server/components/api-docs/types.ts b/src/server/components/api-docs/types.ts new file mode 100644 index 0000000000..1fb28b48d7 --- /dev/null +++ b/src/server/components/api-docs/types.ts @@ -0,0 +1,14 @@ +// Copied from @asteasolutions/zod-to-openapi +export type Method = 'get' | 'post' | 'put' | 'delete' | 'patch' | 'head' | 'options' | 'trace'; + +export type SecuritySchemeType = 'apiKey' | 'http' | 'oauth2' | 'openIdConnect'; + +export type SecuritySchemeObject = { + type: SecuritySchemeType; + description?: string; + name?: string; + in?: string; + scheme?: string; + bearerFormat?: string; + openIdConnectUrl?: string; +}; diff --git a/src/server/components/public-api/constants.ts b/src/server/components/public-api/constants.ts new file mode 100644 index 0000000000..ebe99cde13 --- /dev/null +++ b/src/server/components/public-api/constants.ts @@ -0,0 +1,87 @@ +import {OpenAPIRegistry} from '@asteasolutions/zod-to-openapi'; + +import type {PublicApiRpcMap} from './types'; + +export const publicApiOpenApiRegistry = new OpenAPIRegistry(); + +export const PUBLIC_API_HTTP_METHOD = 'POST'; +export const PUBLIC_API_URL = '/rpc/:version/:action'; +export const PUBLIC_API_ROUTE = `${PUBLIC_API_HTTP_METHOD} ${PUBLIC_API_URL}`; + +enum ApiTag { + Connection = 'Connection', + Dataset = 'Dataset', + Wizard = 'Wizard', + Editor = 'Editor', + Dashboard = 'Dashboard', +} + +export const PUBLIC_API_PROXY_MAP = { + v0: { + // Connection + deleteConnection: { + resolve: (api) => api.bi.deleteConnection, + openApi: { + summary: 'Delete connection', + tags: [ApiTag.Connection], + }, + }, + + // Dataset + getDataset: { + resolve: (api) => api.bi.getDatasetByVersion, + openApi: { + summary: 'Get dataset', + tags: [ApiTag.Dataset], + }, + }, + updateDataset: { + resolve: (api) => api.bi.updateDataset, + openApi: { + summary: 'Update dataset', + tags: [ApiTag.Dataset], + }, + }, + createDataset: { + resolve: (api) => api.bi.createDataset, + openApi: { + summary: 'Create dataset', + tags: [ApiTag.Dataset], + }, + }, + deleteDataset: { + resolve: (api) => api.bi.deleteDataset, + openApi: { + summary: 'Delete dataset', + tags: [ApiTag.Dataset], + }, + }, + + // Wizard + deleteWizardChart: { + resolve: (api) => api.mix.__deleteWizardChart__, + openApi: { + summary: 'Delete wizard chart', + tags: [ApiTag.Wizard], + }, + }, + + // Editor + deleteEditorChart: { + resolve: (api) => api.mix.__deleteEditorChart__, + openApi: { + summary: 'Delete editor chart', + tags: [ApiTag.Editor], + }, + }, + + // Dashboard + deleteDashboard: { + resolve: (api) => api.mix.__deleteDashboard__, + openApi: { + summary: 'Delete dashboard', + tags: [ApiTag.Dashboard], + }, + }, + }, +} satisfies PublicApiRpcMap; diff --git a/src/server/components/public-api/index.ts b/src/server/components/public-api/index.ts new file mode 100644 index 0000000000..e09e300a19 --- /dev/null +++ b/src/server/components/public-api/index.ts @@ -0,0 +1,7 @@ +export { + PUBLIC_API_PROXY_MAP, + PUBLIC_API_HTTP_METHOD, + PUBLIC_API_ROUTE, + PUBLIC_API_URL, +} from './constants'; +export {initPublicApiSwagger, registerActionToOpenApi} from './utils'; diff --git a/src/server/components/public-api/types.ts b/src/server/components/public-api/types.ts new file mode 100644 index 0000000000..91d6873434 --- /dev/null +++ b/src/server/components/public-api/types.ts @@ -0,0 +1,29 @@ +import type {Request, Response} from '@gravity-ui/expresskit'; +import type {ApiWithRoot, GatewayActionUnaryResponse, SchemasByScope} from '@gravity-ui/gateway'; + +import type {DatalensGatewaySchemas} from '../../types/gateway'; +import type {SecuritySchemeObject} from '../api-docs'; + +export type PublicApiRpcMap = Record< + string, + Record< + string, + { + resolve: ( + api: ApiWithRoot, + ) => (params: any) => Promise>; + openApi: { + summary: string; + tags?: string[]; + }; + } + > +>; + +export type PublicApiSecuritySchemes = Record; + +export type PublicApiConfig = { + proxyMap: PublicApiRpcMap; + securitySchemes: PublicApiSecuritySchemes; + securityTypes: string[]; +}; diff --git a/src/server/components/public-api/utils/index.ts b/src/server/components/public-api/utils/index.ts new file mode 100644 index 0000000000..435db20399 --- /dev/null +++ b/src/server/components/public-api/utils/index.ts @@ -0,0 +1,2 @@ +export {initPublicApiSwagger} from './init-public-api-swagger'; +export {registerActionToOpenApi} from './register-action-to-open-api'; diff --git a/src/server/components/public-api/utils/init-public-api-swagger.ts b/src/server/components/public-api/utils/init-public-api-swagger.ts new file mode 100644 index 0000000000..a4e6ed57c7 --- /dev/null +++ b/src/server/components/public-api/utils/init-public-api-swagger.ts @@ -0,0 +1,56 @@ +import {OpenApiGeneratorV31} from '@asteasolutions/zod-to-openapi'; +import type {OpenAPIObjectConfigV31} from '@asteasolutions/zod-to-openapi/dist/v3.1/openapi-generator'; +import type {ExpressKit} from '@gravity-ui/expresskit'; +import swaggerUi from 'swagger-ui-express'; + +import {publicApiOpenApiRegistry} from '../constants'; +import type {PublicApiSecuritySchemes} from '../types'; + +export const initPublicApiSwagger = ( + app: ExpressKit, + securitySchemes?: PublicApiSecuritySchemes, +) => { + const {config} = app; + + const installationText = `Installation – ${config.appInstallation}`; + const envText = `Env – ${config.appEnv}`; + const descriptionText = `
Datalens api.`; + + setImmediate(() => { + if (securitySchemes) { + Object.keys(securitySchemes).forEach((securityType) => { + publicApiOpenApiRegistry.registerComponent('securitySchemes', securityType, { + ...securitySchemes[securityType], + }); + }); + } + + const generator = new OpenApiGeneratorV31(publicApiOpenApiRegistry.definitions); + + const generateDocumentParams: OpenAPIObjectConfigV31 = { + openapi: '3.1.0', + info: { + version: `v0`, + title: `DataLens API `, + description: [installationText, envText, descriptionText].join('
'), + }, + servers: [{url: '/'}], + }; + + const openApiDocument = generator.generateDocument(generateDocumentParams); + + app.express.get('/api-docs.json', (req, res) => { + const host = req.get('host'); + const serverUrl = `https://${host}`; + + const result: typeof openApiDocument = { + ...openApiDocument, + servers: [{url: serverUrl}], + }; + + return res.json(result); + }); + + app.express.use('/api-docs/', swaggerUi.serve, swaggerUi.setup(openApiDocument)); + }); +}; diff --git a/src/server/components/public-api/utils/register-action-to-open-api.ts b/src/server/components/public-api/utils/register-action-to-open-api.ts new file mode 100644 index 0000000000..ab33d7ab8c --- /dev/null +++ b/src/server/components/public-api/utils/register-action-to-open-api.ts @@ -0,0 +1,103 @@ +import type {ZodMediaTypeObject} from '@asteasolutions/zod-to-openapi'; +import z from 'zod'; +import z4 from 'zod/v4'; + +import {getValidationSchema} from '../../../../shared/schema/gateway-utils'; +import {registry} from '../../../registry'; +import type {AnyApiServiceActionConfig} from '../../../types/gateway'; +import {CONTENT_TYPE_JSON} from '../../api-docs/constants'; +import {PUBLIC_API_HTTP_METHOD, PUBLIC_API_URL, publicApiOpenApiRegistry} from '../constants'; + +const resolveUrl = ({version, actionName}: {version: string; actionName: string}) => { + return PUBLIC_API_URL.replace(':version', version).replace(':action', actionName); +}; + +const defaultSchema = { + summary: 'Type not defined', + request: { + body: { + content: { + [CONTENT_TYPE_JSON]: { + schema: z.object({}), + }, + }, + }, + }, + responses: { + 200: { + description: 'TBD', + content: { + [CONTENT_TYPE_JSON]: { + schema: z.object({}), + }, + }, + }, + }, +}; + +export const registerActionToOpenApi = ({ + actionConfig, + version, + actionName, + openApi, +}: { + actionConfig: AnyApiServiceActionConfig; + version: string; + actionName: string; + openApi: { + summary: string; + tags?: string[]; + }; +}) => { + const {securityTypes} = registry.getPublicApiConfig(); + + const actionSchema = getValidationSchema(actionConfig); + + const security = securityTypes.map((type) => ({ + [type]: [], + })); + + if (actionSchema) { + publicApiOpenApiRegistry.registerPath({ + method: PUBLIC_API_HTTP_METHOD.toLocaleLowerCase() as Lowercase< + typeof PUBLIC_API_HTTP_METHOD + >, + path: resolveUrl({version, actionName}), + ...openApi, + request: { + body: { + content: { + [CONTENT_TYPE_JSON]: { + schema: z4.toJSONSchema( + actionSchema.paramsSchema, + ) as ZodMediaTypeObject['schema'], + }, + }, + }, + }, + responses: { + 200: { + description: 'Response', + content: { + [CONTENT_TYPE_JSON]: { + schema: z4.toJSONSchema( + actionSchema.resultSchema, + ) as ZodMediaTypeObject['schema'], + }, + }, + }, + }, + security, + }); + } else { + publicApiOpenApiRegistry.registerPath({ + method: PUBLIC_API_HTTP_METHOD.toLocaleLowerCase() as Lowercase< + typeof PUBLIC_API_HTTP_METHOD + >, + path: resolveUrl({version, actionName}), + ...openApi, + ...defaultSchema, + security, + }); + } +}; diff --git a/src/server/constants/public-api.ts b/src/server/constants/public-api.ts new file mode 100644 index 0000000000..105acced81 --- /dev/null +++ b/src/server/constants/public-api.ts @@ -0,0 +1 @@ +export const PUBLIC_API_ORG_ID_HEADER = 'x-dl-org-id'; diff --git a/src/server/controllers/index.ts b/src/server/controllers/index.ts index a572cd10ab..bc303a6fa9 100644 --- a/src/server/controllers/index.ts +++ b/src/server/controllers/index.ts @@ -2,5 +2,12 @@ import {apiControllers} from './api'; import {dlMainController} from './dl-main'; import {navigateController} from './navigate'; import {navigationController} from './navigation'; +import {createPublicApiController} from './public-api'; -export {apiControllers, dlMainController, navigateController, navigationController}; +export { + apiControllers, + dlMainController, + navigateController, + navigationController, + createPublicApiController, +}; diff --git a/src/server/controllers/public-api/constants.ts b/src/server/controllers/public-api/constants.ts new file mode 100644 index 0000000000..5d91666b23 --- /dev/null +++ b/src/server/controllers/public-api/constants.ts @@ -0,0 +1,10 @@ +import {AppError} from '@gravity-ui/nodekit'; + +export class PublicApiError extends AppError {} + +export const PUBLIC_API_ERRORS = { + VALIDATION_ERROR: 'VALIDATION_ERROR', + ENDPOINT_NOT_FOUND: 'ENDPOINT_NOT_FOUND', + ACTION_CONFIG_NOT_FOUND: 'ACTION_CONFIG_NOT_FOUND', + ACTION_VALIDATION_SCHEMA_NOT_FOUND: 'ACTION_VALIDATION_SCHEMA_NOT_FOUND', +} as const; diff --git a/src/server/controllers/public-api/index.ts b/src/server/controllers/public-api/index.ts new file mode 100644 index 0000000000..bc77032ae7 --- /dev/null +++ b/src/server/controllers/public-api/index.ts @@ -0,0 +1,106 @@ +import type {Request, Response} from '@gravity-ui/expresskit'; +import {AppError, REQUEST_ID_PARAM_NAME} from '@gravity-ui/nodekit'; + +import {getValidationSchema} from '../../../shared/schema/gateway-utils'; +import {registerActionToOpenApi} from '../../components/public-api'; +import {registry} from '../../registry'; +import type {AnyApiServiceActionConfig, DatalensGatewaySchemas} from '../../types/gateway'; +import Utils from '../../utils'; + +import {PUBLIC_API_ERRORS, PublicApiError} from './constants'; +import {prepareError, validateRequestBody} from './utils'; + +export const createPublicApiController = () => { + const {gatewayApi} = registry.getGatewayApi(); + const schemasByScope = registry.getGatewaySchemasByScope(); + const {proxyMap} = registry.getPublicApiConfig(); + + const actionToPathMap = new Map(); + + Object.entries(gatewayApi).forEach(([serviceName, actions]) => { + Object.entries(actions).forEach(([actionName, action]) => { + actionToPathMap.set(action, {serviceName, actionName}); + }); + }); + + const actionToConfigMap = new Map(); + + Object.entries(proxyMap).forEach(([version, actions]) => { + Object.entries(actions).forEach(([actionName, {resolve, openApi}]) => { + const gatewayAction = resolve(gatewayApi); + const pathObject = actionToPathMap.get(gatewayAction); + + if (!pathObject) { + throw new AppError('Public api proxyMap action not found in gatewayApi.'); + } + + const actionConfig = + schemasByScope.root[pathObject.serviceName].actions[pathObject.actionName]; + + actionToConfigMap.set(gatewayAction, actionConfig); + + registerActionToOpenApi({actionConfig, actionName, version, openApi}); + }); + }); + + return async function publicApiController(req: Request, res: Response) { + try { + const {version, action: actionName} = req.params; + + if (!version || !actionName || !proxyMap[version] || !proxyMap[version][actionName]) { + throw new PublicApiError(`Endpoint ${req.path} does not exist`, { + code: PUBLIC_API_ERRORS.ENDPOINT_NOT_FOUND, + }); + } + + const action = proxyMap[version][actionName]; + + const {ctx} = req; + + const headers = Utils.pickRpcHeaders(req); + const requestId = ctx.get(REQUEST_ID_PARAM_NAME) || ''; + + const gatewayAction = action.resolve(gatewayApi); + const gatewayActionConfig = actionToConfigMap.get(gatewayAction); + + if (!gatewayActionConfig) { + req.ctx.logError(`Couldn't find action config in actionToConfigMap`); + throw new PublicApiError(PUBLIC_API_ERRORS.ACTION_CONFIG_NOT_FOUND, { + code: PUBLIC_API_ERRORS.ACTION_CONFIG_NOT_FOUND, + }); + } + + const validationSchema = getValidationSchema(gatewayActionConfig); + + if (!validationSchema) { + req.ctx.logError(`Couldn't find action validation schema`); + throw new PublicApiError(PUBLIC_API_ERRORS.ACTION_VALIDATION_SCHEMA_NOT_FOUND, { + code: PUBLIC_API_ERRORS.ACTION_VALIDATION_SCHEMA_NOT_FOUND, + }); + } + + const {paramsSchema} = validationSchema; + + const validatedArgs = await validateRequestBody(paramsSchema, req.body); + + const result = await gatewayAction({ + headers, + args: validatedArgs, + ctx, + requestId, + }); + + res.status(200).send(result.responseData); + } catch (err: unknown) { + const {status, message, code, details} = prepareError(err); + + res.status(status).send({ + status, + code, + message, + requestId: req.ctx.get(REQUEST_ID_PARAM_NAME) || '', + details, + }); + } + }; +}; diff --git a/src/server/controllers/public-api/utils.ts b/src/server/controllers/public-api/utils.ts new file mode 100644 index 0000000000..ea69470fae --- /dev/null +++ b/src/server/controllers/public-api/utils.ts @@ -0,0 +1,110 @@ +import {AppError} from '@gravity-ui/nodekit'; +import {AxiosError} from 'axios'; +import isObject from 'lodash/isObject'; +import type z from 'zod/v4'; +import {ZodError} from 'zod/v4'; + +import {isGatewayError} from '../../utils/gateway'; + +import {PUBLIC_API_ERRORS, PublicApiError} from './constants'; + +export const prepareError = ( + error: unknown, +): {status: number; message: string; code?: string; details?: unknown} => { + if (error instanceof PublicApiError) { + const {code, message, details} = error; + + switch (code) { + case PUBLIC_API_ERRORS.VALIDATION_ERROR: { + return {status: 400, message, code, details}; + } + + case PUBLIC_API_ERRORS.ENDPOINT_NOT_FOUND: { + return {status: 404, message, code, details}; + } + + default: { + return { + status: 500, + message: 'Internal server error', + }; + } + } + } + + if (isGatewayError(error)) { + const {error: innerError} = error; + + if (innerError.status !== 500) { + return { + status: innerError.status, + code: innerError.code, + message: innerError.message, + details: innerError.details, + }; + } + + const originalError = innerError.debug.originalError; + + if (originalError instanceof AxiosError) { + const status = originalError.status ?? 500; + let message = originalError.message; + let code: string | undefined; + let details: unknown; + + const data = originalError.response?.data; + + if (isObject(data)) { + if ('message' in data && typeof data.message === 'string') { + message = data.message; + } + + if ('code' in data && typeof data.code === 'string') { + code = data.code; + } + + if ('details' in data) { + details = data.details; + } + } + + return {status, message, code, details}; + } + + if (originalError instanceof AppError) { + const message = originalError.message; + const code = originalError.code ? String(originalError.code) : undefined; + const details = originalError.details; + + return {status: innerError.status, message, code, details}; + } + + if ( + !(originalError instanceof TypeError) && + !(originalError instanceof ReferenceError) && + !(originalError instanceof SyntaxError) + ) { + return {status: innerError.status, message: innerError.message}; + } + } + + return { + status: 500, + message: 'Internal server error', + }; +}; + +export const validateRequestBody = async (schema: z.ZodType, data: unknown): Promise => { + try { + return await schema.parseAsync(data); + } catch (error) { + if (error instanceof ZodError) { + throw new PublicApiError('Validation error', { + code: PUBLIC_API_ERRORS.VALIDATION_ERROR, + details: error.issues, + }); + } + + throw error; + } +}; diff --git a/src/server/expresskit.ts b/src/server/expresskit.ts index b903955a40..66e12cc043 100644 --- a/src/server/expresskit.ts +++ b/src/server/expresskit.ts @@ -20,5 +20,7 @@ export function getExpressKit({ routes[route] = params; }); - return new ExpressKit(nodekit, routes); + const app = new ExpressKit(nodekit, routes); + + return app; } diff --git a/src/server/modes/charts/plugins/datalens/url/build-request-body/default-request.ts b/src/server/modes/charts/plugins/datalens/url/build-request-body/default-request.ts index 3a3bdd3f81..3af73c0799 100644 --- a/src/server/modes/charts/plugins/datalens/url/build-request-body/default-request.ts +++ b/src/server/modes/charts/plugins/datalens/url/build-request-body/default-request.ts @@ -111,7 +111,7 @@ export type BuildDefaultRequestArgs = { fields: ServerField[]; apiVersion: ApiVersion; params: StringParams; - revisionId: string; + revisionId?: string; datasetId: string; allMeasuresMap: Record; diff --git a/src/server/modes/charts/plugins/datalens/url/build-request-body/index.ts b/src/server/modes/charts/plugins/datalens/url/build-request-body/index.ts index e8c63f7c1f..60cd7d310b 100644 --- a/src/server/modes/charts/plugins/datalens/url/build-request-body/index.ts +++ b/src/server/modes/charts/plugins/datalens/url/build-request-body/index.ts @@ -433,7 +433,7 @@ export function prepareSingleRequest({ layerId: string | undefined; extraSettings?: ServerChartsConfig['extraSettings']; sharedData: SharedData; - revisionId: string; + revisionId?: string; }): ApiV2RequestBody { preprocessHierarchies({ visualizationId: visualization.id, @@ -682,7 +682,7 @@ export const getUrlsRequestBody = (args: { datasetFields: ServerDatasetField[]; datasetId: string; layerId: string; - revisionId: string; + revisionId?: string; }): ApiV2RequestBody => { const {params, shared, datasetId, layerId, revisionId} = args; diff --git a/src/server/modes/charts/plugins/request-with-dataset/middlewareAdapters/charts-with-dataset.ts b/src/server/modes/charts/plugins/request-with-dataset/middlewareAdapters/charts-with-dataset.ts index 51f00a5eaf..c286f4fd50 100644 --- a/src/server/modes/charts/plugins/request-with-dataset/middlewareAdapters/charts-with-dataset.ts +++ b/src/server/modes/charts/plugins/request-with-dataset/middlewareAdapters/charts-with-dataset.ts @@ -33,7 +33,7 @@ export default async ( const shared = urlsSourceArgs.shared; const wizardDataset = shared.wizardDataset; - let revisionId: string; + let revisionId: string | undefined; let datasetFields: PartialDatasetField[]; // When Urls are executed on the Wizard side, we don't need a dataset from the CHARTS side to avoid an unnecessary request diff --git a/src/server/modes/opensource/app.ts b/src/server/modes/opensource/app.ts index b4c5ff63fc..e5f5ccc479 100644 --- a/src/server/modes/opensource/app.ts +++ b/src/server/modes/opensource/app.ts @@ -139,7 +139,7 @@ function initApiApp({ beforeAuth: AppMiddleware[]; afterAuth: AppMiddleware[]; }) { - // As charts app execpt chartEngine + // As charts app except chartEngine if (isApiMode) { afterAuth.push(xDlContext(), setSubrequestHeaders, patchLogger, getCtxMiddleware()); beforeAuth.push(beforeAuthDefaults); diff --git a/src/server/registry/index.ts b/src/server/registry/index.ts index 2276561fd9..003b951b93 100644 --- a/src/server/registry/index.ts +++ b/src/server/registry/index.ts @@ -5,6 +5,7 @@ import {getGatewayControllers} from '@gravity-ui/gateway'; import type {AppContext} from '@gravity-ui/nodekit'; import type {ChartsEngine} from '../components/charts-engine'; +import type {PublicApiConfig} from '../components/public-api/types'; import type {QLConnectionTypeMap} from '../modes/charts/plugins/ql/utils/connection'; import {getConnectorToQlConnectionTypeMap} from '../modes/charts/plugins/ql/utils/connection'; import type {GetLayoutConfig} from '../types/app-layout'; @@ -21,10 +22,12 @@ export const wrapperGetGatewayControllers = ( ) => getGatewayControllers(schemasByScope, config); let gateway: ReturnType; +let gatewaySchemasByScope: SchemasByScope; let getLayoutConfig: GetLayoutConfig | undefined; let yfmPlugins: MarkdownItPluginCb[]; let getXlsxConverter: XlsxConverterFn | undefined; let qLConnectionTypeMap: QLConnectionTypeMap | undefined; +let publicApiConfig: PublicApiConfig | undefined; export const registry = { common: commonRegistry, @@ -60,6 +63,7 @@ export const registry = { throw new Error('The method must not be called more than once'); } gateway = wrapperGetGatewayControllers(schemasByScope, config); + gatewaySchemasByScope = schemasByScope; }, getGatewayController() { if (!gateway) { @@ -77,6 +81,13 @@ export const registry = { gatewayApi: ApiWithRoot; }; }, + getGatewaySchemasByScope() { + if (!gatewaySchemasByScope) { + throw new Error('First of all setup the gateway'); + } + + return gatewaySchemasByScope; + }, registerGetLayoutConfig(fn: GetLayoutConfig) { if (getLayoutConfig) { throw new Error( @@ -117,4 +128,17 @@ export const registry = { getQLConnectionTypeMap() { return qLConnectionTypeMap ?? getConnectorToQlConnectionTypeMap(); }, + setupPublicApiConfig(config: PublicApiConfig) { + if (publicApiConfig) { + throw new Error('The method must not be called more than once [setupPublicApiConfig]'); + } + publicApiConfig = config; + }, + getPublicApiConfig() { + if (!publicApiConfig) { + throw new Error('First of all setup the publicApiConfig'); + } + + return publicApiConfig; + }, }; diff --git a/src/server/types/gateway.ts b/src/server/types/gateway.ts index 8d2f1bc0c8..f378ddad8a 100644 --- a/src/server/types/gateway.ts +++ b/src/server/types/gateway.ts @@ -1,6 +1,10 @@ +import type {ApiServiceActionConfig} from '@gravity-ui/gateway'; + import type {authSchema, schema} from '../../shared/schema'; export type DatalensGatewaySchemas = { root: typeof schema; auth: typeof authSchema; }; + +export type AnyApiServiceActionConfig = ApiServiceActionConfig; diff --git a/src/server/utils/index.ts b/src/server/utils/index.ts index 8a657de72f..65725b99ff 100644 --- a/src/server/utils/index.ts +++ b/src/server/utils/index.ts @@ -17,8 +17,10 @@ import { SuperuserHeader, TENANT_ID_HEADER, US_MASTER_TOKEN_HEADER, + makeTenantIdFromOrgId, } from '../../shared'; import {isOpensourceInstallation} from '../app-env'; +import {PUBLIC_API_ORG_ID_HEADER} from '../constants/public-api'; import {isGatewayError} from './gateway'; @@ -84,6 +86,19 @@ class Utils { }; } + static pickRpcHeaders(req: Request) { + const headersMap = req.ctx.config.headersMap; + + const orgId = req.headers[PUBLIC_API_ORG_ID_HEADER]; + const tenantId = orgId && !Array.isArray(orgId) ? makeTenantIdFromOrgId(orgId) : undefined; + + return { + ...pick(req.headers, [AuthHeader.Authorization, headersMap.subjectToken]), + ...Utils.pickForwardHeaders(req.headers), + [TENANT_ID_HEADER]: tenantId, + }; + } + static pickUsMasterToken(req: Request) { const token = req.headers[US_MASTER_TOKEN_HEADER]; if (typeof token !== 'string') { diff --git a/src/server/utils/routes.ts b/src/server/utils/routes.ts index 00581962ba..da59b01d8e 100644 --- a/src/server/utils/routes.ts +++ b/src/server/utils/routes.ts @@ -40,6 +40,7 @@ export const getConfiguredRoute = ( ...params, }; } + default: return null as never; } diff --git a/src/shared/constants/common.ts b/src/shared/constants/common.ts index 62aa493452..5309013f30 100644 --- a/src/shared/constants/common.ts +++ b/src/shared/constants/common.ts @@ -19,6 +19,7 @@ export enum AppMode { Datalens = 'datalens', Charts = 'charts', Api = 'api', + PublicApi = 'public-api', } export enum Language { diff --git a/src/shared/schema/bi/actions/connections.ts b/src/shared/schema/bi/actions/connections.ts index 743a4e38bc..191c58dce3 100644 --- a/src/shared/schema/bi/actions/connections.ts +++ b/src/shared/schema/bi/actions/connections.ts @@ -1,12 +1,11 @@ import {US_MASTER_TOKEN_HEADER, WORKBOOK_ID_HEADER} from '../../../constants'; -import {createAction} from '../../gateway-utils'; +import {createAction, createTypedAction} from '../../gateway-utils'; import {filterUrlFragment} from '../../utils'; import {transformConnectionResponseError} from '../helpers'; +import {deleteConnectionArgsSchema, deleteConnectionResultSchema} from '../schemas/connections'; import type { CreateConnectionArgs, CreateConnectionResponse, - DeleteConnectionArgs, - DeleteConnectionResponse, EnsureUploadRobotArgs, EnsureUploadRobotResponse, ExportConnectionArgs, @@ -95,11 +94,18 @@ export const actions = { params: ({connectionId: _connectionId, ...body}, headers) => ({body, headers}), transformResponseError: transformConnectionResponseError, }), - deleteConnnection: createAction({ - method: 'DELETE', - path: ({connectionId}) => `${PATH_PREFIX}/connections/${filterUrlFragment(connectionId)}`, - params: (_, headers) => ({headers}), - }), + deleteConnection: createTypedAction( + { + paramsSchema: deleteConnectionArgsSchema, + resultSchema: deleteConnectionResultSchema, + }, + { + method: 'DELETE', + path: ({connectionId}) => + `${PATH_PREFIX}/connections/${filterUrlFragment(connectionId)}`, + params: (_, headers) => ({headers}), + }, + ), getConnectionSources: createAction({ method: 'GET', path: ({connectionId}) => `${PATH_PREFIX}/connections/${connectionId}/info/sources`, diff --git a/src/shared/schema/bi/actions/datasets.ts b/src/shared/schema/bi/actions/datasets.ts index b88ffddf2f..8365e83b75 100644 --- a/src/shared/schema/bi/actions/datasets.ts +++ b/src/shared/schema/bi/actions/datasets.ts @@ -4,7 +4,7 @@ import { US_MASTER_TOKEN_HEADER, WORKBOOK_ID_HEADER, } from '../../../constants'; -import {createAction} from '../../gateway-utils'; +import {createAction, createTypedAction} from '../../gateway-utils'; import {filterUrlFragment} from '../../utils'; import { prepareDatasetProperty, @@ -12,6 +12,16 @@ import { transformValidateDatasetFormulaResponseError, transformValidateDatasetResponseError, } from '../helpers'; +import { + createDatasetArgsSchema, + createDatasetResultSchema, + deleteDatasetArgsSchema, + deleteDatasetResultSchema, + getDatasetByVersionArgsSchema, + getDatasetByVersionResultSchema, + updateDatasetArgsSchema, + updateDatasetResultSchema, +} from '../schemas'; import type { CheckConnectionsForPublicationArgs, CheckConnectionsForPublicationResponse, @@ -19,16 +29,10 @@ import type { CheckDatasetsForPublicationResponse, CopyDatasetArgs, CopyDatasetResponse, - CreateDatasetArgs, - CreateDatasetResponse, - DeleteDatasetArgs, - DeleteDatasetResponse, ExportDatasetArgs, ExportDatasetResponse, GetDataSetFieldsByIdArgs, GetDataSetFieldsByIdResponse, - GetDatasetByVersionArgs, - GetDatasetByVersionResponse, GetDistinctsApiV2Args, GetDistinctsApiV2Response, GetDistinctsApiV2TransformedResponse, @@ -39,8 +43,6 @@ import type { GetSourceResponse, ImportDatasetArgs, ImportDatasetResponse, - UpdateDatasetArgs, - UpdateDatasetResponse, ValidateDatasetArgs, ValidateDatasetFormulaArgs, ValidateDatasetFormulaResponse, @@ -62,17 +64,25 @@ export const actions = { }), timeout: TIMEOUT_60_SEC, }), - getDatasetByVersion: createAction({ - method: 'GET', - path: ({datasetId, version}) => - `${API_V1}/datasets/${filterUrlFragment(datasetId)}/versions/${filterUrlFragment( - version, - )}`, - params: ({workbookId, rev_id}, headers) => ({ - headers: {...(workbookId ? {[WORKBOOK_ID_HEADER]: workbookId} : {}), ...headers}, - query: {rev_id}, - }), - }), + + getDatasetByVersion: createTypedAction( + { + paramsSchema: getDatasetByVersionArgsSchema, + resultSchema: getDatasetByVersionResultSchema, + }, + { + method: 'GET', + path: ({datasetId, version}) => + `${API_V1}/datasets/${filterUrlFragment(datasetId)}/versions/${filterUrlFragment( + version, + )}`, + params: ({workbookId, rev_id}, headers) => ({ + headers: {...(workbookId ? {[WORKBOOK_ID_HEADER]: workbookId} : {}), ...headers}, + query: {rev_id}, + }), + }, + ), + getFieldTypes: createAction({ method: 'GET', path: () => `${API_V1}/info/field_types`, @@ -132,14 +142,20 @@ export const actions = { headers: {...(workbookId ? {[WORKBOOK_ID_HEADER]: workbookId} : {}), ...headers}, }), }), - createDataset: createAction({ - method: 'POST', - path: () => `${API_V1}/datasets`, - params: ({dataset, ...restBody}, headers, {ctx}) => { - const resultDataset = prepareDatasetProperty(ctx, dataset); - return {body: {...restBody, dataset: resultDataset}, headers}; + createDataset: createTypedAction( + { + paramsSchema: createDatasetArgsSchema, + resultSchema: createDatasetResultSchema, }, - }), + { + method: 'POST', + path: () => `${API_V1}/datasets`, + params: ({dataset, ...restBody}, headers, {ctx}) => { + const resultDataset = prepareDatasetProperty(ctx, dataset); + return {body: {...restBody, dataset: resultDataset}, headers}; + }, + }, + ), validateDataset: createAction({ method: 'POST', path: ({datasetId, version}) => @@ -158,17 +174,24 @@ export const actions = { transformResponseError: transformValidateDatasetResponseError, timeout: TIMEOUT_95_SEC, }), - updateDataset: createAction({ - method: 'PUT', - path: ({datasetId, version}) => - `${API_V1}/datasets/${filterUrlFragment(datasetId)}/versions/${filterUrlFragment( - version, - )}`, - params: ({dataset, multisource}, headers, {ctx}) => { - const resultDataset = prepareDatasetProperty(ctx, dataset); - return {body: {dataset: resultDataset, multisource}, headers}; + + updateDataset: createTypedAction( + { + paramsSchema: updateDatasetArgsSchema, + resultSchema: updateDatasetResultSchema, }, - }), + { + method: 'PUT', + path: ({datasetId, version}) => + `${API_V1}/datasets/${filterUrlFragment(datasetId)}/versions/${filterUrlFragment( + version, + )}`, + params: ({dataset, multisource}, headers, {ctx}) => { + const resultDataset = prepareDatasetProperty(ctx, dataset); + return {body: {dataset: resultDataset, multisource}, headers}; + }, + }, + ), getPreview: createAction({ method: 'POST', endpoint: 'datasetDataApiEndpoint', @@ -247,11 +270,19 @@ export const actions = { transformResponseData: transformApiV2DistinctsResponse, timeout: TIMEOUT_95_SEC, }), - deleteDataset: createAction({ - method: 'DELETE', - path: ({datasetId}) => `${API_V1}/datasets/${filterUrlFragment(datasetId)}`, - params: (_, headers) => ({headers}), - }), + + deleteDataset: createTypedAction( + { + paramsSchema: deleteDatasetArgsSchema, + resultSchema: deleteDatasetResultSchema, + }, + { + method: 'DELETE', + path: ({datasetId}) => `${API_V1}/datasets/${filterUrlFragment(datasetId)}`, + params: (_, headers) => ({headers}), + }, + ), + _proxyExportDataset: createAction({ method: 'POST', path: ({datasetId}) => `${API_V1}/datasets/export/${datasetId}`, diff --git a/src/shared/schema/bi/schemas/connections.ts b/src/shared/schema/bi/schemas/connections.ts new file mode 100644 index 0000000000..250f0518bc --- /dev/null +++ b/src/shared/schema/bi/schemas/connections.ts @@ -0,0 +1,7 @@ +import z from 'zod/v4'; + +export const deleteConnectionArgsSchema = z.object({ + connectionId: z.string(), +}); + +export const deleteConnectionResultSchema = z.unknown(); diff --git a/src/shared/schema/bi/schemas/datasets.ts b/src/shared/schema/bi/schemas/datasets.ts new file mode 100644 index 0000000000..fcf0f7a4b2 --- /dev/null +++ b/src/shared/schema/bi/schemas/datasets.ts @@ -0,0 +1,49 @@ +import z from 'zod/v4'; + +import {datasetBodySchema, datasetOptionsSchema, datasetSchema} from '../../../zod-schemas/dataset'; + +const createDatasetDefaultArgsSchema = z.object({ + name: z.string(), + created_via: z.string().optional(), + multisource: z.boolean(), + dataset: datasetBodySchema, +}); + +export const createDatasetArgsSchema = z.union([ + z.object({...createDatasetDefaultArgsSchema.shape, dir_path: z.string()}), + z.object({...createDatasetDefaultArgsSchema.shape, workbook_id: z.string()}), +]); + +export const createDatasetResultSchema = z.object({ + id: z.string(), + dataset: datasetBodySchema, + options: datasetOptionsSchema, +}); + +export const updateDatasetArgsSchema = z.object({ + version: z.literal('draft'), + datasetId: z.string(), + multisource: z.boolean(), + dataset: datasetBodySchema, +}); + +export const updateDatasetResultSchema = z.object({ + id: z.string(), + dataset: datasetBodySchema, + options: datasetOptionsSchema, +}); + +export const deleteDatasetArgsSchema = z.object({ + datasetId: z.string(), +}); + +export const deleteDatasetResultSchema = z.unknown(); + +export const getDatasetByVersionArgsSchema = z.object({ + datasetId: z.string(), + version: z.literal('draft'), + workbookId: z.union([z.null(), z.string()]), + rev_id: z.string().optional(), +}); + +export const getDatasetByVersionResultSchema = datasetSchema; diff --git a/src/shared/schema/bi/schemas/index.ts b/src/shared/schema/bi/schemas/index.ts new file mode 100644 index 0000000000..81df9143bc --- /dev/null +++ b/src/shared/schema/bi/schemas/index.ts @@ -0,0 +1 @@ +export * from './datasets'; diff --git a/src/shared/schema/bi/types/connections.ts b/src/shared/schema/bi/types/connections.ts index 74f31627b8..bd8999f489 100644 --- a/src/shared/schema/bi/types/connections.ts +++ b/src/shared/schema/bi/types/connections.ts @@ -1,3 +1,5 @@ +import type z from 'zod/v4'; + import type {ConnectorType} from '../../../constants'; import type { ConnectionData, @@ -5,6 +7,7 @@ import type { ConnectionTypedQueryApiResponse, TransferNotification, } from '../../../types'; +import type {deleteConnectionResultSchema} from '../schemas/connections'; import type {WorkbookIdArg} from './common'; @@ -56,9 +59,7 @@ export type GetAvailableCountersResponse = { export type GetAvailableCountersArgs = BaseArgs; -export type DeleteConnectionResponse = unknown; - -export type DeleteConnectionArgs = BaseArgs; +export type DeleteConnectionResponse = z.infer; export type GetConnectorsResponse = { /** @deprecated use `sections` & `uncategorized` fields instead */ diff --git a/src/shared/schema/bi/types/datasets.ts b/src/shared/schema/bi/types/datasets.ts index 13187e8038..8287a5ebb6 100644 --- a/src/shared/schema/bi/types/datasets.ts +++ b/src/shared/schema/bi/types/datasets.ts @@ -1,3 +1,5 @@ +import type z from 'zod/v4'; + import type { Dataset, DatasetField, @@ -8,6 +10,7 @@ import type { } from '../../../types'; import type {ApiV2RequestBody, ApiV2ResultData} from '../../../types/bi-api/v2'; import type {EntryFieldData} from '../../types'; +import type {createDatasetResultSchema, deleteDatasetResultSchema} from '../schemas'; import type {WorkbookIdArg} from './common'; @@ -63,14 +66,7 @@ export type GetSourceArgs = { limit?: number; } & WorkbookIdArg; -export type DeleteDatasetResponse = unknown; - -export type DeleteDatasetArgs = DatasetId; - -export type GetDatasetByVersionResponse = Dataset; - -export type GetDatasetByVersionArgs = {version: string; rev_id?: string} & DatasetId & - WorkbookIdArg; +export type DeleteDatasetResponse = z.infer; export type CheckDatasetsForPublicationResponse = { result: { @@ -140,32 +136,7 @@ export type GetDataSetFieldsByIdArgs = WorkbookIdArg & { dataSetId: string; }; -export type CreateDatasetResponse = Id & DatasetWithOptions; - -type CreateDatasetBaseArgs = { - dataset: Dataset['dataset']; - multisource: boolean; - name: string; - created_via?: string; -}; - -type CreateDirDatasetArgs = CreateDatasetBaseArgs & { - dir_path: string; -}; - -type CreateWorkbookDatsetArgs = CreateDatasetBaseArgs & { - workbook_id: string; -}; - -export type CreateDatasetArgs = CreateDirDatasetArgs | CreateWorkbookDatsetArgs; - -export type UpdateDatasetResponse = DatasetWithOptions; - -export type UpdateDatasetArgs = { - dataset: Dataset['dataset']; - version: DatasetVersion; - multisource: boolean; -} & DatasetId; +export type CreateDatasetResponse = z.infer; export type GetPreviewResponse = Partial; diff --git a/src/shared/schema/gateway-utils.ts b/src/shared/schema/gateway-utils.ts index e0d795fc75..d7f96b4e81 100644 --- a/src/shared/schema/gateway-utils.ts +++ b/src/shared/schema/gateway-utils.ts @@ -1,6 +1,7 @@ import type {Request, Response} from '@gravity-ui/expresskit'; import type {ApiServiceActionConfig, GetAuthHeaders} from '@gravity-ui/gateway'; import type {AppContext} from '@gravity-ui/nodekit'; +import type z from 'zod/v4'; import {AuthHeader, SERVICE_USER_ACCESS_TOKEN_HEADER} from '../constants'; @@ -12,6 +13,51 @@ export function createAction(value: T, schema: TypedActionSchema): T => { + Object.defineProperty(value, VALIDATION_SCHEMA_KEY, { + value: schema, + enumerable: false, + }); + + return value; +}; + +export const hasValidationSchema = ( + value: object, +): value is {[VALIDATION_SCHEMA_KEY]: TypedActionSchema} => { + return Object.prototype.hasOwnProperty.call(value, VALIDATION_SCHEMA_KEY); +}; + +export const getValidationSchema = (value: object): TypedActionSchema | null => { + return hasValidationSchema(value) ? value[VALIDATION_SCHEMA_KEY] : null; +}; + +export function createTypedAction( + schema: {paramsSchema: TParamsSchema; resultSchema: TOutputSchema}, + actionConfig: ApiServiceActionConfig< + AppContext, + Request, + Response, + z.infer, + z.infer, + z.infer + >, +) { + const schemaValidationObject = { + paramsSchema: schema.paramsSchema, + resultSchema: schema.resultSchema, + }; + + return registerValidationSchema(actionConfig, schemaValidationObject); +} + type AuthArgsData = { userAccessToken?: string; serviceUserAccessToken?: string; diff --git a/src/shared/schema/mix/actions/dash.ts b/src/shared/schema/mix/actions/dash.ts index 718d92eb53..2529664ac9 100644 --- a/src/shared/schema/mix/actions/dash.ts +++ b/src/shared/schema/mix/actions/dash.ts @@ -1,7 +1,7 @@ import type {DeepNonNullable} from 'utility-types'; import type {ChartsStats} from '../../../types/charts'; -import {createAction} from '../../gateway-utils'; +import {createAction, createTypedAction} from '../../gateway-utils'; import {getTypedApi} from '../../simple-schema'; import {getEntryVisualizationType} from '../helpers'; import type {DatasetDictResponse, DatasetFieldsDictResponse} from '../helpers/dash'; @@ -11,18 +11,37 @@ import { prepareDatasetData, prepareWidgetDatasetData, } from '../helpers/dash'; -import { - type CollectChartkitStatsArgs, - type CollectChartkitStatsResponse, - type CollectDashStatsArgs, - type CollectDashStatsResponse, - type GetEntriesDatasetsFieldsArgs, - type GetEntriesDatasetsFieldsResponse, - type GetWidgetsDatasetsFieldsArgs, - type GetWidgetsDatasetsFieldsResponse, +import {deleteDashArgsSchema, deleteDashResultSchema} from '../schemas/dash'; +import type { + CollectChartkitStatsArgs, + CollectChartkitStatsResponse, + CollectDashStatsArgs, + CollectDashStatsResponse, + GetEntriesDatasetsFieldsArgs, + GetEntriesDatasetsFieldsResponse, + GetWidgetsDatasetsFieldsArgs, + GetWidgetsDatasetsFieldsResponse, } from '../types'; export const dashActions = { + // WIP + __deleteDashboard__: createTypedAction( + { + paramsSchema: deleteDashArgsSchema, + resultSchema: deleteDashResultSchema, + }, + async (api, {lockToken, dashboardId}) => { + const typedApi = getTypedApi(api); + + await typedApi.us._deleteUSEntry({ + entryId: dashboardId, + lockToken, + }); + + return {}; + }, + ), + collectDashStats: createAction( async (_, args, {ctx}) => { ctx.stats('dashStats', { diff --git a/src/shared/schema/mix/actions/editor.ts b/src/shared/schema/mix/actions/editor.ts index ac72cfa3e5..7309bfba65 100644 --- a/src/shared/schema/mix/actions/editor.ts +++ b/src/shared/schema/mix/actions/editor.ts @@ -1,5 +1,5 @@ import {DeveloperModeCheckStatus} from '../../../types'; -import {createAction} from '../../gateway-utils'; +import {createAction, createTypedAction} from '../../gateway-utils'; import {getTypedApi} from '../../simple-schema'; import type { CreateEditorChartArgs, @@ -9,6 +9,7 @@ import type { } from '../../us/types'; import {getEntryLinks} from '../helpers'; import {validateData} from '../helpers/editor/validation'; +import {deleteEditorChartArgsSchema, deleteEditorChartResultSchema} from '../schemas/editor'; export const editorActions = { createEditorChart: createAction( @@ -45,4 +46,20 @@ export const editorActions = { } }, ), + // WIP + __deleteEditorChart__: createTypedAction( + { + paramsSchema: deleteEditorChartArgsSchema, + resultSchema: deleteEditorChartResultSchema, + }, + async (api, {chartId}) => { + const typedApi = getTypedApi(api); + + await typedApi.us._deleteUSEntry({ + entryId: chartId, + }); + + return {}; + }, + ), }; diff --git a/src/shared/schema/mix/actions/entries.ts b/src/shared/schema/mix/actions/entries.ts index 9d83b75899..4e8e8ee65c 100644 --- a/src/shared/schema/mix/actions/entries.ts +++ b/src/shared/schema/mix/actions/entries.ts @@ -43,7 +43,7 @@ export const entriesActions = { return data; } case EntryScope.Connection: { - const data = await typedApi.bi.deleteConnnection({connectionId: entryId}); + const data = await typedApi.bi.deleteConnection({connectionId: entryId}); return data; } default: { diff --git a/src/shared/schema/mix/actions/index.ts b/src/shared/schema/mix/actions/index.ts index 1c24244896..d4bdfdca5b 100644 --- a/src/shared/schema/mix/actions/index.ts +++ b/src/shared/schema/mix/actions/index.ts @@ -3,6 +3,7 @@ import {editorActions} from './editor'; import {entriesActions} from './entries'; import {markdownActions} from './markdown'; import {navigationActions} from './navigation'; +import {wizardActions} from './wizard'; export const actions = { ...navigationActions, @@ -10,4 +11,5 @@ export const actions = { ...markdownActions, ...dashActions, ...editorActions, + ...wizardActions, }; diff --git a/src/shared/schema/mix/actions/navigation.ts b/src/shared/schema/mix/actions/navigation.ts index 3ce49ca5fc..efba1faba4 100644 --- a/src/shared/schema/mix/actions/navigation.ts +++ b/src/shared/schema/mix/actions/navigation.ts @@ -34,6 +34,7 @@ export const navigationActions = { excludeLocked: true, }); } + return { breadCrumbs: 'breadCrumbs' in data ? data.breadCrumbs : [], hasNextPage: data.hasNextPage, diff --git a/src/shared/schema/mix/actions/wizard.ts b/src/shared/schema/mix/actions/wizard.ts new file mode 100644 index 0000000000..a6618be158 --- /dev/null +++ b/src/shared/schema/mix/actions/wizard.ts @@ -0,0 +1,22 @@ +import {createTypedAction} from '../../gateway-utils'; +import {getTypedApi} from '../../simple-schema'; +import {deleteWizardChartArgsSchema, deleteWizardChartResultSchema} from '../schemas/wizard'; + +export const wizardActions = { + // WIP + __deleteWizardChart__: createTypedAction( + { + paramsSchema: deleteWizardChartArgsSchema, + resultSchema: deleteWizardChartResultSchema, + }, + async (api, {chartId}) => { + const typedApi = getTypedApi(api); + + await typedApi.us._deleteUSEntry({ + entryId: chartId, + }); + + return {}; + }, + ), +}; diff --git a/src/shared/schema/mix/schemas/dash.ts b/src/shared/schema/mix/schemas/dash.ts new file mode 100644 index 0000000000..74e3f8c236 --- /dev/null +++ b/src/shared/schema/mix/schemas/dash.ts @@ -0,0 +1,8 @@ +import z from 'zod/v4'; + +export const deleteDashArgsSchema = z.object({ + dashboardId: z.string(), + lockToken: z.string().optional(), +}); + +export const deleteDashResultSchema = z.object({}); diff --git a/src/shared/schema/mix/schemas/editor.ts b/src/shared/schema/mix/schemas/editor.ts new file mode 100644 index 0000000000..96f89fee9c --- /dev/null +++ b/src/shared/schema/mix/schemas/editor.ts @@ -0,0 +1,7 @@ +import z from 'zod/v4'; + +export const deleteEditorChartArgsSchema = z.object({ + chartId: z.string(), +}); + +export const deleteEditorChartResultSchema = z.object({}); diff --git a/src/shared/schema/mix/schemas/wizard.ts b/src/shared/schema/mix/schemas/wizard.ts new file mode 100644 index 0000000000..e1af087bdd --- /dev/null +++ b/src/shared/schema/mix/schemas/wizard.ts @@ -0,0 +1,7 @@ +import z from 'zod/v4'; + +export const deleteWizardChartArgsSchema = z.object({ + chartId: z.string(), +}); + +export const deleteWizardChartResultSchema = z.object({}); diff --git a/src/shared/types/dataset.ts b/src/shared/types/dataset.ts index ef9df8d146..eb36b9980e 100644 --- a/src/shared/types/dataset.ts +++ b/src/shared/types/dataset.ts @@ -120,11 +120,11 @@ export interface Dataset { }; }; rls: {[key: string]: string}; - rls2: unknown[]; + rls2: {[key: string]: string}; source_avatars: DatasetSourceAvatar[]; - source_features: {}; + source_features?: {}; sources: DatasetSource[]; - revisionId: string; + revisionId?: string; load_preview_by_default: boolean; template_enabled: boolean; data_export_forbidden?: boolean; @@ -196,7 +196,8 @@ export interface DatasetField { value_constraint?: | {type: typeof DATASET_VALUE_CONSTRAINT_TYPE.DEFAULT} | {type: typeof DATASET_VALUE_CONSTRAINT_TYPE.NULL} - | {type: typeof DATASET_VALUE_CONSTRAINT_TYPE.REGEX; pattern: string}; + | {type: typeof DATASET_VALUE_CONSTRAINT_TYPE.REGEX; pattern: string} + | null; ui_settings?: string; } @@ -275,28 +276,28 @@ export type DatasetRawSchema = { has_auto_aggregation: boolean; native_type: { name: string; - conn_type: string; + conn_type?: string; }; }; export interface DatasetSource { id: string; connection_id: string; - ref_source_id: string | null; - name: string; + ref_source_id?: string | null; + name?: string; title: string; source_type: string; managed_by: string; parameter_hash: string; valid: boolean; - is_ref: boolean; + is_ref?: boolean; virtual: boolean; raw_schema: DatasetRawSchema[]; - group: string[]; + group?: string[]; parameters: { - table_name: string; - db_version: string; - db_name: string | null; + table_name?: string; + db_version?: string; + db_name?: string | null; }; } diff --git a/src/shared/zod-schemas/dataset.ts b/src/shared/zod-schemas/dataset.ts new file mode 100644 index 0000000000..e1870a0662 --- /dev/null +++ b/src/shared/zod-schemas/dataset.ts @@ -0,0 +1,299 @@ +import z from 'zod/v4'; + +import {ConnectorType} from '..'; +import { + DATASET_FIELD_TYPES, + DATASET_VALUE_CONSTRAINT_TYPE, + DatasetFieldAggregation, + DatasetFieldType, +} from '../types/dataset'; + +// Basic type schemas +const parameterDefaultValueSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]); + +const datasetRlsSchema = z.record(z.string(), z.string()); + +// Dataset field aggregation schema +const datasetFieldAggregationSchema = z.enum(DatasetFieldAggregation); + +// Dataset field type schema +const datasetFieldTypeSchema = z.enum(DatasetFieldType); + +// Dataset field types schema +const datasetFieldTypesSchema = z.enum(DATASET_FIELD_TYPES); + +// Dataset field calc mode schema +const datasetFieldCalcModeSchema = z.union([ + z.literal('formula'), + z.literal('direct'), + z.literal('parameter'), +]); + +// Dataset value constraint schema +const datasetValueConstraintSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal(DATASET_VALUE_CONSTRAINT_TYPE.DEFAULT), + }), + z.object({ + type: z.literal(DATASET_VALUE_CONSTRAINT_TYPE.NULL), + }), + z.object({ + type: z.literal(DATASET_VALUE_CONSTRAINT_TYPE.REGEX), + pattern: z.string(), + }), +]); + +// Dataset field schema +const datasetFieldSchema = z.object({ + aggregation: datasetFieldAggregationSchema, + type: datasetFieldTypeSchema, + calc_mode: datasetFieldCalcModeSchema, + default_value: parameterDefaultValueSchema, + initial_data_type: datasetFieldTypesSchema, + cast: datasetFieldTypesSchema, + data_type: datasetFieldTypesSchema, + description: z.string(), + guid: z.string(), + title: z.string(), + managed_by: z.string(), + source: z.string(), + avatar_id: z.string(), + formula: z.string().optional(), + guid_formula: z.string().optional(), + has_auto_aggregation: z.boolean(), + aggregation_locked: z.boolean(), + lock_aggregation: z.boolean(), + virtual: z.boolean(), + valid: z.boolean(), + hidden: z.boolean(), + autoaggregated: z.boolean(), + template_enabled: z.boolean().optional(), + value_constraint: datasetValueConstraintSchema.optional().nullable(), +}); + +// Dataset component error item schema +const datasetComponentErrorItemSchema = z.object({ + code: z.string(), + level: z.string(), + message: z.string(), + details: z.object({ + db_message: z.string().optional(), + query: z.string().optional(), + }), +}); + +// Dataset component error schema +const datasetComponentErrorSchema = z.object({ + id: z.string(), + type: z.union([z.literal('data_source'), z.literal('field')]), + errors: z.array(datasetComponentErrorItemSchema), +}); + +// Obligatory default filter schema +const obligatoryDefaultFilterSchema = z.object({ + column: z.string(), + operation: z.string(), + values: z.array(z.string()), +}); + +// Obligatory filter schema +const obligatoryFilterSchema = z.object({ + id: z.string(), + field_guid: z.string(), + managed_by: z.string(), + valid: z.boolean(), + default_filters: z.array(obligatoryDefaultFilterSchema), +}); + +// Dataset raw schema +const datasetRawSchemaSchema = z.object({ + user_type: z.string(), + name: z.string(), + title: z.string(), + description: z.string(), + nullable: z.boolean(), + lock_aggregation: z.boolean(), + has_auto_aggregation: z.boolean(), + native_type: z.object({ + name: z.string(), + conn_type: z.string().optional(), + }), +}); + +// Dataset source schema +const datasetSourceSchema = z.object({ + id: z.string(), + connection_id: z.string(), + ref_source_id: z.union([z.string(), z.null(), z.undefined()]), + name: z.string().optional(), + title: z.string(), + source_type: z.string(), + managed_by: z.string(), + parameter_hash: z.string(), + valid: z.boolean(), + is_ref: z.boolean().optional(), + virtual: z.boolean(), + raw_schema: z.array(datasetRawSchemaSchema), + group: z.array(z.string()).optional(), + parameters: z.object({ + table_name: z.string().optional(), + db_version: z.string().optional(), + db_name: z.union([z.string(), z.null(), z.undefined()]), + }), +}); + +// Dataset source avatar schema +const datasetSourceAvatarSchema = z.object({ + id: z.string(), + title: z.string(), + source_id: z.string(), + managed_by: z.string(), + valid: z.boolean(), + is_root: z.boolean(), + virtual: z.boolean(), +}); + +// Dataset avatar relation condition schema +const datasetAvatarRelationConditionSchema = z.object({ + operator: z.string(), + type: z.string(), + left: z.object({ + calc_mode: z.string(), + source: z.union([z.string(), z.null()]), + }), + right: z.object({ + calc_mode: z.string(), + source: z.union([z.string(), z.null()]), + }), +}); + +// Dataset avatar relation schema +const datasetAvatarRelationSchema = z.object({ + id: z.string(), + join_type: z.string(), + left_avatar_id: z.string(), + right_avatar_id: z.string(), + managed_by: z.string(), + virtual: z.boolean(), + conditions: z.array(datasetAvatarRelationConditionSchema), + required: z.boolean(), +}); + +// Dataset option data type item schema +const datasetOptionDataTypeItemSchema = z.object({ + aggregations: z.array(datasetFieldAggregationSchema), + casts: z.array(datasetFieldTypesSchema), + type: z.string(), + filter_operations: z.array(z.string()), +}); + +// Dataset option field item schema +const datasetOptionFieldItemSchema = z.object({ + aggregations: z.array(datasetFieldAggregationSchema), + casts: z.array(datasetFieldTypesSchema), + guid: z.string(), +}); + +// Dataset options schema +const datasetOptionsSchema = z.object({ + connections: z.object({ + compatible_types: z.array(z.string()), + items: z.array( + z.object({ + id: z.string(), + replacement_types: z.array( + z.object({ + conn_type: z.enum(ConnectorType), + }), + ), + }), + ), + max: z.number(), + }), + syntax_highlighting_url: z.string(), + sources: z.object({ + compatible_types: z.array(z.string()), + items: z.array( + z.object({ + schema_update_enabled: z.boolean(), + id: z.string(), + }), + ), + }), + preview: z.object({ + enabled: z.boolean(), + }), + source_avatars: z.object({ + items: z.array( + z.object({ + schema_update_enabled: z.boolean(), + id: z.string(), + }), + ), + max: z.number(), + }), + schema_update_enabled: z.boolean(), + supports_offset: z.boolean(), + supported_functions: z.array(z.string()), + data_types: z.object({ + items: z.array(datasetOptionDataTypeItemSchema), + }), + fields: z.object({ + items: z.array(datasetOptionFieldItemSchema), + }), + join: z.object({ + types: z.array(z.string()), + operators: z.array(z.string()), + }), +}); + +const datasetBodySchema = z.object({ + avatar_relations: z.array(datasetAvatarRelationSchema), + component_errors: z.object({ + items: z.array(datasetComponentErrorSchema), + }), + obligatory_filters: z.array(obligatoryFilterSchema), + preview_enabled: z.boolean(), + result_schema: z.array(datasetFieldSchema), + result_schema_aux: z.object({ + inter_dependencies: z.object({ + deps: z.array(z.string()), + }), + }), + rls: datasetRlsSchema, + rls2: z.object({}), + source_avatars: z.array(datasetSourceAvatarSchema), + source_features: z.record(z.string(), z.any()).optional(), + sources: z.array(datasetSourceSchema), + revisionId: z.string().optional(), + load_preview_by_default: z.boolean(), + template_enabled: z.boolean(), + data_export_forbidden: z.boolean().optional(), +}); + +// Main Dataset schema +const datasetSchema = z.object({ + id: z.string(), + realName: z.string(), + is_favorite: z.boolean(), + key: z.string(), + options: datasetOptionsSchema, + dataset: datasetBodySchema, + workbook_id: z.string().optional(), + permissions: z.any().optional(), // Using z.any() for Permissions type as it's complex + + // Backward compatibility fields + avatar_relations: z.array(datasetAvatarRelationSchema), + component_errors: z.object({ + items: z.array(datasetComponentErrorSchema), + }), + preview_enabled: z.boolean(), + raw_schema: z.array(datasetRawSchemaSchema).optional(), + result_schema: z.array(datasetFieldSchema).optional(), + rls: datasetRlsSchema, + source_avatars: z.array(datasetSourceAvatarSchema), + source_features: z.record(z.string(), z.any()), + sources: z.array(datasetSourceSchema), +}); + +export {datasetBodySchema, datasetOptionsSchema, datasetSchema}; diff --git a/src/ui/components/DialogQLParameter/DialogQLParameter.tsx b/src/ui/components/DialogQLParameter/DialogQLParameter.tsx index 9844ee90be..9f2d845909 100644 --- a/src/ui/components/DialogQLParameter/DialogQLParameter.tsx +++ b/src/ui/components/DialogQLParameter/DialogQLParameter.tsx @@ -3,7 +3,6 @@ import React from 'react'; import {Dialog} from '@gravity-ui/uikit'; import block from 'bem-cn-lite'; import {I18n} from 'i18n'; -import _ from 'lodash'; import {DialogQLParameterQA, QLParamType} from '../../../shared'; import DialogManager from '../DialogManager/DialogManager'; diff --git a/src/ui/units/workbooks/components/WorkbookMainTabContent/useChunkedEntries.ts b/src/ui/units/workbooks/components/WorkbookMainTabContent/useChunkedEntries.ts index 8c364ec212..d35e99feaf 100644 --- a/src/ui/units/workbooks/components/WorkbookMainTabContent/useChunkedEntries.ts +++ b/src/ui/units/workbooks/components/WorkbookMainTabContent/useChunkedEntries.ts @@ -1,6 +1,5 @@ import React from 'react'; -import _ from 'lodash'; import type {EntryScope} from 'shared'; import type {GetEntryResponse} from 'shared/schema'; import Utils from 'utils';