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/charts-engine/components/storage/united-storage/provider.ts b/src/server/components/charts-engine/components/storage/united-storage/provider.ts index 1b4f5f2f26..af31dd0c5e 100644 --- a/src/server/components/charts-engine/components/storage/united-storage/provider.ts +++ b/src/server/components/charts-engine/components/storage/united-storage/provider.ts @@ -27,6 +27,7 @@ import { TRACE_ID_HEADER, US_PUBLIC_API_TOKEN_HEADER, WORKBOOK_ID_HEADER, + mapChartsConfigToLatestVersion, } from '../../../../../../shared'; import {ErrorCode, TIMEOUT_10_SEC} from '../../../../../../shared/constants'; import {createErrorHandler} from '../../error-handler'; @@ -226,7 +227,7 @@ export type ProviderCreateParams = { recursion?: boolean; meta?: Record; includePermissionsInfo?: boolean | string; - workbookId: string; + workbookId: string | null; name: string; mode?: EntryUpdateMode; annotation?: EntryAnnotationArgs; @@ -357,6 +358,29 @@ export class USProvider { }); } + static async retrieveParsedWizardChart( + ctx: AppContext, + props: { + id: string; + storageApiPath?: string; + extraAllowedHeaders?: string[]; + unreleased: boolean | string; + includeLinks?: boolean | string; + includePermissionsInfo?: boolean | string; + revId?: string; + headers: Request['headers']; + workbookId?: WorkbookId; + includeServicePlan?: boolean; + includeTenantFeatures?: boolean; + }, + ) { + const result = await USProvider.retrieveById(ctx, props); + + result.data = mapChartsConfigToLatestVersion(JSON.parse(result.data.shared)) as any; + + return result; + } + static retrieveByKey( ctx: AppContext, { @@ -610,7 +634,7 @@ export class USProvider { recursion: boolean; links?: unknown; meta: Record; - workbookId: string; + workbookId: string | null; name: string; includePermissionsInfo?: boolean; mode: EntryUpdateMode; diff --git a/src/server/components/public-api/constants.ts b/src/server/components/public-api/constants.ts new file mode 100644 index 0000000000..15dd67df69 --- /dev/null +++ b/src/server/components/public-api/constants.ts @@ -0,0 +1,146 @@ +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 + getWizardChart: { + resolve: (api) => api.mix.__getWizardChart__, + openApi: { + summary: 'Get wizard chart', + tags: [ApiTag.Wizard], + }, + }, + updateWizardChart: { + resolve: (api) => api.mix.__updateWizardChart__, + openApi: { + summary: 'Update wizard chart', + tags: [ApiTag.Wizard], + }, + }, + createWizardChart: { + resolve: (api) => api.mix.__createWizardChart__, + openApi: { + summary: 'Create wizard chart', + tags: [ApiTag.Wizard], + }, + }, + deleteWizardChart: { + resolve: (api) => api.mix.__deleteWizardChart__, + openApi: { + summary: 'Delete wizard chart', + tags: [ApiTag.Wizard], + }, + }, + // Editor + getEditorChart: { + resolve: (api) => api.mix.__getEditorChart__, + openApi: { + summary: 'Get editor chart', + tags: [ApiTag.Editor], + }, + }, + // updateEditorChart: { + // resolve: (api) => api.mix.updateEditorChart, + // openApi: { + // summary: 'Update editor chart', + // tags: [ApiTag.Editor], + // }, + // }, + // createEditorChart: { + // resolve: (api) => api.mix.createEditorChart, + // openApi: { + // summary: 'Create editor chart', + // tags: [ApiTag.Editor], + // }, + // }, + deleteEditorChart: { + resolve: (api) => api.mix.__deleteEditorChart__, + openApi: { + summary: 'Delete editor chart', + tags: [ApiTag.Editor], + }, + }, + // Dashboard + getDashboard: { + resolve: (api) => api.mix.__getDashboard__, + openApi: { + summary: 'Get dashboard', + tags: [ApiTag.Dashboard], + }, + }, + updateDashboard: { + resolve: (api) => api.mix.__updateDashboard__, + openApi: { + summary: 'Delete dashboard', + tags: [ApiTag.Dashboard], + }, + }, + createDashboard: { + resolve: (api) => api.mix.__createDashboard__, + openApi: { + summary: 'Create dashboard', + tags: [ApiTag.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/components/sdk/dash.ts b/src/server/components/sdk/dash.ts index 30e737c53b..3b56d02552 100644 --- a/src/server/components/sdk/dash.ts +++ b/src/server/components/sdk/dash.ts @@ -283,6 +283,7 @@ class Dash { params: EntryReadParams | null, headers: IncomingHttpHeaders, ctx: AppContext, + options?: {forceMigrate?: boolean}, ): Promise { try { const headersWithMetadata = { @@ -297,7 +298,10 @@ class Dash { const isServerMigrationEnabled = Boolean( isEnabledServerFeature(Feature.DashServerMigrationEnable), ); - if (isServerMigrationEnabled && DashSchemeConverter.isUpdateNeeded(result.data)) { + if ( + (options?.forceMigrate || isServerMigrationEnabled) && + DashSchemeConverter.isUpdateNeeded(result.data) + ) { result.data = await Dash.migrate(result.data); } @@ -398,6 +402,7 @@ class Dash { headers: IncomingHttpHeaders, ctx: AppContext, I18n: ServerI18n, + options?: {forceMigrate?: boolean}, ): Promise { try { const usData: typeof data & {skipSyncLinks?: boolean} = {...data}; @@ -406,7 +411,7 @@ class Dash { const needDataSend = !(mode === EntryUpdateMode.Publish && data.revId); if (needDataSend) { if (needSetDefaultData(usData.data)) { - const initialData = await Dash.read(entryId, null, headers, ctx); + const initialData = await Dash.read(entryId, null, headers, ctx, options); usData.data = setDefaultData(I18n, usData.data, initialData.data); } 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..af7967ce90 --- /dev/null +++ b/src/server/controllers/public-api/index.ts @@ -0,0 +1,107 @@ +import type {Request, Response} from '@gravity-ui/expresskit'; +import {AppError, REQUEST_ID_PARAM_NAME} from '@gravity-ui/nodekit'; +import _ from 'lodash'; + +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..720e630f4d --- /dev/null +++ b/src/server/controllers/public-api/utils.ts @@ -0,0 +1,109 @@ +import {AppError} from '@gravity-ui/nodekit'; +import {AxiosError} from 'axios'; +import _, {isObject} from 'lodash'; +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 AppError('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/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..3f9bcc5a9f --- /dev/null +++ b/src/shared/schema/bi/schemas/datasets.ts @@ -0,0 +1,53 @@ +import z from 'zod/v4'; + +import { + datasetBodySchema, + datasetOptionsSchema, + datasetSchema, +} from '../../../sdk/zod-schemas/dataset-api.schema'; + +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..2ce4a2efec 100644 --- a/src/shared/schema/mix/actions/dash.ts +++ b/src/shared/schema/mix/actions/dash.ts @@ -1,7 +1,11 @@ +import _, {pick} from 'lodash'; import type {DeepNonNullable} from 'utility-types'; +import Dash from '../../../../server/components/sdk/dash'; +import {DASH_ENTRY_RELEVANT_FIELDS} from '../../../../server/constants'; import type {ChartsStats} from '../../../types/charts'; -import {createAction} from '../../gateway-utils'; +import {EntryScope} from '../../../types/common'; +import {createAction, createTypedAction} from '../../gateway-utils'; import {getTypedApi} from '../../simple-schema'; import {getEntryVisualizationType} from '../helpers'; import type {DatasetDictResponse, DatasetFieldsDictResponse} from '../helpers/dash'; @@ -12,17 +16,113 @@ import { prepareWidgetDatasetData, } from '../helpers/dash'; import { - type CollectChartkitStatsArgs, - type CollectChartkitStatsResponse, - type CollectDashStatsArgs, - type CollectDashStatsResponse, - type GetEntriesDatasetsFieldsArgs, - type GetEntriesDatasetsFieldsResponse, - type GetWidgetsDatasetsFieldsArgs, - type GetWidgetsDatasetsFieldsResponse, + createDashArgsSchema, + createDashResultSchema, + deleteDashArgsSchema, + deleteDashResultSchema, + getDashArgsSchema, + getDashResultSchema, + updateDashArgsSchema, + updateDashResultSchema, +} from '../schemas/dash'; +import type { + CollectChartkitStatsArgs, + CollectChartkitStatsResponse, + CollectDashStatsArgs, + CollectDashStatsResponse, + CreateDashResponse, + GetEntriesDatasetsFieldsArgs, + GetEntriesDatasetsFieldsResponse, + GetWidgetsDatasetsFieldsArgs, + GetWidgetsDatasetsFieldsResponse, + UpdateDashResponse, } from '../types'; export const dashActions = { + // WIP + __getDashboard__: createTypedAction( + { + paramsSchema: getDashArgsSchema, + resultSchema: getDashResultSchema, + }, + async (_, args, {headers, ctx}) => { + const {dashboardId, includePermissions, includeLinks, branch, revId} = args; + + if (!dashboardId || dashboardId === 'null') { + throw new Error(`Not found ${dashboardId} id`); + } + + const result = await Dash.read( + dashboardId, + { + includePermissions: includePermissions ? includePermissions?.toString() : '0', + includeLinks: includeLinks ? includeLinks?.toString() : '0', + ...(branch ? {branch} : {branch: 'published'}), + ...(revId ? {revId} : {}), + }, + headers, + ctx, + {forceMigrate: true}, + ); + + if (result.scope !== EntryScope.Dash) { + throw new Error('No entry found'); + } + + return pick(result, DASH_ENTRY_RELEVANT_FIELDS) as any; + }, + ), + // WIP + __deleteDashboard__: createTypedAction( + { + paramsSchema: deleteDashArgsSchema, + resultSchema: deleteDashResultSchema, + }, + async (api, {lockToken, dashboardId}) => { + const typedApi = getTypedApi(api); + + await typedApi.us._deleteUSEntry({ + entryId: dashboardId, + lockToken, + }); + + return {}; + }, + ), + // WIP + __updateDashboard__: createTypedAction( + { + paramsSchema: updateDashArgsSchema, + resultSchema: updateDashResultSchema, + }, + async (_, args, {headers, ctx}) => { + const {entryId} = args; + + const I18n = ctx.get('i18n'); + + return (await Dash.update(entryId as any, args as any, headers, ctx, I18n, { + forceMigrate: true, + })) as unknown as UpdateDashResponse; + }, + ), + // WIP + __createDashboard__: createTypedAction( + { + paramsSchema: createDashArgsSchema, + resultSchema: createDashResultSchema, + }, + async (_, args, {headers, ctx}) => { + const I18n = ctx.get('i18n'); + + return (await Dash.create( + args as any, + headers, + ctx, + I18n, + )) as unknown as CreateDashResponse; + }, + ), + 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..e5b0c7d4c3 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,8 +9,35 @@ import type { } from '../../us/types'; import {getEntryLinks} from '../helpers'; import {validateData} from '../helpers/editor/validation'; +import { + deleteEditorChartArgsSchema, + deleteEditorChartResultSchema, + getEditorChartArgsSchema, + getEditorChartResultSchema, +} from '../schemas/editor'; +import type {GetEditorChartResponse} from '../types'; export const editorActions = { + // WIP + __getEditorChart__: createTypedAction( + { + paramsSchema: getEditorChartArgsSchema, + resultSchema: getEditorChartResultSchema, + }, + async (api, args) => { + const {includePermissions, includeLinks, revId, chardId, branch, workbookId} = args; + const typedApi = getTypedApi(api); + + return typedApi.us.getEntry({ + entryId: chardId, + includePermissionsInfo: includePermissions ? Boolean(includePermissions) : false, + includeLinks: includeLinks ? Boolean(includeLinks) : false, + ...(revId ? {revId} : {}), + workbookId: workbookId || null, + branch: branch || 'published', + }) as unknown as GetEditorChartResponse; + }, + ), createEditorChart: createAction( async (api, args, {ctx}) => { const {checkRequestForDeveloperModeAccess} = ctx.get('gateway'); @@ -45,4 +72,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 140702e136..489551c3e3 100644 --- a/src/shared/schema/mix/actions/entries.ts +++ b/src/shared/schema/mix/actions/entries.ts @@ -40,7 +40,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..2d09c336f0 --- /dev/null +++ b/src/shared/schema/mix/actions/wizard.ts @@ -0,0 +1,96 @@ +import {USProvider} from '../../../../server/components/charts-engine/components/storage/united-storage/provider'; +import {EntryScope} from '../../../types'; +import {createTypedAction} from '../../gateway-utils'; +import {getTypedApi} from '../../simple-schema'; +import { + createWizardChartArgsSchema, + createWizardChartResultSchema, + deleteWizardChartArgsSchema, + deleteWizardChartResultSchema, + getWizardChartArgsSchema, + getWizardChartResultSchema, + updateWizardChartArgsSchema, + updateWizardChartResultSchema, +} from '../schemas/wizard'; + +export const wizardActions = { + // WIP + __getWizardChart__: createTypedAction( + { + paramsSchema: getWizardChartArgsSchema, + resultSchema: getWizardChartResultSchema, + }, + async (_, args, {ctx, headers}) => { + const {includePermissions, includeLinks, unreleased, revId, chardId} = args; + + const result = await USProvider.retrieveParsedWizardChart(ctx, { + id: chardId, + includePermissionsInfo: includePermissions ? includePermissions?.toString() : '0', + includeLinks: includeLinks ? includeLinks?.toString() : '0', + ...(revId ? {revId} : {}), + ...(unreleased ? {unreleased} : {unreleased: false}), + headers, + }); + + return result as any; + }, + ), + // WIP + __createWizardChart__: createTypedAction( + { + paramsSchema: createWizardChartArgsSchema, + resultSchema: createWizardChartResultSchema, + }, + async (_, args, {ctx, headers}) => { + const {data, type, key, workbookId, name} = args; + + const result = await USProvider.create(ctx, { + type, + data, + key, + name, + scope: EntryScope.Widget, + ...(workbookId ? {workbookId} : {workbookId: null}), + headers, + }); + + return result as any; + }, + ), + // WIP + __updateWizardChart__: createTypedAction( + { + paramsSchema: updateWizardChartArgsSchema, + resultSchema: updateWizardChartResultSchema, + }, + async (_, args, {ctx, headers}) => { + const {entryId, revId, data, type} = args; + + const result = await USProvider.update(ctx, { + entryId, + ...(revId ? {revId} : {}), + ...(type ? {type} : {}), + data, + headers, + }); + + return result as any; + }, + ), + // 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..640f32e70a --- /dev/null +++ b/src/shared/schema/mix/schemas/dash.ts @@ -0,0 +1,57 @@ +import z from 'zod/v4'; + +import {EntryScope} from '../../..'; +import {dashSchema} from '../../../sdk/zod-schemas/dash-api.schema'; + +export const deleteDashArgsSchema = z.object({ + dashboardId: z.string(), + lockToken: z.string().optional(), +}); + +export const deleteDashResultSchema = z.object({}); + +const dashUsSchema = z.object({ + ...dashSchema.shape, + entryId: z.string(), + scope: z.literal(EntryScope.Dash), + public: z.boolean(), + isFavorite: z.boolean(), + createdAt: z.string(), + createdBy: z.string(), + updatedAt: z.string(), + updatedBy: z.string(), + revId: z.string(), + savedId: z.string(), + publishedId: z.string(), + meta: z.record(z.string(), z.string()), + links: z.record(z.string(), z.string()).optional(), + key: z.union([z.null(), z.string()]), + workbookId: z.union([z.null(), z.string()]), + type: z.literal(''), +}); + +export const updateDashArgsSchema = z.object({ + ...dashSchema.partial().shape, + entryId: z.string(), +}); + +export const updateDashResultSchema = dashUsSchema; + +export const createDashArgsSchema = z.object({ + ...dashSchema.shape, + workbookId: z.union([z.null(), z.string()]).optional(), + lockToken: z.string().optional(), + mode: z.literal(['publish', 'save']), +}); + +export const createDashResultSchema = dashUsSchema; + +export const getDashArgsSchema = z.object({ + dashboardId: z.string(), + revId: z.string().optional(), + includePermissions: z.boolean().optional().default(false), + includeLinks: z.boolean().optional().default(false), + branch: z.literal(['published', 'saved']).optional().default('published'), +}); + +export const getDashResultSchema = dashUsSchema; diff --git a/src/shared/schema/mix/schemas/editor.ts b/src/shared/schema/mix/schemas/editor.ts new file mode 100644 index 0000000000..270031563e --- /dev/null +++ b/src/shared/schema/mix/schemas/editor.ts @@ -0,0 +1,54 @@ +import z from 'zod/v4'; + +import {EDITOR_TYPE, EntryScope} from '../../..'; + +export const deleteEditorChartArgsSchema = z.object({ + chartId: z.string(), +}); + +export const deleteEditorChartResultSchema = z.object({}); + +export const getEditorChartArgsSchema = z.object({ + chardId: z.string(), + workbookId: z.union([z.string(), z.null()]).default(null).optional(), + revId: z.string().optional(), + includePermissions: z.boolean().default(false).optional(), + includeLinks: z.boolean().default(false).optional(), + branch: z.literal(['saved', 'published']).default('published').optional(), +}); + +const editorUsSchema = z.object({ + data: z.union([ + z.object({ + js: z.string(), + url: z.string(), + params: z.string(), + shared: z.string(), + }), + z.object({ + controls: z.string(), + meta: z.string(), + params: z.string(), + prepare: z.string(), + sources: z.string(), + }), + ]), + entryId: z.string(), + scope: z.literal(EntryScope.Widget), + type: z.enum(EDITOR_TYPE), + public: z.boolean(), + isFavorite: z.boolean(), + createdAt: z.string(), + createdBy: z.string(), + updatedAt: z.string(), + updatedBy: z.string(), + revId: z.string(), + savedId: z.string(), + publishedId: z.string(), + meta: z.record(z.string(), z.string()), + links: z.record(z.string(), z.string()).optional(), + key: z.union([z.null(), z.string()]), + workbookId: z.union([z.null(), z.string()]), +}); + +export const getEditorChartResultSchema = editorUsSchema; diff --git a/src/shared/schema/mix/schemas/wizard.ts b/src/shared/schema/mix/schemas/wizard.ts new file mode 100644 index 0000000000..386ac0560b --- /dev/null +++ b/src/shared/schema/mix/schemas/wizard.ts @@ -0,0 +1,60 @@ +import z from 'zod/v4'; + +import {EntryScope, WizardType} from '../../..'; +import {v12ChartsConfigSchema} from '../../../sdk/zod-schemas/wizard-chart-api.schema'; + +export const getWizardChartArgsSchema = z.object({ + chardId: z.string(), + unreleased: z.boolean().default(false).optional(), + revId: z.string().optional(), + includePermissions: z.boolean().default(false).optional(), + includeLinks: z.boolean().default(false).optional(), +}); + +const wizardUsSchema = z.object({ + data: v12ChartsConfigSchema, + entryId: z.string(), + scope: z.literal(EntryScope.Widget), + type: z.enum(WizardType), + public: z.boolean(), + isFavorite: z.boolean(), + createdAt: z.string(), + createdBy: z.string(), + updatedAt: z.string(), + updatedBy: z.string(), + revId: z.string(), + savedId: z.string(), + publishedId: z.string(), + meta: z.record(z.string(), z.string()), + links: z.record(z.string(), z.string()).optional(), + key: z.union([z.null(), z.string()]), + workbookId: z.union([z.null(), z.string()]), +}); + +export const getWizardChartResultSchema = wizardUsSchema; + +export const createWizardChartArgsSchema = z.object({ + entryId: z.string(), + data: v12ChartsConfigSchema, + key: z.string(), + workbookId: z.union([z.string(), z.null()]).optional(), + type: z.enum(WizardType).optional(), + name: z.string(), +}); + +export const createWizardChartResultSchema = wizardUsSchema; + +export const updateWizardChartArgsSchema = z.object({ + entryId: z.string(), + revId: z.string().optional(), + data: v12ChartsConfigSchema, + type: z.enum(WizardType).optional(), +}); + +export const updateWizardChartResultSchema = wizardUsSchema; + +export const deleteWizardChartArgsSchema = z.object({ + chartId: z.string(), +}); + +export const deleteWizardChartResultSchema = z.object({}); diff --git a/src/shared/schema/mix/types/dash.ts b/src/shared/schema/mix/types/dash.ts index 6a947623e5..649e267ad9 100644 --- a/src/shared/schema/mix/types/dash.ts +++ b/src/shared/schema/mix/types/dash.ts @@ -1,6 +1,9 @@ +import type z from 'zod/v4'; + import type {WizardVisualizationId} from '../../../constants'; import type {ChartsStats, DashStats, WorkbookId} from '../../../types'; import type {GetEntriesEntryResponse} from '../../us/types'; +import type {createDashResultSchema, updateDashResultSchema} from '../schemas/dash'; export type CollectDashStatsResponse = { status: string; @@ -51,3 +54,7 @@ export type GetWidgetsDatasetsFieldsArgs = { entriesIds: string[]; workbookId: WorkbookId; }; + +export type UpdateDashResponse = z.infer; + +export type CreateDashResponse = z.infer; diff --git a/src/shared/schema/mix/types/editor.ts b/src/shared/schema/mix/types/editor.ts new file mode 100644 index 0000000000..f645cc0149 --- /dev/null +++ b/src/shared/schema/mix/types/editor.ts @@ -0,0 +1,5 @@ +import type z from 'zod/v4'; + +import type {getEditorChartResultSchema} from '../schemas/editor'; + +export type GetEditorChartResponse = z.infer; diff --git a/src/shared/schema/mix/types/index.ts b/src/shared/schema/mix/types/index.ts index 90757ac7a7..7de86b74d3 100644 --- a/src/shared/schema/mix/types/index.ts +++ b/src/shared/schema/mix/types/index.ts @@ -2,3 +2,4 @@ export * from './navigation'; export * from './entries'; export * from './markdown'; export * from './dash'; +export * from './editor'; diff --git a/src/shared/sdk/zod-schemas/__tests__/dash-api.schema.test.ts b/src/shared/sdk/zod-schemas/__tests__/dash-api.schema.test.ts new file mode 100644 index 0000000000..fccb1451a3 --- /dev/null +++ b/src/shared/sdk/zod-schemas/__tests__/dash-api.schema.test.ts @@ -0,0 +1,989 @@ +import { + CONTROLS_PLACEMENT_MODE, + DASH_CURRENT_SCHEME_VERSION, + DashLoadPriority, + DashTabConnectionKind, + DashTabItemControlElementType, + DashTabItemControlSourceType, + DashTabItemTitleSizes, + DashTabItemType, +} from '../../..'; +import {type DashSchema, dashSchema} from '../dash-api.schema'; + +const DASH_DEFAULT_NAMESPACE = 'default'; + +describe('dashSchema', () => { + describe('valid configurations', () => { + it('should validate minimal valid dashboard configuration', () => { + const validConfig: DashSchema = { + data: { + tabs: [ + { + id: 'tab1', + title: 'Test Tab', + items: [], + layout: [], + connections: [], + aliases: { + [DASH_DEFAULT_NAMESPACE]: [], + }, + }, + ], + }, + }; + + const result = dashSchema.safeParse(validConfig); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual(validConfig); + } + }); + + it('should validate complete dashboard configuration with all optional fields', () => { + const validConfig: DashSchema = { + key: 'dashboard-key-123', + data: { + counter: 1, + salt: 'random-salt-123', + schemeVersion: DASH_CURRENT_SCHEME_VERSION, + tabs: [ + { + id: 'tab1', + title: 'Complete Tab', + items: [ + { + id: 'text1', + namespace: DASH_DEFAULT_NAMESPACE, + type: DashTabItemType.Text, + data: { + text: 'Sample text content', + }, + }, + { + id: 'title1', + namespace: DASH_DEFAULT_NAMESPACE, + type: DashTabItemType.Title, + data: { + text: 'Sample Title', + size: DashTabItemTitleSizes.L, + showInTOC: true, + }, + }, + { + id: 'widget1', + namespace: DASH_DEFAULT_NAMESPACE, + type: DashTabItemType.Widget, + data: { + hideTitle: false, + tabs: [ + { + id: 'widget-tab1', + title: 'Widget Tab', + description: 'Widget description', + chartId: 'chart123', + isDefault: true, + params: {param1: 'value1'}, + autoHeight: true, + }, + ], + }, + }, + ], + layout: [ + { + i: 'text1', + h: 2, + w: 12, + x: 0, + y: 0, + }, + { + i: 'title1', + h: 1, + w: 12, + x: 0, + y: 2, + parent: 'text1', + }, + ], + connections: [ + { + from: 'control1', + to: 'widget1', + kind: DashTabConnectionKind.Ignore, + }, + ], + aliases: { + [DASH_DEFAULT_NAMESPACE]: [['alias1', 'alias2']], + }, + }, + ], + settings: { + autoupdateInterval: 60, + maxConcurrentRequests: 5, + loadPriority: DashLoadPriority.Charts, + silentLoading: true, + dependentSelectors: false, + globalParams: {globalParam: 'value'}, + hideTabs: false, + hideDashTitle: true, + expandTOC: false, + }, + }, + meta: {metaKey: 'metaValue'}, + links: {linkKey: 'linkValue'}, + }; + + const result = dashSchema.safeParse(validConfig); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual(validConfig); + } + }); + + it('should validate dashboard with control items', () => { + const validConfig: DashSchema = { + data: { + tabs: [ + { + id: 'tab1', + title: 'Tab with Controls', + items: [ + { + id: 'control1', + namespace: DASH_DEFAULT_NAMESPACE, + type: DashTabItemType.Control, + data: { + title: 'Dataset Control', + sourceType: DashTabItemControlSourceType.Dataset, + source: { + datasetId: 'dataset123', + datasetFieldId: 'field123', + elementType: DashTabItemControlElementType.Select, + required: true, + showHint: true, + showTitle: true, + defaultValue: 'default', + multiselectable: false, + }, + }, + defaults: {defaultParam: 'value'}, + }, + { + id: 'control2', + namespace: DASH_DEFAULT_NAMESPACE, + type: DashTabItemType.Control, + data: { + title: 'Manual Control', + sourceType: DashTabItemControlSourceType.Manual, + source: { + fieldName: 'manualField', + elementType: DashTabItemControlElementType.Date, + required: false, + showHint: false, + showTitle: true, + defaultValue: '2023-01-01', + isRange: true, + acceptableValues: { + from: '2023-01-01', + to: '2023-12-31', + }, + }, + }, + defaults: {}, + }, + { + id: 'control3', + namespace: DASH_DEFAULT_NAMESPACE, + type: DashTabItemType.Control, + data: { + title: 'External Control', + sourceType: DashTabItemControlSourceType.External, + source: { + chartId: 'external-chart-123', + text: 'External control text', + autoHeight: true, + }, + }, + defaults: {}, + }, + ], + layout: [], + connections: [], + aliases: { + [DASH_DEFAULT_NAMESPACE]: [], + }, + }, + ], + }, + }; + + const result = dashSchema.safeParse(validConfig); + expect(result.success).toBe(true); + }); + + it('should validate dashboard with group control', () => { + const validConfig: DashSchema = { + data: { + tabs: [ + { + id: 'tab1', + title: 'Tab with Group Control', + items: [ + { + id: 'groupControl1', + namespace: DASH_DEFAULT_NAMESPACE, + type: DashTabItemType.GroupControl, + data: { + group: [ + { + id: 'groupItem1', + title: 'Group Item 1', + namespace: DASH_DEFAULT_NAMESPACE, + sourceType: DashTabItemControlSourceType.Dataset, + defaults: {param: 'value'}, + placementMode: CONTROLS_PLACEMENT_MODE.AUTO, + width: '50%', + source: { + datasetId: 'dataset123', + datasetFieldId: 'field123', + elementType: + DashTabItemControlElementType.Input, + required: true, + showHint: false, + showTitle: true, + defaultValue: 'input value', + }, + }, + ], + autoHeight: true, + buttonApply: true, + buttonReset: false, + showGroupName: true, + updateControlsOnChange: false, + }, + }, + ], + layout: [], + connections: [], + aliases: { + [DASH_DEFAULT_NAMESPACE]: [], + }, + }, + ], + }, + }; + + const result = dashSchema.safeParse(validConfig); + expect(result.success).toBe(true); + }); + + it('should validate different control element types', () => { + // Test Select control + const selectConfig: DashSchema = { + data: { + tabs: [ + { + id: 'tab1', + title: 'Test Tab', + items: [ + { + id: 'control-select', + namespace: DASH_DEFAULT_NAMESPACE, + type: DashTabItemType.Control, + data: { + title: 'Select Control', + sourceType: DashTabItemControlSourceType.Manual, + source: { + fieldName: 'selectField', + elementType: DashTabItemControlElementType.Select, + required: false, + showHint: true, + showTitle: true, + defaultValue: ['option1', 'option2'], + multiselectable: true, + }, + }, + defaults: {}, + }, + ], + layout: [], + connections: [], + aliases: { + [DASH_DEFAULT_NAMESPACE]: [], + }, + }, + ], + }, + }; + + // Test Date control + const dateConfig: DashSchema = { + data: { + tabs: [ + { + id: 'tab1', + title: 'Test Tab', + items: [ + { + id: 'control-date', + namespace: DASH_DEFAULT_NAMESPACE, + type: DashTabItemType.Control, + data: { + title: 'Date Control', + sourceType: DashTabItemControlSourceType.Manual, + source: { + fieldName: 'dateField', + elementType: DashTabItemControlElementType.Date, + required: false, + showHint: true, + showTitle: true, + defaultValue: '2023-01-01', + isRange: false, + }, + }, + defaults: {}, + }, + ], + layout: [], + connections: [], + aliases: { + [DASH_DEFAULT_NAMESPACE]: [], + }, + }, + ], + }, + }; + + // Test Input control + const inputConfig: DashSchema = { + data: { + tabs: [ + { + id: 'tab1', + title: 'Test Tab', + items: [ + { + id: 'control-input', + namespace: DASH_DEFAULT_NAMESPACE, + type: DashTabItemType.Control, + data: { + title: 'Input Control', + sourceType: DashTabItemControlSourceType.Manual, + source: { + fieldName: 'inputField', + elementType: DashTabItemControlElementType.Input, + required: false, + showHint: true, + showTitle: true, + defaultValue: 'input text', + }, + }, + defaults: {}, + }, + ], + layout: [], + connections: [], + aliases: { + [DASH_DEFAULT_NAMESPACE]: [], + }, + }, + ], + }, + }; + + // Test Checkbox control + const checkboxConfig: DashSchema = { + data: { + tabs: [ + { + id: 'tab1', + title: 'Test Tab', + items: [ + { + id: 'control-checkbox', + namespace: DASH_DEFAULT_NAMESPACE, + type: DashTabItemType.Control, + data: { + title: 'Checkbox Control', + sourceType: DashTabItemControlSourceType.Manual, + source: { + fieldName: 'checkboxField', + elementType: DashTabItemControlElementType.Checkbox, + required: false, + showHint: true, + showTitle: true, + defaultValue: 'true', + }, + }, + defaults: {}, + }, + ], + layout: [], + connections: [], + aliases: { + [DASH_DEFAULT_NAMESPACE]: [], + }, + }, + ], + }, + }; + + const configs = [selectConfig, dateConfig, inputConfig, checkboxConfig]; + configs.forEach((config) => { + const result = dashSchema.safeParse(config); + expect(result.success).toBe(true); + }); + }); + + it('should validate different title sizes', () => { + const titleSizes = Object.values(DashTabItemTitleSizes); + + titleSizes.forEach((size, index) => { + const config: DashSchema = { + data: { + tabs: [ + { + id: 'tab1', + title: 'Test Tab', + items: [ + { + id: `title${index}`, + namespace: DASH_DEFAULT_NAMESPACE, + type: DashTabItemType.Title, + data: { + text: `Title ${size}`, + size: size, + }, + }, + ], + layout: [], + connections: [], + aliases: { + [DASH_DEFAULT_NAMESPACE]: [], + }, + }, + ], + }, + }; + + const result = dashSchema.safeParse(config); + expect(result.success).toBe(true); + }); + }); + }); + + describe('invalid configurations', () => { + it('should reject configuration without required data field', () => { + const invalidConfig = { + key: 'test-key', + }; + + const result = dashSchema.safeParse(invalidConfig); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toContainEqual( + expect.objectContaining({ + code: 'invalid_type', + expected: 'object', + message: 'Invalid input: expected object, received undefined', + path: ['data'], + }), + ); + } + }); + + it('should reject configuration without tabs', () => { + const invalidConfig = { + data: { + settings: {}, + }, + }; + + const result = dashSchema.safeParse(invalidConfig); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toContainEqual( + expect.objectContaining({ + code: 'invalid_type', + path: ['data', 'tabs'], + message: 'Invalid input: expected array, received undefined', + }), + ); + } + }); + + it('should reject tab with empty id', () => { + const invalidConfig = { + data: { + tabs: [ + { + id: '', + title: 'Test Tab', + items: [], + layout: [], + connections: [], + aliases: { + [DASH_DEFAULT_NAMESPACE]: [], + }, + }, + ], + }, + }; + + const result = dashSchema.safeParse(invalidConfig); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toContainEqual( + expect.objectContaining({ + code: 'too_small', + path: ['data', 'tabs', 0, 'id'], + }), + ); + } + }); + + it('should reject tab with empty title', () => { + const invalidConfig = { + data: { + tabs: [ + { + id: 'tab1', + title: '', + items: [], + layout: [], + connections: [], + aliases: { + [DASH_DEFAULT_NAMESPACE]: [], + }, + }, + ], + }, + }; + + const result = dashSchema.safeParse(invalidConfig); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toContainEqual( + expect.objectContaining({ + code: 'too_small', + path: ['data', 'tabs', 0, 'title'], + }), + ); + } + }); + + it('should reject item with invalid namespace', () => { + const invalidConfig = { + data: { + tabs: [ + { + id: 'tab1', + title: 'Test Tab', + items: [ + { + id: 'text1', + namespace: 'invalid-namespace', + type: DashTabItemType.Text, + data: { + text: 'Sample text', + }, + }, + ], + layout: [], + connections: [], + aliases: { + [DASH_DEFAULT_NAMESPACE]: [], + }, + }, + ], + }, + }; + + const result = dashSchema.safeParse(invalidConfig); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toContainEqual( + expect.objectContaining({ + code: 'invalid_value', + path: ['data', 'tabs', 0, 'items', 0, 'namespace'], + }), + ); + } + }); + + it('should reject control with invalid element type', () => { + const invalidConfig = { + data: { + tabs: [ + { + id: 'tab1', + title: 'Test Tab', + items: [ + { + id: 'control1', + namespace: DASH_DEFAULT_NAMESPACE, + type: DashTabItemType.Control, + data: { + title: 'Invalid Control', + sourceType: DashTabItemControlSourceType.Manual, + source: { + fieldName: 'field1', + elementType: 'invalid-type', + required: false, + showHint: false, + showTitle: true, + }, + }, + defaults: {}, + }, + ], + layout: [], + connections: [], + aliases: { + [DASH_DEFAULT_NAMESPACE]: [], + }, + }, + ], + }, + }; + + const result = dashSchema.safeParse(invalidConfig); + expect(result.success).toBe(false); + }); + + it('should reject widget tab with empty chartId', () => { + const invalidConfig = { + data: { + tabs: [ + { + id: 'tab1', + title: 'Test Tab', + items: [ + { + id: 'widget1', + namespace: DASH_DEFAULT_NAMESPACE, + type: DashTabItemType.Widget, + data: { + hideTitle: false, + tabs: [ + { + id: 'widget-tab1', + title: 'Widget Tab', + chartId: '', + }, + ], + }, + }, + ], + layout: [], + connections: [], + aliases: { + [DASH_DEFAULT_NAMESPACE]: [], + }, + }, + ], + }, + }; + + const result = dashSchema.safeParse(invalidConfig); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toContainEqual( + expect.objectContaining({ + code: 'too_small', + path: ['data', 'tabs', 0, 'items', 0, 'data', 'tabs', 0, 'chartId'], + }), + ); + } + }); + + it('should reject invalid scheme version', () => { + const invalidConfig = { + data: { + schemeVersion: DASH_CURRENT_SCHEME_VERSION + 1, + tabs: [ + { + id: 'tab1', + title: 'Test Tab', + items: [], + layout: [], + connections: [], + aliases: { + [DASH_DEFAULT_NAMESPACE]: [], + }, + }, + ], + }, + }; + + const result = dashSchema.safeParse(invalidConfig); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toContainEqual( + expect.objectContaining({ + code: 'too_big', + path: ['data', 'schemeVersion'], + }), + ); + } + }); + + it('should reject invalid autoupdate interval', () => { + const invalidConfig = { + data: { + tabs: [], + settings: { + autoupdateInterval: 15, // less than minimum 30 + }, + }, + }; + + const result = dashSchema.safeParse(invalidConfig); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toContainEqual( + expect.objectContaining({ + code: 'invalid_union', + path: ['data', 'settings', 'autoupdateInterval'], + }), + ); + } + }); + + it('should reject invalid maxConcurrentRequests', () => { + const invalidConfig = { + data: { + tabs: [], + settings: { + maxConcurrentRequests: 0, // less than minimum 1 + }, + }, + }; + + const result = dashSchema.safeParse(invalidConfig); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toContainEqual( + expect.objectContaining({ + code: 'invalid_union', + path: ['data', 'settings', 'maxConcurrentRequests'], + }), + ); + } + }); + + it('should accept layout item with negative dimensions (schema allows this)', () => { + const validConfig = { + data: { + tabs: [ + { + id: 'tab1', + title: 'Test Tab', + items: [], + layout: [ + { + i: 'item1', + h: -1, + w: 12, + x: 0, + y: 0, + }, + ], + connections: [], + aliases: { + [DASH_DEFAULT_NAMESPACE]: [], + }, + }, + ], + }, + }; + + const result = dashSchema.safeParse(validConfig); + expect(result.success).toBe(true); + }); + + it('should reject connection with empty from/to fields', () => { + const invalidConfig = { + data: { + tabs: [ + { + id: 'tab1', + title: 'Test Tab', + items: [], + layout: [], + connections: [ + { + from: '', + to: 'widget1', + kind: DashTabConnectionKind.Ignore, + }, + ], + aliases: { + [DASH_DEFAULT_NAMESPACE]: [], + }, + }, + ], + }, + }; + + const result = dashSchema.safeParse(invalidConfig); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toContainEqual( + expect.objectContaining({ + code: 'too_small', + path: ['data', 'tabs', 0, 'connections', 0, 'from'], + }), + ); + } + }); + + it('should reject aliases with invalid structure', () => { + const invalidConfig = { + data: { + tabs: [ + { + id: 'tab1', + title: 'Test Tab', + items: [], + layout: [], + connections: [], + aliases: { + [DASH_DEFAULT_NAMESPACE]: [['single-item']], // should have at least 2 items + }, + }, + ], + }, + }; + + const result = dashSchema.safeParse(invalidConfig); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toContainEqual( + expect.objectContaining({ + code: 'too_small', + path: ['data', 'tabs', 0, 'aliases', DASH_DEFAULT_NAMESPACE, 0], + }), + ); + } + }); + }); + + describe('edge cases', () => { + it('should handle null values in settings', () => { + const config = { + data: { + tabs: [], + settings: { + autoupdateInterval: null, + maxConcurrentRequests: null, + }, + }, + }; + + const result = dashSchema.safeParse(config); + expect(result.success).toBe(true); + }); + + it('should handle string autoupdate interval', () => { + const config = { + data: { + tabs: [], + settings: { + autoupdateInterval: '60', + }, + }, + }; + + const result = dashSchema.safeParse(config); + expect(result.success).toBe(true); + }); + + it('should handle empty key', () => { + const config = { + key: '', + data: { + tabs: [], + }, + }; + + const result = dashSchema.safeParse(config); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toContainEqual( + expect.objectContaining({ + code: 'too_small', + path: ['key'], + }), + ); + } + }); + + it('should handle complex nested structures', () => { + const config: DashSchema = { + data: { + tabs: [ + { + id: 'complex-tab', + title: 'Complex Tab', + items: [ + { + id: 'complex-widget', + namespace: DASH_DEFAULT_NAMESPACE, + type: DashTabItemType.Widget, + data: { + hideTitle: false, + tabs: [ + { + id: 'nested-tab-1', + title: 'Nested Tab 1', + description: 'First nested tab', + chartId: 'chart-1', + isDefault: true, + params: { + complexParam: { + nested: { + value: 'deep-value', + array: [1, 2, 3], + }, + }, + }, + autoHeight: false, + }, + { + id: 'nested-tab-2', + title: 'Nested Tab 2', + chartId: 'chart-2', + params: {}, + }, + ], + }, + }, + ], + layout: [ + { + i: 'complex-widget', + h: 10, + w: 24, + x: 6, + y: 5, + }, + ], + connections: [], + aliases: { + [DASH_DEFAULT_NAMESPACE]: [ + ['alias-group-1', 'alias-group-2'], + ['another-alias-1', 'another-alias-2'], + ], + }, + }, + ], + }, + }; + + const result = dashSchema.safeParse(config); + expect(result.success).toBe(true); + }); + }); +}); diff --git a/src/shared/sdk/zod-schemas/dash-api.schema.ts b/src/shared/sdk/zod-schemas/dash-api.schema.ts new file mode 100644 index 0000000000..f1918d1325 --- /dev/null +++ b/src/shared/sdk/zod-schemas/dash-api.schema.ts @@ -0,0 +1,284 @@ +import * as z from 'zod/v4'; + +import { + CONTROLS_PLACEMENT_MODE, + DASH_CURRENT_SCHEME_VERSION, + DashLoadPriority, + DashTabConnectionKind, + DashTabItemControlElementType, + DashTabItemControlSourceType, + DashTabItemTitleSizes, + DashTabItemType, +} from '../..'; +const DASH_DEFAULT_NAMESPACE = 'default'; + +// Text definition +const textSchema = z.object({ + text: z.string(), +}); + +// Title definition +const titleSchema = z.object({ + text: z.string(), + size: z.enum(DashTabItemTitleSizes), + showInTOC: z.boolean().optional(), +}); + +// Widget definition +const widgetSchema = z.object({ + hideTitle: z.boolean().optional(), + tabs: z.array( + z.object({ + id: z.string().min(1), + title: z.string().min(1), + description: z.string().optional(), + chartId: z.string().min(1).optional(), + isDefault: z.boolean().optional(), + params: z.record(z.any(), z.any()).optional(), + autoHeight: z.boolean().optional(), + }), + ), +}); + +// Control element type definition +const controlElementTypeSchema = z + .object({ + required: z.boolean().optional(), + showHint: z.boolean().optional(), + showTitle: z.boolean().optional(), + elementType: z.enum(DashTabItemControlElementType), + }) + .and( + z.discriminatedUnion('elementType', [ + z.object({ + elementType: z.literal(DashTabItemControlElementType.Select), + defaultValue: z.union([z.string(), z.array(z.string()), z.null()]).optional(), + multiselectable: z.boolean().optional(), + }), + z.object({ + elementType: z.literal(DashTabItemControlElementType.Date), + defaultValue: z.string().optional(), + isRange: z.boolean().optional(), + }), + z.object({ + elementType: z.literal(DashTabItemControlElementType.Input), + defaultValue: z.string().optional(), + }), + z.object({ + elementType: z.literal(DashTabItemControlElementType.Checkbox), + defaultValue: z.string().optional(), + }), + ]), + ); + +// Control source dataset definition +const controlSourceDatasetSchema = controlElementTypeSchema.and( + z.object({ + datasetId: z.string().min(1), + datasetFieldId: z.string().min(1), + }), +); + +// Control source manual definition +const controlSourceManualSchema = controlElementTypeSchema.and( + z.object({ + fieldName: z.string().min(1), + acceptableValues: z + .union([ + // elementType: select + z.array( + z.object({ + value: z.string(), + title: z.string(), + }), + ), + // elementType: date + z.object({ + from: z.string().optional(), + to: z.string().optional(), + }), + ]) + .optional(), + }), +); + +// Control source external definition +const controlSourceExternalSchema = z.object({ + chartId: z.string().min(1), + text: z.string().optional(), + autoHeight: z.boolean().optional(), +}); + +// Control definition +const controlSchema = z + .object({ + title: z.string().min(1), + sourceType: z.enum(DashTabItemControlSourceType), + }) + .and( + z.discriminatedUnion('sourceType', [ + z.object({ + sourceType: z.literal(DashTabItemControlSourceType.Dataset), + source: controlSourceDatasetSchema, + }), + z.object({ + sourceType: z.literal(DashTabItemControlSourceType.Manual), + source: controlSourceManualSchema, + }), + z.object({ + sourceType: z.literal(DashTabItemControlSourceType.External), + source: controlSourceExternalSchema, + }), + ]), + ); + +// Group control items definition +const groupControlItemsSchema = z + .object({ + id: z.string().min(1), + title: z.string().min(1), + namespace: z.literal(DASH_DEFAULT_NAMESPACE), + sourceType: z.union([ + z.literal(DashTabItemControlSourceType.Dataset), + z.literal(DashTabItemControlSourceType.Manual), + ]), + defaults: z.record(z.any(), z.any()), + placementMode: z.enum(CONTROLS_PLACEMENT_MODE).optional(), + width: z.string().optional(), + }) + .and( + z.discriminatedUnion('sourceType', [ + z.object({ + sourceType: z.literal(DashTabItemControlSourceType.Dataset), + source: controlSourceDatasetSchema, + }), + z.object({ + sourceType: z.literal(DashTabItemControlSourceType.Manual), + source: controlSourceManualSchema, + }), + ]), + ); + +// Group control definition +const groupControlSchema = z.object({ + group: z.array(groupControlItemsSchema), + autoHeight: z.boolean().optional(), + buttonApply: z.boolean().optional(), + buttonReset: z.boolean().optional(), + showGroupName: z.boolean().optional(), + updateControlsOnChange: z.boolean().optional(), +}); + +// Layout item definition +const layoutItemSchema = z.object({ + i: z.string().min(1), + h: z.number(), + w: z.number(), + x: z.number(), + y: z.number(), + parent: z.string().optional(), +}); + +// Connection definition +const connectionSchema = z.object({ + from: z.string().min(1), + to: z.string().min(1), + kind: z.enum(DashTabConnectionKind), +}); + +// Tab item definition +const tabItemSchema = z + .object({ + id: z.string().min(1), + namespace: z.literal(DASH_DEFAULT_NAMESPACE), + type: z.enum(DashTabItemType), + }) + .and( + z.discriminatedUnion('type', [ + z.object({ + type: z.literal(DashTabItemType.Text), + data: textSchema, + }), + z.object({ + type: z.literal(DashTabItemType.Title), + data: titleSchema, + }), + z.object({ + type: z.literal(DashTabItemType.Widget), + data: widgetSchema, + }), + z.object({ + type: z.literal(DashTabItemType.Control), + data: controlSchema, + defaults: z.record(z.any(), z.any()), + }), + z.object({ + type: z.literal(DashTabItemType.GroupControl), + data: groupControlSchema, + }), + ]), + ); + +// Alias definition +const aliasRecordSchema = z.array(z.string().min(1)).max(2).min(2); + +// Tab definition +const tabSchema = z + .object({ + id: z.string().min(1), + title: z.string().min(1), + items: z.array(tabItemSchema), + layout: z.array(layoutItemSchema), + connections: z.array(connectionSchema), + aliases: z + .object({ + [DASH_DEFAULT_NAMESPACE]: z.array(aliasRecordSchema).optional(), + }) + .strict(), + }) + .strict(); + +// Settings definition +const settingsSchema = z.object({ + autoupdateInterval: z.union([z.number().min(30), z.string(), z.null()]).optional(), + maxConcurrentRequests: z.union([z.number().min(1), z.null()]).optional(), + loadPriority: z.enum(DashLoadPriority).optional(), + silentLoading: z.boolean().optional(), + dependentSelectors: z.boolean().optional(), + globalParams: z.record(z.any(), z.any()).optional(), + hideTabs: z.boolean().optional(), + hideDashTitle: z.boolean().optional(), + expandTOC: z.boolean().optional(), + assistantEnabled: z.boolean().optional(), +}); + +// Data definition +const dataSchema = z.object({ + counter: z.number().int().min(1).optional(), + salt: z.string().min(1).optional(), + schemeVersion: z + .number() + .int() + .min(1) + .max(DASH_CURRENT_SCHEME_VERSION) + .default(DASH_CURRENT_SCHEME_VERSION) + .optional(), + tabs: z.array(tabSchema), + settings: settingsSchema.optional(), + supportDescription: z.string().optional(), + accessDescription: z.string().optional(), +}); + +// Main dashboard API validation schema +export const dashSchema = z.object({ + key: z.string().min(1).optional(), + workbookId: z.union([z.null(), z.string()]).optional(), + data: dataSchema, + meta: z.record(z.any(), z.any()).optional(), + links: z.record(z.string(), z.string()).optional(), +}); + +export const dashApiValidationJsonSchema = z.toJSONSchema(dashSchema); + +// Export the type for TypeScript usage +export type DashSchema = z.infer; diff --git a/src/shared/sdk/zod-schemas/dataset-api.schema.ts b/src/shared/sdk/zod-schemas/dataset-api.schema.ts new file mode 100644 index 0000000000..8623d4b787 --- /dev/null +++ b/src/shared/sdk/zod-schemas/dataset-api.schema.ts @@ -0,0 +1,342 @@ +import * as 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(), +}); + +// Dataset field error schema +const datasetFieldErrorSchema = z.object({ + guid: z.string(), + title: z.string(), + errors: z.array( + z.object({ + column: z.union([z.number(), z.null()]), + row: z.union([z.number(), z.null()]), + message: z.string(), + }), + ), +}); + +// 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(), + }), +}); + +// Dataset source schema +const datasetSourceSchema = z.object({ + id: z.string(), + connection_id: z.string(), + ref_source_id: z.union([z.string(), z.null()]), + name: z.string(), + title: z.string(), + source_type: z.string(), + managed_by: z.string(), + parameter_hash: z.string(), + valid: z.boolean(), + is_ref: z.boolean(), + virtual: z.boolean(), + raw_schema: z.array(datasetRawSchemaSchema), + group: z.array(z.string()), + parameters: z.object({ + table_name: z.string(), + db_version: z.string(), + db_name: z.union([z.string(), z.null()]), + }), +}); + +// 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()), + }), +}); + +// Dataset API error schema +const datasetApiErrorSchema = z.object({ + datasetId: z.string(), + error: z.object({ + code: z.string().optional(), + message: z.string().optional(), + }), +}); + +// Dataset selection map schema +const datasetSelectionMapSchema = z.record(z.string(), z.literal(true)); + +export 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.array(z.unknown()), + source_avatars: z.array(datasetSourceAvatarSchema), + source_features: z.record(z.string(), z.any()), + sources: z.array(datasetSourceSchema), + revisionId: z.string(), + load_preview_by_default: z.boolean(), + template_enabled: z.boolean(), + data_export_forbidden: z.boolean().optional(), +}); + +// Main Dataset schema +export 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 JSON schema generation +export const datasetApiValidationJsonSchema = z.toJSONSchema(datasetSchema); + +// Export the TypeScript type +export type DatasetApiValidationType = z.infer; + +// Export individual schemas for potential reuse +export { + datasetFieldSchema, + datasetSourceSchema, + datasetOptionsSchema, + datasetAvatarRelationSchema, + datasetSourceAvatarSchema, + obligatoryFilterSchema, + datasetComponentErrorSchema, + datasetFieldErrorSchema, + datasetApiErrorSchema, + datasetSelectionMapSchema, +}; diff --git a/src/shared/sdk/zod-schemas/wizard-chart-api.schema.ts b/src/shared/sdk/zod-schemas/wizard-chart-api.schema.ts new file mode 100644 index 0000000000..ea3370d78e --- /dev/null +++ b/src/shared/sdk/zod-schemas/wizard-chart-api.schema.ts @@ -0,0 +1,496 @@ +import * as z from 'zod/v4'; + +import { + ColorMode, + GradientNullModes, + IndicatorTitleMode, + LabelsPositions, + MapCenterMode, + ZoomMode, +} from '../..'; +import {WidgetSize} from '../../constants'; +import {MARKUP_TYPE} from '../../types/charts'; +import type {DatasetFieldCalcMode} from '../../types/dataset'; +import { + AxisLabelFormatMode, + AxisMode, + AxisNullsMode, + ChartsConfigVersion, + NumberFormatType, + NumberFormatUnit, +} from '../../types/wizard'; + +// Helper type for enum to literal conversion +type EnumToLiteral = T extends string + ? `${T}` + : `${T}` extends `${infer N extends number}` + ? N + : never; + +type ValueOf = T[keyof T]; + +// Constants for enum arrays - base arrays +const showHideValuesList = ['show', 'hide'] as const; +const onOffValuesList = ['on', 'off'] as const; +const autoManualValuesList = ['auto', 'manual'] as const; +const yesNoValuesList = ['yes', 'no'] as const; + +// Constants for literal values +const SCALE_VALUE_LITERAL = '0-max'; +const LOGARITHMIC_TYPE_LITERAL = 'logarithmic'; +const MANUAL_GRID_STEP_LITERAL = 'manual'; +const DATALENS_TYPE_LITERAL = 'datalens'; + +// Constants for enum arrays - specific arrays +const DatasetFieldCalcModeList: DatasetFieldCalcMode[] = ['formula', 'direct', 'parameter']; +const titleModeList = showHideValuesList; +const legendModeList = showHideValuesList; +const tooltipModeList = showHideValuesList; +const onOffList = onOffValuesList; +const groupingList = ['disabled', 'off'] as const; +const scaleList = autoManualValuesList; +const titleList = ['auto', 'manual', 'off'] as const; +const gridList = onOffValuesList; +const hideLabelsLis = yesNoValuesList; +const labelsViewList = ['horizontal', 'vertical', 'angle'] as const; +const holidaysList = onOffValuesList; +const axisVisibilityList = showHideValuesList; +const colorFieldTitleList = onOffValuesList; +const v12UpdateActionsList = [ + 'add_field', + 'add', + 'update_field', + 'update', + 'delete', + 'delete_field', +] as const; + +// Basic schemas +const parameterDefaultValueSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]); +const datasetFieldCalcModeSchema = z.enum(DatasetFieldCalcModeList); + +// Enum schemas using Object.values for regular enums +const colorModeSchema = z.enum(Object.values(ColorMode) as EnumToLiteral[]); +const gradientNullModeSchema = z.enum( + Object.values(GradientNullModes) as ValueOf[], +); +const indicatorTitleModeSchema = z.enum([ + IndicatorTitleMode.ByField, + IndicatorTitleMode.Hide, + IndicatorTitleMode.Manual, +]); +const labelsPositionsSchema = z.enum( + Object.values(LabelsPositions) as EnumToLiteral[], +); +const markupTypeSchema = z.enum( + Object.values(MARKUP_TYPE) as [ + EnumToLiteral<(typeof MARKUP_TYPE)[keyof typeof MARKUP_TYPE]>, + ...EnumToLiteral<(typeof MARKUP_TYPE)[keyof typeof MARKUP_TYPE]>[], + ], +); +const widgetSizeTypeSchema = z.enum( + Object.values(WidgetSize) as [ + EnumToLiteral<(typeof WidgetSize)[keyof typeof WidgetSize]>, + ...EnumToLiteral<(typeof WidgetSize)[keyof typeof WidgetSize]>[], + ], +); + +// Const enum schemas using direct values +const axisLabelFormatModeSchema = z.enum([AxisLabelFormatMode.Auto, AxisLabelFormatMode.ByField]); +const axisModeSchema = z.enum([AxisMode.Discrete, AxisMode.Continuous]); +const axisNullsModeSchema = z.enum([ + AxisNullsMode.Ignore, + AxisNullsMode.Connect, + AxisNullsMode.AsZero, + AxisNullsMode.UsePrevious, +]); +const numberFormatTypeSchema = z.enum([NumberFormatType.Number, NumberFormatType.Percent]); +const numberFormatUnitSchema = z.enum([ + NumberFormatUnit.Auto, + NumberFormatUnit.B, + NumberFormatUnit.K, + NumberFormatUnit.M, + NumberFormatUnit.T, +]); + +// Simple string enums for types that are not available as runtime values +const mapCenterModesSchema = z.enum([MapCenterMode.Auto, MapCenterMode.Manual]); +const zoomModesSchema = z.enum([ZoomMode.Auto, ZoomMode.Manual]); + +// V12ClientOnlyFields schema +const v12ClientOnlyFieldsSchema = z.object({ + fakeTitle: z.string().optional(), + originalTitle: z.string().optional(), + markupType: markupTypeSchema.optional(), +}); + +// V12Formatting schema +const v12FormattingSchema = z.object({ + format: numberFormatTypeSchema.optional(), + showRankDelimiter: z.boolean().optional(), + prefix: z.string().optional(), + postfix: z.string().optional(), + unit: numberFormatUnitSchema.optional(), + precision: z.number().optional(), + labelMode: z.string().optional(), +}); + +// V12NavigatorSettings schema +const v12NavigatorSettingsSchema = z.object({ + navigatorMode: z.string(), + isNavigatorAvailable: z.boolean(), + selectedLines: z.array(z.string()), + linesMode: z.string(), + periodSettings: z.object({ + type: z.string(), + value: z.string(), + period: z.string(), + }), +}); + +// V12CommonSharedExtraSettings schema +const v12CommonSharedExtraSettingsSchema = z.object({ + title: z.string().optional(), + titleMode: z.enum(titleModeList).optional(), + indicatorTitleMode: indicatorTitleModeSchema.optional(), + legendMode: z.enum(legendModeList).optional(), + metricFontSize: z.string().optional(), + metricFontColor: z.string().optional(), + tooltip: z.enum(tooltipModeList).optional(), + tooltipSum: z.enum(onOffList).optional(), + limit: z.number().optional(), + pagination: z.enum(onOffList).optional(), + navigatorMode: z.string().optional(), + navigatorSeriesName: z.string().optional(), + totals: z.enum(onOffList).optional(), + pivotFallback: z.enum(onOffList).optional(), + pivotInlineSort: z.enum(onOffList).optional(), + stacking: z.enum(onOffList).optional(), + overlap: z.enum(onOffList).optional(), + feed: z.string().optional(), + navigatorSettings: v12NavigatorSettingsSchema.optional(), + enableGPTInsights: z.boolean().optional(), + labelsPosition: labelsPositionsSchema.optional(), + pinnedColumns: z.number().optional(), + size: widgetSizeTypeSchema.optional(), + zoomMode: zoomModesSchema.optional(), + zoomValue: z.number().nullable().optional(), + mapCenterMode: mapCenterModesSchema.optional(), + mapCenterValue: z.string().nullable().optional(), + preserveWhiteSpace: z.boolean().optional(), +}); + +// V12Filter schema +const v12FilterSchema = z + .object({ + guid: z.string(), + datasetId: z.string(), + disabled: z.string().optional(), + filter: z.object({ + operation: z.object({ + code: z.string(), + }), + value: z.union([z.string(), z.array(z.string())]).optional(), + }), + type: z.string(), + title: z.string(), + calc_mode: datasetFieldCalcModeSchema, + }) + .merge(v12ClientOnlyFieldsSchema); + +// V12Sort schema +const v12SortSchema = z + .object({ + guid: z.string(), + title: z.string(), + source: z.string().optional(), + datasetId: z.string(), + direction: z.string(), + data_type: z.string(), + format: z.string().optional(), + type: z.string(), + default_value: parameterDefaultValueSchema.optional(), + }) + .merge(v12ClientOnlyFieldsSchema); + +// V12LinkField schema +const v12LinkFieldSchema = z.object({ + field: z.object({ + title: z.string(), + guid: z.string(), + }), + dataset: z.object({ + id: z.string(), + realName: z.string(), + }), +}); + +// V12Link schema +const v12LinkSchema = z.object({ + id: z.string(), + fields: z.record(z.string(), v12LinkFieldSchema), +}); + +// V12PlaceholderSettings schema +const v12PlaceholderSettingsSchema = z.object({ + groupping: z.enum(groupingList).optional(), + autoscale: z.boolean().optional(), + scale: z.enum(scaleList).optional(), + scaleValue: z + .union([z.literal(SCALE_VALUE_LITERAL), z.tuple([z.string(), z.string()])]) + .optional(), + title: z.enum(titleList).optional(), + titleValue: z.string().optional(), + type: z.literal(LOGARITHMIC_TYPE_LITERAL).optional(), + grid: z.enum(gridList).optional(), + gridStep: z.literal(MANUAL_GRID_STEP_LITERAL).optional(), + gridStepValue: z.number().optional(), + hideLabels: z.enum(hideLabelsLis).optional(), + labelsView: z.enum(labelsViewList).optional(), + nulls: axisNullsModeSchema.optional(), + holidays: z.enum(holidaysList).optional(), + axisFormatMode: axisLabelFormatModeSchema.optional(), + axisModeMap: z.record(z.string(), axisModeSchema).optional(), + disableAxisMode: z.boolean().optional(), + axisVisibility: z.enum(axisVisibilityList).optional(), +}); + +// Forward declaration for recursive types +const v12FieldSchemaInner = z.object({ + ...v12ClientOnlyFieldsSchema.shape, + data_type: z.string(), + type: z.string(), + title: z.string(), + guid: z.string(), + formatting: v12FormattingSchema.optional(), + format: z.string().optional(), + datasetId: z.string(), + source: z.string().optional(), + datasetName: z.string().optional(), + hideLabelMode: z.string().optional(), + calc_mode: datasetFieldCalcModeSchema, + default_value: parameterDefaultValueSchema.optional(), + barsSettings: z.any().optional(), // TableBarsSettings + subTotalsSettings: z.any().optional(), // TableSubTotalsSettings + backgroundSettings: z.any().optional(), // TableFieldBackgroundSettings + columnSettings: z.any().optional(), // ColumnSettings + hintSettings: z.any().optional(), // HintSettings +}); + +const v12FieldSchema = z.object({ + ...v12FieldSchemaInner.shape, + fields: z.array(v12FieldSchemaInner).optional(), +}); + +// V12Placeholder schema +const v12PlaceholderSchema = z.object({ + id: z.string(), + settings: v12PlaceholderSettingsSchema.optional(), + required: z.boolean().optional(), + capacity: z.number().optional(), + items: z.array(v12FieldSchema), +}); + +// V12Color schema +const v12ColorSchema = z + .object({ + datasetId: z.string(), + guid: z.string(), + title: z.string(), + type: z.string(), + data_type: z.string(), + formatting: v12FormattingSchema.optional(), + calc_mode: datasetFieldCalcModeSchema, + }) + .merge(v12ClientOnlyFieldsSchema); + +// V12Shape schema +const v12ShapeSchema = z + .object({ + datasetId: z.string(), + guid: z.string(), + title: z.string(), + originalTitle: z.string().optional(), + type: z.string(), + data_type: z.string(), + calc_mode: datasetFieldCalcModeSchema, + }) + .merge(v12ClientOnlyFieldsSchema); + +// V12Tooltip schema +const v12TooltipSchema = z + .object({ + datasetId: z.string(), + guid: z.string(), + title: z.string(), + formatting: v12FormattingSchema.optional(), + data_type: z.string(), + calc_mode: datasetFieldCalcModeSchema, + }) + .merge(v12ClientOnlyFieldsSchema); + +// V12Label schema +const v12LabelSchema = z.object({ + datasetId: z.string(), + type: z.string(), + title: z.string(), + guid: z.string(), + formatting: v12FormattingSchema.optional(), + format: z.string().optional(), + data_type: z.string(), + calc_mode: datasetFieldCalcModeSchema, +}); + +// V12HierarchyField schema +const v12HierarchyFieldSchema = z.object({ + data_type: z.string(), + fields: z.array(v12FieldSchema), + type: z.string(), +}); + +// V12PointSizeConfig schema +const v12PointSizeConfigSchema = z.object({ + radius: z.number(), + minRadius: z.number(), + maxRadius: z.number(), +}); + +// V12ColorsConfig schema +const v12ColorsConfigSchema = z.object({ + thresholdsMode: z.string().optional(), + leftThreshold: z.string().optional(), + middleThreshold: z.string().optional(), + rightThreshold: z.string().optional(), + gradientPalette: z.string().optional(), + gradientMode: z.string().optional(), + polygonBorders: z.string().optional(), + reversed: z.boolean().optional(), + fieldGuid: z.string().optional(), + mountedColors: z.record(z.string(), z.string()).optional(), + coloredByMeasure: z.boolean().optional(), + palette: z.string().optional(), + colorMode: colorModeSchema.optional(), + nullMode: gradientNullModeSchema.optional(), +}); + +// V12ShapesConfig schema +const v12ShapesConfigSchema = z.object({ + mountedShapes: z.record(z.string(), z.string()).optional(), + fieldGuid: z.string().optional(), +}); + +// V12TooltipConfig schema +const v12TooltipConfigSchema = z.object({ + color: z.enum(colorFieldTitleList).optional(), + fieldTitle: z.enum(colorFieldTitleList).optional(), +}); + +// V12LayerSettings schema +const v12LayerSettingsSchema = z.object({ + id: z.string(), + name: z.string(), + type: z.string(), + alpha: z.number(), + valid: z.boolean(), +}); + +// V12CommonPlaceholders schema +const v12CommonPlaceholdersSchema = z.object({ + colors: z.array(v12ColorSchema), + labels: z.array(v12LabelSchema), + tooltips: z.array(v12TooltipSchema), + filters: z.array(v12FilterSchema), + sort: z.array(v12SortSchema), + shapes: z.array(v12ShapeSchema).optional(), + colorsConfig: v12ColorsConfigSchema.optional(), + geopointsConfig: v12PointSizeConfigSchema.optional(), + shapesConfig: v12ShapesConfigSchema.optional(), + tooltipConfig: v12TooltipConfigSchema.optional(), +}); + +// V12Layer schema +const v12LayerSchema = z.object({ + id: z.string(), + commonPlaceholders: v12CommonPlaceholdersSchema, + layerSettings: v12LayerSettingsSchema, + placeholders: z.array(v12PlaceholderSchema), +}); + +// V12Visualization schema +const v12VisualizationSchema = z.object({ + id: z.string(), + highchartsId: z.string().optional(), + selectedLayerId: z.string().optional(), + layers: z.array(v12LayerSchema).optional(), + placeholders: z.array(v12PlaceholderSchema), +}); + +// V12Update schema +const v12UpdateSchema = z.object({ + action: z.enum(v12UpdateActionsList), + field: z.any(), + debug_info: z.string().optional(), +}); + +// V12ChartsConfigDatasetField schema +const v12ChartsConfigDatasetFieldSchema = z.object({ + guid: z.string(), + title: z.string(), + calc_mode: datasetFieldCalcModeSchema.optional(), +}); + +// Main V12ChartsConfig schema +export const v12ChartsConfigSchema = z.object({ + title: z.string().optional(), + colors: z.array(v12ColorSchema), + colorsConfig: v12ColorsConfigSchema.optional(), + extraSettings: v12CommonSharedExtraSettingsSchema.optional(), + filters: z.array(v12FilterSchema), + geopointsConfig: v12PointSizeConfigSchema.optional(), + hierarchies: z.array(v12HierarchyFieldSchema), + labels: z.array(v12LabelSchema), + links: z.array(v12LinkSchema), + sort: z.array(v12SortSchema), + tooltips: z.array(v12TooltipSchema), + tooltipConfig: v12TooltipConfigSchema.optional(), + type: z.literal(DATALENS_TYPE_LITERAL), + updates: z.array(v12UpdateSchema), + visualization: v12VisualizationSchema, + shapes: z.array(v12ShapeSchema), + shapesConfig: v12ShapesConfigSchema.optional(), + version: z.literal(ChartsConfigVersion.V12), + datasetsIds: z.array(z.string()), + datasetsPartialFields: z.array(z.array(v12ChartsConfigDatasetFieldSchema)), + segments: z.array(v12FieldSchema), + chartType: z.string().optional(), +}); + +// Export JSON schema generation +export const chartApiValidationJsonSchema = z.toJSONSchema(v12ChartsConfigSchema); + +// Export the TypeScript type +export type V12ChartsConfigSchema = z.infer; + +// Export individual schemas for potential reuse +export { + v12ColorSchema, + v12ShapeSchema, + v12TooltipSchema, + v12LabelSchema, + v12FilterSchema, + v12SortSchema, + v12LinkSchema, + v12VisualizationSchema, + v12UpdateSchema, + v12FieldSchema, + v12HierarchyFieldSchema, + v12PointSizeConfigSchema, + v12ColorsConfigSchema, + v12ShapesConfigSchema, + v12TooltipConfigSchema, + v12CommonSharedExtraSettingsSchema, + v12NavigatorSettingsSchema, + v12FormattingSchema, + v12ClientOnlyFieldsSchema, + v12ChartsConfigDatasetFieldSchema, +};