@malloy-publisher/server 0.0.92 → 0.0.93

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@malloy-publisher/server",
3
3
  "description": "Malloy Publisher Server",
4
- "version": "0.0.92",
4
+ "version": "0.0.93",
5
5
  "main": "dist/server.js",
6
6
  "bin": {
7
7
  "malloy-publisher": "dist/server.js"
package/src/constants.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
2
4
  export const API_PREFIX = "/api/v0";
3
5
  export const README_NAME = "README.md";
4
6
  export const PUBLISHER_CONFIG_NAME = "publisher.config.json";
@@ -7,10 +9,24 @@ export const CONNECTIONS_MANIFEST_NAME = "publisher.connections.json";
7
9
  export const MODEL_FILE_SUFFIX = ".malloy";
8
10
  export const NOTEBOOK_FILE_SUFFIX = ".malloynb";
9
11
  export const ROW_LIMIT = 1000;
12
+ export const TEMP_DIR_PATH = os.tmpdir();
13
+ export let publisherPath: string;
10
14
 
11
- export let publisherPath = "/etc/publisher";
12
- try {
13
- fs.accessSync(publisherPath, fs.constants.W_OK);
14
- } catch {
15
- publisherPath = "/tmp/publisher";
15
+ if (process.platform === "win32") {
16
+ publisherPath = path.join(
17
+ process.env.PROGRAMDATA || "C:\\ProgramData",
18
+ "publisher",
19
+ );
20
+ try {
21
+ fs.accessSync(publisherPath, fs.constants.W_OK);
22
+ } catch {
23
+ publisherPath = path.join(os.tmpdir(), "publisher");
24
+ }
25
+ } else {
26
+ publisherPath = "/etc/publisher";
27
+ try {
28
+ fs.accessSync(publisherPath, fs.constants.W_OK);
29
+ } catch {
30
+ publisherPath = path.join(os.tmpdir(), "publisher");
31
+ }
16
32
  }
@@ -4,15 +4,15 @@ import {
4
4
  TestableConnection,
5
5
  } from "@malloydata/malloy";
6
6
  import { Connection, PersistSQLResults } from "@malloydata/malloy/connection";
7
- import { components } from "../api";
8
- import { ConnectionError } from "../errors";
7
+ import { BadRequestError, ConnectionError } from "../errors";
9
8
  import { logger } from "../logger";
9
+ import { components } from "../api";
10
10
  import {
11
11
  getSchemasForConnection,
12
12
  getTablesForSchema,
13
13
  } from "../service/db_utils";
14
14
  import { ProjectStore } from "../service/project_store";
15
-
15
+ import { testConnectionConfig } from "../service/connection";
16
16
  type ApiConnection = components["schemas"]["Connection"];
17
17
  type ApiConnectionStatus = components["schemas"]["ConnectionStatus"];
18
18
  type ApiSqlSource = components["schemas"]["SqlSource"];
@@ -189,4 +189,33 @@ export class ConnectionController {
189
189
  throw new ConnectionError((error as Error).message);
190
190
  }
191
191
  }
192
+
193
+ public async testConnectionConfiguration(
194
+ connectionConfig: ApiConnection,
195
+ ): Promise<ApiConnectionStatus> {
196
+ if (
197
+ !connectionConfig ||
198
+ typeof connectionConfig !== "object" ||
199
+ Object.keys(connectionConfig).length === 0
200
+ ) {
201
+ throw new BadRequestError(
202
+ "Connection configuration is required and cannot be empty",
203
+ );
204
+ }
205
+
206
+ if (!connectionConfig.type || typeof connectionConfig.type !== "string") {
207
+ throw new BadRequestError(
208
+ "Connection type is required and must be a string",
209
+ );
210
+ }
211
+
212
+ try {
213
+ return await testConnectionConfig(connectionConfig);
214
+ } catch (error) {
215
+ return {
216
+ status: "failed",
217
+ errorMessage: `Connection test failed: ${(error as Error).message}`,
218
+ };
219
+ }
220
+ }
192
221
  }
package/src/server.ts CHANGED
@@ -311,6 +311,20 @@ app.get(
311
311
  },
312
312
  );
313
313
 
