diff --git a/.github/workflows/node-test.yml b/.github/workflows/node-test.yml index 1445c6cbd31..0c71e6a54d6 100644 --- a/.github/workflows/node-test.yml +++ b/.github/workflows/node-test.yml @@ -124,3 +124,21 @@ jobs: node-version: ${{ matrix.node-version }} - run: npm install --package-lock-only - run: "git diff --exit-code -- package-lock.json || (echo 'Error: package-lock.json is changed during npm install! Please make sure to use npm >= 6.9.0 and commit package-lock.json.' && false)" + + check-json-schema: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: + - 12.x + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm run generate:json-schema + - run: "git diff --exit-code -- schema/*.json || (echo 'Error: JSON schema is changed! Please run npm run generate:json-schema and commit the results.' && false)" diff --git a/package-lock.json b/package-lock.json index 1497cbc0fe4..6697ff01880 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2274,11 +2274,11 @@ } }, "ajv": { - "version": "6.10.2", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.2.tgz", - "integrity": "sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw==", + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "requires": { - "fast-deep-equal": "^2.0.1", + "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" @@ -3961,6 +3961,18 @@ "v8-compile-cache": "^2.0.3" }, "dependencies": { + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, "ansi-regex": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", @@ -4489,9 +4501,9 @@ "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" }, "fast-deep-equal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", - "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=" + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "fast-diff": { "version": "1.2.0", @@ -5488,6 +5500,19 @@ "requires": { "ajv": "^6.5.5", "har-schema": "^2.0.0" + }, + "dependencies": { + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + } } }, "hard-rejection": { @@ -6144,6 +6169,15 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" }, + "json-stable-stringify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz", + "integrity": "sha1-mnWdOcXy/1A/1TAGRu1EX4jE+a8=", + "dev": true, + "requires": { + "jsonify": "~0.0.0" + } + }, "json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -6182,6 +6216,12 @@ "graceful-fs": "^4.1.6" } }, + "jsonify": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz", + "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=", + "dev": true + }, "jsonparse": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", @@ -10255,6 +10295,166 @@ "integrity": "sha512-hSAifV3k+i6lEoCJ2k6R2Z/rp/H3+8sdmcn5NrS3/3kE7+RyZXm9aqvxWqjEXHAd8b0pShatpcdMTvEdvAJltQ==", "dev": true }, + "typescript-json-schema": { + "version": "0.50.1", + "resolved": "https://registry.npmjs.org/typescript-json-schema/-/typescript-json-schema-0.50.1.tgz", + "integrity": "sha512-GCof/SDoiTDl0qzPonNEV4CHyCsZEIIf+mZtlrjoD8vURCcEzEfa2deRuxYid8Znp/e27eDR7Cjg8jgGrimBCA==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.7", + "@types/node": "^14.14.33", + "glob": "^7.1.6", + "json-stable-stringify": "^1.0.1", + "ts-node": "^9.1.1", + "typescript": "~4.2.3", + "yargs": "^16.2.0" + }, + "dependencies": { + "@types/json-schema": { + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.7.tgz", + "integrity": "sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==", + "dev": true + }, + "@types/node": { + "version": "14.17.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.3.tgz", + "integrity": "sha512-e6ZowgGJmTuXa3GyaPbTGxX17tnThl2aSSizrFthQ7m9uLGZBXiGhgE55cjRZTF5kjZvYn9EOPOMljdjwbflxw==", + "dev": true + }, + "ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "glob": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", + "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true + }, + "string-width": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.2.tgz", + "integrity": "sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA==", + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.0" + } + }, + "strip-ansi": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", + "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.0" + } + }, + "typescript": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.2.4.tgz", + "integrity": "sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg==", + "dev": true + }, + "wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, + "y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true + }, + "yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "requires": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + } + }, + "yargs-parser": { + "version": "20.2.7", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.7.tgz", + "integrity": "sha512-FiNkvbeHzB/syOjIUxFDCnhSfzAL8R5vs40MgLFBorXACCOAEaWu0gRZl14vG8MR9AOJIZbmkjhusqBYZ3HTHw==", + "dev": true + } + } + }, "unbzip2-stream": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", diff --git a/package.json b/package.json index a12a031bd49..6a06c3677ab 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "format:other": "npm run lint:other -- --write", "format:ts": "npm run lint:ts -- --fix --quiet", "generate:auth-api": "ts-node scripts/gen-auth-api-spec.ts", + "generate:json-schema": "typescript-json-schema --strictNullChecks --required --noExtraProps src/firebaseConfig.ts FirebaseConfig > schema/firebase-config.json", "lint": "npm run lint:ts && npm run lint:other", "lint:changed-files": "ts-node ./scripts/lint-changed-files.ts", "lint:other": "prettier --check '**/*.{md,yaml,yml}'", @@ -85,6 +86,7 @@ "@types/archiver": "^5.1.0", "JSONStream": "^1.2.1", "abort-controller": "^3.0.0", + "ajv": "^6.12.6", "archiver": "^5.0.0", "body-parser": "^1.19.0", "chokidar": "^3.0.2", @@ -205,6 +207,7 @@ "supertest": "^3.3.0", "swagger2openapi": "^6.0.3", "ts-node": "^9.1.1", - "typescript": "^3.9.5" + "typescript": "^3.9.5", + "typescript-json-schema": "^0.50.1" } } diff --git a/schema/firebase-config.json b/schema/firebase-config.json new file mode 100644 index 00000000000..e630196c201 --- /dev/null +++ b/schema/firebase-config.json @@ -0,0 +1,393 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "database": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "rules": { + "type": "string" + } + }, + "type": "object" + }, + { + "items": { + "additionalProperties": false, + "properties": { + "instance": { + "type": "string" + }, + "rules": { + "type": "string" + }, + "target": { + "type": "string" + } + }, + "required": [ + "rules" + ], + "type": "object" + }, + "type": "array" + } + ] + }, + "emulators": { + "additionalProperties": false, + "properties": { + "auth": { + "additionalProperties": false, + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "number" + } + }, + "type": "object" + }, + "database": { + "additionalProperties": false, + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "number" + } + }, + "type": "object" + }, + "firestore": { + "additionalProperties": false, + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "number" + } + }, + "type": "object" + }, + "functions": { + "additionalProperties": false, + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "number" + } + }, + "type": "object" + }, + "hosting": { + "additionalProperties": false, + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "number" + } + }, + "type": "object" + }, + "hub": { + "additionalProperties": false, + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "number" + } + }, + "type": "object" + }, + "logging": { + "additionalProperties": false, + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "number" + } + }, + "type": "object" + }, + "pubsub": { + "additionalProperties": false, + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "number" + } + }, + "type": "object" + }, + "storage": { + "additionalProperties": false, + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "number" + } + }, + "type": "object" + }, + "ui": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "host": { + "type": "string" + }, + "port": { + "type": [ + "string", + "number" + ] + } + }, + "type": "object" + } + }, + "type": "object" + }, + "firestore": { + "additionalProperties": false, + "properties": { + "indexes": { + "type": "string" + }, + "rules": { + "type": "string" + } + }, + "type": "object" + }, + "functions": { + "additionalProperties": false, + "properties": { + "ignore": { + "items": { + "type": "string" + }, + "type": "array" + }, + "predeploy": { + "items": { + "type": "string" + }, + "type": "array" + }, + "source": { + "type": "string" + } + }, + "type": "object" + }, + "hosting": { + "additionalProperties": false, + "properties": { + "appAssociation": { + "type": "string" + }, + "cleanUrls": { + "type": "boolean" + }, + "headers": { + "items": { + "additionalProperties": false, + "properties": { + "headers": { + "items": { + "additionalProperties": false, + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ], + "type": "object" + }, + "type": "array" + }, + "source": { + "type": "string" + } + }, + "required": [ + "headers", + "source" + ], + "type": "object" + }, + "type": "array" + }, + "i18n": { + "additionalProperties": false, + "properties": { + "root": { + "type": "string" + } + }, + "required": [ + "root" + ], + "type": "object" + }, + "ignore": { + "items": { + "type": "string" + }, + "type": "array" + }, + "postdeploy": { + "type": "string" + }, + "public": { + "type": "string" + }, + "redirects": { + "items": { + "additionalProperties": false, + "properties": { + "destination": { + "type": "string" + }, + "source": { + "type": "string" + }, + "type": { + "type": "number" + } + }, + "required": [ + "destination", + "source", + "type" + ], + "type": "object" + }, + "type": "array" + }, + "rewrites": { + "items": { + "additionalProperties": false, + "properties": { + "destination": { + "type": "string" + }, + "dynamicLinks": { + "type": "boolean" + }, + "function": { + "type": "string" + }, + "run": { + "additionalProperties": false, + "properties": { + "region": { + "type": "string" + }, + "serviceId": { + "type": "string" + } + }, + "required": [ + "serviceId" + ], + "type": "object" + }, + "source": { + "type": "string" + } + }, + "required": [ + "source" + ], + "type": "object" + }, + "type": "array" + }, + "trailingSlash": { + "type": "boolean" + } + }, + "required": [ + "public" + ], + "type": "object" + }, + "remoteconfig": { + "additionalProperties": false, + "properties": { + "template": { + "type": "string" + } + }, + "required": [ + "template" + ], + "type": "object" + }, + "storage": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "rules": { + "type": "string" + } + }, + "required": [ + "rules" + ], + "type": "object" + }, + { + "items": { + "additionalProperties": false, + "properties": { + "bucket": { + "type": "string" + }, + "rules": { + "type": "string" + } + }, + "required": [ + "bucket", + "rules" + ], + "type": "object" + }, + "type": "array" + } + ] + } + }, + "type": "object" +} + diff --git a/src/config.ts b/src/config.ts index 6ea5063c9f0..1f998d16228 100644 --- a/src/config.ts +++ b/src/config.ts @@ -14,6 +14,8 @@ import * as fsutils from "./fsutils"; import { promptOnce } from "./prompt"; import { resolveProjectPath } from "./projectPath"; import * as utils from "./utils"; +import { getValidator, getErrorMessage } from "./firebaseConfigValidate"; +import { logger } from "./logger"; const loadCJSON = require("./loadCJSON"); const parseBoltRules = require("./parseBoltRules"); @@ -230,6 +232,20 @@ export class Config { try { const filePath = path.resolve(pd, path.basename(filename)); const data = cjson.load(filePath); + + // Validate config against JSON Schema. For now we just print these to debug + // logs but in a future CLI version they could be warnings and/or errors. + const validator = getValidator(); + const valid = validator(data); + if (!valid && validator.errors) { + for (const e of validator.errors) { + // TODO: We should probably collapse these errors on the 'dataPath' property + // and then pick out the most important error on each field. Otherwise + // some simple mistakes can cause 2-3 errors. + logger.debug(getErrorMessage(e)); + } + } + return new Config(data, options); } catch (e) { throw new FirebaseError(`There was an error loading ${filename}:\n\n` + e.message, { diff --git a/src/emulator/controller.ts b/src/emulator/controller.ts index f4bb80cf785..ac33660d22a 100644 --- a/src/emulator/controller.ts +++ b/src/emulator/controller.ts @@ -61,7 +61,7 @@ async function getAndCheckAddress(emulator: Emulators, options: Options): Promis let port; let findAvailablePort = false; if (portVal) { - port = parseInt(portVal, 10); + port = parseInt(`${portVal}`, 10); } else { port = Constants.getDefaultPort(emulator); findAvailablePort = FIND_AVAILBLE_PORT_BY_DEFAULT[emulator]; diff --git a/src/firebaseConfig.ts b/src/firebaseConfig.ts index 475dd5aecb0..facef237550 100644 --- a/src/firebaseConfig.ts +++ b/src/firebaseConfig.ts @@ -1,6 +1,13 @@ +// +// NOTE: +// The contents of this file are used to generate the JSON Schema documents in +// the schema/ directory. After changing this file you will need to run +// 'npm run generate:json-schema' to regenerate the schema files. +// + export type DatabaseConfig = | { - rules: string; + rules?: string; } | { target?: string; @@ -9,8 +16,8 @@ export type DatabaseConfig = }[]; export type FirestoreConfig = { - rules: string; - indexes: string; + rules?: string; + indexes?: string; }; export type FunctionsConfig = { @@ -70,44 +77,44 @@ export type RemoteConfigConfig = { export type EmulatorsConfig = { auth?: { host?: string; - port?: string; + port?: number; }; database?: { host?: string; - port?: string; + port?: number; }; firestore?: { host?: string; - port?: string; + port?: number; }; functions?: { host?: string; - port?: string; + port?: number; }; hosting?: { host?: string; - port?: string; + port?: number; }; pubsub?: { host?: string; - port?: string; + port?: number; }; storage?: { host?: string; - port?: string; + port?: number; }; logging?: { host?: string; - port?: string; + port?: number; }; hub?: { host?: string; - port?: string; + port?: number; }; ui?: { enabled?: boolean; host?: string; - port?: string; + port?: number | string; }; }; diff --git a/src/firebaseConfigValidate.ts b/src/firebaseConfigValidate.ts new file mode 100644 index 00000000000..a2ed3291e24 --- /dev/null +++ b/src/firebaseConfigValidate.ts @@ -0,0 +1,43 @@ +// Note: we are using ajv version 6.x because it's compatible with TypeScript +// 3.x, if we upgrade the TS version in this project we can upgrade ajv as well. +import { ValidateFunction, ErrorObject } from "ajv"; +import * as fs from "fs"; +import * as path from "path"; + +const Ajv = require("ajv"); + +const ajv = new Ajv(); +let _VALIDATOR: ValidateFunction | undefined = undefined; + +/** + * Lazily load the 'schema/firebase-config.json' file and return an AJV validation + * function. By doing this lazily we don't impose this I/O cost on those using + * the CLI as a Node module. + */ +export function getValidator(): ValidateFunction { + if (!_VALIDATOR) { + const schemaStr = fs.readFileSync( + path.resolve(__dirname, "../schema/firebase-config.json"), + "UTF-8" + ); + const schema = JSON.parse(schemaStr); + + _VALIDATOR = ajv.compile(schema); + } + + return _VALIDATOR!; +} + +export function getErrorMessage(e: ErrorObject) { + if (e.keyword === "additionalProperties") { + return `Object "${e.dataPath}" in "firebase.json" has unknown property: ${JSON.stringify( + e.params + )}`; + } else if (e.keyword === "required") { + return `Object "${ + e.dataPath + }" in "firebase.json" is missing required property: ${JSON.stringify(e.params)}`; + } else { + return `Field "${e.dataPath}" in "firebase.json" is possibly invalid: ${e.message}`; + } +} diff --git a/src/test/firebaseConfigValidate.spec.ts b/src/test/firebaseConfigValidate.spec.ts new file mode 100644 index 00000000000..66ba6bffe74 --- /dev/null +++ b/src/test/firebaseConfigValidate.spec.ts @@ -0,0 +1,115 @@ +import { expect } from "chai"; +import { getValidator } from "../firebaseConfigValidate"; +import { FirebaseConfig } from "../firebaseConfig"; +import { valid } from "semver"; + +describe("firebaseConfigValidate", () => { + it("should accept a basic, valid config", () => { + const config: FirebaseConfig = { + database: { + rules: "myrules.json", + }, + hosting: { + public: "public", + }, + emulators: { + database: { + port: 8080, + }, + }, + }; + + const validator = getValidator(); + const isValid = validator(config); + + expect(isValid).to.be.true; + }); + + it("should report an extra top-level field", () => { + // This config has an extra 'bananas' top-level property + const config = { + database: { + rules: "myrules.json", + }, + bananas: {}, + }; + + const validator = getValidator(); + const isValid = validator(config); + + expect(isValid).to.be.false; + expect(validator.errors).to.exist; + expect(validator.errors!.length).to.eq(1); + + const firstError = validator.errors![0]; + expect(firstError.keyword).to.eq("additionalProperties"); + expect(firstError.dataPath).to.eq(""); + expect(firstError.params).to.deep.equal({ additionalProperty: "bananas" }); + }); + + it("should report a missing required field", () => { + // This config is missing 'storage.rules' + const config = { + storage: {}, + }; + + const validator = getValidator(); + const isValid = validator(config); + + expect(isValid).to.be.false; + expect(validator.errors).to.exist; + expect(validator.errors!.length).to.eq(3); + + const [firstError, secondError, thirdError] = validator.errors!; + + // Missing required param + expect(firstError.keyword).to.eq("required"); + expect(firstError.dataPath).to.eq(".storage"); + expect(firstError.params).to.deep.equal({ missingProperty: "rules" }); + + // Because it doesn't match the object type, we also get an "is not an array" + // error since JSON Schema can't tell which type it is closest to. + expect(secondError.keyword).to.eq("type"); + expect(secondError.dataPath).to.eq(".storage"); + expect(secondError.params).to.deep.equal({ type: "array" }); + + // Finally we get an error saying that 'storage' is not any of the known types + expect(thirdError.keyword).to.eq("anyOf"); + expect(thirdError.dataPath).to.eq(".storage"); + expect(thirdError.params).to.deep.equal({}); + }); + + it("should report a field with an incorrect type", () => { + // This config has a number where it should have a string + const config = { + storage: { + rules: 1234, + }, + }; + + const validator = getValidator(); + const isValid = validator(config); + + expect(isValid).to.be.false; + expect(validator.errors).to.exist; + expect(validator.errors!.length).to.eq(3); + + const [firstError, secondError, thirdError] = validator.errors!; + + // Wrong type + expect(firstError.keyword).to.eq("type"); + expect(firstError.dataPath).to.eq(".storage.rules"); + expect(firstError.params).to.deep.equal({ type: "string" }); + + // Because it doesn't match the object type, we also get an "is not an array" + // error since JSON Schema can't tell which type it is closest to. + expect(secondError.keyword).to.eq("type"); + expect(secondError.dataPath).to.eq(".storage"); + expect(secondError.params).to.deep.equal({ type: "array" }); + + // Finally we get an error saying that 'storage' is not any of the known types + expect(thirdError.keyword).to.eq("anyOf"); + expect(thirdError.dataPath).to.eq(".storage"); + expect(thirdError.params).to.deep.equal({}); + }); +});