diff options
-rw-r--r-- | .changeset/wise-cameras-trade.md | 5 | ||||
-rw-r--r-- | packages/astro/src/core/errors/errors-data.ts | 4 | ||||
-rw-r--r-- | packages/astro/src/env/validators.ts | 151 | ||||
-rw-r--r-- | packages/astro/src/env/vite-plugin-env.ts | 24 | ||||
-rw-r--r-- | packages/astro/test/env-secret.test.js | 2 | ||||
-rw-r--r-- | packages/astro/test/units/env/env-validators.test.js | 65 |
6 files changed, 151 insertions, 100 deletions
diff --git a/.changeset/wise-cameras-trade.md b/.changeset/wise-cameras-trade.md new file mode 100644 index 000000000..f2c000630 --- /dev/null +++ b/.changeset/wise-cameras-trade.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Improves `astro:env` invalid variables errors diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index cae077053..236dd4dfd 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -1239,8 +1239,8 @@ export const RouteNotFound = { export const EnvInvalidVariables = { name: 'EnvInvalidVariables', title: 'Invalid Environment Variables', - message: (variables: string) => - `The following environment variables do not match the data type and/or properties defined in \`experimental.env.schema\`:\n\n${variables}\n`, + message: (errors: Array<string>) => + `The following environment variables defined in \`experimental.env.schema\` are invalid:\n\n${errors.map(err => `- ${err}`).join('\n')}\n`, } satisfies ErrorData; /** diff --git a/packages/astro/src/env/validators.ts b/packages/astro/src/env/validators.ts index a09eeda8f..4e5d34287 100644 --- a/packages/astro/src/env/validators.ts +++ b/packages/astro/src/env/validators.ts @@ -1,16 +1,16 @@ import type { EnumSchema, EnvFieldType, NumberSchema, StringSchema } from './schema.js'; export type ValidationResultValue = EnvFieldType['default']; +export type ValidationResultErrors = ['missing'] | ['type'] | Array<string>; type ValidationResult = | { ok: true; - type: string; value: ValidationResultValue; } | { ok: false; - type: string; + errors: ValidationResultErrors; }; export function getEnvFieldType(options: EnvFieldType) { @@ -26,45 +26,50 @@ export function getEnvFieldType(options: EnvFieldType) { return `${type}${optional ? ' | undefined' : ''}`; } -type ValueValidator = (input: string | undefined) => { - valid: boolean; - parsed: ValidationResultValue; -}; +type ValueValidator = (input: string | undefined) => ValidationResult; const stringValidator = ({ max, min, length, url, includes, startsWith, endsWith }: StringSchema): ValueValidator => (input) => { - let valid = typeof input === 'string'; + if (typeof input !== 'string') { + return { + ok: false, + errors: ['type'], + }; + } + const errors: Array<string> = []; - if (valid && max !== undefined) { - valid = input!.length <= max; + if (max !== undefined && !(input.length <= max)) { + errors.push('max'); } - if (valid && min !== undefined) { - valid = input!.length >= min; + if (min !== undefined && !(input.length >= min)) { + errors.push('min'); } - if (valid && length !== undefined) { - valid = input!.length === length; + if (length !== undefined && !(input.length === length)) { + errors.push('length'); } - if (valid && url !== undefined) { - try { - new URL(input!); - } catch (_) { - valid = false; - } + if (url !== undefined && !URL.canParse(input)) { + errors.push('url'); } - if (valid && includes !== undefined) { - valid = input!.includes(includes); + if (includes !== undefined && !input.includes(includes)) { + errors.push('includes'); } - if (valid && startsWith !== undefined) { - valid = input!.startsWith(startsWith); + if (startsWith !== undefined && !input.startsWith(startsWith)) { + errors.push('startsWith'); } - if (valid && endsWith !== undefined) { - valid = input!.endsWith(endsWith); + if (endsWith !== undefined && !input.endsWith(endsWith)) { + errors.push('endsWith'); } + if (errors.length > 0) { + return { + ok: false, + errors, + }; + } return { - valid, - parsed: input, + ok: true, + value: input, }; }; @@ -72,45 +77,71 @@ const numberValidator = ({ gt, min, lt, max, int }: NumberSchema): ValueValidator => (input) => { const num = parseFloat(input ?? ''); - let valid = !isNaN(num); + if (isNaN(num)) { + return { + ok: false, + errors: ['type'], + }; + } + const errors: Array<string> = []; - if (valid && gt !== undefined) { - valid = num > gt; + if (gt !== undefined && !(num > gt)) { + errors.push('gt'); } - if (valid && min !== undefined) { - valid = num >= min; + if (min !== undefined && !(num >= min)) { + errors.push('min'); } - if (valid && lt !== undefined) { - valid = num < lt; + if (lt !== undefined && !(num < lt)) { + errors.push('lt'); } - if (valid && max !== undefined) { - valid = num <= max; + if (max !== undefined && !(num <= max)) { + errors.push('max'); } - if (valid && int !== undefined) { + if (int !== undefined) { const isInt = Number.isInteger(num); - valid = int ? isInt : !isInt; + if (!(int ? isInt : !isInt)) { + errors.push('int'); + } } + if (errors.length > 0) { + return { + ok: false, + errors, + }; + } return { - valid, - parsed: num, + ok: true, + value: num, }; }; const booleanValidator: ValueValidator = (input) => { const bool = input === 'true' ? true : input === 'false' ? false : undefined; + if (typeof bool !== 'boolean') { + return { + ok: false, + errors: ['type'], + }; + } return { - valid: typeof bool === 'boolean', - parsed: bool, + ok: true, + value: bool, }; }; const enumValidator = ({ values }: EnumSchema): ValueValidator => (input) => { + if (!(typeof input === 'string' ? values.includes(input) : false)) { + return { + ok: false, + errors: ['type'], + }; + } return { - valid: typeof input === 'string' ? values.includes(input) : false, - parsed: input, + ok: true, + value: input, }; }; @@ -131,29 +162,19 @@ export function validateEnvVariable( value: string | undefined, options: EnvFieldType ): ValidationResult { - const validator = selectValidator(options); - - const type = getEnvFieldType(options); - - if (options.optional || options.default !== undefined) { - if (value === undefined) { - return { - ok: true, - value: options.default, - type, - }; - } - } - const { valid, parsed } = validator(value); - if (valid) { + const isOptional = options.optional || options.default !== undefined; + if (isOptional && value === undefined) { return { ok: true, - value: parsed, - type, + value: options.default, }; } - return { - ok: false, - type, - }; + if (!isOptional && value === undefined) { + return { + ok: false, + errors: ['missing'], + }; + } + + return selectValidator(options)(value); } diff --git a/packages/astro/src/env/vite-plugin-env.ts b/packages/astro/src/env/vite-plugin-env.ts index 1bcb021e0..9eeb7dcd9 100644 --- a/packages/astro/src/env/vite-plugin-env.ts +++ b/packages/astro/src/env/vite-plugin-env.ts @@ -9,7 +9,7 @@ import { VIRTUAL_MODULES_IDS_VALUES, } from './constants.js'; import type { EnvSchema } from './schema.js'; -import { validateEnvVariable } from './validators.js'; +import { getEnvFieldType, validateEnvVariable, type ValidationResultErrors } from './validators.js'; // TODO: reminders for when astro:env comes out of experimental // Types should always be generated (like in types/content.d.ts). That means the client module will be empty @@ -105,7 +105,7 @@ function validatePublicVariables({ validateSecrets: boolean; }) { const valid: Array<{ key: string; value: any; type: string; context: 'server' | 'client' }> = []; - const invalid: Array<{ key: string; type: string }> = []; + const invalid: Array<{ key: string; type: string; errors: ValidationResultErrors }> = []; for (const [key, options] of Object.entries(schema)) { const variable = loadedEnv[key] === '' ? undefined : loadedEnv[key]; @@ -115,20 +115,30 @@ function validatePublicVariables({ } const result = validateEnvVariable(variable, options); + const type = getEnvFieldType(options); if (!result.ok) { - invalid.push({ key, type: result.type }); + invalid.push({ key, type, errors: result.errors }); // We don't do anything with validated secrets so we don't store them } else if (options.access === 'public') { - valid.push({ key, value: result.value, type: result.type, context: options.context }); + valid.push({ key, value: result.value, type, context: options.context }); } } if (invalid.length > 0) { + const _errors: Array<string> = []; + for (const { key, type, errors } of invalid) { + if (errors[0] === 'missing') { + _errors.push(`${key} is missing`); + } else if (errors[0] === 'type') { + _errors.push(`${key}'s type is invalid, expected: ${type}`); + } else { + // constraints + _errors.push(`The following constraints for ${key} are not met: ${errors.join(', ')}`); + } + } throw new AstroError({ ...AstroErrorData.EnvInvalidVariables, - message: AstroErrorData.EnvInvalidVariables.message( - invalid.map(({ key, type }) => `Variable ${key} is not of type: ${type}.`).join('\n') - ), + message: AstroErrorData.EnvInvalidVariables.message(_errors), }); } diff --git a/packages/astro/test/env-secret.test.js b/packages/astro/test/env-secret.test.js index 4505254a6..7a569e35a 100644 --- a/packages/astro/test/env-secret.test.js +++ b/packages/astro/test/env-secret.test.js @@ -84,7 +84,7 @@ describe('astro:env secret variables', () => { } catch (error) { assert.equal(error instanceof Error, true); assert.equal(error.title, 'Invalid Environment Variables'); - assert.equal(error.message.includes('Variable KNOWN_SECRET is not of type: number.'), true); + assert.equal(error.message.includes('KNOWN_SECRET is missing'), true); } }); }); diff --git a/packages/astro/test/units/env/env-validators.test.js b/packages/astro/test/units/env/env-validators.test.js index 468c86d8e..8d446c045 100644 --- a/packages/astro/test/units/env/env-validators.test.js +++ b/packages/astro/test/units/env/env-validators.test.js @@ -29,9 +29,17 @@ const createFixture = () => { assert.equal(result.value, value); input = undefined; }, - thenResultShouldBeInvalid() { + /** + * @param {string | Array<string>} providedErrors + */ + thenResultShouldBeInvalid(providedErrors) { const result = validateEnvVariable(input.value, input.options); assert.equal(result.ok, false); + const errors = typeof providedErrors === 'string' ? [providedErrors] : providedErrors; + assert.equal( + result.errors.every((element) => errors.includes(element)), + true + ); input = undefined; }, }; @@ -158,7 +166,7 @@ describe('astro:env validators', () => { fixture.givenInput(undefined, { type: 'string', }); - fixture.thenResultShouldBeInvalid(); + fixture.thenResultShouldBeInvalid('missing'); }); it('Should not fail is the variable type is incorrect', () => { @@ -179,7 +187,7 @@ describe('astro:env validators', () => { type: 'string', max: 3, }); - fixture.thenResultShouldBeInvalid(); + fixture.thenResultShouldBeInvalid('max'); fixture.givenInput('abc', { type: 'string', @@ -191,7 +199,7 @@ describe('astro:env validators', () => { type: 'string', min: 5, }); - fixture.thenResultShouldBeInvalid(); + fixture.thenResultShouldBeInvalid('min'); fixture.givenInput('abc', { type: 'string', @@ -203,13 +211,13 @@ describe('astro:env validators', () => { type: 'string', length: 10, }); - fixture.thenResultShouldBeInvalid(); + fixture.thenResultShouldBeInvalid('length'); fixture.givenInput('abc', { type: 'string', url: true, }); - fixture.thenResultShouldBeInvalid(); + fixture.thenResultShouldBeInvalid('url'); fixture.givenInput('https://example.com', { type: 'string', @@ -221,7 +229,7 @@ describe('astro:env validators', () => { type: 'string', includes: 'cd', }); - fixture.thenResultShouldBeInvalid(); + fixture.thenResultShouldBeInvalid('includes'); fixture.givenInput('abc', { type: 'string', @@ -233,7 +241,7 @@ describe('astro:env validators', () => { type: 'string', startsWith: 'za', }); - fixture.thenResultShouldBeInvalid(); + fixture.thenResultShouldBeInvalid('startsWith'); fixture.givenInput('abc', { type: 'string', @@ -245,7 +253,7 @@ describe('astro:env validators', () => { type: 'string', endsWith: 'za', }); - fixture.thenResultShouldBeInvalid(); + fixture.thenResultShouldBeInvalid('endsWith'); fixture.givenInput('abc', { type: 'string', @@ -264,7 +272,14 @@ describe('astro:env validators', () => { type: 'string', min: 5, }); - fixture.thenResultShouldBeInvalid(); + fixture.thenResultShouldBeInvalid('missing'); + + fixture.givenInput('ab', { + type: 'string', + startsWith: 'x', + min: 5 + }) + fixture.thenResultShouldBeInvalid(['startsWith', 'min']); }); it('Should not fail if the optional variable is missing', () => { @@ -297,14 +312,14 @@ describe('astro:env validators', () => { fixture.givenInput(undefined, { type: 'number', }); - fixture.thenResultShouldBeInvalid(); + fixture.thenResultShouldBeInvalid('missing'); }); it('Should fail is the variable type is incorrect', () => { fixture.givenInput('abc', { type: 'number', }); - fixture.thenResultShouldBeInvalid(); + fixture.thenResultShouldBeInvalid('type'); }); it('Should fail if conditions are not met', () => { @@ -312,13 +327,13 @@ describe('astro:env validators', () => { type: 'number', gt: 15, }); - fixture.thenResultShouldBeInvalid(); + fixture.thenResultShouldBeInvalid('gt'); fixture.givenInput('10', { type: 'number', gt: 10, }); - fixture.thenResultShouldBeInvalid(); + fixture.thenResultShouldBeInvalid('gt'); fixture.givenInput('10', { type: 'number', @@ -330,7 +345,7 @@ describe('astro:env validators', () => { type: 'number', min: 25, }); - fixture.thenResultShouldBeInvalid(); + fixture.thenResultShouldBeInvalid('min'); fixture.givenInput('20', { type: 'number', @@ -348,13 +363,13 @@ describe('astro:env validators', () => { type: 'number', lt: 10, }); - fixture.thenResultShouldBeInvalid(); + fixture.thenResultShouldBeInvalid('lt'); fixture.givenInput('10', { type: 'number', lt: 10, }); - fixture.thenResultShouldBeInvalid(); + fixture.thenResultShouldBeInvalid('lt'); fixture.givenInput('5', { type: 'number', @@ -366,7 +381,7 @@ describe('astro:env validators', () => { type: 'number', max: 20, }); - fixture.thenResultShouldBeInvalid(); + fixture.thenResultShouldBeInvalid('max'); fixture.givenInput('25', { type: 'number', @@ -384,7 +399,7 @@ describe('astro:env validators', () => { type: 'number', int: true, }); - fixture.thenResultShouldBeInvalid(); + fixture.thenResultShouldBeInvalid('int'); fixture.givenInput('25', { type: 'number', @@ -396,7 +411,7 @@ describe('astro:env validators', () => { type: 'number', int: false, }); - fixture.thenResultShouldBeInvalid(); + fixture.thenResultShouldBeInvalid('int'); fixture.givenInput('4.5', { type: 'number', @@ -408,7 +423,7 @@ describe('astro:env validators', () => { type: 'number', gt: 10, }); - fixture.thenResultShouldBeInvalid(); + fixture.thenResultShouldBeInvalid('missing'); }); it('Should accept integers', () => { @@ -455,14 +470,14 @@ describe('astro:env validators', () => { fixture.givenInput(undefined, { type: 'boolean', }); - fixture.thenResultShouldBeInvalid(); + fixture.thenResultShouldBeInvalid('missing'); }); it('Should fail is the variable type is incorrect', () => { fixture.givenInput('abc', { type: 'boolean', }); - fixture.thenResultShouldBeInvalid(); + fixture.thenResultShouldBeInvalid('type'); }); it('Should not fail if the optional variable is missing', () => { @@ -496,7 +511,7 @@ describe('astro:env validators', () => { type: 'enum', values: ['a', 'b'], }); - fixture.thenResultShouldBeInvalid(); + fixture.thenResultShouldBeInvalid('missing'); }); it('Should fail is the variable type is incorrect', () => { @@ -504,7 +519,7 @@ describe('astro:env validators', () => { type: 'enum', values: ['a', 'b'], }); - fixture.thenResultShouldBeInvalid(); + fixture.thenResultShouldBeInvalid('type'); }); it('Should not fail if the optional variable is missing', () => { |