Skip to content

Commit f4b15b5

Browse files
tcoratgerclaude
andauthored
refactor(testing): fold api endpoint response logic into the test spec (leanEthereum#1155)
Replace the module-level response builders and the (method, path) dispatch dict with one cohesive object. Every endpoint's expected response now lives in a single match-based method on the test spec, and the response builders that only ever needed the spec's own fields are no longer free functions. - Delete the seven _*_response free functions and the _ENDPOINT_HANDLERS table. - Dispatch by structural pattern matching in ApiEndpointTest._expected_response. - Inline the single-use store builder into generate(). - Hoist the metrics-registry import to module top (no circular dependency). - Lift the required metric names to a module constant documenting the cross-client surface. - Drop the unreachable validator_count guard so a missing head state fails loud instead of silently emitting zero. Pure structural refactor: emitted vectors are byte-identical (verified by filling tests/consensus/lstar/api before and after). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f0837a4 commit f4b15b5

1 file changed

Lines changed: 146 additions & 189 deletions

File tree

Lines changed: 146 additions & 189 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,39 @@
11
"""API endpoint response conformance fixtures."""
22

3-
from collections.abc import Callable
43
from typing import Any, ClassVar
54

65
from consensus_testing.genesis import build_anchor
76
from consensus_testing.test_fixtures.base import BaseConsensusFixture, BaseTestSpec
87
from lean_spec.base import StrictBaseModel
8+
from lean_spec.node.metrics.registry import registry as metrics_registry
99
from lean_spec.spec.forks import Slot
1010
from lean_spec.spec.forks.lstar import Store
1111
from lean_spec.spec.forks.lstar.spec import LstarSpec
1212
from lean_spec.spec.ssz import Uint64
1313

14+
REQUIRED_METRIC_NAMES = [
15+
"lean_node_info",
16+
"lean_node_start_time_seconds",
17+
"lean_head_slot",
18+
"lean_current_slot",
19+
"lean_safe_target_slot",
20+
"lean_fork_choice_block_processing_time_seconds",
21+
"lean_attestations_valid_total",
22+
"lean_attestations_invalid_total",
23+
"lean_attestation_validation_time_seconds",
24+
"lean_fork_choice_reorgs_total",
25+
"lean_fork_choice_reorg_depth",
26+
"lean_attestation_aggregate_coverage_validators",
27+
"lean_attestation_aggregate_coverage_subnets",
28+
"lean_attestation_aggregate_coverage_diff_validators",
29+
"lean_latest_justified_slot",
30+
"lean_latest_finalized_slot",
31+
"lean_state_transition_time_seconds",
32+
"lean_validators_count",
33+
"lean_connected_peers",
34+
]
35+
"""Metric names every client must expose. Changing this list is a cross-client surface change."""
36+
1437

1538
class EndpointResponseContract(StrictBaseModel):
1639
"""The status, content type, and body one endpoint handler expects a client to return."""
@@ -25,183 +48,6 @@ class EndpointResponseContract(StrictBaseModel):
2548
"""Expected response payload: a JSON object or a hex SSZ string."""
2649

2750

28-
EndpointHandler = Callable[[Store, "ApiEndpointTest"], EndpointResponseContract]
29-
"""Uniform signature for endpoint response builders, so the dispatch table has one call site."""
30-
31-
32-
def _build_store(num_validators: int, genesis_time: int, anchor_slot: int = 0) -> Store:
33-
"""
34-
Build a deterministic store, genesis-only or an empty chain advanced to the anchor slot.
35-
36-
No attestations are injected, so justification and finalization stay at genesis.
37-
"""
38-
fork = LstarSpec()
39-
# Walk the chain from genesis using empty blocks; slot 0 returns the genesis pair unchanged.
40-
state, block = build_anchor(
41-
fork=fork,
42-
num_validators=num_validators,
43-
anchor_slot=Slot(anchor_slot),
44-
genesis_time=Uint64(genesis_time),
45-
)
46-
# No validator identity — fixture only reads store data, never signs.
47-
return fork.create_store(state, block, validator_index=None)
48-
49-
50-
def _health_response(_store: Store, _fixture: "ApiEndpointTest") -> EndpointResponseContract:
51-
"""Static liveness check, independent of consensus state."""
52-
return EndpointResponseContract(
53-
status_code=200,
54-
content_type="application/json",
55-
body={"status": "healthy", "service": "lean-rpc-api"},
56-
)
57-
58-
59-
def _justified_response(store: Store, _fixture: "ApiEndpointTest") -> EndpointResponseContract:
60-
"""Latest justified checkpoint as slot and root, with the root varying by validator count."""
61-
return EndpointResponseContract(
62-
status_code=200,
63-
content_type="application/json",
64-
body={
65-
"slot": int(store.latest_justified.slot),
66-
"root": "0x" + store.latest_justified.root.hex(),
67-
},
68-
)
69-
70-
71-
def _finalized_state_response(
72-
store: Store, _fixture: "ApiEndpointTest"
73-
) -> EndpointResponseContract:
74-
"""Full SSZ-encoded finalized state as hex bytes."""
75-
state = store.states[store.latest_finalized.root]
76-
return EndpointResponseContract(
77-
status_code=200,
78-
content_type="application/octet-stream",
79-
body="0x" + state.encode_bytes().hex(),
80-
)
81-
82-
83-
def _fork_choice_response(store: Store, _fixture: "ApiEndpointTest") -> EndpointResponseContract:
84-
"""Fork choice tree: blocks with weights, head, checkpoints, validator count."""
85-
weights = LstarSpec().compute_block_weights(store)
86-
87-
# Only post-finalization blocks are relevant to head selection.
88-
nodes = [
89-
{
90-
"root": "0x" + root.hex(),
91-
"slot": int(block.slot),
92-
"parent_root": "0x" + block.parent_root.hex(),
93-
"proposer_index": int(block.proposer_index),
94-
"weight": weights.get(root, 0),
95-
}
96-
for root, block in store.blocks.items()
97-
if block.slot >= store.latest_finalized.slot
98-
]
99-
100-
# Validator count from head state (most current view).
101-
head_state = store.states.get(store.head)
102-
return EndpointResponseContract(
103-
status_code=200,
104-
content_type="application/json",
105-
body={
106-
"nodes": nodes,
107-
"head": "0x" + store.head.hex(),
108-
"justified": {
109-
"slot": int(store.latest_justified.slot),
110-
"root": "0x" + store.latest_justified.root.hex(),
111-
},
112-
"finalized": {
113-
"slot": int(store.latest_finalized.slot),
114-
"root": "0x" + store.latest_finalized.root.hex(),
115-
},
116-
"safe_target": "0x" + store.safe_target.hex(),
117-
"validator_count": len(head_state.validators) if head_state is not None else 0,
118-
},
119-
)
120-
121-
122-
def _aggregator_status_response(
123-
_store: Store, fixture: "ApiEndpointTest"
124-
) -> EndpointResponseContract:
125-
"""Current aggregator role as seeded by initial_is_aggregator."""
126-
return EndpointResponseContract(
127-
status_code=200,
128-
content_type="application/json",
129-
body={"is_aggregator": fixture.initial_is_aggregator},
130-
)
131-
132-
133-
def _metrics_response(_store: Store, _fixture: "ApiEndpointTest") -> EndpointResponseContract:
134-
"""
135-
Prometheus-format metrics scrape.
136-
137-
The body is dynamic, so the fixture pins only status, content type, and the metric names.
138-
"""
139-
from lean_spec.node.metrics.registry import registry as metrics_registry
140-
141-
# Enumerated from the metrics spec; changing the list is a cross-client surface change.
142-
required_metric_names = [
143-
"lean_node_info",
144-
"lean_node_start_time_seconds",
145-
"lean_head_slot",
146-
"lean_current_slot",
147-
"lean_safe_target_slot",
148-
"lean_fork_choice_block_processing_time_seconds",
149-
"lean_attestations_valid_total",
150-
"lean_attestations_invalid_total",
151-
"lean_attestation_validation_time_seconds",
152-
"lean_fork_choice_reorgs_total",
153-
"lean_fork_choice_reorg_depth",
154-
"lean_attestation_aggregate_coverage_validators",
155-
"lean_attestation_aggregate_coverage_subnets",
156-
"lean_attestation_aggregate_coverage_diff_validators",
157-
"lean_latest_justified_slot",
158-
"lean_latest_finalized_slot",
159-
"lean_state_transition_time_seconds",
160-
"lean_validators_count",
161-
"lean_connected_peers",
162-
]
163-
# Touch the registry so its removal trips this fixture instead of failing silently.
164-
assert metrics_registry is not None
165-
return EndpointResponseContract(
166-
status_code=200,
167-
content_type="text/plain; version=0.0.4; charset=utf-8",
168-
body={"required_metric_names": required_metric_names},
169-
)
170-
171-
172-
def _aggregator_toggle_response(
173-
_store: Store, fixture: "ApiEndpointTest"
174-
) -> EndpointResponseContract:
175-
"""Expected response after toggling the aggregator role, with the new and previous values."""
176-
body = fixture.request_body
177-
if not isinstance(body, dict) or not isinstance(body.get("enabled"), bool):
178-
raise ValueError(
179-
"POST /lean/v0/admin/aggregator fixture requires request_body "
180-
"with a boolean 'enabled' field"
181-
)
182-
new_value = body["enabled"]
183-
return EndpointResponseContract(
184-
status_code=200,
185-
content_type="application/json",
186-
body={
187-
"is_aggregator": new_value,
188-
"previous": fixture.initial_is_aggregator,
189-
},
190-
)
191-
192-
193-
_ENDPOINT_HANDLERS: dict[tuple[str, str], EndpointHandler] = {
194-
("GET", "/lean/v0/health"): _health_response,
195-
("GET", "/lean/v0/checkpoints/justified"): _justified_response,
196-
("GET", "/lean/v0/states/finalized"): _finalized_state_response,
197-
("GET", "/lean/v0/fork_choice"): _fork_choice_response,
198-
("GET", "/lean/v0/admin/aggregator"): _aggregator_status_response,
199-
("POST", "/lean/v0/admin/aggregator"): _aggregator_toggle_response,
200-
("GET", "/metrics"): _metrics_response,
201-
}
202-
"""Maps (method, path) tuples to response builders."""
203-
204-
20551
class ApiEndpointFixture(BaseConsensusFixture):
20652
"""Emitted vector for API endpoint response conformance."""
20753

@@ -259,23 +105,134 @@ class ApiEndpointTest(BaseTestSpec):
259105

260106
def generate(self) -> ApiEndpointFixture:
261107
"""Build genesis store, compute expected response, emit the vector."""
262-
handler = _ENDPOINT_HANDLERS.get((self.method, self.endpoint))
263-
if handler is None:
264-
raise ValueError(f"Unknown endpoint: {self.method} {self.endpoint}")
265-
266-
store = _build_store(
108+
# Build a deterministic store: genesis-only, or an empty chain advanced to the anchor slot.
109+
# No attestations are injected, so justification and finalization stay at genesis.
110+
fork = LstarSpec()
111+
# Walk the chain from genesis using empty blocks; slot 0 returns the genesis pair unchanged.
112+
state, block = build_anchor(
113+
fork=fork,
267114
num_validators=self.genesis_params.get("numValidators", 4),
268-
genesis_time=self.genesis_params.get("genesisTime", 0),
269-
anchor_slot=self.genesis_params.get("anchorSlot", 0),
115+
anchor_slot=Slot(self.genesis_params.get("anchorSlot", 0)),
116+
genesis_time=Uint64(self.genesis_params.get("genesisTime", 0)),
270117
)
271-
response_contract = handler(store, self)
118+
# No validator identity — fixture only reads store data, never signs.
119+
store = fork.create_store(state, block, validator_index=None)
120+
121+
response = self._expected_response(store)
272122
return ApiEndpointFixture(
273123
endpoint=self.endpoint,
274124
method=self.method,
275125
genesis_params=self.genesis_params,
276126
request_body=self.request_body,
277127
initial_is_aggregator=self.initial_is_aggregator,
278-
expected_status_code=response_contract.status_code,
279-
expected_content_type=response_contract.content_type,
280-
expected_body=response_contract.body,
128+
expected_status_code=response.status_code,
129+
expected_content_type=response.content_type,
130+
expected_body=response.body,
281131
)
132+
133+
def _expected_response(self, store: Store) -> EndpointResponseContract:
134+
"""Compute the response a conforming client must return for the route under test."""
135+
match (self.method, self.endpoint):
136+
case ("GET", "/lean/v0/health"):
137+
# Static liveness check, independent of consensus state.
138+
return EndpointResponseContract(
139+
status_code=200,
140+
content_type="application/json",
141+
body={"status": "healthy", "service": "lean-rpc-api"},
142+
)
143+
144+
case ("GET", "/lean/v0/checkpoints/justified"):
145+
# Latest justified checkpoint; the root varies with validator count.
146+
return EndpointResponseContract(
147+
status_code=200,
148+
content_type="application/json",
149+
body={
150+
"slot": int(store.latest_justified.slot),
151+
"root": "0x" + store.latest_justified.root.hex(),
152+
},
153+
)
154+
155+
case ("GET", "/lean/v0/states/finalized"):
156+
# Full SSZ-encoded finalized state as hex bytes.
157+
finalized_state = store.states[store.latest_finalized.root]
158+
return EndpointResponseContract(
159+
status_code=200,
160+
content_type="application/octet-stream",
161+
body="0x" + finalized_state.encode_bytes().hex(),
162+
)
163+
164+
case ("GET", "/lean/v0/fork_choice"):
165+
# Fork choice tree: blocks with weights, head, checkpoints, validator count.
166+
weights = LstarSpec().compute_block_weights(store)
167+
168+
# Only post-finalization blocks are relevant to head selection.
169+
nodes = [
170+
{
171+
"root": "0x" + root.hex(),
172+
"slot": int(block.slot),
173+
"parent_root": "0x" + block.parent_root.hex(),
174+
"proposer_index": int(block.proposer_index),
175+
"weight": weights.get(root, 0),
176+
}
177+
for root, block in store.blocks.items()
178+
if block.slot >= store.latest_finalized.slot
179+
]
180+
181+
# The head always has a stored state, so a missing one is a broken invariant.
182+
head_state = store.states[store.head]
183+
return EndpointResponseContract(
184+
status_code=200,
185+
content_type="application/json",
186+
body={
187+
"nodes": nodes,
188+
"head": "0x" + store.head.hex(),
189+
"justified": {
190+
"slot": int(store.latest_justified.slot),
191+
"root": "0x" + store.latest_justified.root.hex(),
192+
},
193+
"finalized": {
194+
"slot": int(store.latest_finalized.slot),
195+
"root": "0x" + store.latest_finalized.root.hex(),
196+
},
197+
"safe_target": "0x" + store.safe_target.hex(),
198+
"validator_count": len(head_state.validators),
199+
},
200+
)
201+
202+
case ("GET", "/lean/v0/admin/aggregator"):
203+
# Current aggregator role as seeded by the spec.
204+
return EndpointResponseContract(
205+
status_code=200,
206+
content_type="application/json",
207+
body={"is_aggregator": self.initial_is_aggregator},
208+
)
209+
210+
case ("POST", "/lean/v0/admin/aggregator"):
211+
# Toggling reports the new aggregator value and the previous one.
212+
body = self.request_body
213+
if not isinstance(body, dict) or not isinstance(body.get("enabled"), bool):
214+
raise ValueError(
215+
"POST /lean/v0/admin/aggregator fixture requires request_body "
216+
"with a boolean 'enabled' field"
217+
)
218+
return EndpointResponseContract(
219+
status_code=200,
220+
content_type="application/json",
221+
body={
222+
"is_aggregator": body["enabled"],
223+
"previous": self.initial_is_aggregator,
224+
},
225+
)
226+
227+
case ("GET", "/metrics"):
228+
# The body is dynamic, so pin only status, content type, and the metric names.
229+
# Touch the registry so its removal trips this fixture instead of failing silently.
230+
assert metrics_registry is not None
231+
return EndpointResponseContract(
232+
status_code=200,
233+
content_type="text/plain; version=0.0.4; charset=utf-8",
234+
body={"required_metric_names": REQUIRED_METRIC_NAMES},
235+
)
236+
237+
case _:
238+
raise ValueError(f"Unknown endpoint: {self.method} {self.endpoint}")

0 commit comments

Comments
 (0)