From 77ac8780678abd811419ab1b66ae423b1a1f4fb6 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Thu, 14 May 2026 16:52:04 +0200 Subject: [PATCH] feat(context): add metabase, looker, lookml driver schemas with mappings --- .../src/project/driver-schemas.test.ts | 54 ++++++++++++ .../context/src/project/driver-schemas.ts | 84 ++++++++++++++++++- 2 files changed, 137 insertions(+), 1 deletion(-) diff --git a/packages/context/src/project/driver-schemas.test.ts b/packages/context/src/project/driver-schemas.test.ts index 2cb6eac4..731d4342 100644 --- a/packages/context/src/project/driver-schemas.test.ts +++ b/packages/context/src/project/driver-schemas.test.ts @@ -33,3 +33,57 @@ describe('connectionConfigSchema (driver discriminated union)', () => { expect(() => connectionConfigSchema.parse({ driver: 'nope', url: 'x' })).toThrow(); }); }); + +describe('connectionConfigSchema - context source drivers with mappings', () => { + it('parses a metabase connection with mappings', () => { + const parsed = connectionConfigSchema.parse({ + driver: 'metabase', + api_url: 'https://metabase.example.com', + api_key_ref: 'env:METABASE_API_KEY', + mappings: { + databaseMappings: { '3': 'prod-warehouse' }, + syncEnabled: { '3': true }, + syncMode: 'ONLY', + }, + }); + expect(parsed).toMatchObject({ + driver: 'metabase', + api_url: 'https://metabase.example.com', + mappings: { + databaseMappings: { '3': 'prod-warehouse' }, + syncMode: 'ONLY', + }, + }); + }); + + it('parses a looker connection with connectionMappings', () => { + const parsed = connectionConfigSchema.parse({ + driver: 'looker', + base_url: 'https://looker.example.com', + client_id: 'abc', + client_secret_ref: 'env:LOOKER_CLIENT_SECRET', + mappings: { connectionMappings: { bigquery_prod: 'wh' } }, + }); + expect(parsed.mappings).toEqual({ connectionMappings: { bigquery_prod: 'wh' } }); + }); + + it('parses a lookml connection with expectedLookerConnectionName', () => { + const parsed = connectionConfigSchema.parse({ + driver: 'lookml', + repoUrl: 'https://github.com/acme/looker.git', + branch: 'main', + mappings: { expectedLookerConnectionName: 'bigquery_prod' }, + }); + expect(parsed.mappings).toEqual({ expectedLookerConnectionName: 'bigquery_prod' }); + }); + + it('rejects metabase mapping with non-integer database key', () => { + expect(() => + connectionConfigSchema.parse({ + driver: 'metabase', + api_url: 'https://x', + mappings: { databaseMappings: { abc: 'wh' } }, + }), + ).toThrow(); + }); +}); diff --git a/packages/context/src/project/driver-schemas.ts b/packages/context/src/project/driver-schemas.ts index a584fc7c..5ec2afe4 100644 --- a/packages/context/src/project/driver-schemas.ts +++ b/packages/context/src/project/driver-schemas.ts @@ -1,4 +1,9 @@ import * as z from 'zod'; +import { + lookerMappingsSchema, + lookmlMappingsSchema, + metabaseMappingsSchema, +} from './mappings-yaml-schema.js'; const warehouseDrivers = [ 'postgres', @@ -39,6 +44,83 @@ const warehouseConnectionSchemas = [ warehouseConnectionSchema('sqlserver'), ] as const; -export const connectionConfigSchema = z.discriminatedUnion('driver', warehouseConnectionSchemas); +const positiveIntKeyMessage = (field: string) => `${field} keys must be positive-integer strings (e.g. "1", "42")`; + +const positiveIntKeyRegex = /^[1-9]\d*$/; + +const metabaseMappingsStrictSchema = metabaseMappingsSchema.superRefine((value, ctx) => { + for (const key of Object.keys(value.databaseMappings ?? {})) { + if (!positiveIntKeyRegex.test(key)) { + ctx.addIssue({ + code: 'custom', + path: ['databaseMappings', key], + message: positiveIntKeyMessage('databaseMappings'), + }); + } + } + for (const key of Object.keys(value.syncEnabled ?? {})) { + if (!positiveIntKeyRegex.test(key)) { + ctx.addIssue({ + code: 'custom', + path: ['syncEnabled', key], + message: positiveIntKeyMessage('syncEnabled'), + }); + } + } +}); + +const metabaseConnectionSchema = z + .looseObject({ + driver: z.literal('metabase'), + api_url: z.string().url().describe('Metabase instance API URL (e.g. https://metabase.example.com).'), + api_key: z.string().min(1).optional().describe('Literal Metabase API key. Prefer api_key_ref for safety.'), + api_key_ref: z + .string() + .min(1) + .optional() + .describe('Reference to Metabase API key (e.g. env:METABASE_API_KEY or file:/path).'), + network_proxy: z.looseObject({}).optional().describe('Optional network proxy configuration (snake_case form).'), + networkProxy: z.looseObject({}).optional().describe('Optional network proxy configuration (camelCase form).'), + mappings: metabaseMappingsStrictSchema + .optional() + .describe('Metabase database-to-warehouse mappings and sync configuration.'), + }) + .describe('Metabase context-source connection.'); + +const lookerConnectionSchema = z + .looseObject({ + driver: z.literal('looker'), + base_url: z.string().url().describe('Looker instance base URL (e.g. https://looker.example.com).'), + client_id: z.string().min(1).describe('Looker OAuth client ID.'), + client_secret: z.string().min(1).optional().describe('Literal Looker OAuth client secret. Prefer client_secret_ref.'), + client_secret_ref: z + .string() + .min(1) + .optional() + .describe('Reference to Looker OAuth client secret (e.g. env:LOOKER_CLIENT_SECRET).'), + mappings: lookerMappingsSchema.optional().describe('Looker connection-name to KTX warehouse mappings.'), + }) + .describe('Looker context-source connection.'); + +const lookmlConnectionSchema = z + .looseObject({ + driver: z.literal('lookml'), + repoUrl: z + .string() + .min(1) + .describe('Git URL of the LookML project (https, ssh, or file:). Field is camelCase by convention.'), + branch: z.string().min(1).optional().describe('Git branch (default "main" downstream).'), + path: z.string().optional().describe('Subdirectory within the repo when the LookML project lives in a monorepo.'), + auth_token_ref: z.string().min(1).optional().describe('Reference to Git auth token for private repos (e.g. env:GITHUB_TOKEN).'), + mappings: lookmlMappingsSchema.optional().describe('LookML expected-connection mapping for ingest gating.'), + }) + .describe('LookML context-source connection.'); + +export const connectionConfigSchema = z.discriminatedUnion('driver', [ + ...warehouseConnectionSchemas, + metabaseConnectionSchema, + lookerConnectionSchema, + lookmlConnectionSchema, +]); export type KtxConnectionConfig = z.infer;