Skip to content

Commit

Permalink
feat: support custom validator for file existence (#2246)
Browse files Browse the repository at this point in the history
  • Loading branch information
tuanphamcybozu authored Aug 30, 2023
1 parent 23a8c40 commit 53a6ba8
Show file tree
Hide file tree
Showing 3 changed files with 231 additions and 16 deletions.
9 changes: 6 additions & 3 deletions packages/plugin-manifest-validator/manifest-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@
"description": "internal only",
"minLength": 1,
"format": "relative-path",
"maxFileSize": "20MB"
"maxFileSize": "20MB",
"fileExists": true
},
"homepage_url": {
"type": "object",
Expand Down Expand Up @@ -129,7 +130,8 @@
"type": "string",
"format": "relative-path",
"maxFileSize": "65535B",
"minLength": 1
"minLength": 1,
"fileExists": true
},
"js": {
"$ref": "#/definitions/resources"
Expand Down Expand Up @@ -162,7 +164,8 @@
},
{
"format": "relative-path",
"maxFileSize": "20MB"
"maxFileSize": "20MB",
"fileExists": true
}
]
},
Expand Down
140 changes: 140 additions & 0 deletions packages/plugin-manifest-validator/src/__tests__/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,30 @@ describe("validator", () => {
});
});

it("should throw the custom message when there is one invalid file size and the custom message is specified", () => {
const customMessage = "A custom message: invalid file size";
const actual = validator(json({}), {
maxFileSize: (maxFileSizeInBytes, path) => {
return {
valid: false,
message: customMessage,
};
},
});

assert(actual.valid === false);
assert(actual.errors?.length === 1);
assert.deepStrictEqual(actual.errors[0], {
instancePath: "/icon",
keyword: "maxFileSize",
message: customMessage,
params: {
limit: MAX_FILE_SIZE,
},
schemaPath: "#/properties/icon/maxFileSize",
});
});

