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.
@@ -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,7 @@
1
+ from __future__ import annotations
2
+
3
+ from .errors import OfrepError
4
+ from .router import OfrepRouter
5
+ from .service import EvaluationService
6
+
7
+ __all__ = ["OfrepRouter", "EvaluationService", "OfrepError"]
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from .base import OfrepError
4
+
5
+ __all__ = ["OfrepError"]
@@ -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
+ """
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from .router import OfrepRouter
4
+
5
+ __all__ = ["OfrepRouter"]
@@ -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,11 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class EvaluationRequest(BaseModel):
9
+ """Body of a single-flag evaluation request."""
10
+
11
+ context: dict[str, Any] = Field(default_factory=dict)
@@ -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,5 @@
1
+ from __future__ import annotations
2
+
3
+ from .evaluation_service import EvaluationService
4
+
5
+ __all__ = ["EvaluationService"]
@@ -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