From 1c5e868fb7056a04ac11a085a8698d81b3ea0ce4 Mon Sep 17 00:00:00 2001 From: Vincent Date: Thu, 25 Apr 2024 13:41:39 +0200 Subject: [PATCH] Add script to generate TS for Nimbus API response --- .github/workflows/lint.yaml | 1 + README.md | 6 + config/nimbus.yaml | 3 - package-lock.json | 23 ++- package.json | 6 +- src/app/functions/server/getExperiments.ts | 12 +- src/scripts/build/nimbusTypes.js | 202 +++++++++++++++++++++ 7 files changed, 240 insertions(+), 13 deletions(-) create mode 100644 src/scripts/build/nimbusTypes.js diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index b7808daed..e8dd7b922 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -15,6 +15,7 @@ jobs: node-version: '20.11.x' - run: npm ci - run: npm run build-glean + - run: npm run build-nimbus - run: cp .env-dist .env # Mirror old linter from CircleCI, verifies that linter succeeds - run: npm run lint diff --git a/README.md b/README.md index 26ea44e2a..f23e7b2b4 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,12 @@ We track commits that are largely style/formatting via `.git-blame-ignore-revs`. npm run build-glean ``` +6. Generate required Nimbus files (needs re-ran anytime Nimbus' `config/nimbus.yaml` file is updated): + + ```sh + npm run build-nimbus + ``` + ### Run 1. To run the server similar to production using a build phase, which includes minified and bundled assets: diff --git a/config/nimbus.yaml b/config/nimbus.yaml index c44223b81..0b9f32d55 100644 --- a/config/nimbus.yaml +++ b/config/nimbus.yaml @@ -21,6 +21,3 @@ features: - channel: production value: { "something": "wicked" } -types: - objects: {} - enums: {} diff --git a/package-lock.json b/package-lock.json index 6ceeae4d5..d3f36e19a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -99,7 +99,8 @@ "stylelint-config-recommended-scss": "^14.0.0", "stylelint-scss": "^6.2.1", "ts-jest": "^29.1.2", - "typescript": "^5.4.2" + "typescript": "^5.4.2", + "yaml": "^2.4.1" }, "engines": { "node": "20.12.x", @@ -12403,6 +12404,15 @@ "node": ">=10" } }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/create-ecdh": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz", @@ -27705,12 +27715,15 @@ "dev": true }, "node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.1.tgz", + "integrity": "sha512-pIXzoImaqmfOrL7teGUBt/T7ZDnyeGBWyXQBvOVhLkWLN37GXv8NMLK406UY6dS51JfcQHsmcW5cJ441bHg6Lg==", "dev": true, + "bin": { + "yaml": "bin.mjs" + }, "engines": { - "node": ">= 6" + "node": ">= 14" } }, "node_modules/yargs": { diff --git a/package.json b/package.json index af3137b80..26fbaaba5 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "type": "module", "scripts": { "dev": "next dev --port=6060", - "build": "npm run get-location-data && npm run build-glean && next build", + "build": "npm run get-location-data && npm run build-glean && npm run build-nimbus && next build", "start": "next start", "lint": "stylelint '**/*.scss' && prettier --check './src' && next lint --max-warnings=0", "fix": "prettier --write './src' && next lint --fix && stylelint --fix '**/*.scss'", @@ -24,6 +24,7 @@ "build-storybook": "npm run build-glean && storybook build", "create-location-data": "node src/scripts/build/uploadAutoCompleteLocations.js", "get-location-data": "node src/scripts/build/getAutoCompleteLocations.js", + "build-nimbus": "node src/scripts/build/nimbusTypes.js", "build-glean": "glean translate src/telemetry/metrics.yaml --format typescript --output src/telemetry/generated && npm run build-glean-types", "build-glean-types": "node src/scripts/build/gleanTypes.js", "build-glean-docs": "glean translate src/telemetry/metrics.yaml --format markdown --output docs" @@ -129,6 +130,7 @@ "stylelint-config-recommended-scss": "^14.0.0", "stylelint-scss": "^6.2.1", "ts-jest": "^29.1.2", - "typescript": "^5.4.2" + "typescript": "^5.4.2", + "yaml": "^2.4.1" } } diff --git a/src/app/functions/server/getExperiments.ts b/src/app/functions/server/getExperiments.ts index 81d1a40e9..4fa19d9a5 100644 --- a/src/app/functions/server/getExperiments.ts +++ b/src/app/functions/server/getExperiments.ts @@ -4,6 +4,10 @@ import { captureException } from "@sentry/node"; import { logger } from "./logging"; +import { + ExperimentData, + defaultExperimentData, +} from "../../../telemetry/generated/nimbus/experiments"; /** * Call the Cirrus sidecar, which returns a list of eligible experiments for the current user. @@ -14,7 +18,7 @@ import { logger } from "./logging"; */ export async function getExperiments( userId: string | undefined, -): Promise { +): Promise { if (["stage", "production"].includes(process.env.APP_ENV ?? "local")) { const serverUrl = process.env.NIMBUS_SIDECAR_URL; if (!serverUrl) { @@ -22,7 +26,7 @@ export async function getExperiments( } try { - const features = await fetch(`${serverUrl}/v1/features`, { + const response = await fetch(`${serverUrl}/v1/features`, { headers: { "Content-Type": "application/json", }, @@ -33,10 +37,12 @@ export async function getExperiments( }), }); - return features?.json(); + const experimentData = (await response.json()) as ExperimentData; + return experimentData ?? defaultExperimentData; } catch (ex) { logger.error(`Could not connect to Cirrus on ${serverUrl}`, ex); captureException(ex); } } + return defaultExperimentData; } diff --git a/src/scripts/build/nimbusTypes.js b/src/scripts/build/nimbusTypes.js new file mode 100644 index 000000000..11d46bf39 --- /dev/null +++ b/src/scripts/build/nimbusTypes.js @@ -0,0 +1,202 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { parse } from "yaml"; + +run(); + +/** + * See https://experimenter.info/fml-spec/#additional-types + * @typedef {"String" | "Boolean" | "Int" | "Text" | "Image" | `Option<${Type}>` | `List<${Type}>` | `Map<${Type}, ${Type}>`} Type + */ +/** + * @typedef {"staging" | "production"} Channel + */ +/** + * @typedef {Record< + * string, + * { + * description: string; + * type: Type; + * default: unknown; + * string-alias?: string; + * } + * >} Variables + */ +/** + * @typedef {{ + * about: { description: string; }; + * channels: Channel[]; + * features: Record; + * }>; + * }>; + * enums?: Record; + * }>; + * objects?: Record; + * }>; + * }} NimbusConfig + */ + +async function run() { + const nimbusConfigSource = await readFile("config/nimbus.yaml", "utf-8"); + /** @type {NimbusConfig} */ + const nimbusConfig = parse(nimbusConfigSource); + + const typedef = + "/* This Source Code Form is subject to the terms of the Mozilla Public\n" + + " * License, v. 2.0. If a copy of the MPL was not distributed with this\n" + + " * file, You can obtain one at http://mozilla.org/MPL/2.0/. */\n\n" + + "// AUTOGENERATED `npm run build-nimbus`. DO NOT EDIT. DO NOT COMMIT.\n\n" + + getFeaturesTypeDef(nimbusConfig) + + "\n" + + getFallbackObject(nimbusConfig); + await mkdir("src/telemetry/generated/nimbus/", { recursive: true }); + await writeFile("src/telemetry/generated/nimbus/experiments.ts", typedef); +} + +/** + * @param {NimbusConfig} nimbusConfig + * @returns string + */ +function getFallbackObject(nimbusConfig) { + const featureFallbackDefs = Object.keys(nimbusConfig.features).map( + (featureId) => { + const variableNames = Object.keys( + nimbusConfig.features[featureId].variables, + ); + const variableFallbackDefs = variableNames.map((variableName) => { + return ` "${variableName}": ${nimbusConfig.features[featureId].variables[variableName].default},\n`; + }); + + return ` "${featureId}": {\n${variableFallbackDefs.join("")} },\n`; + }, + ); + + return `export const defaultExperimentData: ExperimentData = {\n${featureFallbackDefs.join("\n")}};\n`; +} + +/** + * @param {NimbusConfig} nimbusConfig + * @returns string + */ +function getFeaturesTypeDef(nimbusConfig) { + const featureDefs = Object.keys(nimbusConfig.features).map((featureId) => { + const variableNames = Object.keys( + nimbusConfig.features[featureId].variables, + ); + const variableDefs = variableNames.map((variableName) => { + return ` "${variableName}": ${getType(nimbusConfig.features[featureId].variables[variableName].type)};\n`; + }); + return ` "${featureId}": {\n${variableDefs.join("")} };\n`; + }); + const experimentDataTypeDef = `/** Status of experiments, as setup in Experimenter */\nexport type ExperimentData = {\n${featureDefs.join("")}};\n`; + const featureTypeDef = + getStringAliases(nimbusConfig) + + "\n\n" + + getTypeAliases(nimbusConfig) + + "\n\n" + + experimentDataTypeDef; + return featureTypeDef; +} + +/** + * @param {NimbusConfig} nimbusConfig + * @returns string + */ +function getStringAliases(nimbusConfig) { + const features = Object.values(nimbusConfig.features); + const stringAliasDefs = features.flatMap((feature) => { + const variablesWithStringAlias = Object.values(feature.variables).filter( + (variable) => { + return typeof variable["string-alias"] === "string"; + }, + ); + return variablesWithStringAlias.map( + (variable) => `type ${variable["string-alias"]} = string;\n`, + ); + }); + + return `/* Nimbus string aliases */\n${stringAliasDefs.join("")}`; +} + +/** + * @param {NimbusConfig} nimbusConfig + * @returns string + */ +function getTypeAliases(nimbusConfig) { + const objects = nimbusConfig.objects ?? {}; + const objectDefs = Object.keys(objects).map((typeAlias) => { + const propertyNames = Object.keys(nimbusConfig.objects[typeAlias].fields); + const propertyDefs = propertyNames.map((propertyName) => { + // TODO: Add descriptions as TSDoc comment? + return ` "${propertyName}": ${getType(nimbusConfig.objects[typeAlias].fields[propertyName].type)};\n`; + }); + // TODO: Add description as TSDoc comment? + return `type ${typeAlias} = {\n${propertyDefs.join("")}};\n`; + }); + + const enums = nimbusConfig.enums ?? {}; + const enumDefs = Object.keys(enums).map((typeAlias) => { + // TODO: Add values as TSDoc comment? + const unionOfStrings = Object.keys(nimbusConfig.enums[typeAlias].variants) + .map((variant) => `"${variant}"`) + .join(" | "); + return `type ${typeAlias} = ${unionOfStrings};`; + }); + + return ( + "/* Nimbus object types */\n" + + objectDefs.join("\n") + + "\n\n/* Nimbus enum types */\n" + + enumDefs.join("\n") + ); +} + +/** + * @param {string} type + * @returns string + */ +function getType(type) { + if (type === "String") { + return "string"; + } + if (type === "Boolean") { + return "boolean"; + } + if (type === "Int") { + return "number"; + } + if (type === "Text" || type === "Image") { + return "string"; + } + if (type.startsWith("Option<")) { + const t = type.substring("Option<".length, type.length - 1).trim(); + return `null | ${getType(t)}`; + } + if (type.startsWith("List<")) { + const t = type.substring("List<".length, type.length - 1).trim(); + return `Array<${getType(t)}>`; + } + if (type.startsWith("Map<")) { + const kv = type.substring("Map<".length, type.length - 1).trim(); + const [k, v] = kv.split(",").map((part) => part.trim()); + return `Record<${getType(k)}, ${getType(v)}>`; + } + + return type; +}