314
+ app.post(`${API_PREFIX}/connections/test`, async (req, res) => {
315
+ try {
316
+ const connectionStatus =
317
+ await connectionController.testConnectionConfiguration(
318
+ req.body.connectionConfig,
319
+ );
320
+ res.status(200).json(connectionStatus);
321
+ } catch (error) {
322
+ logger.error(error);
323
+ const { json, status } = internalErrorToHttpError(error as Error);
324
+ res.status(status).json(json);
325
+ }
326
+ });
327
+
314
328
  app.get(
315
329
  `${API_PREFIX}/projects/:projectName/connections/:connectionName/test`,
316
330
  async (req, res) => {
@@ -4,16 +4,19 @@ import { PostgresConnection } from "@malloydata/db-postgres";
4
4
  import { SnowflakeConnection } from "@malloydata/db-snowflake";
5
5
  import { TrinoConnection } from "@malloydata/db-trino";
6
6
  import { Connection } from "@malloydata/malloy";
7
+ import { BadRequestError } from "../errors";
8
+ import { logAxiosError, logger } from "../logger";
7
9
  import { BaseConnection } from "@malloydata/malloy/connection";
8
10
  import fs from "fs/promises";
9
11
  import path from "path";
10
12
  import { v4 as uuidv4 } from "uuid";
11
13
  import { components } from "../api";
12
- import { CONNECTIONS_MANIFEST_NAME } from "../constants";
13
- import { logger } from "../logger";
14
+ import { CONNECTIONS_MANIFEST_NAME, TEMP_DIR_PATH } from "../constants";
15
+ import { AxiosError } from "axios";
14
16
 
15
17
  type ApiConnection = components["schemas"]["Connection"];
16
18
  type ApiConnectionAttributes = components["schemas"]["ConnectionAttributes"];
19
+ type ApiConnectionStatus = components["schemas"]["ConnectionStatus"];
17
20
 
18
21
  // Extends the public API connection with the internal connection objects
19
22
  // which contains passwords and connection strings.
@@ -129,7 +132,7 @@ export async function createConnections(
129
132
  let serviceAccountKeyPath = undefined;
130
133
  if (connection.bigqueryConnection.serviceAccountKeyJson) {
131
134
  serviceAccountKeyPath = path.join(
132
- "/tmp",
135
+ TEMP_DIR_PATH,
133
136
  `${connection.name}-${uuidv4()}-service-account-key.json`,
134
137
  );
135
138
  await fs.writeFile(
@@ -255,3 +258,257 @@ function getConnectionAttributes(
255
258
  canStream: canStream,
256
259
  };
257
260
  }
261
+
262
+ export async function testConnectionConfig(
263
+ connectionConfig: ApiConnection,
264
+ ): Promise<ApiConnectionStatus> {
265
+ let testResult: { status: "ok" | "failed"; errorMessage?: string };
266
+
267
+ switch (connectionConfig.type) {
268
+ case "postgres": {
269
+ if (!connectionConfig.postgresConnection) {
270
+ throw new Error(
271
+ "Invalid connection configuration. No postgres connection.",
272
+ );
273
+ }
274
+
275
+ const postgresConfig = connectionConfig.postgresConnection;
276
+ if (
277
+ !postgresConfig.host ||
278
+ !postgresConfig.port ||
279
+ !postgresConfig.userName ||
280
+ !postgresConfig.password ||
281
+ !postgresConfig.databaseName
282
+ ) {
283
+ throw new Error(
284
+ "PostgreSQL connection requires: host, port, userName, password, and databaseName",
285
+ );
286
+ }
287
+
288
+ const configReader = async () => {
289
+ return {
290
+ host: postgresConfig.host,
291
+ port: postgresConfig.port,
292
+ username: postgresConfig.userName,
293
+ password: postgresConfig.password,
294
+ databaseName: postgresConfig.databaseName,
295
+ connectionString: postgresConfig.connectionString,
296
+ };
297
+ };
298
+
299
+ const postgresConnection = new PostgresConnection(
300
+ "testConnection",
301
+ () => ({}),
302
+ configReader,
303
+ );
304
+
305
+ try {
306
+ await postgresConnection.test();
307
+ testResult = { status: "ok" };
308
+ } catch (error) {
309
+ if (error instanceof AxiosError) {
310
+ logAxiosError(error);
311
+ } else {
312
+ logger.error(error);
313
+ }
314
+ testResult = {
315
+ status: "failed",
316
+ errorMessage: (error as Error).message,
317
+ };
318
+ }
319
+ break;
320
+ }
321
+
322
+ case "snowflake": {
323
+ if (!connectionConfig.snowflakeConnection) {
324
+ throw new Error(
325
+ "Invalid connection configuration. No snowflake connection.",
326
+ );
327
+ }
328
+
329
+ const snowflakeConfig = connectionConfig.snowflakeConnection;
330
+ if (
331
+ !snowflakeConfig.account ||
332
+ !snowflakeConfig.username ||
333
+ !snowflakeConfig.password ||
334
+ !snowflakeConfig.warehouse ||
335
+ !snowflakeConfig.database ||
336
+ !snowflakeConfig.schema
337
+ ) {
338
+ throw new Error(
339
+ "Snowflake connection requires: account, username, password, warehouse, database, and schema",
340
+ );
341
+ }
342
+
343
+ const snowflakeConnectionOptions = {
344
+ connOptions: {
345
+ account: snowflakeConfig.account,
346
+ username: snowflakeConfig.username,
347
+ password: snowflakeConfig.password,
348
+ warehouse: snowflakeConfig.warehouse,
349
+ database: snowflakeConfig.database,
350
+ schema: snowflakeConfig.schema,
351
+ timeout: snowflakeConfig.responseTimeoutMilliseconds,
352
+ },
353
+ };
354
+ const snowflakeConnection = new SnowflakeConnection(
355
+ "testConnection",
356
+ snowflakeConnectionOptions,
357
+ );
358
+
359
+ try {
360
+ await snowflakeConnection.test();
361
+ testResult = { status: "ok" };
362
+ } catch (error) {
363
+ if (error instanceof AxiosError) {
364
+ logAxiosError(error);
365
+ } else {
366
+ logger.error(error);
367
+ }
368
+ testResult = {
369
+ status: "failed",
370
+ errorMessage: (error as Error).message,
371
+ };
372
+ }
373
+ break;
374
+ }
375
+
376
+ case "bigquery": {
377
+ if (!connectionConfig.bigqueryConnection) {
378
+ throw new Error(
379
+ "Invalid connection configuration. No bigquery connection.",
380
+ );
381
+ }
382
+
383
+ const bigqueryConfig = connectionConfig.bigqueryConnection;
384
+ if (
385
+ !bigqueryConfig.serviceAccountKeyJson ||
386
+ !bigqueryConfig.defaultProjectId
387
+ ) {
388
+ throw new Error(
389
+ "BigQuery connection requires: serviceAccountKeyJson and defaultProjectId",
390
+ );
391
+ }
392
+
393
+ const serviceAccountKeyPath = path.join(
394
+ TEMP_DIR_PATH,
395
+ `test-${uuidv4()}-service-account-key.json`,
396
+ );
397
+
398
+ try {
399
+ await fs.writeFile(
400
+ serviceAccountKeyPath,
401
+ connectionConfig.bigqueryConnection
402
+ .serviceAccountKeyJson as string,
403
+ );
404
+
405
+ const bigqueryConnectionOptions = {
406
+ projectId: connectionConfig.bigqueryConnection.defaultProjectId,
407
+ serviceAccountKeyPath: serviceAccountKeyPath,
408
+ location: connectionConfig.bigqueryConnection.location,
409
+ maximumBytesBilled:
410
+ connectionConfig.bigqueryConnection.maximumBytesBilled,
411
+ timeoutMs:
412
+ connectionConfig.bigqueryConnection.queryTimeoutMilliseconds,
413
+ billingProjectId:
414
+ connectionConfig.bigqueryConnection.billingProjectId,
415
+ };
416
+ const bigqueryConnection = new BigQueryConnection(
417
+ "testConnection",
418
+ () => ({}),
419
+ bigqueryConnectionOptions,
420
+ );
421
+
422
+ await bigqueryConnection.test();
423
+ testResult = { status: "ok" };
424
+ } catch (error) {
425
+ if (error instanceof AxiosError) {
426
+ logAxiosError(error);
427
+ } else {
428
+ logger.error(error);
429
+ }
430
+ testResult = {
431
+ status: "failed",
432
+ errorMessage: (error as Error).message,
433
+ };
434
+ } finally {
435
+ try {
436
+ await fs.unlink(serviceAccountKeyPath);
437
+ } catch (cleanupError) {
438
+ logger.warn(
439
+ `Failed to cleanup temporary file ${serviceAccountKeyPath}:`,
440
+ cleanupError,
441
+ );
442
+ }
443
+ }
444
+ break;
445
+ }
446
+
447
+ case "trino": {
448
+ if (!connectionConfig.trinoConnection) {
449
+ throw new Error("Trino connection configuration is missing.");
450
+ }
451
+
452
+ const trinoConfig = connectionConfig.trinoConnection;
453
+ if (
454
+ !trinoConfig.server ||
455
+ !trinoConfig.port ||
456
+ !trinoConfig.catalog ||
457
+ !trinoConfig.schema ||
458
+ !trinoConfig.user ||
459
+ !trinoConfig.password
460
+ ) {
461
+ throw new Error(
462
+ "Trino connection requires: server, port, catalog, schema, user, and password",
463
+ );
464
+ }
465
+
466
+ const trinoConnectionOptions = {
467
+ server: trinoConfig.server,
468
+ port: trinoConfig.port,
469
+ catalog: trinoConfig.catalog,
470
+ schema: trinoConfig.schema,
471
+ user: trinoConfig.user,
472
+ password: trinoConfig.password,
473
+ };
474
+ const trinoConnection = new TrinoConnection(
475
+ "testConnection",
476
+ {},
477
+ trinoConnectionOptions,
478
+ );
479
+
480
+ try {
481
+ await trinoConnection.test();
482
+ testResult = { status: "ok" };
483
+ } catch (error) {
484
+ if (error instanceof AxiosError) {
485
+ logAxiosError(error);
486
+ } else {
487
+ logger.error(error);
488
+ }
489
+ testResult = {
490
+ status: "failed",
491
+ errorMessage: (error as Error).message,
492
+ };
493
+ }
494
+ break;
495
+ }
496
+
497
+ default:
498
+ throw new BadRequestError(
499
+ `Unsupported connection type: ${connectionConfig.type}`,
500
+ );
501
+ }
502
+
503
+ if (testResult.status === "failed") {
504
+ return {
505
+ status: "failed",
506
+ errorMessage: testResult.errorMessage || "Connection test failed",
507
+ };
508
+ }
509
+
510
+ return {
511
+ status: "ok",
512
+ errorMessage: "",
513
+ };
514
+ }
@@ -6,10 +6,14 @@ import { components } from "../api";
6
6
  import { isPublisherConfigFrozen } from "../config";
7
7
  import { ProjectStore } from "./project_store";
8
8
  import { Project } from "./project";
9
+ import { TEMP_DIR_PATH } from "../constants";
9
10
 
10
11
  type Connection = components["schemas"]["Connection"];
11
12
 
12
- const serverRootPath = "/tmp/pathways-worker-publisher-project-store-test";
13
+ const serverRootPath = path.join(
14
+ TEMP_DIR_PATH,
15
+ "pathways-worker-publisher-project-store-test",
16
+ );
13
17
  const projectName = "organizationName-projectName";
14
18
  const testConnections: Connection[] = [
15
19
  {
@@ -353,7 +357,10 @@ describe("ProjectStore Service", () => {
353
357
  describe("Project Service Error Recovery", () => {
354
358
  let sandbox: sinon.SinonSandbox;
355
359
  let projectStore: ProjectStore;
356
- const serverRootPath = "/tmp/pathways-worker-publisher-error-recovery-test";
360
+ const serverRootPath = path.join(
361
+ TEMP_DIR_PATH,
362
+ "pathways-worker-publisher-error-recovery-test",
363
+ );
357
364
  const projectName = "organizationName-projectName-error-recovery";
358
365
  const testConnections: Connection[] = [
359
366
  {
@@ -473,29 +473,6 @@ export class ProjectStore {
473
473
  projectName: string,
474
474
  packageName: string,
475
475
  ) {
476
- // Handle absolute paths
477
- if (location.startsWith("/")) {
478
- try {
479
- logger.info(
480
- `Mounting local directory at "${location}" to "${targetPath}"`,
481
- );
482
- await this.mountLocalDirectory(
483
- location,
484
- targetPath,
485
- projectName,
486
- packageName,
487
- );
488
- return;
489
- } catch (error) {
490
- logger.error(`Failed to mount local directory "${location}"`, {
491
- error,
492
- });
493
- throw new PackageNotFoundError(
494
- `Failed to mount local directory: ${location}`,
495
- );
496
- }
497
- }
498
-
499
476
  // Handle GCS paths
500
477
  if (location.startsWith("gs://")) {
501
478
  try {
@@ -553,6 +530,29 @@ export class ProjectStore {
553
530
  }
554
531
  }
555
532
 
533
+ // Handle absolute paths
534
+ if (path.isAbsolute(location)) {
535
+ try {
536
+ logger.info(
537
+ `Mounting local directory at "${location}" to "${targetPath}"`,
538
+ );
539
+ await this.mountLocalDirectory(
540
+ location,
541
+ targetPath,
542
+ projectName,
543
+ packageName,
544
+ );
545
+ return;
546
+ } catch (error) {
547
+ logger.error(`Failed to mount local directory "${location}"`, {
548
+ error,
549
+ });
550
+ throw new PackageNotFoundError(
551
+ `Failed to mount local directory: ${location}`,
552
+ );
553
+ }
554
+ }
555
+
556
556
  // If we get here, the path format is not supported
557
557
  const errorMsg = `Invalid package path: "${location}". Must be an absolute mounted path or a GCS/S3/GitHub URI.`;
558
558
  logger.error(errorMsg, { projectName, location });