fast-feature-ofrep 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fast_feature_ofrep-0.0.1/PKG-INFO +22 -0
- fast_feature_ofrep-0.0.1/pyproject.toml +38 -0
- fast_feature_ofrep-0.0.1/src/fast_feature/ofrep/__init__.py +7 -0
- fast_feature_ofrep-0.0.1/src/fast_feature/ofrep/errors/__init__.py +5 -0
- fast_feature_ofrep-0.0.1/src/fast_feature/ofrep/errors/base.py +10 -0
- fast_feature_ofrep-0.0.1/src/fast_feature/ofrep/py.typed +0 -0
- fast_feature_ofrep-0.0.1/src/fast_feature/ofrep/router/__init__.py +5 -0
- fast_feature_ofrep-0.0.1/src/fast_feature/ofrep/router/router.py +85 -0
- fast_feature_ofrep-0.0.1/src/fast_feature/ofrep/schemas/__init__.py +17 -0
- fast_feature_ofrep-0.0.1/src/fast_feature/ofrep/schemas/bulk_evaluation_request.py +11 -0
- fast_feature_ofrep-0.0.1/src/fast_feature/ofrep/schemas/bulk_evaluation_success.py +28 -0
- fast_feature_ofrep-0.0.1/src/fast_feature/ofrep/schemas/evaluation_failure.py +18 -0
- fast_feature_ofrep-0.0.1/src/fast_feature/ofrep/schemas/evaluation_request.py +11 -0
- fast_feature_ofrep-0.0.1/src/fast_feature/ofrep/schemas/evaluation_success.py +27 -0
- fast_feature_ofrep-0.0.1/src/fast_feature/ofrep/schemas/flag_not_found.py +17 -0
- fast_feature_ofrep-0.0.1/src/fast_feature/ofrep/service/__init__.py +5 -0
- fast_feature_ofrep-0.0.1/src/fast_feature/ofrep/service/evaluation_service.py +48 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fast-feature-ofrep
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: OpenFeature Remote Evaluation Protocol (OFREP) HTTP layer for fast-feature.
|
|
5
|
+
Author: byunjuneseok
|
|
6
|
+
Author-email: byunjuneseok <byunjuneseok@gmail.com>
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Framework :: FastAPI
|
|
10
|
+
Classifier: Framework :: AsyncIO
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Dist: fast-feature-core==0.0.1
|
|
19
|
+
Requires-Dist: fast-feature-engine==0.0.1
|
|
20
|
+
Requires-Dist: fastapi>=0.110
|
|
21
|
+
Requires-Dist: pydantic>=2.6
|
|
22
|
+
Requires-Python: >=3.10
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fast-feature-ofrep"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "OpenFeature Remote Evaluation Protocol (OFREP) HTTP layer for fast-feature."
|
|
5
|
+
license = "Apache-2.0"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "byunjuneseok", email = "byunjuneseok@gmail.com" },
|
|
9
|
+
]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Framework :: FastAPI",
|
|
13
|
+
"Framework :: AsyncIO",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"License :: OSI Approved :: Apache Software License",
|
|
16
|
+
"Programming Language :: Python :: 3.10",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"fast-feature-core==0.0.1",
|
|
24
|
+
"fast-feature-engine==0.0.1",
|
|
25
|
+
"fastapi>=0.110",
|
|
26
|
+
"pydantic>=2.6",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["uv_build>=0.11.17,<0.12.0"]
|
|
31
|
+
build-backend = "uv_build"
|
|
32
|
+
|
|
33
|
+
[tool.uv.build-backend]
|
|
34
|
+
module-name = "fast_feature.ofrep"
|
|
35
|
+
|
|
36
|
+
[tool.uv.sources]
|
|
37
|
+
fast-feature-core = { workspace = true }
|
|
38
|
+
fast-feature-engine = { workspace = true }
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class OfrepError(Exception):
|
|
5
|
+
"""Base class for all OFREP-layer errors.
|
|
6
|
+
|
|
7
|
+
Storage or engine failures are translated into this at the service
|
|
8
|
+
boundary (with chaining) so the HTTP layer never couples to a dependency's
|
|
9
|
+
concrete exception types.
|
|
10
|
+
"""
|
|
File without changes
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Request, Response
|
|
8
|
+
from fastapi.responses import JSONResponse
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from fast_feature.core import ErrorCode, FlagRepository
|
|
12
|
+
from fast_feature.engine import TargetingEngine
|
|
13
|
+
|
|
14
|
+
from ..schemas import (
|
|
15
|
+
BulkEvaluationRequest,
|
|
16
|
+
BulkEvaluationSuccess,
|
|
17
|
+
EvaluationFailure,
|
|
18
|
+
EvaluationRequest,
|
|
19
|
+
EvaluationSuccess,
|
|
20
|
+
FlagNotFound,
|
|
21
|
+
)
|
|
22
|
+
from ..service import EvaluationService
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class OfrepRouter:
|
|
26
|
+
"""Builds a pluggable ``APIRouter`` exposing the OFREP endpoints.
|
|
27
|
+
|
|
28
|
+
Mount it onto any FastAPI app::
|
|
29
|
+
|
|
30
|
+
app.include_router(OfrepRouter.build(repository))
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self, repository: FlagRepository, *, engine: TargetingEngine | None = None
|
|
35
|
+
) -> None:
|
|
36
|
+
self._service = EvaluationService(repository, engine=engine)
|
|
37
|
+
self._router = APIRouter(prefix="/ofrep/v1", tags=["OFREP"])
|
|
38
|
+
self._register_routes()
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def router(self) -> APIRouter:
|
|
42
|
+
return self._router
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def build(
|
|
46
|
+
cls, repository: FlagRepository, *, engine: TargetingEngine | None = None
|
|
47
|
+
) -> APIRouter:
|
|
48
|
+
return cls(repository, engine=engine).router
|
|
49
|
+
|
|
50
|
+
def _register_routes(self) -> None:
|
|
51
|
+
self._router.add_api_route("/evaluate/flags/{key}", self._evaluate_flag, methods=["POST"])
|
|
52
|
+
self._router.add_api_route("/evaluate/flags", self._evaluate_all, methods=["POST"])
|
|
53
|
+
|
|
54
|
+
async def _evaluate_flag(self, key: str, request: EvaluationRequest) -> Response:
|
|
55
|
+
outcome = await self._service.evaluate(key, request.context)
|
|
56
|
+
if outcome.error_code is ErrorCode.FLAG_NOT_FOUND:
|
|
57
|
+
return self._json(404, FlagNotFound.from_outcome(outcome))
|
|
58
|
+
if outcome.is_error:
|
|
59
|
+
return self._json(400, EvaluationFailure.from_outcome(outcome))
|
|
60
|
+
return self._json(200, EvaluationSuccess.from_outcome(outcome))
|
|
61
|
+
|
|
62
|
+
async def _evaluate_all(
|
|
63
|
+
self, request: BulkEvaluationRequest, http_request: Request
|
|
64
|
+
) -> Response:
|
|
65
|
+
outcomes = await self._service.evaluate_all(request.context)
|
|
66
|
+
payload = BulkEvaluationSuccess.from_outcomes(outcomes).model_dump(
|
|
67
|
+
by_alias=True, exclude_none=True
|
|
68
|
+
)
|
|
69
|
+
etag = self._etag(payload)
|
|
70
|
+
if http_request.headers.get("if-none-match") == etag:
|
|
71
|
+
return Response(status_code=304, headers={"ETag": etag})
|
|
72
|
+
return JSONResponse(status_code=200, content=payload, headers={"ETag": etag})
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def _json(status_code: int, model: BaseModel) -> JSONResponse:
|
|
76
|
+
return JSONResponse(
|
|
77
|
+
status_code=status_code, content=model.model_dump(by_alias=True, exclude_none=True)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def _etag(payload: dict[str, Any]) -> str:
|
|
82
|
+
digest = hashlib.sha256(
|
|
83
|
+
json.dumps(payload, sort_keys=True, default=str).encode()
|
|
84
|
+
).hexdigest()
|
|
85
|
+
return f'"{digest[:16]}"'
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .bulk_evaluation_request import BulkEvaluationRequest
|
|
4
|
+
from .bulk_evaluation_success import BulkEvaluationSuccess
|
|
5
|
+
from .evaluation_failure import EvaluationFailure
|
|
6
|
+
from .evaluation_request import EvaluationRequest
|
|
7
|
+
from .evaluation_success import EvaluationSuccess
|
|
8
|
+
from .flag_not_found import FlagNotFound
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"EvaluationRequest",
|
|
12
|
+
"BulkEvaluationRequest",
|
|
13
|
+
"EvaluationSuccess",
|
|
14
|
+
"EvaluationFailure",
|
|
15
|
+
"FlagNotFound",
|
|
16
|
+
"BulkEvaluationSuccess",
|
|
17
|
+
]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BulkEvaluationRequest(BaseModel):
|
|
9
|
+
"""Body of a bulk evaluation request (static context for all flags)."""
|
|
10
|
+
|
|
11
|
+
context: dict[str, Any] = Field(default_factory=dict)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from fast_feature.core import EvaluationOutcome
|
|
9
|
+
|
|
10
|
+
from .evaluation_failure import EvaluationFailure
|
|
11
|
+
from .evaluation_success import EvaluationSuccess
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BulkEvaluationSuccess(BaseModel):
|
|
15
|
+
"""The bulk evaluation response: a mix of per-flag successes and failures."""
|
|
16
|
+
|
|
17
|
+
flags: list[EvaluationSuccess | EvaluationFailure]
|
|
18
|
+
metadata: dict[str, Any] | None = None
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def from_outcomes(cls, outcomes: Iterable[EvaluationOutcome]) -> BulkEvaluationSuccess:
|
|
22
|
+
flags: list[EvaluationSuccess | EvaluationFailure] = []
|
|
23
|
+
for outcome in outcomes:
|
|
24
|
+
if outcome.is_error:
|
|
25
|
+
flags.append(EvaluationFailure.from_outcome(outcome))
|
|
26
|
+
else:
|
|
27
|
+
flags.append(EvaluationSuccess.from_outcome(outcome))
|
|
28
|
+
return cls(flags=flags)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from fast_feature.core import ErrorCode, EvaluationOutcome
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EvaluationFailure(BaseModel):
|
|
9
|
+
"""A failed flag evaluation, per the OFREP contract."""
|
|
10
|
+
|
|
11
|
+
key: str
|
|
12
|
+
error_code: str = Field(serialization_alias="errorCode")
|
|
13
|
+
error_details: str | None = Field(default=None, serialization_alias="errorDetails")
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def from_outcome(cls, outcome: EvaluationOutcome) -> EvaluationFailure:
|
|
17
|
+
code = outcome.error_code or ErrorCode.GENERAL
|
|
18
|
+
return cls(key=outcome.key, error_code=code.value, error_details=outcome.error_details)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from fast_feature.core import EvaluationOutcome
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class EvaluationSuccess(BaseModel):
|
|
11
|
+
"""A successful flag evaluation, per the OFREP contract."""
|
|
12
|
+
|
|
13
|
+
key: str
|
|
14
|
+
reason: str
|
|
15
|
+
value: Any = None
|
|
16
|
+
variant: str | None = None
|
|
17
|
+
metadata: dict[str, Any] | None = None
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def from_outcome(cls, outcome: EvaluationOutcome) -> EvaluationSuccess:
|
|
21
|
+
return cls(
|
|
22
|
+
key=outcome.key,
|
|
23
|
+
reason=outcome.reason.value,
|
|
24
|
+
value=outcome.value,
|
|
25
|
+
variant=outcome.variant,
|
|
26
|
+
metadata=outcome.metadata,
|
|
27
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from fast_feature.core import ErrorCode, EvaluationOutcome
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FlagNotFound(BaseModel):
|
|
9
|
+
"""The 404 body returned when a flag key does not exist."""
|
|
10
|
+
|
|
11
|
+
key: str
|
|
12
|
+
error_code: str = Field(default=ErrorCode.FLAG_NOT_FOUND.value, serialization_alias="errorCode")
|
|
13
|
+
error_details: str | None = Field(default=None, serialization_alias="errorDetails")
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def from_outcome(cls, outcome: EvaluationOutcome) -> FlagNotFound:
|
|
17
|
+
return cls(key=outcome.key, error_details=outcome.error_details)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fast_feature.core import (
|
|
4
|
+
CoreError,
|
|
5
|
+
ErrorCode,
|
|
6
|
+
EvaluationContext,
|
|
7
|
+
EvaluationOutcome,
|
|
8
|
+
Flag,
|
|
9
|
+
FlagRepository,
|
|
10
|
+
)
|
|
11
|
+
from fast_feature.engine import TargetingEngine
|
|
12
|
+
|
|
13
|
+
from ..errors import OfrepError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EvaluationService:
|
|
17
|
+
"""Orchestrates the repository and the targeting engine for OFREP."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self, repository: FlagRepository, *, engine: TargetingEngine | None = None
|
|
21
|
+
) -> None:
|
|
22
|
+
self._repository = repository
|
|
23
|
+
self._engine = engine or TargetingEngine()
|
|
24
|
+
|
|
25
|
+
async def evaluate(
|
|
26
|
+
self, key: str, context: EvaluationContext | None = None
|
|
27
|
+
) -> EvaluationOutcome:
|
|
28
|
+
flag = await self._load(key)
|
|
29
|
+
if flag is None:
|
|
30
|
+
return EvaluationOutcome.error(
|
|
31
|
+
key, ErrorCode.FLAG_NOT_FOUND, f"Flag {key!r} was not found"
|
|
32
|
+
)
|
|
33
|
+
return self._engine.evaluate(flag, context)
|
|
34
|
+
|
|
35
|
+
async def evaluate_all(
|
|
36
|
+
self, context: EvaluationContext | None = None
|
|
37
|
+
) -> list[EvaluationOutcome]:
|
|
38
|
+
try:
|
|
39
|
+
flags = await self._repository.list_all()
|
|
40
|
+
except CoreError as exc:
|
|
41
|
+
raise OfrepError("failed to list flags") from exc
|
|
42
|
+
return [self._engine.evaluate(flag, context) for flag in flags]
|
|
43
|
+
|
|
44
|
+
async def _load(self, key: str) -> Flag | None:
|
|
45
|
+
try:
|
|
46
|
+
return await self._repository.get(key)
|
|
47
|
+
except CoreError as exc:
|
|
48
|
+
raise OfrepError(f"failed to load flag {key!r}") from exc
|