Skip to content
Open
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
195 changes: 172 additions & 23 deletions graphql/env/__tests__/merge.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
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';

const writeConfig = (dir: string, config: Record<string, unknown>): void => {
fs.writeFileSync(path.join(dir, 'pgpm.json'), JSON.stringify(config, null, 2));
fs.writeFileSync(
path.join(dir, 'pgpm.json'),
JSON.stringify(config, null, 2)
);
};

describe('getEnvOptions', () => {
Expand All @@ -22,22 +26,31 @@ describe('getEnvOptions', () => {
writeConfig(tempDir, {
pg: {
host: 'config-host',
database: 'config-db'
database: 'config-db',
},
server: {
port: 4000
port: 4000,
},
graphile: {
schema: ['config_schema']
schema: ['config_schema'],
},
features: {
simpleInflection: false
simpleInflection: false,
},
api: {
enableServicesApi: false,
isPublic: false,
metaSchemas: ['config_meta']
}
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,30 +65,39 @@ 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(
{
db: {
cwd: '<CWD>'
cwd: '<CWD>',
},
pg: {
host: 'override-host'
host: 'override-host',
},
server: {
port: 5000
port: 5000,
},
graphile: {
schema: ['override_schema']
schema: ['override_schema'],
},
features: {
oppositeBaseNames: false
oppositeBaseNames: false,
},
api: {
enableServicesApi: false,
defaultDatabaseId: 'override_db'
}
defaultDatabaseId: 'override_db',
},
sms: {
senderId: 'OverrideSender',
requestTimeoutMs: 9000,
},
},
tempDir,
testEnv
Expand All @@ -88,37 +110,164 @@ describe('getEnvOptions', () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graphql-env-replace-'));
writeConfig(tempDir, {
graphile: {
schema: ['config_schema', 'shared_schema']
schema: ['config_schema', 'shared_schema'],
},
api: {
exposedSchemas: ['public', 'shared'],
metaSchemas: ['metaschema_public', 'services_public', 'config_meta']
}
metaSchemas: ['metaschema_public', 'services_public', 'config_meta'],
},
});

const testEnv: NodeJS.ProcessEnv = {
GRAPHILE_SCHEMA: 'shared_schema,env_schema',
API_EXPOSED_SCHEMAS: 'shared,env_schema',
API_META_SCHEMAS: 'services_public,env_meta'
API_META_SCHEMAS: 'services_public,env_meta',
};

const result = getEnvOptions(
{
graphile: {
schema: ['override_schema', 'shared_schema']
schema: ['override_schema', 'shared_schema'],
},
api: {
exposedSchemas: ['public', 'override_schema'],
metaSchemas: ['env_meta', 'override_meta']
}
metaSchemas: ['env_meta', 'override_meta'],
},
},
tempDir,
testEnv
);

// Arrays are replaced, not merged - overrides win completely
expect(result.graphile?.schema).toEqual(['override_schema', 'shared_schema']);
expect(result.graphile?.schema).toEqual([
'override_schema',
'shared_schema',
]);
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 defaults, 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 provider and DevSms base URL optional while applying defaults', () => {
const result = getEnvOptions({}, process.cwd(), {});

expect(result.sms).toEqual({
requestTimeoutMs: 5000,
dryRun: false,
});
});

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,
});
});
});
70 changes: 59 additions & 11 deletions graphql/env/src/env.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { ConstructiveOptions } from '@constructive-io/graphql-types';
import { parseEnvBoolean } from '12factor-env';
import type { ConstructiveOptions } from '@constructive-io/graphql-types';
import { parseEnvBoolean, parseEnvNumber } from '12factor-env';

/**
* @param env - Environment object to read from (defaults to process.env for backwards compatibility)
*/
export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial<ConstructiveOptions> => {
export const getGraphQLEnvVars = (
env: NodeJS.ProcessEnv = process.env
): Partial<ConstructiveOptions> => {
const {
GRAPHILE_SCHEMA,

Expand All @@ -26,29 +28,60 @@ 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. Defaults are applied
// before config in constructiveGraphqlDefaults; injecting them here would
// incorrectly let an absent env var overwrite pgpm.json.
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 && {
schema: GRAPHILE_SCHEMA.includes(',')
? GRAPHILE_SCHEMA.split(',').map(s => s.trim())
: GRAPHILE_SCHEMA
? GRAPHILE_SCHEMA.split(',').map((s) => s.trim())
: GRAPHILE_SCHEMA,
}),
},
features: {
...(FEATURES_SIMPLE_INFLECTION && { simpleInflection: parseEnvBoolean(FEATURES_SIMPLE_INFLECTION) }),
...(FEATURES_OPPOSITE_BASE_NAMES && { oppositeBaseNames: parseEnvBoolean(FEATURES_OPPOSITE_BASE_NAMES) }),
...(FEATURES_SIMPLE_INFLECTION && {
simpleInflection: parseEnvBoolean(FEATURES_SIMPLE_INFLECTION),
}),
...(FEATURES_OPPOSITE_BASE_NAMES && {
oppositeBaseNames: parseEnvBoolean(FEATURES_OPPOSITE_BASE_NAMES),
}),
...(FEATURES_POSTGIS && { postgis: parseEnvBoolean(FEATURES_POSTGIS) }),
},
api: {
...(API_ENABLE_SERVICES && { enableServicesApi: parseEnvBoolean(API_ENABLE_SERVICES) }),
...(API_ENABLE_SERVICES && {
enableServicesApi: parseEnvBoolean(API_ENABLE_SERVICES),
}),
...(API_IS_PUBLIC && { isPublic: parseEnvBoolean(API_IS_PUBLIC) }),
...(API_EXPOSED_SCHEMAS && { exposedSchemas: API_EXPOSED_SCHEMAS.split(',').map(s => s.trim()) }),
...(API_META_SCHEMAS && { metaSchemas: API_META_SCHEMAS.split(',').map(s => s.trim()) }),
...(API_EXPOSED_SCHEMAS && {
exposedSchemas: API_EXPOSED_SCHEMAS.split(',').map((s) => s.trim()),
}),
...(API_META_SCHEMAS && {
metaSchemas: API_META_SCHEMAS.split(',').map((s) => s.trim()),
}),
...(API_ANON_ROLE && { anonRole: API_ANON_ROLE }),
...(API_ROLE_NAME && { roleName: API_ROLE_NAME }),
...(API_DEFAULT_DATABASE_ID && { defaultDatabaseId: API_DEFAULT_DATABASE_ID }),
...(API_DEFAULT_DATABASE_ID && {
defaultDatabaseId: API_DEFAULT_DATABASE_ID,
}),
},
...((EMBEDDER_PROVIDER || CHAT_PROVIDER) && {
llm: {
Expand All @@ -68,5 +101,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,
},
}),
},
}),
};
};
Loading
Loading