Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions tools/openfeature-flagd-core/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ dependencies = [
"panzi-json-logic==1.0.1",
"semver>=3,<4",
"cbor2>=5.6.5,<6.0.0",
"jsonschema>=4.18.0,<5.0.0",
]
requires-python = ">=3.10"

Expand All @@ -36,6 +37,7 @@ dev = [
"pytest>=9.0.0,<10.0.0",
"pytest-bdd>=8.1.0,<9.0.0",
"openfeature-flagd-api-testkit",
"types-jsonschema>=4.18.0",
]

[tool.uv.sources]
Expand Down Expand Up @@ -74,6 +76,7 @@ omit = ["tests/**"]

[tool.poe.tasks]
sync-test-harness = "python ../openfeature-flagd-api-testkit/hatch_build_sync.py"
sync-schemas = "python sync_schemas.py"
test = ["sync-test-harness", {cmd = "pytest tests"}]
test-cov = ["sync-test-harness", {cmd = "coverage run -m pytest tests"}]
cov-report = "coverage xml"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import json
import logging
import re
from collections.abc import Mapping

from openfeature.exception import ParseError

from ..schema import validate_flag_configuration
from .flag import Flag, _validate_metadata

logger = logging.getLogger("openfeature.contrib")


class FlagStore:
def __init__(self) -> None:
Expand All @@ -30,6 +34,17 @@ def update(self, flags_data: dict) -> list[str]:
)
flags = json.loads(transposed)

# Like the other flagd cores, an invalid configuration is only reported:
# rejecting it would block updates to every other flag in the set.
schema_errors = validate_flag_configuration(
{**flags_data, "flags": flags} if evaluators else flags_data
)
if schema_errors:
logger.warning(
"Flag configuration does not match the flagd schema, applying it anyway:\n%s",
"\n".join(schema_errors),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
)

if not isinstance(flags, dict):
raise ParseError("`flags` key of configuration must be a dictionary")
if not isinstance(metadata, dict):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""Validation of flag configurations against the bundled flagd JSON schema.

The schema files in ``schemas/`` are copies of ``json/flags.json`` and
``json/targeting.json`` from https://github.com/open-feature/schemas, which the
flagd provider already checks out as a git submodule. Run ``poe sync-schemas``
to refresh them after updating that submodule.
"""

import json
import typing
from collections.abc import Mapping
from functools import cache
from importlib.resources import files

from jsonschema import Draft7Validator
from referencing import Registry, Resource

_SCHEMA_FILES = ("flags.json", "targeting.json")
_FLAGS_SCHEMA_ID = "https://flagd.dev/schema/v0/flags.json"


@cache
def _validator() -> Draft7Validator:
schema_dir = files(__package__).joinpath("schemas")
schemas = [
json.loads(schema_dir.joinpath(name).read_text(encoding="utf-8"))
for name in _SCHEMA_FILES
]
# flags.json references targeting.json by a relative "$ref", which resolves
# against the "$id" of flags.json. flags.json also has a top-level "$ref",
# and draft-07 ignores keywords next to "$ref", including "$id". So each
# schema is registered under the "$id" read from the file, and validation
# starts from a "$ref" to that URI, giving relative references a base.
registry: Registry[typing.Any] = Registry().with_resources(
(schema["$id"], Resource.from_contents(schema)) for schema in schemas
)
return Draft7Validator({"$ref": _FLAGS_SCHEMA_ID}, registry=registry)


def validate_flag_configuration(configuration: Mapping[str, typing.Any]) -> list[str]:
"""Validate a flag configuration against the flagd JSON schema.

Returns a description of every schema violation, or an empty list if the
configuration is valid. Each description names the location and the failed
schema keyword only: ``jsonschema`` messages embed the offending instance,
which could leak variant values or targeting literals into logs.
"""
errors = sorted(
_validator().iter_errors(configuration), key=lambda error: error.json_path
)
return [
f"{error.json_path}: does not match the schema ('{error.validator}')"
for error in errors
]
Loading
Loading