Skip to content

Commit

Permalink
feat: better astro:env errors (#11455)
Browse files Browse the repository at this point in the history
  • Loading branch information
florian-lefebvre authored Jul 17, 2024
1 parent 2cdb685 commit 645e128
Show file tree
Hide file tree
Showing 6 changed files with 151 additions and 100 deletions.
5 changes: 5 additions & 0 deletions .changeset/wise-cameras-trade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Improves `astro:env` invalid variables errors
4 changes: 2 additions & 2 deletions packages/astro/src/core/errors/errors-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down
151 changes: 86 additions & 65 deletions packages/astro/src/env/validators.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -26,91 +26,122 @@ 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,
};
};

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,
};
};

Expand All @@ -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);
}
24 changes: 17 additions & 7 deletions packages/astro/src/env/vite-plugin-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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];
Expand All @@ -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),
});
}

Expand Down
2 changes: 1 addition & 1 deletion packages/astro/test/env-secret.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
});
Loading

0 comments on commit 645e128

Please sign in to comment.