diff --git a/schemas/VERSION b/schemas/VERSION index bd859435c02..142bb53d9d5 100644 --- a/schemas/VERSION +++ b/schemas/VERSION @@ -1 +1 @@ -2024.9.3 +2024.10.1 diff --git a/schemas/generate_json_schema.py b/schemas/generate_json_schema.py index 2f20c1b8cea..6e772f21481 100644 --- a/schemas/generate_json_schema.py +++ b/schemas/generate_json_schema.py @@ -4,10 +4,11 @@ """ import json +import re import subprocess from pathlib import Path from tempfile import TemporaryDirectory -from typing import Any +from typing import Any, Iterable import click from polyfactory.factories.pydantic_factory import ModelFactory @@ -15,6 +16,8 @@ from mozilla_nimbus_schemas import experiments, jetstream +NEWLINES_RE = re.compile("\n+") + def clean_output_file(ts_path: Path) -> None: """Clean up the output file typescript definitions were written to by: @@ -98,6 +101,114 @@ def iterate_models() -> dict[str, Any]: return schema +def prettify_json_schema(schema: dict[str, Any]) -> dict[str, Any]: + # Add a $schema field. + pretty_schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + } + + # Re-order the properties in the dict so that they are in a sensible order + # for humans consuming these schemas. + + # Use this order for top-level keys. + key_order = [ + "title", + "description", + "type", + "properties", + "required", + "additionalProperties", + "$defs", + ] + + # If there are any other keys not listed above, splice them in before $defs. + key_order = [ + *key_order[:-1], + *(set(schema.keys()) - set(key_order)), + key_order[-1], + ] + + pretty_schema.update({key: schema[key] for key in key_order if key in schema}) + + # Assert that the schemas have not structurally changed. + # + # We have to add the $schema field back to the original schema for comparison. + schema["$schema"] = pretty_schema["$schema"] + assert schema == pretty_schema + + # Next, lets walk the schema and remove attributes we don't care about. + def _walk_objects(objs: Iterable[dict[str, Any]]): + for obj in objs: + _walk_object(obj) + + def _walk_object(obj: dict[str, Any], top_level: bool = False): + # All but the top-level title will be auto-generated base on field names. They are + # not useful. + if not top_level: + obj.pop("title", None) + + # We don't support defaults. + obj.pop("default", None) + + # This is an OpenAPI extension and it leads to incorrect code generation in our + # case (due to using a boolean discriminator). + obj.pop("discriminator", None) + + # Strip newlines from descriptions. + if description := obj.get("description"): + obj["description"] = NEWLINES_RE.sub(" ", description) + + # Remove redundant enum entries for constants. + if obj.get("const") is not None: + obj.pop("enum", None) + + match obj.get("type"): + case "object": + if properties := obj.get("properties"): + _walk_objects(properties.values()) + + case "array": + if items := obj.get("items"): + _walk_object(items) + + for group_key in ("allOf", "anyOf", "oneOf"): + if group := obj.get(group_key): + _walk_objects(group) + + _walk_object(pretty_schema, top_level=True) + if defs := pretty_schema.get("$defs"): + _walk_objects(defs.values()) + + return pretty_schema + + +def write_json_schemas(json_schemas_path: Path): + json_schemas_path.mkdir(exist_ok=True) + + models = { + model_name: getattr(experiments, model_name) + for model_name in experiments.__all__ + if issubclass(getattr(experiments, model_name), BaseModel) + } + + written_paths = set() + + for model_name, model in models.items(): + model_schema_path = json_schemas_path / f"{model_name}.schema.json" + written_paths.add(model_schema_path) + + json_schema = prettify_json_schema(model.model_json_schema()) + with model_schema_path.open("w") as f: + json.dump(json_schema, f, indent=2) + f.write("\n") + + # Ensure we don't include any files in schemas/ that we did not generate (e.g., if a + # model gets removed). + for path in list(json_schemas_path.iterdir()): + if path not in written_paths: + path.unlink() + + @click.command() @click.option( "--output", @@ -106,7 +217,14 @@ def iterate_models() -> dict[str, Any]: default=Path("index.d.ts"), help="Output typescript file.", ) -def main(*, ts_output_path: Path): +@click.option( + "--json-schemas", + "json_schemas_path", + type=Path, + default=Path("schemas"), + help="Output JSON Schemas to this directory.", +) +def main(*, ts_output_path: Path, json_schemas_path: Path): json_schema = iterate_models() with TemporaryDirectory() as tmp_dir: @@ -132,6 +250,8 @@ def main(*, ts_output_path: Path): clean_output_file(ts_output_path) + write_json_schemas(json_schemas_path) + if __name__ == "__main__": main() diff --git a/schemas/index.d.ts b/schemas/index.d.ts index 217d4f5df6d..efe2e0eb698 100644 --- a/schemas/index.d.ts +++ b/schemas/index.d.ts @@ -5,13 +5,13 @@ /* Do not modify by hand - update the pydantic models and re-run the script */ +export type DesktopApplication = "firefox-desktop" | "firefox-desktop-background-task"; +export type FeatureVariableType = "int" | "string" | "boolean" | "json"; +export type PrefBranch = "default" | "user"; /** * A unique, stable indentifier for the user used as an input to bucket hashing. */ export type RandomizationUnit = "normandy_id" | "nimbus_id" | "user_id" | "group_id"; -export type Feature = FeatureWithExposure | FeatureWithoutExposure; -export type FeatureVariableType = "int" | "string" | "boolean" | "json"; -export type PrefBranch = "default" | "user"; export type AnalysisBasis = "enrollments" | "exposures"; export type LogSource = "jetstream" | "sizing" | "jetstream-preview"; export type AnalysisErrors = AnalysisError[]; @@ -31,6 +31,110 @@ export type SizingMetricName = "active_hours" | "search_count" | "days_of_use" | export type StatisticIngestEnum = "percentage" | "binomial" | "mean" | "count"; export type Statistics = Statistic[]; +/** + * A Firefox Desktop-specific feature. + * + * Firefox Desktop requires different fields for its features compared to general purpose + * Nimbus features. + */ +export interface DesktopFeature { + /** + * The description of the feature. + */ + description: string; + /** + * Whether or not this feature records exposure telemetry. + */ + hasExposure: boolean; + /** + * A description of the exposure telemetry collected by this feature. + * + * Only required if hasExposure is true. + */ + exposureDescription?: string; + /** + * The owner of the feature. + */ + owner: string; + /** + * If true, the feature values will be cached in prefs so that they can be read before Nimbus is initialized during Firefox startup. + */ + isEarlyStartup?: boolean; + /** + * The applications that can enroll in experiments for this feature. + * + * Defaults to "firefox-desktop". + */ + applications?: DesktopApplication[]; + /** + * The variables that this feature can set. + */ + variables: { + [k: string]: DesktopFeatureVariable; + }; + schema?: NimbusFeatureSchema; +} +/** + * A feature variable. + */ +export interface DesktopFeatureVariable { + /** + * A description of the feature. + */ + description: string; + type: FeatureVariableType; + /** + * An optional list of possible string or integer values. + * + * Only allowed when type is string or int. + * + * The types in the enum must match the type of the field. + */ + enum?: string[] | number[]; + /** + * A pref that provides the default value for a feature when none is present. + */ + fallbackPref?: string; + /** + * A pref that should be set to the value of this variable when enrolling in experiments. + * + * Using a string is deprecated and unsupported in Firefox 124+. + */ + setPref?: string | SetPref; +} +export interface SetPref { + branch: PrefBranch; + /** + * The name of the pref to set. + */ + pref: string; +} +/** + * Information about a JSON schema. + */ +export interface NimbusFeatureSchema { + /** + * The resource:// or chrome:// URI that can be loaded at runtime within Firefox. + * + * Required by Firefox so that Nimbus can import the schema for validation. + */ + uri: string; + /** + * The path to the schema file in the source checkout. + * + * Required by Experimenter so that it can find schema files in source checkouts. + */ + path: string; +} +/** + * The Firefox Desktop-specific feature manifest. + * + * Firefox Desktop requires different fields for its features compared to the general + * Nimbus feature manifest. + */ +export interface DesktopFeatureManifest { + [k: string]: DesktopFeature; +} /** * The experiment definition accessible to: * @@ -106,7 +210,10 @@ export interface NimbusExperiment { | ExperimentSingleFeatureBranch[] | ExperimentMultiFeatureDesktopBranch[] | ExperimentMultiFeatureMobileBranch[]; - targeting?: string | null; + /** + * A JEXL targeting expression used to filter out experiments. + */ + targeting?: string; /** * Actual publish date of the experiment. * @@ -282,48 +389,32 @@ export interface ExperimentMultiFeatureMobileBranch { */ features: ExperimentFeatureConfig[]; } -export interface FeatureManifest { - [k: string]: Feature; -} /** - * A feature that has exposure. + * The SDK-specific feature manifest. */ -export interface FeatureWithExposure { - description?: string | null; - isEarlyStartup?: boolean | null; +export interface SdkFeatureManifest { + [k: string]: SdkFeature; +} +export interface SdkFeature { variables: { - [k: string]: FeatureVariable; + [k: string]: SdkFeatureVariable; }; - schema?: NimbusFeatureSchema | null; - hasExposure: true; - exposureDescription: string; -} -export interface FeatureVariable { - description?: string | null; - enum?: string[] | null; - fallbackPref?: string | null; - type?: FeatureVariableType | null; - setPref?: string | SetPref | null; -} -export interface SetPref { - branch: PrefBranch; - pref: string; -} -export interface NimbusFeatureSchema { - uri: string; - path: string; } /** - * A feature without exposure. + * A feature variable. */ -export interface FeatureWithoutExposure { - description?: string | null; - isEarlyStartup?: boolean | null; - variables: { - [k: string]: FeatureVariable; - }; - schema?: NimbusFeatureSchema | null; - hasExposure: false; +export interface SdkFeatureVariable { + /** + * A description of the feature. + */ + description: string; + type: FeatureVariableType; + /** + * An optional list of possible string values. + * + * Only allowed when type is string. + */ + enum?: string[]; } export interface AnalysisError { analysis_basis?: AnalysisBasis | null; diff --git a/schemas/mozilla_nimbus_schemas/experiments/__init__.py b/schemas/mozilla_nimbus_schemas/experiments/__init__.py index 5b4c6583f94..4dcfeb4a307 100644 --- a/schemas/mozilla_nimbus_schemas/experiments/__init__.py +++ b/schemas/mozilla_nimbus_schemas/experiments/__init__.py @@ -1,4 +1,10 @@ from .experiments import NimbusExperiment, RandomizationUnit -from .feature_manifests import FeatureManifest +from .feature_manifests import DesktopFeature, DesktopFeatureManifest, SdkFeatureManifest -__all__ = ("NimbusExperiment", "RandomizationUnit", "FeatureManifest") +__all__ = ( + "DesktopFeature", + "DesktopFeatureManifest", + "NimbusExperiment", + "RandomizationUnit", + "SdkFeatureManifest", +) diff --git a/schemas/mozilla_nimbus_schemas/experiments/experiments.py b/schemas/mozilla_nimbus_schemas/experiments/experiments.py index 2f98d3f4c9d..8d89303aa5a 100644 --- a/schemas/mozilla_nimbus_schemas/experiments/experiments.py +++ b/schemas/mozilla_nimbus_schemas/experiments/experiments.py @@ -193,15 +193,18 @@ class NimbusExperiment(BaseModel): list[ExperimentMultiFeatureDesktopBranch], list[ExperimentMultiFeatureMobileBranch], ] = Field(description="Branch configuration for the experiment.") - targeting: str | None = None - startDate: str | None = Field( + targeting: str = Field( + description="A JEXL targeting expression used to filter out experiments.", + default=None, + ) + startDate: datetime.date | None = Field( description=( "Actual publish date of the experiment.\n" "\n" "Note that this value is expected to be null in Remote Settings." ), ) - enrollmentEndDate: Optional[str] = Field( + enrollmentEndDate: Optional[datetime.date] = Field( description=( "Actual enrollment end date of the experiment.\n" "\n" @@ -209,7 +212,7 @@ class NimbusExperiment(BaseModel): ), default=None, ) - endDate: str | None = Field( + endDate: datetime.date | None = Field( description=( "Actual end date of this experiment.\n" "\n" @@ -262,7 +265,7 @@ class NimbusExperiment(BaseModel): ), default=None, ) - publishedDate: datetime.datetime | SkipJsonSchema[None] = Field( + publishedDate: datetime.datetime = Field( description=( "The date that this experiment was first published to Remote Settings.\n" "\n" diff --git a/schemas/mozilla_nimbus_schemas/experiments/feature_manifests.py b/schemas/mozilla_nimbus_schemas/experiments/feature_manifests.py index c07c46e11c3..c14df4b7145 100644 --- a/schemas/mozilla_nimbus_schemas/experiments/feature_manifests.py +++ b/schemas/mozilla_nimbus_schemas/experiments/feature_manifests.py @@ -1,77 +1,352 @@ from enum import Enum -from typing import Literal, Optional, Union +from typing import Any, Literal, Optional, Union -from pydantic import BaseModel, Field, RootModel +from pydantic import ( + BaseModel, + ConfigDict, + Field, + RootModel, + model_validator, + ValidationError, +) +from pydantic.json_schema import SkipJsonSchema -class FeatureVariableType(Enum): +class FeatureVariableType(str, Enum): INT = "int" STRING = "string" BOOLEAN = "boolean" JSON = "json" -class PrefBranch(Enum): +class PrefBranch(str, Enum): DEFAULT = "default" USER = "user" +class DesktopApplication(str, Enum): + FIREFOX_DESKTOP = "firefox-desktop" + BACKGROUND_TASK = "firefox-desktop-background-task" + + class SetPref(BaseModel): - branch: PrefBranch - pref: str + branch: PrefBranch = Field( + description=( + "The branch the pref will be set on.\n" + "\n" + "Prefs set on the user branch persists through restarts." + ), + ) + pref: str = Field(description="The name of the pref to set.") + + +class BaseFeatureVariable(BaseModel): + description: str = Field(description="A description of the feature.") + type: FeatureVariableType = Field(description="The field type.") + + +class SdkFeatureVariable(BaseFeatureVariable): + """A feature variable.""" + + enum: list[str] | SkipJsonSchema[None] = Field( + description=( + "An optional list of possible string values.\n" + "\n" + f"Only allowed when type is {FeatureVariableType.STRING.value}." + ), + default=None, + ) + + model_config = ConfigDict( + json_schema_extra={ + "dependentSchemas": { + "enum": { + "properties": { + "type": {"const": FeatureVariableType.STRING.value}, + } + } + } + } + ) + + @model_validator(mode="after") + @classmethod + def validate_enum(cls, data: "FeatureVariable") -> "FeatureVariable": + if ( + data.enum is not None + and data.type == FeatureVariableType.STRING + and not all(isinstance(variant, str) for variant in data.enum) + ): + raise ValueError("enum values do not match variable type") + + return data + + +class DesktopFeatureVariable(BaseFeatureVariable): + """A feature variable.""" + + enum: list[str] | list[int] | SkipJsonSchema[None] = Field( + description=( + "An optional list of possible string or integer values.\n" + "\n" + f"Only allowed when type is {FeatureVariableType.STRING.value} or " + f"{FeatureVariableType.INT.value}.\n" + "\n" + "The types in the enum must match the type of the field." + ), + default=None, + ) + + fallback_pref: str | SkipJsonSchema[None] = Field( + alias="fallbackPref", + description=( + "A pref that provides the default value for a feature when none is present." + ), + default=None, + ) + + set_pref: str | SetPref | SkipJsonSchema[None] = Field( + alias="setPref", + description=( + "A pref that should be set to the value of this variable when enrolling in experiments.\n" + "\n" + "Using a string is deprecated and unsupported in Firefox 124+." + ), + default=None, + ) + + model_config = ConfigDict( + json_schema_extra={ + "dependentSchemas": { + # This is the equivalent of the `validate_enums` validator. + # + # This could also be done declaratively by specializing FeatureVariable + # into specifically typed child classes and using a union in the parent + # class, but that is much more verbose and generates a bunch of + # boilerplate types. + # + # From a JSON Schema perspective, don't have to tuck this away in + # dependentSchemas and the allOf clause could live at the top-level, but + # then json-schema-to-typescript gets confused and generates an empty type + # for `DesktopFeatureVariable`. + "enum": { + "allOf": [ + *( + { + "if": { + "type": {"const": ty}, + }, + "then": { + "properties": { + "enum": { + "items": {"type": ty}, + }, + }, + }, + } + for ty in ( + FeatureVariableType.STRING, + FeatureVariableType.INT, + ) + ), + *( + { + "if": { + "type": {"const": ty}, + }, + "then": { + "properties": { + "enum": {"const": None}, + }, + }, + } + for ty in ( + FeatureVariableType.JSON, + FeatureVariableType.BOOLEAN, + ) + ), + ], + }, + # These are the the equivalent of the + # `validate_set_pref_fallback_pref_mutually_exclusive` validator. + # + # Pydantic does not have a way to encode this relationship outside custom + # validation. + "fallbackPref": { + "description": "setPref is mutually exclusive with fallbackPref", + "properties": { + "setPref": { + "const": None, + } + }, + }, + "setPref": { + "description": "fallbackPref is mutually exclusive with setPref", + "properties": { + "fallbackPref": { + "const": None, + } + }, + }, + }, + }, + ) + + @model_validator(mode="after") + @classmethod + def validate_set_pref_fallback_pref_mutually_exclusive( + cls, data: "FeatureVariable" + ) -> "FeatureVariable": + has_set_pref = data.set_pref is not None + has_fallback_pref = data.fallback_pref is not None + + if has_set_pref and has_fallback_pref: + raise ValidationError("fallback_pref and set_pref are mutually exclusive") + return data -class FeatureVariable(BaseModel): - description: Optional[str] = None - enum: Optional[list[str]] = None - fallback_pref: Optional[str] = Field(None, alias="fallbackPref") - type: Optional[FeatureVariableType] = None - set_pref: Optional[Union[str, SetPref]] = Field(None, alias="setPref") + @model_validator(mode="after") + @classmethod + def validate_enum(cls, data: "FeatureVariable") -> "FeatureVariable": + if data.enum is not None: + if data.type in (FeatureVariableType.STRING, FeatureVariableType.INT): + if data.type == FeatureVariableType.STRING: + expected_cls = str + else: + expected_cls = int + + if not all(isinstance(variant, expected_cls) for variant in data.enum): + raise ValueError("enum values do not match variable type") + + else: + raise ValueError("enums are only supported for string and integer types") + + return data class NimbusFeatureSchema(BaseModel): - uri: str - path: str + """Information about a JSON schema.""" + + uri: str = Field( + description=( + "The resource:// or chrome:// URI that can be loaded at runtime within " + "Firefox.\n" + "\n" + "Required by Firefox so that Nimbus can import the schema for validation." + ), + ) + + path: str = Field( + description=( + "The path to the schema file in the source checkout.\n" + "\n" + "Required by Experimenter so that it can find schema files in source " + "checkouts." + ) + ) class BaseFeature(BaseModel): - """The base Feature type. + """The Feature type.""" - The real Feature type has conditionally required fields (if the feature has - exposure, then the exposure description is required), so this class includes - the fields common in both cases. - """ + description: str = Field(description="The description of the feature.") + + has_exposure: bool = Field( + alias="hasExposure", + description="Whether or not this feature records exposure telemetry.", + ) - description: Optional[str] = None - is_early_startup: Optional[bool] = Field(None, alias="isEarlyStartup") - variables: dict[str, FeatureVariable] + exposure_description: str = Field( + alias="exposureDescription", + description=( + "A description of the exposure telemetry collected by this feature.\n" + "\n" + "Only required if hasExposure is true." + ), + default=None, + ) - #: Only used by Firefox Desktop. - json_schema: Optional[NimbusFeatureSchema] = Field(None, alias="schema") + # This could be done declaratively by splitting the feature into + model_config = ConfigDict( + json_schema_extra={ + "if": { + "properties": { + "hasExposure": { + "const": True, + }, + }, + }, + "then": { + "required": ["exposure_description"], + }, + } + ) + @model_validator(mode="after") + @classmethod + def validate_exposure_description(cls, data: "Feature") -> "Feature": + if data.has_exposure and exposure_description is None: + raise ValidationError( + "exposure_description is required if has_exposure is True" + ) -class FeatureWithExposure(BaseFeature): - """A feature that has exposure.""" + return data - has_exposure: Literal[True] = Field(alias="hasExposure") - exposure_description: str = Field(alias="exposureDescription") +class SdkFeature(BaseModel): + variables: dict[str, SdkFeatureVariable] -class FeatureWithoutExposure(BaseFeature): - """A feature without exposure.""" - has_exposure: Literal[False] = Field(alias="hasExposure") +class DesktopFeature(BaseFeature): + """A Firefox Desktop-specific feature. - @property - def exposure_description(self): - return None + Firefox Desktop requires different fields for its features compared to general purpose + Nimbus features. + """ + owner: str = Field(description="The owner of the feature.") -class Feature(RootModel): - root: Union[FeatureWithExposure, FeatureWithoutExposure] = Field( - discriminator="has_exposure" + is_early_startup: bool = Field( + alias="isEarlyStartup", + description=( + "If true, the feature values will be cached in prefs so that they can be " + "read before Nimbus is initialized during Firefox startup." + ), + default=False, ) + applications: list[DesktopApplication] | SkipJsonSchema[None] = Field( + description=( + "The applications that can enroll in experiments for this feature.\n" + "\n" + 'Defaults to "firefox-desktop".' + ), + default_factory=lambda: [DesktopApplication.FIREFOX_DESKTOP.value], + min_items=1, + ) + + variables: dict[str, DesktopFeatureVariable] = Field( + description="The variables that this feature can set.", + ) + + json_schema: NimbusFeatureSchema | SkipJsonSchema[None] = Field( + alias="schema", + description="An optional JSON schema that describes the feature variables.", + default=None, + ) + + +class DesktopFeatureManifest(RootModel): + """The Firefox Desktop-specific feature manifest. + + Firefox Desktop requires different fields for its features compared to the general + Nimbus feature manifest. + """ + + root: dict[str, DesktopFeature] + + +class SdkFeatureManifest(RootModel): + """The SDK-specific feature manifest.""" -class FeatureManifest(RootModel): - root: dict[str, Feature] + root: dict[str, SdkFeature] diff --git a/schemas/package.json b/schemas/package.json index ff034cdff30..72412578e39 100644 --- a/schemas/package.json +++ b/schemas/package.json @@ -1,6 +1,6 @@ { "name": "@mozilla/nimbus-schemas", - "version": "2024.9.3", + "version": "2024.10.1", "description": "Schemas used by Mozilla Nimbus and related projects.", "main": "index.d.ts", "repository": { @@ -18,6 +18,7 @@ "typescript": "^5.1.6" }, "files": [ - "index.d.ts" + "index.d.ts", + "schemas" ] } diff --git a/schemas/pyproject.toml b/schemas/pyproject.toml index 46a36b1058a..523aa51f45d 100644 --- a/schemas/pyproject.toml +++ b/schemas/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "mozilla-nimbus-schemas" -version = "2024.9.3" +version = "2024.10.1" description = "Schemas used by Mozilla Nimbus and related projects." authors = ["mikewilli"] license = "MPL 2.0" diff --git a/schemas/schemas/DesktopFeature.schema.json b/schemas/schemas/DesktopFeature.schema.json new file mode 100644 index 00000000000..1a4602806df --- /dev/null +++ b/schemas/schemas/DesktopFeature.schema.json @@ -0,0 +1,257 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "DesktopFeature", + "description": "A Firefox Desktop-specific feature. Firefox Desktop requires different fields for its features compared to general purpose Nimbus features.", + "type": "object", + "properties": { + "description": { + "description": "The description of the feature.", + "type": "string" + }, + "hasExposure": { + "description": "Whether or not this feature records exposure telemetry.", + "type": "boolean" + }, + "exposureDescription": { + "description": "A description of the exposure telemetry collected by this feature. Only required if hasExposure is true.", + "type": "string" + }, + "owner": { + "description": "The owner of the feature.", + "type": "string" + }, + "isEarlyStartup": { + "description": "If true, the feature values will be cached in prefs so that they can be read before Nimbus is initialized during Firefox startup.", + "type": "boolean" + }, + "applications": { + "description": "The applications that can enroll in experiments for this feature. Defaults to \"firefox-desktop\".", + "items": { + "$ref": "#/$defs/DesktopApplication" + }, + "minLength": 1, + "type": "array" + }, + "variables": { + "additionalProperties": { + "$ref": "#/$defs/DesktopFeatureVariable" + }, + "description": "The variables that this feature can set.", + "type": "object" + }, + "schema": { + "$ref": "#/$defs/NimbusFeatureSchema", + "description": "An optional JSON schema that describes the feature variables." + } + }, + "required": [ + "description", + "hasExposure", + "owner", + "variables" + ], + "if": { + "properties": { + "hasExposure": { + "const": true + } + } + }, + "then": { + "required": [ + "exposure_description" + ] + }, + "$defs": { + "DesktopApplication": { + "enum": [ + "firefox-desktop", + "firefox-desktop-background-task" + ], + "type": "string" + }, + "DesktopFeatureVariable": { + "dependentSchemas": { + "enum": { + "allOf": [ + { + "if": { + "type": { + "const": "string" + } + }, + "then": { + "properties": { + "enum": { + "items": { + "type": "string" + } + } + } + } + }, + { + "if": { + "type": { + "const": "int" + } + }, + "then": { + "properties": { + "enum": { + "items": { + "type": "int" + } + } + } + } + }, + { + "if": { + "type": { + "const": "json" + } + }, + "then": { + "properties": { + "enum": { + "const": null + } + } + } + }, + { + "if": { + "type": { + "const": "boolean" + } + }, + "then": { + "properties": { + "enum": { + "const": null + } + } + } + } + ] + }, + "fallbackPref": { + "description": "setPref is mutually exclusive with fallbackPref", + "properties": { + "setPref": { + "const": null + } + } + }, + "setPref": { + "description": "fallbackPref is mutually exclusive with setPref", + "properties": { + "fallbackPref": { + "const": null + } + } + } + }, + "description": "A feature variable.", + "properties": { + "description": { + "description": "A description of the feature.", + "type": "string" + }, + "type": { + "$ref": "#/$defs/FeatureVariableType", + "description": "The field type." + }, + "enum": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "items": { + "type": "integer" + }, + "type": "array" + } + ], + "description": "An optional list of possible string or integer values. Only allowed when type is string or int. The types in the enum must match the type of the field." + }, + "fallbackPref": { + "description": "A pref that provides the default value for a feature when none is present.", + "type": "string" + }, + "setPref": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/SetPref" + } + ], + "description": "A pref that should be set to the value of this variable when enrolling in experiments. Using a string is deprecated and unsupported in Firefox 124+." + } + }, + "required": [ + "description", + "type" + ], + "type": "object" + }, + "FeatureVariableType": { + "enum": [ + "int", + "string", + "boolean", + "json" + ], + "type": "string" + }, + "NimbusFeatureSchema": { + "description": "Information about a JSON schema.", + "properties": { + "uri": { + "description": "The resource:// or chrome:// URI that can be loaded at runtime within Firefox. Required by Firefox so that Nimbus can import the schema for validation.", + "type": "string" + }, + "path": { + "description": "The path to the schema file in the source checkout. Required by Experimenter so that it can find schema files in source checkouts.", + "type": "string" + } + }, + "required": [ + "uri", + "path" + ], + "type": "object" + }, + "PrefBranch": { + "enum": [ + "default", + "user" + ], + "type": "string" + }, + "SetPref": { + "properties": { + "branch": { + "$ref": "#/$defs/PrefBranch", + "description": "The branch the pref will be set on. Prefs set on the user branch persists through restarts." + }, + "pref": { + "description": "The name of the pref to set.", + "type": "string" + } + }, + "required": [ + "branch", + "pref" + ], + "type": "object" + } + } +} diff --git a/schemas/schemas/DesktopFeatureManifest.schema.json b/schemas/schemas/DesktopFeatureManifest.schema.json new file mode 100644 index 00000000000..ed60c4159c0 --- /dev/null +++ b/schemas/schemas/DesktopFeatureManifest.schema.json @@ -0,0 +1,264 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "DesktopFeatureManifest", + "description": "The Firefox Desktop-specific feature manifest. Firefox Desktop requires different fields for its features compared to the general Nimbus feature manifest.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/DesktopFeature" + }, + "$defs": { + "DesktopApplication": { + "enum": [ + "firefox-desktop", + "firefox-desktop-background-task" + ], + "type": "string" + }, + "DesktopFeature": { + "description": "A Firefox Desktop-specific feature. Firefox Desktop requires different fields for its features compared to general purpose Nimbus features.", + "if": { + "properties": { + "hasExposure": { + "const": true + } + } + }, + "properties": { + "description": { + "description": "The description of the feature.", + "type": "string" + }, + "hasExposure": { + "description": "Whether or not this feature records exposure telemetry.", + "type": "boolean" + }, + "exposureDescription": { + "description": "A description of the exposure telemetry collected by this feature. Only required if hasExposure is true.", + "type": "string" + }, + "owner": { + "description": "The owner of the feature.", + "type": "string" + }, + "isEarlyStartup": { + "description": "If true, the feature values will be cached in prefs so that they can be read before Nimbus is initialized during Firefox startup.", + "type": "boolean" + }, + "applications": { + "description": "The applications that can enroll in experiments for this feature. Defaults to \"firefox-desktop\".", + "items": { + "$ref": "#/$defs/DesktopApplication" + }, + "minLength": 1, + "type": "array" + }, + "variables": { + "additionalProperties": { + "$ref": "#/$defs/DesktopFeatureVariable" + }, + "description": "The variables that this feature can set.", + "type": "object" + }, + "schema": { + "$ref": "#/$defs/NimbusFeatureSchema", + "description": "An optional JSON schema that describes the feature variables." + } + }, + "required": [ + "description", + "hasExposure", + "owner", + "variables" + ], + "then": { + "required": [ + "exposure_description" + ] + }, + "type": "object" + }, + "DesktopFeatureVariable": { + "dependentSchemas": { + "enum": { + "allOf": [ + { + "if": { + "type": { + "const": "string" + } + }, + "then": { + "properties": { + "enum": { + "items": { + "type": "string" + } + } + } + } + }, + { + "if": { + "type": { + "const": "int" + } + }, + "then": { + "properties": { + "enum": { + "items": { + "type": "int" + } + } + } + } + }, + { + "if": { + "type": { + "const": "json" + } + }, + "then": { + "properties": { + "enum": { + "const": null + } + } + } + }, + { + "if": { + "type": { + "const": "boolean" + } + }, + "then": { + "properties": { + "enum": { + "const": null + } + } + } + } + ] + }, + "fallbackPref": { + "description": "setPref is mutually exclusive with fallbackPref", + "properties": { + "setPref": { + "const": null + } + } + }, + "setPref": { + "description": "fallbackPref is mutually exclusive with setPref", + "properties": { + "fallbackPref": { + "const": null + } + } + } + }, + "description": "A feature variable.", + "properties": { + "description": { + "description": "A description of the feature.", + "type": "string" + }, + "type": { + "$ref": "#/$defs/FeatureVariableType", + "description": "The field type." + }, + "enum": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "items": { + "type": "integer" + }, + "type": "array" + } + ], + "description": "An optional list of possible string or integer values. Only allowed when type is string or int. The types in the enum must match the type of the field." + }, + "fallbackPref": { + "description": "A pref that provides the default value for a feature when none is present.", + "type": "string" + }, + "setPref": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/SetPref" + } + ], + "description": "A pref that should be set to the value of this variable when enrolling in experiments. Using a string is deprecated and unsupported in Firefox 124+." + } + }, + "required": [ + "description", + "type" + ], + "type": "object" + }, + "FeatureVariableType": { + "enum": [ + "int", + "string", + "boolean", + "json" + ], + "type": "string" + }, + "NimbusFeatureSchema": { + "description": "Information about a JSON schema.", + "properties": { + "uri": { + "description": "The resource:// or chrome:// URI that can be loaded at runtime within Firefox. Required by Firefox so that Nimbus can import the schema for validation.", + "type": "string" + }, + "path": { + "description": "The path to the schema file in the source checkout. Required by Experimenter so that it can find schema files in source checkouts.", + "type": "string" + } + }, + "required": [ + "uri", + "path" + ], + "type": "object" + }, + "PrefBranch": { + "enum": [ + "default", + "user" + ], + "type": "string" + }, + "SetPref": { + "properties": { + "branch": { + "$ref": "#/$defs/PrefBranch", + "description": "The branch the pref will be set on. Prefs set on the user branch persists through restarts." + }, + "pref": { + "description": "The name of the pref to set.", + "type": "string" + } + }, + "required": [ + "branch", + "pref" + ], + "type": "object" + } + } +} diff --git a/schemas/schemas/NimbusExperiment.schema.json b/schemas/schemas/NimbusExperiment.schema.json new file mode 100644 index 00000000000..a847f061c2b --- /dev/null +++ b/schemas/schemas/NimbusExperiment.schema.json @@ -0,0 +1,383 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NimbusExperiment", + "description": "The experiment definition accessible to: 1. The Nimbus SDK via Remote Settings 2. Jetstream via the Experimenter API", + "type": "object", + "properties": { + "schemaVersion": { + "description": "Version of the NimbusExperiment schema this experiment refers to", + "type": "string" + }, + "slug": { + "description": "Unique identifier for the experiment", + "type": "string" + }, + "id": { + "description": "Unique identifier for the experiiment. This is a duplicate of slug, but is required field for all Remote Settings records.", + "type": "string" + }, + "appName": { + "description": "A slug identifying the targeted product of this experiment. It should be a lowercased_with_underscores name that is short and unambiguous and it should match the app_name found in https://probeinfo.telemetry.mozilla.org/glean/repositories. Examples are \"fenix\" and \"firefox_desktop\".", + "type": "string" + }, + "appId": { + "description": "The platform identifier for the targeted app. This should match app's identifier exactly as it appears in the relevant app store listing (for relevant platforms) or the app's Glean initialization (for other platforms). Examples are \"org.mozilla.firefox_beta\" and \"firefox-desktop\".", + "type": "string" + }, + "channel": { + "description": "A specific channel of an application such as \"nightly\", \"beta\", or \"release\".", + "type": "string" + }, + "userFacingName": { + "description": "Public name of the experiment that will be displayed on \"about:studies\".", + "type": "string" + }, + "userFacingDescription": { + "description": "Short public description of the experiment that will be displayed on \"about:studies\".", + "type": "string" + }, + "isEnrollmentPaused": { + "description": "When this property is set to true, the SDK should not enroll new users into the experiment that have not already been enrolled.", + "type": "boolean" + }, + "isRollout": { + "description": "When this property is set to true, treat this experiment as a rollout. Rollouts are currently handled as single-branch experiments separated from the bucketing namespace for normal experiments. See-also: https://mozilla-hub.atlassian.net/browse/SDK-405", + "type": "boolean" + }, + "bucketConfig": { + "$ref": "#/$defs/ExperimentBucketConfig", + "description": "Bucketing configuration." + }, + "outcomes": { + "description": "A list of outcomes relevant to the experiment analysis.", + "items": { + "$ref": "#/$defs/ExperimentOutcome" + }, + "type": "array" + }, + "featureIds": { + "description": "A list of featureIds the experiment contains configurations for.", + "items": { + "type": "string" + }, + "type": "array" + }, + "branches": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/ExperimentSingleFeatureBranch" + }, + "type": "array" + }, + { + "items": { + "$ref": "#/$defs/ExperimentMultiFeatureDesktopBranch" + }, + "type": "array" + }, + { + "items": { + "$ref": "#/$defs/ExperimentMultiFeatureMobileBranch" + }, + "type": "array" + } + ], + "description": "Branch configuration for the experiment." + }, + "targeting": { + "description": "A JEXL targeting expression used to filter out experiments.", + "type": "string" + }, + "startDate": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Actual publish date of the experiment. Note that this value is expected to be null in Remote Settings." + }, + "enrollmentEndDate": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Actual enrollment end date of the experiment. Note that this value is expected to be null in Remote Settings." + }, + "endDate": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Actual end date of this experiment. Note that this field is expected to be null in Remote Settings." + }, + "proposedDuration": { + "description": "Duration of the experiment from the start date in days. Note that this property is only used during the analysis phase (i.e., not by the SDK).", + "type": "integer" + }, + "proposedEnrollment": { + "description": "This represents the number of days that we expect to enroll new users. Note that this property is only used during the analysis phase (i.e., not by the SDK).", + "type": "integer" + }, + "referenceBranch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The slug of the reference branch (i.e., the branch we consider \"control\")." + }, + "featureValidationOptOut": { + "description": "Opt out of feature schema validation. Only supported on desktop.", + "type": "boolean" + }, + "localizations": { + "anyOf": [ + { + "additionalProperties": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "description": "Per-locale localization substitutions. The top level key is the locale (e.g., \"en-US\" or \"fr\"). Each entry is a mapping of string IDs to their localized equivalents. Only supported on desktop." + }, + "locales": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "The list of locale codes (e.g., \"en-US\" or \"fr\") that this experiment is targeting. If null, all locales are targeted." + }, + "publishedDate": { + "description": "The date that this experiment was first published to Remote Settings. If null, it has not yet been published.", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "schemaVersion", + "slug", + "id", + "appName", + "appId", + "channel", + "userFacingName", + "userFacingDescription", + "isEnrollmentPaused", + "bucketConfig", + "branches", + "startDate", + "endDate", + "proposedEnrollment", + "referenceBranch" + ], + "$defs": { + "DesktopTombstoneFeatureConfig": { + "properties": { + "featureId": { + "const": "unused-feature-id-for-legacy-support", + "type": "string" + }, + "value": { + "type": "object" + }, + "enabled": { + "const": false, + "type": "boolean" + } + }, + "required": [ + "featureId", + "value", + "enabled" + ], + "type": "object" + }, + "ExperimentBucketConfig": { + "properties": { + "randomizationUnit": { + "$ref": "#/$defs/RandomizationUnit" + }, + "namespace": { + "description": "Additional inputs to the hashing function.", + "type": "string" + }, + "start": { + "description": "Index of the starting bucket of the range.", + "type": "integer" + }, + "count": { + "description": "Number of buckets in the range.", + "type": "integer" + }, + "total": { + "description": "The total number of buckets. You can assume this will always be 10000", + "type": "integer" + } + }, + "required": [ + "randomizationUnit", + "namespace", + "start", + "count", + "total" + ], + "type": "object" + }, + "ExperimentFeatureConfig": { + "properties": { + "featureId": { + "description": "The identifier for the feature flag.", + "type": "string" + }, + "value": { + "description": "The values that define the feature configuration. This should be validated against a schema.", + "type": "object" + } + }, + "required": [ + "featureId", + "value" + ], + "type": "object" + }, + "ExperimentMultiFeatureDesktopBranch": { + "description": "The branch definition supported on Firefox Desktop 95+.", + "properties": { + "slug": { + "description": "Identifier for the branch.", + "type": "string" + }, + "ratio": { + "description": "Relative ratio of population for the branch. e.g., if branch A=1 and branch B=3, then branch A would get 25% of the population.", + "type": "integer" + }, + "features": { + "description": "An array of feature configurations.", + "items": { + "$ref": "#/$defs/ExperimentFeatureConfig" + }, + "type": "array" + }, + "feature": { + "$ref": "#/$defs/DesktopTombstoneFeatureConfig", + "description": "The feature key must be provided with values to prevent crashes if the is encountered by Desktop clients earlier than version 95." + } + }, + "required": [ + "slug", + "ratio", + "features", + "feature" + ], + "type": "object" + }, + "ExperimentMultiFeatureMobileBranch": { + "description": "The branch definition for mobile browsers. Supported on Firefox for Android 96+ and Firefox for iOS 39+.", + "properties": { + "slug": { + "description": "Identifier for the branch.", + "type": "string" + }, + "ratio": { + "description": "Relative ratio of population for the branch. e.g., if branch A=1 and branch B=3, then branch A would get 25% of the population.", + "type": "integer" + }, + "features": { + "description": "An array of feature configurations.", + "items": { + "$ref": "#/$defs/ExperimentFeatureConfig" + }, + "type": "array" + } + }, + "required": [ + "slug", + "ratio", + "features" + ], + "type": "object" + }, + "ExperimentOutcome": { + "properties": { + "slug": { + "description": "Identifier for the outcome.", + "type": "string" + }, + "priority": { + "description": "e.g., \"primary\" or \"secondary\".", + "type": "string" + } + }, + "required": [ + "slug", + "priority" + ], + "type": "object" + }, + "ExperimentSingleFeatureBranch": { + "description": "A single-feature branch definition. Supported by Firefox Desktop for versions before 95, Firefox for Android for versions before 96, and Firefox for iOS for versions before 39.", + "properties": { + "slug": { + "description": "Identifier for the branch.", + "type": "string" + }, + "ratio": { + "description": "Relative ratio of population for the branch. e.g., if branch A=1 and branch B=3, then branch A would get 25% of the population.", + "type": "integer" + }, + "feature": { + "$ref": "#/$defs/ExperimentFeatureConfig", + "description": "A single feature configuration." + } + }, + "required": [ + "slug", + "ratio", + "feature" + ], + "type": "object" + }, + "RandomizationUnit": { + "description": "A unique, stable indentifier for the user used as an input to bucket hashing.", + "enum": [ + "normandy_id", + "nimbus_id", + "user_id", + "group_id" + ], + "type": "string" + } + } +} diff --git a/schemas/schemas/SdkFeatureManifest.schema.json b/schemas/schemas/SdkFeatureManifest.schema.json new file mode 100644 index 00000000000..d09ee5e3367 --- /dev/null +++ b/schemas/schemas/SdkFeatureManifest.schema.json @@ -0,0 +1,68 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "SdkFeatureManifest", + "description": "The SDK-specific feature manifest.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/SdkFeature" + }, + "$defs": { + "FeatureVariableType": { + "enum": [ + "int", + "string", + "boolean", + "json" + ], + "type": "string" + }, + "SdkFeature": { + "properties": { + "variables": { + "additionalProperties": { + "$ref": "#/$defs/SdkFeatureVariable" + }, + "type": "object" + } + }, + "required": [ + "variables" + ], + "type": "object" + }, + "SdkFeatureVariable": { + "dependentSchemas": { + "enum": { + "properties": { + "type": { + "const": "string" + } + } + } + }, + "description": "A feature variable.", + "properties": { + "description": { + "description": "A description of the feature.", + "type": "string" + }, + "type": { + "$ref": "#/$defs/FeatureVariableType", + "description": "The field type." + }, + "enum": { + "description": "An optional list of possible string values. Only allowed when type is string.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "description", + "type" + ], + "type": "object" + } + } +}