it("mobile", () => {
const actual = validator(
json({
Expand All @@ -303,6 +327,122 @@ describe("validator", () => {
assert(actual.errors === null);
});
});

describe("fileExists", () => {
it("should return no error when all files exist", () => {
const actual = validator(json({}), {
fileExists: (path) => {
return true;
},
});
assert(actual.valid === true);
assert(actual.errors?.length === undefined);
});

it.each`
filePath | instancePath | schemaPath
${"icon.png"} | ${"/icon"} | ${"#/properties/icon/fileExists"}
${"desktop/js/desktop.js"} | ${"/desktop/js/0"} | ${"#/definitions/resources/items/anyOf/1/fileExists"}
${"desktop/css/style.css"} | ${"/desktop/css/0"} | ${"#/definitions/resources/items/anyOf/1/fileExists"}
${"config/config.html"} | ${"/config/html"} | ${"#/properties/config/properties/html/fileExists"}
${"config/js/config.js"} | ${"/config/js/0"} | ${"#/definitions/resources/items/anyOf/1/fileExists"}
${"config/css/style.css"} | ${"/config/css/0"} | ${"#/definitions/resources/items/anyOf/1/fileExists"}
${"mobile/js/mobile.js"} | ${"/mobile/js/0"} | ${"#/definitions/resources/items/anyOf/1/fileExists"}
${"mobile/css/style.css"} | ${"/mobile/css/0"} | ${"#/definitions/resources/items/anyOf/1/fileExists"}
`(
"should throw the default message when there is non-existent file at $instancePath",
({ filePath, instancePath, schemaPath }) => {
const properties = instancePath.split("/");
const path1 = properties[1];
const path2 = properties[2];

let source = {};
switch (path2) {
case undefined:
// "icon" config
source = { [path1]: filePath };
break;
case "html":
source = { [path1]: { [path2]: filePath } };
break;
default:
source = { [path1]: { [path2]: [filePath] } };
}

const actual = validator(json(source), {
fileExists: (path) => {
return path.indexOf(filePath) === -1;
},
});
const error = actual.errors?.[1] ?? actual.errors?.[0];

assert(actual.valid === false);
assert.deepStrictEqual(error, {
instancePath,
keyword: "fileExists",
message: `File not found: ${filePath}`,
schemaPath,
});
}
);

it.each`
filePath | instancePath | schemaPath
${"icon.png"} | ${"/icon"} | ${"#/properties/icon/fileExists"}
${"desktop/js/desktop.js"} | ${"/desktop/js/0"} | ${"#/definitions/resources/items/anyOf/1/fileExists"}
${"desktop/css/style.css"} | ${"/desktop/css/0"} | ${"#/definitions/resources/items/anyOf/1/fileExists"}
${"config/config.html"} | ${"/config/html"} | ${"#/properties/config/properties/html/fileExists"}
${"config/js/config.js"} | ${"/config/js/0"} | ${"#/definitions/resources/items/anyOf/1/fileExists"}
${"config/css/style.css"} | ${"/config/css/0"} | ${"#/definitions/resources/items/anyOf/1/fileExists"}
${"mobile/js/mobile.js"} | ${"/mobile/js/0"} | ${"#/definitions/resources/items/anyOf/1/fileExists"}
${"mobile/css/style.css"} | ${"/mobile/css/0"} | ${"#/definitions/resources/items/anyOf/1/fileExists"}
`(
`should throw the custom message when there is non-existent file at $instancePath and the custom message is specified`,
({ filePath, instancePath, schemaPath }) => {
const properties = instancePath.split("/");
const path1 = properties[1];
const path2 = properties[2];

let source = {};
let errorIndex = 0;
switch (path2) {
case undefined:
// "icon" config
source = { [path1]: filePath };
errorIndex = 0;
break;
case "html":
source = { [path1]: { [path2]: filePath } };
errorIndex = 0;
break;
default:
source = { [path1]: { [path2]: [filePath] } };
errorIndex = 1;
}

const customMessage = "Custom message: File not found 404";
const actual = validator(json(source), {
fileExists: (path) => {
return {
valid: path.indexOf(filePath) === -1,
message: customMessage,
};
},
});

const error = actual.errors?.[errorIndex];

assert(actual.valid === false);
assert.deepStrictEqual(error, {
instancePath,
keyword: "fileExists",
message: customMessage,
schemaPath,
});
}
);
});

describe("maxItems", () => {
it("exceed the max item counts", () => {
const urls = [...new Array(100)].map(
Expand Down
98 changes: 85 additions & 13 deletions packages/plugin-manifest-validator/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use strict";

import type { ErrorObject } from "ajv";
import type { ErrorObject, SchemaValidateFunction } from "ajv";
import Ajv from "ajv";
import bytes from "bytes";
import jsonSchema from "../manifest-schema.json";
Expand All @@ -13,10 +13,17 @@ type ValidateResult = {

// https://ajv.js.org/docs/keywords.html#define-keyword-with-validation-function
// FIXME: use the type definition that Ajv provides if https://github.com/ajv-validator/ajv/pull/1460 has been merged
interface SchemaValidateFunction {
(schema: string, data: string): boolean;
errors?: Array<Partial<ErrorObject>>;
}

type ValidatorResult =
| boolean
| { valid: true }
| { valid: false; message?: string };

type Options = {
relativePath?: (filePath: string) => boolean;
maxFileSize?: (maxBytes: number, filePath: string) => ValidatorResult;
fileExists?: (filePath: string) => ValidatorResult;
};

/**
* @param {Object} json
Expand All @@ -25,16 +32,21 @@ interface SchemaValidateFunction {
*/
export default (
json: Record<string, any>,
options: { [s: string]: (...args: any) => boolean } = {}
options: Options = {}
): ValidateResult => {
let relativePath = (...args: any) => true;
let maxFileSize = (...args: any) => true;
let relativePath: Options["relativePath"] = () => true;
let maxFileSize: Options["maxFileSize"];
let fileExists: Options["fileExists"];

if (typeof options.relativePath === "function") {
relativePath = options.relativePath;
}
if (typeof options.maxFileSize === "function") {
maxFileSize = options.maxFileSize;
}
if (typeof options.fileExists === "function") {
fileExists = options.fileExists;
}

const ajv = new Ajv({
allErrors: true,
Expand All @@ -47,31 +59,91 @@ export default (

const validateMaxFileSize: SchemaValidateFunction = (
schema: string,
data: string
filePath: string
) => {
// schema: max file size like "512KB" or 123 (in bytes)
// data: path to the file
if (maxFileSize === undefined) {
return true;
}

const maxBytes = bytes.parse(schema);
const valid = maxFileSize(maxBytes, data);
if (!valid) {
const result = maxFileSize(maxBytes, filePath);
const defaultMessage = `file size should be <= ${schema}`;

if (result === false) {
validateMaxFileSize.errors = [
{
keyword: "maxFileSize",
params: {
limit: maxBytes,
},
message: `file size should be <= ${schema}`,
message: defaultMessage,
},
];
return false;
}

if (typeof result === "object" && !result.valid) {
validateMaxFileSize.errors = [
{
keyword: "maxFileSize",
params: {
limit: maxBytes,
},
message: result.message ?? defaultMessage,
},
];
return false;
}

return true;
};

const validateFileExists: SchemaValidateFunction = (
schema: boolean,
filePath: string
) => {
if (fileExists === undefined || !schema) {
return true;
}

const result = fileExists(filePath);
const defaultMessage = `File not found: ${filePath}`;

if (result === false) {
validateFileExists.errors = [
{
keyword: "fileExists",
message: defaultMessage,
},
];
return false;
}
return valid;

if (typeof result === "object" && !result.valid) {
validateFileExists.errors = [
{
keyword: "fileExists",
message: result.message ?? defaultMessage,
},
];
return false;
}

return true;
};

ajv.addKeyword({
keyword: "maxFileSize",
validate: validateMaxFileSize,
});

ajv.addKeyword({
keyword: "fileExists",
validate: validateFileExists,
});

const validate = ajv.compile(jsonSchema);
const valid = validate(json);
return { valid, errors: transformErrors(validate.errors) };
Expand Down

0 comments on commit 53a6ba8

Please sign in to comment.