Skip to content

Commit

Permalink
feat: improve staticwebapp.config.json validation
Browse files Browse the repository at this point in the history
Closes  #216
  • Loading branch information
manekinekko committed Apr 27, 2022
1 parent 742c061 commit 8a5041c
Show file tree
Hide file tree
Showing 5 changed files with 203 additions and 70 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"globrex": "^0.1.2",
"http-proxy": "^1.18.1",
"internal-ip": "^6.2.0",
"json-source-map": "^0.6.1",
"keytar": "^7.9.0",
"ms-rest-azure": "^3.0.1",
"node-fetch": "^2.6.6",
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ export async function deploy(outputLocationOrConfigName: string, options: SWACLI
SWA_CLI_DEBUG: verbose as DebugFilterLevel,
SWA_RUNTIME_WORKFLOW_LOCATION: userWorkflowConfig?.files?.[0],
SWA_RUNTIME_CONFIG_LOCATION: swaConfigLocation,
SWA_RUNTIME_CONFIG: swaConfigLocation ? (await findSWAConfigFile(swaConfigLocation))?.file : undefined,
SWA_RUNTIME_CONFIG: swaConfigLocation ? (await findSWAConfigFile(swaConfigLocation))?.filepath : undefined,
SWA_CLI_VERSION: packageInfo.version,
SWA_CLI_DEPLOY_DRY_RUN: `${dryRun}`,
SWA_CLI_DEPLOY_BINARY: undefined,
Expand Down
251 changes: 196 additions & 55 deletions src/core/utils/user-config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import Ajv4, { JSONSchemaType, ValidateFunction } from "ajv-draft-04";
import chalk from "chalk";
import fs, { promises as fsPromises } from "fs";
import type http from "http";
import jsonMap from "json-source-map";
import fetch from "node-fetch";
import path from "path";
import Ajv4 from "ajv-draft-04";
import { DEFAULT_CONFIG } from "../../config";
import { logger } from "./logger";
import { isHttpUrl } from "./net";
Expand Down Expand Up @@ -40,88 +42,136 @@ export async function* traverseFolder(folder: string): AsyncGenerator<string> {
* @param folder The folder where to lookup for the configuration file.
* @returns `staticwebapp.config.json` if it was found, or fallback to `routes.json`. Return `null` if none were found.
*/
export async function findSWAConfigFile(folder: string) {
const configFiles = new Map<string, { filepath: string; isLegacyConfigFile: boolean; content: string }>();
// const parse = await getSWAConfigSchemaParser();
const validate = await getSWAConfigSchemaValidator();

export async function findSWAConfigFile(folder: string): Promise<{ filepath: string; content: SWAConfigFile } | null> {
const configFiles = new Map<string, { filepath: string; isLegacyConfigFile: boolean }>();
for await (const filepath of traverseFolder(folder)) {
const filename = path.basename(filepath) as string;

if (filename === DEFAULT_CONFIG.swaConfigFilename || filename === DEFAULT_CONFIG.swaConfigFilenameLegacy) {
const content = (await readFile(filepath)).toString("utf-8");
const config = JSON.parse(content);

// make sure we are using the right SWA config file.
const isValidSWAConfigFile = validate(config);
if (isValidSWAConfigFile) {
const isLegacyConfigFile = filename === DEFAULT_CONFIG.swaConfigFilenameLegacy;
configFiles.set(filename, { filepath, isLegacyConfigFile, content });
} else {
logger.warn(`WARNING: invalid ${filename} file detected`);
logger.warn((validate as any).errors);
}
const isLegacyConfigFile = filename === DEFAULT_CONFIG.swaConfigFilenameLegacy;
configFiles.set(filename, { filepath, isLegacyConfigFile });
}
}
// take staticwebapp.config.json if it exists (and ignore routes.json legacy file)

// take staticwebapp.config.json if it exists
if (configFiles.has(DEFAULT_CONFIG.swaConfigFilename!)) {
const file = configFiles.get(DEFAULT_CONFIG.swaConfigFilename!);
logger.silly(`Found ${DEFAULT_CONFIG.swaConfigFilename} in ${file?.filepath}`);
return file;

if (file) {
const content = await validateRuntimeConfigAndGetData(file.filepath!);

if (content) {
logger.silly(`Content parsed successfully`);

logger.log(`\nFound configuration file:\n ${chalk.green(file.filepath)}\n`);
return {
filepath: file.filepath,
content,
};
}
}

return null;
}

// fallback to legacy config file
// legacy config file detected. Warn and return null.
if (configFiles.has(DEFAULT_CONFIG.swaConfigFilenameLegacy!)) {
const file = configFiles.get(DEFAULT_CONFIG.swaConfigFilenameLegacy!);
logger.silly(`Found ${DEFAULT_CONFIG.swaConfigFilenameLegacy} in ${file?.filepath}`);
return file;
logger.warn(`Found legacy configuration file: ${file?.filepath}.`);
logger.warn(
` WARNING: Functionality defined in the routes.json file is now deprecated. File will be ignored!\n` +
` Read more: https://docs.microsoft.com/azure/static-web-apps/configuration#routes`
);
return null;
}

// no config file found
logger.silly(`No ${DEFAULT_CONFIG.swaConfigFilename} found in current project`);
return null;
}

async function loadSchema() {
// const res = await fetch("https://json.schemastore.org/staticwebapp.config.json");
// return await res.json();

return require(path.join(__dirname, "../../schema/staticwebapp.config.schema.json"));
}

async function getSWAConfigSchemaValidator() {
const ajv = new Ajv4({
async function validateRuntimeConfigAndGetData(filepath: string) {
const ajv4 = new Ajv4({
strict: false,
allErrors: true,
});
const validate = ajv.compile(await loadSchema());

// memoise so we avoid recompiling the schema on each call
return (data: string) => validate(data);
logger.silly(`Loading staticwebapp.config.json schema...`);
const schema = await loadSWAConfigSchema();
if (!schema) {
logger.warn(`WARNING: Failed to load staticwebapp.config.json schema. Continuing without validation!`);
return null;
}

logger.silly(`Compiling schema...`);
const validate = ajv4.compile(schema);

logger.silly(`Reading content from staticwebapp.config.json...`);
const content = (await readFile(filepath)).toString("utf-8");

let config;
try {
logger.silly(`Parsing staticwebapp.config.json...`);
config = JSON.parse(content);
} catch (err) {
printJSONValidationWarnings(filepath, content, (err as any).message);
return;
}

logger.silly(`Validating staticwebapp.config.json...`);
const isValidSWAConfigFile = validate(config);

if (!isValidSWAConfigFile) {
printSchemaValidationWarnings(filepath, config, validate);
return;
}

logger.silly(`File validated successfully. Continuing with configuration!`);
return config;
}

// // @ts-ignore
// async function getSWAConfigSchemaParser() {
// const jtd = new JTD({
// meta: false, // optional, to prevent adding draft-06 meta-schema,
function findLineAndColumnByPosition(content: string, position: number | undefined) {
const notFound = { line: -1, column: -1 };
if (!position) {
return notFound;
}

const lines = content.split("\n");
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
const line = lines[lineIndex];
const lineChars = line.split("");
const lineLength = lineChars.length;

// });
// patchDraftV4Schema(jtd);
// const schema = await loadSchema();
// const parse = jtd.compileParser(schema);
for (let columnIndex = 0; columnIndex <= lineLength; columnIndex++) {
// decrement position by 1 until we reach 0
position--;

// // memoise so we avoid recompiling the schema on each call
// return (data: string) => parse(data);
// }
// if position is 0, then we found the line
if (position === 0) {
return {
line: lineIndex,
column: columnIndex,
};
}
}
}
return notFound;
}

// function patchDraftV4Schema(ajv: Ajv) {
// // See https://github.com/ajv-validator/ajv/releases/tag/5.0.0
// const metaSchema = require("ajv-draft-04/dist/refs/json-schema-draft-04.json");
// ajv.addMetaSchema(metaSchema);
// ajv.opts.defaultMeta = metaSchema.id;
async function loadSWAConfigSchema(): Promise<JSONSchemaType<SWACLIConfigFile> | null> {
const schemaUrl = "https://json.schemastore.org/staticwebapp.config.json";
try {
const res = await fetch(schemaUrl, { timeout: 10 * 1000 });
if (res.status === 200) {
logger.silly(`Schema loaded successfully from ${schemaUrl}`);
return await res.json();
}
} catch {}

// // optional, using unversioned URI is out of spec, see https://github.com/json-schema-org/json-schema-spec/issues/216
// ajv.refs["http://json-schema.org/schema"] = "http://json-schema.org/draft-04/schema";
// }
// return require(path.join(__dirname, "../../../../schema/staticwebapp.config.schema.json"));
logger.error(`Failed to load schema from ${schemaUrl}`);
return null;
}

/**
* Valide and normalize all paths of a workflow confifuration.
Expand Down Expand Up @@ -190,3 +240,94 @@ export function validateUserWorkflowConfig(userWorkflowConfig: Partial<GithubAct
export function isSWAConfigFileUrl(req: http.IncomingMessage) {
return req.url?.endsWith(`/${DEFAULT_CONFIG.swaConfigFilename!}`) || req.url?.endsWith(`/${DEFAULT_CONFIG.swaConfigFilenameLegacy!}`);
}

function printJSONValidationWarnings(filepath: string, data: string, errorMessage: string) {
logger.warn(`WARNING: Failed to read staticwebapp.config.json configuration from:\n ${filepath}\n`);
logger.error(`The following error was encountered: ${errorMessage}`);

if (errorMessage.includes("Unexpected token")) {
// extract the position of the error
let [_, position] = errorMessage.match(/in JSON at position (\d+)/)?.map(Number) ?? [undefined, undefined];
const lines = data.split("\n");
const lineAndColumn = findLineAndColumnByPosition(data, position);
const lineIndex = lineAndColumn.line;
if (lineIndex !== -1) {
let errorMessage = "";
const errorOffsetLines = 2;
const startLine = Math.max(lineIndex - errorOffsetLines, 0);
const endLine = Math.min(lineIndex + errorOffsetLines, lines.length);

for (let index = startLine; index < endLine; index++) {
const line = lines[index];
if (index === lineIndex) {
errorMessage += chalk.bgRedBright(chalk.grey(`${index + 1}:`) + ` ${line}\n`);
} else {
errorMessage += chalk.grey(`${index + 1}:`) + ` ${line}\n`;
}
}
logger.warn(errorMessage);
}
}

logger.warn(`Please fix the above error and try again to load and use the configuration.`);
logger.warn(`Read more: https://aka.ms/swa/config-schema`);
}

function printSchemaValidationWarnings(filepath: string, data: SWAConfigFile | undefined, validator: ValidateFunction<any>) {
let sourceCodeWhereErrorHappened = "";
const sourceMap = jsonMap.stringify(data, null, 4);
const jsonLines: string[] = sourceMap.json.split("\n");
const error = validator.errors?.[0];
const errorOffsetLines = 2;

// show only one error at a time
if (error) {
let errorPointer = sourceMap.pointers[error.instancePath];
logger.silly({ errorPointer, error });

let startLine = Math.max(errorPointer.value.line - errorOffsetLines, 0);
const endLine = Math.min(errorPointer.valueEnd.line + errorOffsetLines, jsonLines.length);

for (let index = startLine; index < endLine; index++) {
const line = jsonLines[index];
const isOneLine = errorPointer.value.line === errorPointer.valueEnd.line;
const lineHasError = error.params.additionalProperty && line.match(error.params.additionalProperty)?.length;
let shouldHighlightLine = (isOneLine && index === errorPointer.value.line) || lineHasError;

if (error.params.missingProperty) {
// special case to highlight object where the property is missing
shouldHighlightLine = index >= errorPointer.value.line && index <= errorPointer.valueEnd.line;
}

if (shouldHighlightLine) {
// highlight the line where the error happened
sourceCodeWhereErrorHappened += chalk.bgRedBright(chalk.grey(`${index}:`) + ` ${line}\n`);
} else {
sourceCodeWhereErrorHappened += chalk.grey(`${index}:`) + ` ${line}\n`;
}
}
}

logger.warn(`WARNING: Failed to read staticwebapp.config.json configuration from:\n ${filepath}\n`);
let errorMessage = error?.message ?? "Unknown error";
switch (error?.keyword) {
case "enum":
errorMessage = error?.message + " " + error.params.allowedValues.join(", ");
break;
case "type":
errorMessage = error?.message!;
break;
case "required":
errorMessage = `The property "${error?.params.missingProperty}" is required.`;
break;
case "additionalProperties":
errorMessage = `The property "${error.params.additionalProperty}" is not allowed`;
break;
//TODO: add more cases
}
logger.error(`The following error was encountered: ${errorMessage}`);

logger.warn(sourceCodeWhereErrorHappened);
logger.warn(`Please fix the above error and try again to load and use the configuration.`);
logger.warn(`Read more: https://aka.ms/swa/config-schema`);
}
17 changes: 3 additions & 14 deletions src/msha/middlewares/request.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,23 +51,12 @@ export async function handleUserConfig(appLocation: string | undefined): Promise
return;
}

const configFile = await findSWAConfigFile(appLocation);
if (!configFile) {
const runtimeConfigContent = await findSWAConfigFile(appLocation);
if (!runtimeConfigContent) {
return;
}

let configJson: SWAConfigFile | undefined;
try {
configJson = require(configFile.filepath) as SWAConfigFile;
logger.info(`\nFound configuration file:\n ${chalk.green(configFile.filepath)}`);

return configJson;
} catch (error) {
logger.silly(`${chalk.red("configuration file is invalid!")}`);
logger.silly(`${chalk.red((error as any).toString())}`);
}

return configJson;
return runtimeConfigContent.content;
}

/**
Expand Down
2 changes: 2 additions & 0 deletions src/swa.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ declare global {
}
}

declare module "json-source-map";

declare interface StaticSiteClientEnv {
// StaticSitesClient env vars
DEPLOYMENT_ACTION?: "close" | "upload";
Expand Down

0 comments on commit 8a5041c

Please sign in to comment.