Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions graphql/env/__tests__/__snapshots__/merge.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,15 @@ exports[`getEnvOptions merges pgpm defaults, graphql defaults, config, env, and
"strictAuth": false,
"trustProxy": false,
},
"sms": {
"devsms": {
"baseUrl": "http://env-devsms:4000",
},
"dryRun": true,
"provider": "devsms",
"requestTimeoutMs": 9000,
"senderId": "OverrideSender",
},
"smtp": {
"debug": false,
"logger": false,
Expand Down
146 changes: 143 additions & 3 deletions graphql/env/__tests__/merge.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getEnvOptions } from '../src/merge';
import { getGraphQLEnvVars } from '../src/env';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
Expand Down Expand Up @@ -37,7 +38,16 @@ describe('getEnvOptions', () => {
enableServicesApi: false,
isPublic: false,
metaSchemas: ['config_meta']
}
},
sms: {
provider: 'devsms',
senderId: 'ConfigSender',
requestTimeoutMs: 3000,
dryRun: false,
devsms: {
baseUrl: 'http://config-devsms:4000',
},
},
});

const testEnv: NodeJS.ProcessEnv = {
Expand All @@ -52,7 +62,12 @@ describe('getEnvOptions', () => {
API_META_SCHEMAS: 'env_meta1,env_meta2',
API_ANON_ROLE: 'env_anon',
API_ROLE_NAME: 'env_role',
API_DEFAULT_DATABASE_ID: 'env_db'
API_DEFAULT_DATABASE_ID: 'env_db',
SMS_PROVIDER: 'devsms',
SMS_SENDER_ID: 'EnvSender',
SMS_REQUEST_TIMEOUT_MS: '4000',
SEND_SMS_DRY_RUN: 'true',
DEVSMS_BASE_URL: 'http://env-devsms:4000',
};

const result = getEnvOptions(
Expand All @@ -75,7 +90,11 @@ describe('getEnvOptions', () => {
api: {
enableServicesApi: false,
defaultDatabaseId: 'override_db'
}
},
sms: {
senderId: 'OverrideSender',
requestTimeoutMs: 9000,
},
},
tempDir,
testEnv
Expand Down Expand Up @@ -121,4 +140,125 @@ describe('getEnvOptions', () => {
expect(result.api?.exposedSchemas).toEqual(['public', 'override_schema']);
expect(result.api?.metaSchemas).toEqual(['env_meta', 'override_meta']);
});

it('parses SMS environment variables into typed options', () => {
const result = getGraphQLEnvVars({
SMS_PROVIDER: 'devsms',
SMS_SENDER_ID: 'LocalSender',
SMS_REQUEST_TIMEOUT_MS: '2500',
SEND_SMS_DRY_RUN: 'true',
DEVSMS_BASE_URL: 'http://localhost:4000',
});

expect(result.sms).toEqual({
provider: 'devsms',
senderId: 'LocalSender',
requestTimeoutMs: 2500,
dryRun: true,
devsms: {
baseUrl: 'http://localhost:4000',
},
});
});

it('accepts custom SMS provider names', () => {
const result = getGraphQLEnvVars({
SMS_PROVIDER: 'custom-sms-gateway',
});

expect(result.sms?.provider).toBe('custom-sms-gateway');
});

it('honors config, env, and runtime override priority for SMS', () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graphql-env-sms-'));
writeConfig(tempDir, {
sms: {
provider: 'devsms',
senderId: 'ConfigSender',
requestTimeoutMs: 3000,
dryRun: false,
devsms: {
baseUrl: 'http://config-devsms:4000',
},
},
});

const result = getEnvOptions(
{
sms: {
requestTimeoutMs: 9000,
},
},
tempDir,
{
SMS_SENDER_ID: 'EnvSender',
SEND_SMS_DRY_RUN: 'true',
DEVSMS_BASE_URL: 'http://env-devsms:4000',
}
);

expect(result.sms).toEqual({
provider: 'devsms',
senderId: 'EnvSender',
requestTimeoutMs: 9000,
dryRun: true,
devsms: {
baseUrl: 'http://env-devsms:4000',
},
});
});

it('uses the injected env object instead of global process.env for SMS', () => {
const previousSmsProvider = process.env.SMS_PROVIDER;
process.env.SMS_PROVIDER = 'twilio';

try {
const result = getEnvOptions({}, process.cwd(), {
SMS_PROVIDER: 'devsms',
});

expect(result.sms?.provider).toBe('devsms');
} finally {
if (previousSmsProvider === undefined) {
delete process.env.SMS_PROVIDER;
} else {
process.env.SMS_PROVIDER = previousSmsProvider;
}
}
});

it('keeps SMS absent when it is not configured', () => {
const result = getEnvOptions({}, process.cwd(), {});

expect(result.sms).toBeUndefined();
});

it('omits an invalid SMS timeout from partial env overrides', () => {
const result = getGraphQLEnvVars({
SMS_REQUEST_TIMEOUT_MS: '5s',
});

expect(result.sms).toBeUndefined();
});

it('does not let absent or invalid SMS env values override config', () => {
tempDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'graphql-env-sms-defaults-')
);
writeConfig(tempDir, {
sms: {
requestTimeoutMs: 3000,
dryRun: true,
},
});

const result = getEnvOptions({}, tempDir, {
SMS_REQUEST_TIMEOUT_MS: '5s',
});

expect(result.sms).toEqual({
requestTimeoutMs: 3000,
dryRun: true,
});
});
});
36 changes: 35 additions & 1 deletion graphql/env/src/env.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ConstructiveOptions } from '@constructive-io/graphql-types';
import { parseEnvBoolean } from '12factor-env';
import { parseEnvBoolean, parseEnvNumber } from '12factor-env';

/**
* @param env - Environment object to read from (defaults to process.env for backwards compatibility)
Expand All @@ -26,8 +26,27 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial
CHAT_PROVIDER,
CHAT_MODEL,
CHAT_BASE_URL,

SMS_PROVIDER,
SMS_SENDER_ID,
SMS_REQUEST_TIMEOUT_MS,
SEND_SMS_DRY_RUN,
DEVSMS_BASE_URL,
} = env;

// Keep this function as a partial env-override parser. SMS runtime defaults
// belong to the consuming application; injecting them here would incorrectly
// let an absent env var overwrite pgpm.json or consumer-specific values.
const smsRequestTimeoutMs = parseEnvNumber(SMS_REQUEST_TIMEOUT_MS);
const smsDryRun = parseEnvBoolean(SEND_SMS_DRY_RUN);
const hasSmsEnvOverrides = Boolean(
SMS_PROVIDER ||
SMS_SENDER_ID ||
smsRequestTimeoutMs !== undefined ||
smsDryRun !== undefined ||
DEVSMS_BASE_URL
);

return {
graphile: {
...(GRAPHILE_SCHEMA && {
Expand Down Expand Up @@ -68,5 +87,20 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial
}),
},
}),
...(hasSmsEnvOverrides && {
sms: {
...(SMS_PROVIDER && { provider: SMS_PROVIDER }),
...(SMS_SENDER_ID && { senderId: SMS_SENDER_ID }),
...(smsRequestTimeoutMs !== undefined && {
requestTimeoutMs: smsRequestTimeoutMs,
}),
...(smsDryRun !== undefined && { dryRun: smsDryRun }),
...(DEVSMS_BASE_URL && {
devsms: {
baseUrl: DEVSMS_BASE_URL,
},
}),
},
}),
};
};
1 change: 1 addition & 0 deletions graphql/env/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Export Constructive-specific env functions
export { getEnvOptions, getConstructiveEnvOptions } from './merge';
export { getGraphQLEnvVars } from './env';
export type { DevSmsOptions, SmsOptions } from '@constructive-io/graphql-types';
3 changes: 2 additions & 1 deletion graphql/env/src/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const getEnvOptions = (
const graphqlEnvOptions = getGraphQLEnvVars(env);

// Load config again to get any GraphQL-specific config
// Config files can contain Constructive options (graphile, features, api)
// Config files can contain Constructive options (graphile, features, api, sms)
// even though loadConfigSync returns PgpmOptions type
const configOptions = loadConfigSync(cwd) as Partial<ConstructiveOptions>;

Expand All @@ -43,6 +43,7 @@ export const getEnvOptions = (
...(configOptions.graphile && { graphile: configOptions.graphile }),
...(configOptions.features && { features: configOptions.features }),
...(configOptions.api && { api: configOptions.api }),
...(configOptions.sms && { sms: configOptions.sms }),
},
graphqlEnvOptions,
overrides
Expand Down
3 changes: 3 additions & 0 deletions graphql/types/src/constructive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
apiDefaults
} from './graphile';
import { LlmOptions } from './llm';
import { SmsOptions } from './sms';

/**
* GraphQL-specific options for Constructive
Expand Down Expand Up @@ -59,6 +60,8 @@ export interface ConstructiveOptions extends PgpmOptions, ConstructiveGraphQLOpt
jobs?: JobsConfig;
/** LLM provider configuration (embeddings, chat, RAG) */
llm?: LlmOptions;
/** SMS provider configuration */
sms?: SmsOptions;
}

/**
Expand Down
6 changes: 6 additions & 0 deletions graphql/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,9 @@ export {
LlmEmbedderOptions,
LlmChatOptions
} from './llm';

// Export SMS types
export {
SmsOptions,
DevSmsOptions
} from './sms';
24 changes: 24 additions & 0 deletions graphql/types/src/sms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* SMS provider configuration options for Constructive runtimes.
*
* Production providers are intentionally configuration-only here. Runtime
* packages decide which providers they implement and validate that required
* provider-specific values are present before sending.
*/
export interface DevSmsOptions {
/** Base URL for the local DevSms API, e.g. http://localhost:4000 */
baseUrl?: string;
}

export interface SmsOptions {
/** SMS provider implementation to use; runtimes may register custom names. */
provider?: string;
/** Optional sender ID/default source address for providers that support it. */
senderId?: string;
/** Outbound provider HTTP timeout in milliseconds. */
requestTimeoutMs?: number;
/** Validate/render messages without sending them to the provider. */
dryRun?: boolean;
/** DevSms local provider options. */
devsms?: DevSmsOptions;
}
Loading