Add script to generate TS for Nimbus API response

This commit is contained in:
Vincent 2024-04-25 13:41:39 +02:00 коммит произвёл Vincent
Родитель b546e7b705
Коммит 1c5e868fb7
7 изменённых файлов: 240 добавлений и 13 удалений

1
.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

Просмотреть файл

@ -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:

Просмотреть файл

@ -21,6 +21,3 @@ features:
- channel: production
value: { "something": "wicked" }
types:
objects: {}
enums: {}

23
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": {

Просмотреть файл

@ -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"
}
}

Просмотреть файл

@ -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<unknown> {
): Promise<ExperimentData> {
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;
}

Просмотреть файл

@ -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<string, {
* description: string;
* variables: Variables;
* defaults: Array<{
* channel: Channel;
* values: Record<keyof Variables, unknown>;
* }>;
* }>;
* enums?: Record<string, {
* description: string;
* variants: Record<string, string>;
* }>;
* objects?: Record<string, {
* description: string;
* fields: Record<string, {
* description: string;
* type: Type;
* default: unknown;
* }>;
* }>;
* }} 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;
}