Skip to content

Commit bd7be90

Browse files
authored
chore: Add async contract-test service for FDv1 (#481)
1 parent fd041a5 commit bd7be90

6 files changed

Lines changed: 667 additions & 2 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from ldclient.interfaces import AsyncBigSegmentStore
2+
3+
4+
class AsyncBigSegmentStoreFixture(AsyncBigSegmentStore):
5+
"""AsyncBigSegmentStore implementation that calls back to the test harness."""
6+
7+
def __init__(self, callback_uri: str):
8+
self._callback_uri = callback_uri
9+
10+
async def get_metadata(self):
11+
from ldclient.interfaces import BigSegmentStoreMetadata
12+
resp_data = await self._post_callback('/getMetadata', None)
13+
return BigSegmentStoreMetadata(resp_data.get("lastUpToDate"))
14+
15+
async def get_membership(self, context_hash: str):
16+
resp_data = await self._post_callback('/getMembership', {'contextHash': context_hash})
17+
return resp_data.get("values")
18+
19+
async def _post_callback(self, path: str, params) -> dict:
20+
import aiohttp
21+
url = self._callback_uri + path
22+
async with aiohttp.ClientSession() as session:
23+
if params is None:
24+
async with session.post(url) as resp:
25+
if resp.status != 200:
26+
raise Exception("HTTP error %d from callback to %s" % (resp.status, url))
27+
return await resp.json()
28+
else:
29+
async with session.post(url, json=params) as resp:
30+
if resp.status != 200:
31+
raise Exception("HTTP error %d from callback to %s" % (resp.status, url))
32+
return await resp.json()
33+
34+
async def stop(self):
35+
pass
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
import asyncio
2+
import json
3+
import logging
4+
from typing import Optional
5+
6+
import requests
7+
from async_big_segment_store_fixture import AsyncBigSegmentStoreFixture
8+
from async_flag_change_listener import AsyncListenerRegistry
9+
from hook import AsyncPostingHook
10+
11+
from ldclient import Context
12+
from ldclient.async_client import AsyncLDClient
13+
from ldclient.async_config import AsyncBigSegmentsConfig, AsyncConfig
14+
from ldclient.impl.util import Result
15+
from ldclient.migrations import (
16+
AsyncMigratorBuilder,
17+
ExecutionOrder,
18+
Operation,
19+
Stage
20+
)
21+
22+
23+
class AsyncClientEntity:
24+
def __init__(self, tag: str, config_params: dict):
25+
self.log = logging.getLogger(tag)
26+
self._client: Optional[AsyncLDClient] = None
27+
self._listeners: Optional[AsyncListenerRegistry] = None
28+
self._config_params = config_params
29+
self._tag = tag
30+
31+
async def start(self):
32+
"""Build the AsyncLDClient and wait for it to initialize."""
33+
config_params = self._config_params
34+
opts = {"sdk_key": config_params["credential"]}
35+
36+
tags = config_params.get('tags', {})
37+
if tags:
38+
opts['application'] = {
39+
'id': tags.get('applicationId', ''),
40+
'version': tags.get('applicationVersion', ''),
41+
}
42+
43+
datasystem_config = config_params.get('dataSystem')
44+
if datasystem_config is not None:
45+
raise NotImplementedError("FDv2 (dataSystem) is not yet supported in the async contract-test service")
46+
elif config_params.get("streaming") is not None:
47+
streaming = config_params["streaming"]
48+
if streaming.get("baseUri") is not None:
49+
opts["stream_uri"] = streaming["baseUri"]
50+
if streaming.get("filter") is not None:
51+
opts["payload_filter_key"] = streaming["filter"]
52+
_set_optional_time_prop(streaming, "initialRetryDelayMs", opts, "initial_reconnect_delay")
53+
elif config_params.get("polling") is not None:
54+
opts['stream'] = False
55+
polling = config_params["polling"]
56+
if polling.get("baseUri") is not None:
57+
opts["base_uri"] = polling["baseUri"]
58+
if polling.get("filter") is not None:
59+
opts["payload_filter_key"] = polling["filter"]
60+
_set_optional_time_prop(polling, "pollIntervalMs", opts, "poll_interval")
61+
else:
62+
opts['use_ldd'] = True
63+
64+
if config_params.get("events") is not None:
65+
events = config_params["events"]
66+
opts["enable_event_compression"] = events.get("enableGzip", False)
67+
if events.get("baseUri") is not None:
68+
opts["events_uri"] = events["baseUri"]
69+
if events.get("capacity") is not None:
70+
opts["events_max_pending"] = events["capacity"]
71+
opts["diagnostic_opt_out"] = not events.get("enableDiagnostics", False)
72+
opts["all_attributes_private"] = events.get("allAttributesPrivate", False)
73+
opts["private_attributes"] = events.get("globalPrivateAttributes", {})
74+
_set_optional_time_prop(events, "flushIntervalMs", opts, "flush_interval")
75+
opts["omit_anonymous_contexts"] = events.get("omitAnonymousContexts", False)
76+
else:
77+
opts["send_events"] = False
78+
79+
hooks = []
80+
if config_params.get("hooks") is not None:
81+
hooks = [
82+
AsyncPostingHook(h["name"], h["callbackUri"], h.get("data", {}), h.get("errors", {}))
83+
for h in config_params["hooks"]["hooks"]
84+
]
85+
86+
if config_params.get("bigSegments") is not None:
87+
big_params = config_params["bigSegments"]
88+
big_config = {"store": AsyncBigSegmentStoreFixture(big_params["callbackUri"])}
89+
if big_params.get("userCacheSize") is not None:
90+
big_config["context_cache_size"] = big_params["userCacheSize"]
91+
_set_optional_time_prop(big_params, "userCacheTimeMs", big_config, "context_cache_time")
92+
_set_optional_time_prop(big_params, "statusPollIntervalMs", big_config, "status_poll_interval")
93+
_set_optional_time_prop(big_params, "staleAfterMs", big_config, "stale_after")
94+
opts["big_segments"] = AsyncBigSegmentsConfig(**big_config)
95+
96+
start_wait = config_params.get("startWaitTimeMs") or 5000
97+
sdk_config = AsyncConfig(**opts)
98+
99+
self._client = AsyncLDClient(sdk_config)
100+
# The async client accepts AsyncHook instances only; register the
101+
# harness's async posting hooks via add_hook() before start().
102+
for hook in hooks:
103+
self._client.add_hook(hook)
104+
await self._client.start(start_wait / 1000.0)
105+
self._listeners = AsyncListenerRegistry(self._client.flag_tracker)
106+
107+
def is_initializing(self) -> bool:
108+
return self._client.is_initialized() if self._client else False
109+
110+
async def evaluate(self, params: dict) -> dict:
111+
response = {}
112+
if params.get("detail", False):
113+
detail = await self._client.variation_detail(
114+
params["flagKey"], Context.from_dict(params["context"]), params["defaultValue"]
115+
)
116+
response["value"] = detail.value
117+
response["variationIndex"] = detail.variation_index
118+
response["reason"] = detail.reason
119+
else:
120+
response["value"] = await self._client.variation(
121+
params["flagKey"], Context.from_dict(params["context"]), params["defaultValue"]
122+
)
123+
return response
124+
125+
async def evaluate_all(self, params: dict) -> dict:
126+
opts = {}
127+
opts["client_side_only"] = params.get("clientSideOnly", False)
128+
opts["with_reasons"] = params.get("withReasons", False)
129+
opts["details_only_for_tracked_flags"] = params.get("detailsOnlyForTrackedFlags", False)
130+
state = await self._client.all_flags_state(Context.from_dict(params["context"]), **opts)
131+
return {"state": state.to_json_dict()}
132+
133+
def track(self, params: dict):
134+
self._client.track(
135+
params["eventKey"],
136+
Context.from_dict(params["context"]),
137+
params["data"],
138+
params.get("metricValue", None),
139+
)
140+
141+
def identify(self, params: dict):
142+
self._client.identify(Context.from_dict(params["context"]))
143+
144+
async def flush(self):
145+
await self._client.flush()
146+
147+
def secure_mode_hash(self, params: dict) -> dict:
148+
return {"result": self._client.secure_mode_hash(Context.from_dict(params["context"]))}
149+
150+
def context_build(self, params: dict) -> dict:
151+
if params.get("multi"):
152+
b = Context.multi_builder()
153+
for c in params.get("multi"):
154+
b.add(self._context_build_single(c))
155+
return self._context_response(b.build())
156+
return self._context_response(self._context_build_single(params["single"]))
157+
158+
def _context_build_single(self, params: dict) -> Context:
159+
b = Context.builder(params["key"])
160+
if "kind" in params:
161+
b.kind(params["kind"])
162+
if "name" in params:
163+
b.name(params["name"])
164+
if "anonymous" in params:
165+
b.anonymous(params["anonymous"])
166+
if "custom" in params:
167+
for k, v in params.get("custom").items():
168+
b.set(k, v)
169+
if "private" in params:
170+
for attr in params.get("private"):
171+
b.private(attr)
172+
return b.build()
173+
174+
def context_convert(self, params: dict) -> dict:
175+
input_str = params["input"]
176+
try:
177+
props = json.loads(input_str)
178+
return self._context_response(Context.from_dict(props))
179+
except Exception as e:
180+
return {"error": str(e)}
181+
182+
def _context_response(self, c: Context) -> dict:
183+
if c.valid:
184+
return {"output": c.to_json_string()}
185+
return {"error": c.error}
186+
187+
async def get_big_segment_store_status(self) -> dict:
188+
status = self._client.big_segment_store_status_provider.status
189+
return {"available": status.available, "stale": status.stale}
190+
191+
async def migration_variation(self, params: dict) -> dict:
192+
stage, _ = await self._client.migration_variation(
193+
params["key"], Context.from_dict(params["context"]), Stage.from_str(params["defaultStage"])
194+
)
195+
return {'result': stage.value}
196+
197+
async def migration_operation(self, params: dict) -> dict:
198+
# Exercises the real AsyncMigratorBuilder/AsyncMigrator abstraction. The
199+
# user read/write callbacks are async functions that run the blocking
200+
# requests.post off the event loop via asyncio.to_thread.
201+
if params["readExecutionOrder"] == "concurrent":
202+
params["readExecutionOrder"] = "parallel"
203+
204+
def callback(endpoint):
205+
async def fn(payload) -> Result:
206+
def do_post() -> Result:
207+
response = requests.post(endpoint, data=payload)
208+
if response.status_code == 200:
209+
return Result.success(response.text)
210+
return Result.fail(f"Request failed with status code {response.status_code}")
211+
212+
return await asyncio.to_thread(do_post)
213+
214+
return fn
215+
216+
builder = AsyncMigratorBuilder(self._client)
217+
builder.read_execution_order(ExecutionOrder.from_str(params["readExecutionOrder"]))
218+
builder.track_latency(params["trackLatency"])
219+
builder.track_errors(params["trackErrors"])
220+
221+
comparison = (lambda lhs, rhs: lhs == rhs) if params["trackConsistency"] else None
222+
builder.read(callback(params["oldEndpoint"]), callback(params["newEndpoint"]), comparison)
223+
builder.write(callback(params["oldEndpoint"]), callback(params["newEndpoint"]))
224+
225+
migrator = builder.build()
226+
if isinstance(migrator, str):
227+
return {"result": migrator}
228+
229+
key = params["key"]
230+
context = Context.from_dict(params["context"])
231+
default_stage = Stage.from_str(params["defaultStage"])
232+
payload = params["payload"]
233+
234+
if params["operation"] == Operation.READ.value:
235+
result = await migrator.read(key, context, default_stage, payload)
236+
return {"result": result.value if result.is_success() else result.error}
237+
238+
write_result = await migrator.write(key, context, default_stage, payload)
239+
authoritative = write_result.authoritative
240+
return {"result": authoritative.value if authoritative.is_success() else authoritative.error}
241+
242+
async def register_flag_change_listener(self, params: dict):
243+
await self._listeners.register_flag_change_listener(
244+
listener_id=params['listenerId'],
245+
callback_uri=params['callbackUri'],
246+
)
247+
248+
async def register_flag_value_change_listener(self, params: dict):
249+
await self._listeners.register_flag_value_change_listener(
250+
listener_id=params["listenerId"],
251+
flag_key=params["flagKey"],
252+
context=Context.from_dict(params["context"]),
253+
callback_uri=params["callbackUri"],
254+
)
255+
256+
async def unregister_listener(self, params: dict) -> bool:
257+
return await self._listeners.unregister(params['listenerId'])
258+
259+
async def close(self):
260+
if self._listeners is not None:
261+
await self._listeners.close_all()
262+
if self._client is not None:
263+
await self._client.close()
264+
self.log.info('Test ended')
265+
266+
267+
def _set_optional_time_prop(params_in: dict, name_in: str, params_out: dict, name_out: str):
268+
if params_in.get(name_in) is not None:
269+
params_out[name_out] = params_in[name_in] / 1000.0

0 commit comments

Comments
 (0)