Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: better astro:env errors #11455

Merged
merged 8 commits into from
Jul 17, 2024
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
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
Loading