chore: register pages features APIs and data contracts

This commit is contained in:
leefer
2026-07-29 17:07:39 +08:00
parent 444f3c1556
commit 12c0f4777d
8 changed files with 866 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
# Governance Registries
These registries describe the approved product surface during architecture migration.
- `pages.config.json`: primary page identity, navigation group, access expectation, scrolling,
and mobile composition policy.
- `features.config.json`: feature ownership, backend access class, data scope, and availability.
- `api.config.json`: transitional inventory of current routes, generated from `server.py` and
assigned to a feature owner.
- `data-fields.config.json`: canonical data products, provider eligibility, intended use, and
known blocked datasets.
During Stage 04 these files are contract inputs, not runtime replacements. Backend access in
`api_access.py` remains authoritative until the HTTP governance phase switches it atomically.
Frontend visibility remains a presentation concern and never grants backend access.
Regenerate the transitional API inventory after a route change:
```shell
python tools/build_api_registry.py
python tools/build_api_registry.py --check
```
+524
View File
@@ -0,0 +1,524 @@
{
"schema_version": 1,
"generated_from": "server.py",
"routes": [
{
"method": "DELETE",
"path": "/api/account/birth-profile",
"match": "exact",
"feature": "account",
"access": "authenticated"
},
{
"method": "POST",
"path": "/api/account/birth-profile",
"match": "exact",
"feature": "account",
"access": "authenticated"
},
{
"method": "POST",
"path": "/api/account/password",
"match": "exact",
"feature": "account",
"access": "authenticated"
},
{
"method": "GET",
"path": "/api/account/status",
"match": "exact",
"feature": "account",
"access": "authenticated"
},
{
"method": "POST",
"path": "/api/admin/membership",
"match": "exact",
"feature": "admin",
"access": "admin"
},
{
"method": "POST",
"path": "/api/admin/refresh",
"match": "exact",
"feature": "admin",
"access": "admin"
},
{
"method": "GET",
"path": "/api/admin/settings",
"match": "exact",
"feature": "admin",
"access": "admin"
},
{
"method": "POST",
"path": "/api/admin/settings",
"match": "exact",
"feature": "admin",
"access": "admin"
},
{
"method": "POST",
"path": "/api/admin/settings/test",
"match": "exact",
"feature": "admin",
"access": "admin"
},
{
"method": "GET",
"path": "/api/alerts",
"match": "exact",
"feature": "alerts",
"access": "authenticated"
},
{
"method": "POST",
"path": "/api/alerts",
"match": "exact",
"feature": "alerts",
"access": "authenticated"
},
{
"method": "DELETE",
"path": "/api/alerts/(\\d+)",
"match": "regex",
"feature": "alerts",
"access": "authenticated"
},
{
"method": "POST",
"path": "/api/alerts/(\\d+)/read",
"match": "regex",
"feature": "alerts",
"access": "authenticated"
},
{
"method": "POST",
"path": "/api/alerts/read-all",
"match": "exact",
"feature": "alerts",
"access": "authenticated"
},
{
"method": "POST",
"path": "/api/assistant/chat",
"match": "exact",
"feature": "review",
"access": "member"
},
{
"method": "DELETE",
"path": "/api/assistant/messages",
"match": "exact",
"feature": "review",
"access": "member"
},
{
"method": "GET",
"path": "/api/assistant/messages",
"match": "exact",
"feature": "review",
"access": "member"
},
{
"method": "GET",
"path": "/api/auction",
"match": "exact",
"feature": "auction",
"access": "authenticated"
},
{
"method": "POST",
"path": "/api/auth/login",
"match": "exact",
"feature": "auth",
"access": "public"
},
{
"method": "POST",
"path": "/api/auth/logout",
"match": "exact",
"feature": "auth",
"access": "authenticated"
},
{
"method": "GET",
"path": "/api/auth/me",
"match": "exact",
"feature": "auth",
"access": "authenticated"
},
{
"method": "POST",
"path": "/api/auth/register",
"match": "exact",
"feature": "auth",
"access": "public"
},
{
"method": "POST",
"path": "/api/backfill",
"match": "exact",
"feature": "admin",
"access": "admin"
},
{
"method": "GET",
"path": "/api/chart/intraday",
"match": "exact",
"feature": "charts",
"access": "authenticated"
},
{
"method": "GET",
"path": "/api/dashboard",
"match": "exact",
"feature": "market",
"access": "authenticated"
},
{
"method": "GET",
"path": "/api/dragon-tiger",
"match": "exact",
"feature": "dragon_tiger",
"access": "authenticated"
},
{
"method": "GET",
"path": "/api/dragon-tiger/profiles",
"match": "exact",
"feature": "dragon_tiger",
"access": "authenticated"
},
{
"method": "GET",
"path": "/api/health",
"match": "exact",
"feature": "health",
"access": "public"
},
{
"method": "POST",
"path": "/api/heaven/hexagram",
"match": "exact",
"feature": "heaven",
"access": "member"
},
{
"method": "POST",
"path": "/api/heaven/interpret",
"match": "exact",
"feature": "heaven",
"access": "member"
},
{
"method": "POST",
"path": "/api/heaven/personal",
"match": "exact",
"feature": "heaven",
"access": "member"
},
{
"method": "GET",
"path": "/api/heaven/readings",
"match": "exact",
"feature": "heaven",
"access": "member"
},
{
"method": "DELETE",
"path": "/api/heaven/readings/(\\d+)",
"match": "regex",
"feature": "heaven",
"access": "member"
},
{
"method": "POST",
"path": "/api/heaven/sector-phases",
"match": "exact",
"feature": "heaven",
"access": "admin"
},
{
"method": "DELETE",
"path": "/api/heaven/sector-phases/(.+)",
"match": "regex",
"feature": "heaven",
"access": "admin"
},
{
"method": "GET",
"path": "/api/heaven/setup",
"match": "exact",
"feature": "heaven",
"access": "member"
},
{
"method": "POST",
"path": "/api/mentors/chat",
"match": "exact",
"feature": "mentor",
"access": "member"
},
{
"method": "DELETE",
"path": "/api/mentors/messages",
"match": "exact",
"feature": "mentor",
"access": "member"
},
{
"method": "GET",
"path": "/api/mentors/messages",
"match": "exact",
"feature": "mentor",
"access": "member"
},
{
"method": "POST",
"path": "/api/mentors/preferences",
"match": "exact",
"feature": "mentor",
"access": "member"
},
{
"method": "GET",
"path": "/api/mentors/setup",
"match": "exact",
"feature": "mentor",
"access": "member"
},
{
"method": "GET",
"path": "/api/notes",
"match": "exact",
"feature": "review",
"access": "authenticated"
},
{
"method": "POST",
"path": "/api/notes",
"match": "exact",
"feature": "review",
"access": "authenticated"
},
{
"method": "DELETE",
"path": "/api/notes/(\\d+)",
"match": "regex",
"feature": "review",
"access": "authenticated"
},
{
"method": "GET",
"path": "/api/popularity",
"match": "exact",
"feature": "popularity",
"access": "authenticated"
},
{
"method": "GET",
"path": "/api/realtime-aggregate/health",
"match": "exact",
"feature": "market",
"access": "authenticated"
},
{
"method": "POST",
"path": "/api/reasons",
"match": "exact",
"feature": "admin",
"access": "admin"
},
{
"method": "GET",
"path": "/api/rotation/history",
"match": "exact",
"feature": "rotation",
"access": "authenticated"
},
{
"method": "GET",
"path": "/api/rotation/members",
"match": "exact",
"feature": "rotation",
"access": "authenticated"
},
{
"method": "POST",
"path": "/api/screener/compile",
"match": "exact",
"feature": "screener",
"access": "member"
},
{
"method": "POST",
"path": "/api/screener/run",
"match": "exact",
"feature": "screener",
"access": "member"
},
{
"method": "GET",
"path": "/api/screener/setup",
"match": "exact",
"feature": "screener",
"access": "member"
},
{
"method": "POST",
"path": "/api/screener/strategies",
"match": "exact",
"feature": "screener",
"access": "member"
},
{
"method": "DELETE",
"path": "/api/screener/strategies/(\\d+)",
"match": "regex",
"feature": "screener",
"access": "member"
},
{
"method": "POST",
"path": "/api/screener/sync",
"match": "exact",
"feature": "screener",
"access": "member"
},
{
"method": "GET",
"path": "/api/screener/tracking",
"match": "exact",
"feature": "screener",
"access": "member"
},
{
"method": "POST",
"path": "/api/screener/tracking",
"match": "exact",
"feature": "screener",
"access": "member"
},
{
"method": "DELETE",
"path": "/api/screener/tracking/(\\d+)",
"match": "regex",
"feature": "screener",
"access": "member"
},
{
"method": "POST",
"path": "/api/screener/tracking/refresh",
"match": "exact",
"feature": "screener",
"access": "member"
},
{
"method": "GET",
"path": "/api/search",
"match": "exact",
"feature": "search",
"access": "authenticated"
},
{
"method": "GET",
"path": "/api/search/detail",
"match": "exact",
"feature": "search",
"access": "authenticated"
},
{
"method": "GET",
"path": "/api/seat-aliases",
"match": "exact",
"feature": "admin",
"access": "authenticated"
},
{
"method": "POST",
"path": "/api/seat-aliases",
"match": "exact",
"feature": "admin",
"access": "admin"
},
{
"method": "GET",
"path": "/api/sentiment/history",
"match": "exact",
"feature": "sentiment",
"access": "authenticated"
},
{
"method": "GET",
"path": "/api/stock/(\\d{6})",
"match": "regex",
"feature": "market",
"access": "authenticated"
},
{
"method": "GET",
"path": "/api/stock/(\\d{6})/preview",
"match": "regex",
"feature": "market",
"access": "authenticated"
},
{
"method": "GET",
"path": "/api/themes",
"match": "exact",
"feature": "themes",
"access": "authenticated"
},
{
"method": "GET",
"path": "/api/themes/detail",
"match": "exact",
"feature": "themes",
"access": "authenticated"
},
{
"method": "GET",
"path": "/api/trades",
"match": "exact",
"feature": "review",
"access": "authenticated"
},
{
"method": "POST",
"path": "/api/trades",
"match": "exact",
"feature": "review",
"access": "authenticated"
},
{
"method": "DELETE",
"path": "/api/trades/(\\d+)",
"match": "regex",
"feature": "review",
"access": "authenticated"
},
{
"method": "GET",
"path": "/api/watchlist",
"match": "exact",
"feature": "review",
"access": "authenticated"
},
{
"method": "POST",
"path": "/api/watchlist",
"match": "exact",
"feature": "review",
"access": "authenticated"
},
{
"method": "DELETE",
"path": "/api/watchlist/(\\d{6})",
"match": "regex",
"feature": "review",
"access": "authenticated"
}
]
}
+31
View File
@@ -0,0 +1,31 @@
{
"schema_version": 1,
"providers": {
"tushare": {"class": "licensed", "calculation_allowed": true},
"ifind": {"class": "licensed", "calculation_allowed": true},
"eastmoney": {"class": "public_web", "calculation_allowed": false},
"tencent": {"class": "public_web", "calculation_allowed": false},
"local": {"class": "derived", "calculation_allowed": true},
"unresolved": {"class": "missing", "calculation_allowed": false}
},
"datasets": [
{"id": "market.trade_calendar", "entity": "market", "frequency": "daily", "primary": "tushare", "fallbacks": [], "usage": "calculation", "fields": ["trade_date", "is_open", "previous_open_date"]},
{"id": "market.stock_master", "entity": "stock", "frequency": "event", "primary": "tushare", "fallbacks": [], "usage": "calculation", "fields": ["ts_code", "name", "industry", "market", "list_date"]},
{"id": "market.stock_daily", "entity": "stock", "frequency": "daily", "primary": "tushare", "fallbacks": [], "usage": "calculation", "adjustment": "current-unadjusted", "fields": ["open", "high", "low", "close", "pct_chg", "volume_shares", "amount_yuan"]},
{"id": "market.daily_valuation", "entity": "stock", "frequency": "daily", "primary": "tushare", "fallbacks": [], "usage": "calculation", "fields": ["turnover_rate_pct", "volume_ratio", "total_mv_10k_yuan", "circ_mv_10k_yuan", "pe_ttm", "pb", "ps_ttm", "dv_ttm_pct"]},
{"id": "market.fundamentals", "entity": "stock", "frequency": "quarterly", "primary": "tushare", "fallbacks": [], "usage": "calculation", "point_in_time": "announcement_date", "fields": ["roe_pct", "roa_pct", "roic_pct", "gross_margin_pct", "net_profit_yoy_pct", "revenue_yoy_pct", "operating_cashflow_quality"]},
{"id": "market.moneyflow", "entity": "stock", "frequency": "daily", "primary": "tushare", "fallbacks": [], "usage": "calculation", "fields": ["small_net_yuan", "medium_net_yuan", "large_net_yuan", "extra_large_net_yuan", "total_net_yuan"]},
{"id": "market.industry_sw", "entity": "industry", "frequency": "daily", "primary": "tushare", "fallbacks": [], "usage": "calculation", "fields": ["industry_code", "industry_name", "level", "members", "pct_chg", "turnover_rate_pct"]},
{"id": "market.limit_events", "entity": "stock", "frequency": "daily", "primary": "tushare", "fallbacks": [], "usage": "calculation", "fields": ["limit_type", "first_time", "last_time", "open_times", "limit_reason", "consecutive_boards"]},
{"id": "market.auction_close", "entity": "stock", "frequency": "daily", "primary": "tushare", "fallbacks": [], "usage": "calculation", "fields": ["price", "volume_shares", "amount_yuan", "pre_close", "turnover_rate_pct", "volume_ratio", "float_share"]},
{"id": "market.auction_dynamic", "entity": "stock", "frequency": "snapshot", "primary": "ifind", "fallbacks": [], "usage": "calculation", "freshness_seconds": 10, "fields": ["quote_time", "price", "pct_chg", "volume_shares", "amount_yuan"]},
{"id": "market.popularity", "entity": "stock", "frequency": "daily", "primary": "tushare", "fallbacks": [], "usage": "calculation", "fields": ["ths_rank", "dc_rank", "rank_change", "dual_source"]},
{"id": "market.dragon_tiger", "entity": "stock", "frequency": "daily", "primary": "tushare", "fallbacks": [], "usage": "calculation", "fields": ["seat_name", "buy_yuan", "sell_yuan", "net_buy_yuan", "side", "reason"]},
{"id": "chart.stock_daily", "entity": "stock", "frequency": "daily", "primary": "ifind", "fallbacks": [], "usage": "display", "adjustment": "forward1", "fields": ["open", "high", "low", "close", "volume_shares", "amount_yuan"]},
{"id": "chart.intraday", "entity": "stock_or_index_or_board", "frequency": "minute", "primary": "ifind", "fallbacks": ["eastmoney"], "usage": "display", "fields": ["quote_time", "open", "high", "low", "close", "avg_price", "volume_shares", "amount_yuan"]},
{"id": "observation.realtime_indices", "entity": "index", "frequency": "snapshot", "primary": "eastmoney", "fallbacks": ["tencent"], "usage": "display", "fields": ["quote_time", "price", "pct_chg", "amount_yuan"]},
{"id": "derived.sentiment", "entity": "market", "frequency": "daily", "primary": "local", "fallbacks": [], "usage": "calculation", "fields": ["temperature", "stage", "direction", "confidence", "component_scores"]},
{"id": "research.consensus", "entity": "stock", "frequency": "event", "primary": "unresolved", "fallbacks": [], "usage": "blocked", "fields": ["consensus_profit", "forecast_revision", "rating_change", "target_price", "report_count"]},
{"id": "market.level2", "entity": "stock", "frequency": "tick", "primary": "unresolved", "fallbacks": [], "usage": "blocked", "fields": ["order_queue", "unmatched_orders", "tick_trades", "tick_orders", "open_board_depth"]}
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"schema_version": 1,
"roles": ["public", "authenticated", "member", "admin"],
"features": [
{"id": "health", "title": "健康检查", "access": "public", "data_scope": "system", "enabled": true},
{"id": "auth", "title": "账户认证", "access": "public", "data_scope": "user", "enabled": true},
{"id": "account", "title": "账户设置", "access": "authenticated", "data_scope": "user", "enabled": true},
{"id": "admin", "title": "系统管理", "access": "admin", "data_scope": "system", "enabled": true},
{"id": "market", "title": "市场总览", "access": "authenticated", "data_scope": "shared", "enabled": true},
{"id": "sentiment", "title": "情绪周期", "access": "authenticated", "data_scope": "shared", "enabled": true},
{"id": "pools", "title": "市场股池", "access": "authenticated", "data_scope": "shared", "enabled": true},
{"id": "ladder", "title": "市场天梯", "access": "authenticated", "data_scope": "shared", "enabled": true},
{"id": "rotation", "title": "板块轮动", "access": "authenticated", "data_scope": "shared", "enabled": true},
{"id": "auction", "title": "集合竞价", "access": "authenticated", "data_scope": "shared", "enabled": true},
{"id": "themes", "title": "题材库", "access": "authenticated", "data_scope": "shared", "enabled": true},
{"id": "popularity", "title": "人气热榜", "access": "authenticated", "data_scope": "shared", "enabled": true},
{"id": "dragon_tiger", "title": "龙虎榜", "access": "authenticated", "data_scope": "shared", "enabled": true},
{"id": "search", "title": "全局搜索", "access": "authenticated", "data_scope": "shared", "enabled": true},
{"id": "charts", "title": "行情图表", "access": "authenticated", "data_scope": "shared", "enabled": true},
{"id": "screener", "title": "智能选股", "access": "member", "data_scope": "mixed", "daily_llm_quota": true, "enabled": true},
{"id": "mentor", "title": "问师", "access": "member", "data_scope": "user", "daily_llm_quota": true, "enabled": true},
{"id": "heaven", "title": "问天", "access": "member", "data_scope": "user", "daily_llm_quota": true, "enabled": true},
{"id": "review", "title": "我的复盘", "access": "authenticated", "data_scope": "user", "enabled": true},
{"id": "alerts", "title": "提醒中心", "access": "authenticated", "data_scope": "user", "enabled": true}
]
}
+21
View File
@@ -0,0 +1,21 @@
{
"schema_version": 1,
"pages": [
{"id": "sentimentCycleView", "title": "情绪周期", "feature": "sentiment", "group": "market", "access": "authenticated", "desktop_scroll": "page", "mobile_layout": "dedicated", "default": true},
{"id": "limitPool", "title": "涨停池", "feature": "pools", "group": "market", "access": "authenticated", "desktop_scroll": "page", "mobile_layout": "dedicated", "default": false},
{"id": "brokenView", "title": "炸板池", "feature": "pools", "group": "market", "access": "authenticated", "desktop_scroll": "page", "mobile_layout": "dedicated", "default": false},
{"id": "downView", "title": "跌停板", "feature": "pools", "group": "market", "access": "authenticated", "desktop_scroll": "page", "mobile_layout": "dedicated", "default": false},
{"id": "yesterdayView", "title": "昨日涨停", "feature": "pools", "group": "market", "access": "authenticated", "desktop_scroll": "page", "mobile_layout": "dedicated", "default": false},
{"id": "performanceView", "title": "涨停表现", "feature": "pools", "group": "market", "access": "authenticated", "desktop_scroll": "page", "mobile_layout": "dedicated", "default": false},
{"id": "ladderView", "title": "市场天梯", "feature": "ladder", "group": "market", "access": "authenticated", "desktop_scroll": "page", "mobile_layout": "dedicated", "default": false},
{"id": "rotationView", "title": "板块轮动", "feature": "rotation", "group": "market", "access": "authenticated", "desktop_scroll": "page", "mobile_layout": "dedicated", "default": false},
{"id": "auctionView", "title": "集合竞价", "feature": "auction", "group": "market", "access": "authenticated", "desktop_scroll": "page", "mobile_layout": "dedicated", "default": false},
{"id": "themeLibraryView", "title": "题材库", "feature": "themes", "group": "market", "access": "authenticated", "desktop_scroll": "page", "mobile_layout": "dedicated", "default": false},
{"id": "popularityView", "title": "人气热榜", "feature": "popularity", "group": "market", "access": "authenticated", "desktop_scroll": "page", "mobile_layout": "dedicated", "default": false},
{"id": "dragonView", "title": "龙虎榜", "feature": "dragon_tiger", "group": "market", "access": "authenticated", "desktop_scroll": "page", "mobile_layout": "dedicated", "default": false},
{"id": "screenerView", "title": "智能选股", "feature": "screener", "group": "intelligence", "access": "member", "desktop_scroll": "page", "mobile_layout": "dedicated", "default": false},
{"id": "mentorView", "title": "问师", "feature": "mentor", "group": "intelligence", "access": "member", "desktop_scroll": "page", "mobile_layout": "dedicated", "default": false},
{"id": "heavenView", "title": "问天", "feature": "heaven", "group": "intelligence", "access": "member", "desktop_scroll": "page", "mobile_layout": "dedicated", "default": false},
{"id": "reviewWorkspaceView", "title": "我的复盘", "feature": "review", "group": "personal", "access": "authenticated", "desktop_scroll": "page", "mobile_layout": "dedicated", "default": false}
]
}
+48
View File
@@ -0,0 +1,48 @@
# Stage 04: Governance Registries
Date: 2026-07-29
## Result
Four versioned registries now describe the existing product before runtime decomposition:
1. `config/pages.config.json` registers all 16 primary workspaces.
2. `config/features.config.json` registers 20 feature owners and their access/data scope.
3. `config/api.config.json` registers 74 method/path combinations, including dynamic paths.
4. `config/data-fields.config.json` registers 18 initial canonical data products and provider
eligibility.
The API registry is generated from the current request handler so route drift fails tests. The
other registries are curated contracts and may change only through an explicit product or data
governance decision.
## Access Decisions Preserved
- Health, login, and registration are public.
- Market views require an authenticated account.
- Intelligent screening, Mentor, Wentian, and their history endpoints require membership.
- My Review remains visible to authenticated users; LLM assistant endpoints remain member
gated inside that workspace.
- System management and shared knowledge modification remain administrator-only.
- Frontend visibility does not grant API access.
## Data Decisions Preserved
- Tushare and licensed iFinD data may be calculation inputs when a dataset contract permits it.
- Eastmoney and Tencent public endpoints are display-only in the initial registry.
- Analyst consensus history and Level-2 microstructure remain blocked rather than silently
approximated.
- Chart fallbacks remain separate from calculation datasets.
- The current unadjusted deterministic daily-bar behavior is recorded honestly; adjustment
normalization is deferred to the data-quality phase rather than changed here.
## Transitional Rule
These files do not yet replace `server.py`, `api_access.py`, or the current frontend navigation.
They are checked against those surfaces to prevent untracked drift. Later phases make each
registry authoritative in one atomic migration with compatibility tests.
## Verification
`tests/test_governance_registries.py` verifies uniqueness, page coverage, current API coverage,
explicit public routes, feature ownership, role validity, and provider eligibility.
+87
View File
@@ -0,0 +1,87 @@
from __future__ import annotations
import json
import unittest
from pathlib import Path
from tools.build_api_registry import build as build_api_registry
from tools.build_architecture_inventory import build as build_architecture_inventory
ROOT = Path(__file__).resolve().parents[1]
CONFIG = ROOT / "config"
def load(name: str) -> dict:
return json.loads((CONFIG / name).read_text(encoding="utf-8"))
class GovernanceRegistryTests(unittest.TestCase):
def setUp(self) -> None:
self.features = load("features.config.json")
self.pages = load("pages.config.json")
self.api = load("api.config.json")
self.data = load("data-fields.config.json")
def test_features_are_unique_and_use_declared_roles(self) -> None:
roles = set(self.features["roles"])
items = self.features["features"]
ids = [item["id"] for item in items]
self.assertEqual(len(ids), len(set(ids)))
self.assertTrue(all(item["access"] in roles for item in items))
self.assertTrue(all(isinstance(item["enabled"], bool) for item in items))
def test_page_registry_matches_the_current_primary_navigation(self) -> None:
inventory_pages = build_architecture_inventory()["pages"]
registered = self.pages["pages"]
self.assertEqual(
[(item["id"], item["title"]) for item in registered],
[(item["id"], item["title"]) for item in inventory_pages],
)
feature_ids = {item["id"] for item in self.features["features"]}
self.assertTrue(all(page["feature"] in feature_ids for page in registered))
self.assertEqual(sum(bool(page.get("default")) for page in registered), 1)
def test_api_registry_is_current_and_owned(self) -> None:
self.assertEqual(self.api, build_api_registry())
feature_ids = {item["id"] for item in self.features["features"]}
roles = set(self.features["roles"])
keys = []
for route in self.api["routes"]:
keys.append((route["method"], route["path"], route["match"]))
self.assertIn(route["feature"], feature_ids)
self.assertIn(route["access"], roles)
self.assertEqual(len(keys), len(set(keys)))
def test_public_routes_are_explicitly_limited(self) -> None:
public = {
(item["method"], item["path"])
for item in self.api["routes"]
if item["access"] == "public"
}
self.assertEqual(
public,
{
("GET", "/api/health"),
("POST", "/api/auth/login"),
("POST", "/api/auth/register"),
},
)
def test_calculation_datasets_use_approved_providers(self) -> None:
providers = self.data["providers"]
ids = []
for dataset in self.data["datasets"]:
ids.append(dataset["id"])
self.assertIn(dataset["primary"], providers)
for fallback in dataset.get("fallbacks", []):
self.assertIn(fallback, providers)
if dataset["usage"] == "calculation":
self.assertTrue(providers[dataset["primary"]]["calculation_allowed"])
if dataset["primary"] == "unresolved":
self.assertEqual(dataset["usage"], "blocked")
self.assertEqual(len(ids), len(set(ids)))
if __name__ == "__main__":
unittest.main()
+107
View File
@@ -0,0 +1,107 @@
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
OUTPUT = ROOT / "config" / "api.config.json"
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def _owner(path: str) -> str:
ordered = (
("/api/health", "health"),
("/api/auth", "auth"),
("/api/admin", "admin"),
("/api/account", "account"),
("/api/sentiment", "sentiment"),
("/api/dashboard", "market"),
("/api/realtime-aggregate", "market"),
("/api/rotation", "rotation"),
("/api/auction", "auction"),
("/api/themes", "themes"),
("/api/popularity", "popularity"),
("/api/dragon-tiger", "dragon_tiger"),
("/api/search", "search"),
("/api/chart", "charts"),
("/api/stock", "market"),
("/api/screener", "screener"),
("/api/mentors", "mentor"),
("/api/heaven", "heaven"),
("/api/alerts", "alerts"),
("/api/trades", "review"),
("/api/assistant", "review"),
("/api/watchlist", "review"),
("/api/notes", "review"),
("/api/reasons", "admin"),
("/api/seat-aliases", "admin"),
("/api/backfill", "admin"),
)
for prefix, owner in ordered:
if path.startswith(prefix):
return owner
raise ValueError(f"API owner is not registered: {path}")
def _role(method: str, path: str) -> str:
if path == "/api/health" or path in {"/api/auth/register", "/api/auth/login"}:
return "public"
from api_access import required_role
sample = re.sub(r"\\d\{6\}", "000001", path)
sample = re.sub(r"\\d\+", "1", sample)
sample = sample.replace("(.+)", "sample").replace("(", "").replace(")", "")
return required_role(method, sample)
def build() -> dict:
text = (ROOT / "server.py").read_text(encoding="utf-8")
method_matches = list(re.finditer(r"^ def do_(GET|POST|DELETE)\(", text, re.MULTILINE))
routes = []
for index, match in enumerate(method_matches):
method = match.group(1)
end = method_matches[index + 1].start() if index + 1 < len(method_matches) else len(text)
block = text[match.start():end]
exact_paths = set(re.findall(r'parsed\.path\s*==\s*"(/api/[^"]+)"', block))
patterns = set(
re.findall(
r're\.(?:fullmatch|match)\(\s*r?["\']([^"\']*?/api/[^"\']+)["\']\s*,\s*parsed\.path',
block,
)
)
for path in sorted(exact_paths):
routes.append(
{"method": method, "path": path, "match": "exact", "feature": _owner(path), "access": _role(method, path)}
)
for path in sorted(patterns):
normalized = path.replace("^", "").replace("$", "")
routes.append(
{"method": method, "path": normalized, "match": "regex", "feature": _owner(normalized), "access": _role(method, normalized)}
)
routes.sort(key=lambda item: (item["path"], item["method"], item["match"]))
return {"schema_version": 1, "generated_from": "server.py", "routes": routes}
def main() -> int:
parser = argparse.ArgumentParser(description="Build the transitional API ownership registry")
parser.add_argument("--check", action="store_true")
args = parser.parse_args()
rendered = json.dumps(build(), ensure_ascii=False, indent=2) + "\n"
if args.check:
if not OUTPUT.exists() or OUTPUT.read_text(encoding="utf-8") != rendered:
raise SystemExit("API registry is stale; run tools/build_api_registry.py")
print("API registry is current.")
return 0
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
OUTPUT.write_text(rendered, encoding="utf-8")
print(OUTPUT.relative_to(ROOT).as_posix())
return 0
if __name__ == "__main__":
raise SystemExit(main())