ghost-api-cli 0.2.0__py3-none-any.whl
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.
- ghost_api/__init__.py +3 -0
- ghost_api/__main__.py +5 -0
- ghost_api/adopt.py +173 -0
- ghost_api/agent/__init__.py +0 -0
- ghost_api/agent/orchestrator.py +413 -0
- ghost_api/agent/prompts.py +149 -0
- ghost_api/agent/tools.py +84 -0
- ghost_api/analysis/__init__.py +0 -0
- ghost_api/analysis/analyzer.py +130 -0
- ghost_api/analysis/detectors.py +188 -0
- ghost_api/analysis/exposure.py +80 -0
- ghost_api/analysis/graphql.py +67 -0
- ghost_api/analysis/models.py +64 -0
- ghost_api/analysis/schema.py +109 -0
- ghost_api/analysis/url_grouping.py +217 -0
- ghost_api/api.py +264 -0
- ghost_api/audit.py +78 -0
- ghost_api/browser/__init__.py +0 -0
- ghost_api/browser/auth_state.py +172 -0
- ghost_api/browser/driver.py +273 -0
- ghost_api/browser/egress.py +183 -0
- ghost_api/browser/gate.py +127 -0
- ghost_api/browser/install.py +28 -0
- ghost_api/browser/snapshot.py +221 -0
- ghost_api/capture/__init__.py +0 -0
- ghost_api/capture/filters.py +255 -0
- ghost_api/capture/har.py +148 -0
- ghost_api/capture/models.py +41 -0
- ghost_api/capture/personal.py +188 -0
- ghost_api/capture/recorder.py +181 -0
- ghost_api/cli/__init__.py +44 -0
- ghost_api/cli/_common.py +219 -0
- ghost_api/cli/client.py +487 -0
- ghost_api/cli/data.py +492 -0
- ghost_api/cli/explore.py +379 -0
- ghost_api/cli/manage.py +437 -0
- ghost_api/config.py +132 -0
- ghost_api/drift.py +344 -0
- ghost_api/drive.py +211 -0
- ghost_api/errors.py +106 -0
- ghost_api/events.py +213 -0
- ghost_api/exitcodes.py +21 -0
- ghost_api/export/__init__.py +71 -0
- ghost_api/export/confidence.py +45 -0
- ghost_api/export/html.py +41 -0
- ghost_api/export/openapi.py +201 -0
- ghost_api/export/report.py +227 -0
- ghost_api/export/site_data.py +196 -0
- ghost_api/export/tabular.py +88 -0
- ghost_api/export/viewer/report.html +286 -0
- ghost_api/generator/__init__.py +221 -0
- ghost_api/generator/cassettes.py +80 -0
- ghost_api/generator/models_gen.py +144 -0
- ghost_api/generator/naming.py +174 -0
- ghost_api/generator/package.py +197 -0
- ghost_api/generator/python_gen.py +172 -0
- ghost_api/generator/spec.py +486 -0
- ghost_api/generator/templates/client.py.j2 +518 -0
- ghost_api/generator/templates/client.test.ts.j2 +106 -0
- ghost_api/generator/templates/client.ts.j2 +339 -0
- ghost_api/generator/templates/models.py.j2 +45 -0
- ghost_api/generator/templates/package_readme.md.j2 +111 -0
- ghost_api/generator/templates/pyproject.toml.j2 +22 -0
- ghost_api/generator/templates/test_client.py.j2 +105 -0
- ghost_api/generator/ts_gen.py +232 -0
- ghost_api/integrations/__init__.py +0 -0
- ghost_api/integrations/claude_code/commands/ghost-data.md +10 -0
- ghost_api/integrations/claude_code/commands/ghost-diff.md +10 -0
- ghost_api/integrations/claude_code/commands/ghost-explore.md +10 -0
- ghost_api/integrations/claude_code/skills/ghost-api/SKILL.md +46 -0
- ghost_api/integrations/setup.py +84 -0
- ghost_api/llm/__init__.py +0 -0
- ghost_api/llm/doctor.py +60 -0
- ghost_api/llm/gateway.py +476 -0
- ghost_api/llm/providers.py +124 -0
- ghost_api/memory/__init__.py +0 -0
- ghost_api/memory/knowledge.py +157 -0
- ghost_api/memory/store.py +883 -0
- ghost_api/net.py +235 -0
- ghost_api/netguard.py +203 -0
- ghost_api/pipeline.py +745 -0
- ghost_api/probe/__init__.py +0 -0
- ghost_api/probe/prober.py +311 -0
- ghost_api/probe/replay.py +313 -0
- ghost_api/project.py +186 -0
- ghost_api/py.typed +0 -0
- ghost_api/runtime.py +397 -0
- ghost_api/serve/__init__.py +101 -0
- ghost_api/serve/explorer_mcp.py +305 -0
- ghost_api/serve/mcp.py +121 -0
- ghost_api/serve/mock.py +130 -0
- ghost_api/serve/rest.py +132 -0
- ghost_api/site.py +84 -0
- ghost_api/specdiff.py +122 -0
- ghost_api/ui/__init__.py +0 -0
- ghost_api/ui/keys.py +134 -0
- ghost_api/ui/repl.py +453 -0
- ghost_api/ui/terminal.py +573 -0
- ghost_api/verify/__init__.py +0 -0
- ghost_api/verify/classify.py +84 -0
- ghost_api/verify/repair.py +40 -0
- ghost_api/verify/runner.py +110 -0
- ghost_api/verify/verifier.py +291 -0
- ghost_api/watch.py +313 -0
- ghost_api/web/__init__.py +1 -0
- ghost_api/web/server.py +690 -0
- ghost_api/web/static/assets/Endpoints-zPnjlcM8.js +46 -0
- ghost_api/web/static/assets/Run-D85zWHey.js +91 -0
- ghost_api/web/static/assets/Settings-D0aWHFAt.js +11 -0
- ghost_api/web/static/assets/Site-a88shEmP.js +71 -0
- ghost_api/web/static/assets/Watches-CqZWIa9G.js +16 -0
- ghost_api/web/static/assets/circle-x-BcTE_1tr.js +11 -0
- ghost_api/web/static/assets/index-Ds2e5B9f.js +141 -0
- ghost_api/web/static/assets/index-xQpyIBbG.css +1 -0
- ghost_api/web/static/assets/minus-C2xqP1eQ.js +6 -0
- ghost_api/web/static/assets/refresh-cw-CxdR_8Oi.js +6 -0
- ghost_api/web/static/favicon.svg +1 -0
- ghost_api/web/static/index.html +26 -0
- ghost_api/workflows.py +203 -0
- ghost_api_cli-0.2.0.dist-info/METADATA +1019 -0
- ghost_api_cli-0.2.0.dist-info/RECORD +125 -0
- ghost_api_cli-0.2.0.dist-info/WHEEL +5 -0
- ghost_api_cli-0.2.0.dist-info/entry_points.txt +2 -0
- ghost_api_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- ghost_api_cli-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""GraphQL: one URL, many operations. Each named operation becomes its own endpoint.
|
|
2
|
+
|
|
3
|
+
A site's GraphQL traffic all goes to `/graphql`, but `GetUser` and `ListOrders` are
|
|
4
|
+
different calls with different inputs and replies. The operation name is added to the
|
|
5
|
+
endpoint's template after a `#` (`POST /graphql#GetUser`), so every later stage (schemas,
|
|
6
|
+
testing, naming, the client) treats each operation as one endpoint. The `#...` part is
|
|
7
|
+
never sent: it is a URL fragment.
|
|
8
|
+
|
|
9
|
+
Mutations change data: they are never replayed or generated (probe/replay.py).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
from typing import Any
|
|
17
|
+
from urllib.parse import parse_qs, urlsplit
|
|
18
|
+
|
|
19
|
+
_NAMED_OP = re.compile(r"^\s*(query|mutation|subscription)\s+([A-Za-z_]\w*)", re.M)
|
|
20
|
+
_OP_NAME = re.compile(r"^[A-Za-z_]\w{0,80}$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def graphql_payload(method: str, url: str, body: str | None) -> dict[str, Any] | None:
|
|
24
|
+
"""The GraphQL request (query, operationName, variables), or None if it isn't one.
|
|
25
|
+
A batch (a list of operations) is not split: it stays one endpoint."""
|
|
26
|
+
if method.upper() == "GET":
|
|
27
|
+
params = {k: v[0] for k, v in parse_qs(urlsplit(url).query).items()}
|
|
28
|
+
if "query" in params and ("{" in params["query"] or "operationName" in params):
|
|
29
|
+
variables = params.get("variables")
|
|
30
|
+
try:
|
|
31
|
+
parsed = json.loads(variables) if variables else {}
|
|
32
|
+
except ValueError:
|
|
33
|
+
parsed = {}
|
|
34
|
+
return {**params, "variables": parsed if isinstance(parsed, dict) else {}}
|
|
35
|
+
return None
|
|
36
|
+
try:
|
|
37
|
+
data = json.loads(body or "")
|
|
38
|
+
except ValueError:
|
|
39
|
+
return None
|
|
40
|
+
if not isinstance(data, dict):
|
|
41
|
+
return None
|
|
42
|
+
has_query = isinstance(data.get("query"), str) and "{" in data["query"]
|
|
43
|
+
extensions = data.get("extensions")
|
|
44
|
+
persisted = isinstance(extensions, dict) and "persistedQuery" in extensions
|
|
45
|
+
if not (has_query or persisted):
|
|
46
|
+
return None
|
|
47
|
+
return data
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def operation_name(payload: dict[str, Any]) -> str | None:
|
|
51
|
+
name = payload.get("operationName")
|
|
52
|
+
if isinstance(name, str) and _OP_NAME.match(name):
|
|
53
|
+
return name
|
|
54
|
+
match = _NAMED_OP.search(str(payload.get("query") or ""))
|
|
55
|
+
return match.group(2) if match else None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def operation_of(method: str, url: str, body: str | None) -> str | None:
|
|
59
|
+
"""'GetUser' for a GraphQL request with a named operation, else None."""
|
|
60
|
+
payload = graphql_payload(method, url, body)
|
|
61
|
+
return operation_name(payload) if payload is not None else None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def split_template(template: str) -> tuple[str, str | None]:
|
|
65
|
+
"""'/graphql#GetUser' -> ('/graphql', 'GetUser'); other templates are unchanged."""
|
|
66
|
+
path, _, op = template.partition("#")
|
|
67
|
+
return path, op or None
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""The endpoint map: what the analyzer learns from captured traffic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
ParamType = Literal["integer", "number", "boolean", "string"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class QueryParam:
|
|
13
|
+
name: str
|
|
14
|
+
required: bool # present in every sample
|
|
15
|
+
constant: bool # same value in every sample (and seen more than once)
|
|
16
|
+
type: ParamType
|
|
17
|
+
examples: list[str] = field(default_factory=list)
|
|
18
|
+
sensitive: bool = False # value was redacted, so the real one is unknown
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def kind(self) -> str:
|
|
22
|
+
if self.constant:
|
|
23
|
+
return "constant"
|
|
24
|
+
return "required" if self.required else "optional"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class PathParam:
|
|
29
|
+
name: str
|
|
30
|
+
type: ParamType
|
|
31
|
+
examples: list[str] = field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Endpoint:
|
|
36
|
+
method: str
|
|
37
|
+
host: str
|
|
38
|
+
path_template: str # e.g. /api/books/{id}
|
|
39
|
+
path_params: list[PathParam] = field(default_factory=list)
|
|
40
|
+
query_params: list[QueryParam] = field(default_factory=list)
|
|
41
|
+
request_schema: dict[str, Any] | None = None
|
|
42
|
+
response_schema: dict[str, Any] | None = None
|
|
43
|
+
sample_count: int = 0
|
|
44
|
+
request_samples: int = 0 # JSON request bodies that fed the request schema
|
|
45
|
+
response_samples: int = 0 # successful JSON responses that fed the response schema
|
|
46
|
+
statuses: list[int] = field(default_factory=list)
|
|
47
|
+
example_urls: list[str] = field(default_factory=list)
|
|
48
|
+
third_party: bool = False # served from a different domain than the target site
|
|
49
|
+
# Set by the prober (Stage 5):
|
|
50
|
+
replayable: bool | None = None # None = not tested
|
|
51
|
+
auth_type: str | None = None
|
|
52
|
+
pagination: dict[str, Any] | None = None
|
|
53
|
+
required_headers: list[str] = field(default_factory=list)
|
|
54
|
+
# Set by verification (Stage 7): discovered | verified | unverified | failed
|
|
55
|
+
status: str = "discovered"
|
|
56
|
+
status_note: str = ""
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def key(self) -> str:
|
|
60
|
+
return f"{self.method} {self.host}{self.path_template}"
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def label(self) -> str:
|
|
64
|
+
return f"{self.method} {self.path_template}"
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Infer JSON schemas from sample bodies (FR-19).
|
|
2
|
+
|
|
3
|
+
genson merges samples: a field missing from any sample is left out of "required"
|
|
4
|
+
(so it becomes optional), and a field seen as both null and a string becomes
|
|
5
|
+
["null", "string"].
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from genson import SchemaBuilder
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_json(body: str | None) -> Any | None:
|
|
17
|
+
"""Parse a body, returning None if it isn't valid JSON."""
|
|
18
|
+
if not body:
|
|
19
|
+
return None
|
|
20
|
+
try:
|
|
21
|
+
return json.loads(body)
|
|
22
|
+
except ValueError:
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_schema(samples: list[Any]) -> dict[str, Any] | None:
|
|
27
|
+
if not samples:
|
|
28
|
+
return None
|
|
29
|
+
builder = SchemaBuilder(schema_uri=None)
|
|
30
|
+
for sample in samples:
|
|
31
|
+
builder.add_object(sample)
|
|
32
|
+
schema: dict[str, Any] = builder.to_schema()
|
|
33
|
+
return schema
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
MAX_LABEL_DEPTH = 3
|
|
37
|
+
MAX_LABEL_CHARS = 60
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def type_label(schema: dict[str, Any] | None, depth: int = 0) -> str:
|
|
41
|
+
"""Short human-readable description: 'object', 'array[object]', 'string|null'.
|
|
42
|
+
|
|
43
|
+
Deeply nested arrays (Google Maps returns 9 levels) are cut at MAX_LABEL_DEPTH."""
|
|
44
|
+
if not schema:
|
|
45
|
+
return "?"
|
|
46
|
+
if depth >= MAX_LABEL_DEPTH:
|
|
47
|
+
return "…"
|
|
48
|
+
if "anyOf" in schema:
|
|
49
|
+
return "|".join(type_label(s, depth) for s in schema["anyOf"])
|
|
50
|
+
kind = schema.get("type", "any")
|
|
51
|
+
if isinstance(kind, list):
|
|
52
|
+
return "|".join(kind)
|
|
53
|
+
if kind == "array":
|
|
54
|
+
return f"array[{type_label(schema.get('items'), depth + 1)}]"
|
|
55
|
+
return str(kind)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _shorten(text: str) -> str:
|
|
59
|
+
return text if len(text) <= MAX_LABEL_CHARS else text[: MAX_LABEL_CHARS - 1] + "…"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def top_level_fields(schema: dict[str, Any] | None, limit: int = 8) -> str:
|
|
63
|
+
"""'has_next, page, quotes[] (+2 more)' — a one-line summary for tables."""
|
|
64
|
+
if not schema:
|
|
65
|
+
return "-"
|
|
66
|
+
target = schema
|
|
67
|
+
prefix = ""
|
|
68
|
+
if schema.get("type") == "array":
|
|
69
|
+
target = schema.get("items") or {}
|
|
70
|
+
prefix = "[] of "
|
|
71
|
+
props = target.get("properties")
|
|
72
|
+
if not props:
|
|
73
|
+
return _shorten(prefix + type_label(target))
|
|
74
|
+
names = [f"{k}[]" if v.get("type") == "array" else k for k, v in props.items()]
|
|
75
|
+
shown = ", ".join(names[:limit])
|
|
76
|
+
more = f" (+{len(names) - limit} more)" if len(names) > limit else ""
|
|
77
|
+
return prefix + shown + more
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def field_paths(schema: dict[str, Any] | None, max_depth: int = 3, limit: int = 20) -> list[str]:
|
|
81
|
+
"""Leaf field paths, e.g. ['page', 'quotes[].author.name', 'quotes[].tags[]'].
|
|
82
|
+
|
|
83
|
+
Lets the agent see what data an endpoint already returns, including nested fields.
|
|
84
|
+
"""
|
|
85
|
+
paths: list[str] = []
|
|
86
|
+
|
|
87
|
+
def walk(node: dict[str, Any], prefix: str, depth: int) -> None:
|
|
88
|
+
if len(paths) >= limit:
|
|
89
|
+
return
|
|
90
|
+
kind = node.get("type")
|
|
91
|
+
if kind == "array" or (isinstance(kind, list) and "array" in kind):
|
|
92
|
+
items = node.get("items") or {}
|
|
93
|
+
if items.get("properties") and depth < max_depth:
|
|
94
|
+
walk(items, prefix + "[]", depth)
|
|
95
|
+
else:
|
|
96
|
+
paths.append(prefix + "[]")
|
|
97
|
+
return
|
|
98
|
+
props = node.get("properties")
|
|
99
|
+
if props and depth < max_depth:
|
|
100
|
+
for key, child in props.items():
|
|
101
|
+
walk(child, f"{prefix}.{key}" if prefix else key, depth + 1)
|
|
102
|
+
if len(paths) >= limit:
|
|
103
|
+
return
|
|
104
|
+
elif prefix:
|
|
105
|
+
paths.append(prefix)
|
|
106
|
+
|
|
107
|
+
if schema:
|
|
108
|
+
walk(schema, "", 0)
|
|
109
|
+
return paths
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Group concrete URLs into path templates (FR-17).
|
|
2
|
+
|
|
3
|
+
/api/books/1, /api/books/2 -> /api/books/{id}
|
|
4
|
+
/users/7/posts/19 -> /users/{user_id}/posts/{id}
|
|
5
|
+
/author/albert-einstein, /author/... -> /author/{slug}
|
|
6
|
+
|
|
7
|
+
Two rules decide that a path segment is a parameter:
|
|
8
|
+
1. It looks like an identifier on its own (number, UUID, hex hash, date, long token).
|
|
9
|
+
2. It varies across URLs that are identical everywhere else, and the values look
|
|
10
|
+
like slugs, or there are many distinct values. Plain words such as /api/quotes
|
|
11
|
+
vs /api/authors stay literal, because they are usually different endpoints.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from collections import defaultdict
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from urllib.parse import unquote, urlsplit
|
|
20
|
+
|
|
21
|
+
from ghost_api.analysis.models import ParamType
|
|
22
|
+
|
|
23
|
+
_INT_RE = re.compile(r"^-?\d+$")
|
|
24
|
+
_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I)
|
|
25
|
+
_HEX_RE = re.compile(r"^(?=.*\d)[0-9a-f]{16,}$", re.I)
|
|
26
|
+
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
27
|
+
# Long mixed tokens like Mongo/Firebase/base64 IDs: letters AND digits, 20+ chars, no dashes.
|
|
28
|
+
_TOKEN_RE = re.compile(r"^(?=.*\d)(?=.*[A-Za-z])[A-Za-z0-9_]{20,}$")
|
|
29
|
+
_SLUG_RE = re.compile(r"^[a-z0-9]+([-_.][a-z0-9]+)+$", re.I)
|
|
30
|
+
_VERSION_RE = re.compile(r"^v\d+(\.\d+)*$", re.I)
|
|
31
|
+
# Encoded data rather than a name: "pb=!1m8!3m7..." (Google Maps tiles), "@12.9,77.6,15z",
|
|
32
|
+
# matrix parameters ("items;page=2"), or anything very long.
|
|
33
|
+
_OPAQUE_RE = re.compile(r"[!=;,@]")
|
|
34
|
+
OPAQUE_LENGTH = 48
|
|
35
|
+
_KEY_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]{0,30})=")
|
|
36
|
+
|
|
37
|
+
# A varying segment with at least this many distinct values becomes a parameter even
|
|
38
|
+
# when the values are plain words.
|
|
39
|
+
HIGH_CARDINALITY = 6
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def split_path(path: str) -> list[str]:
|
|
43
|
+
return [unquote(s) for s in path.split("/") if s]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def looks_like_id(segment: str) -> bool:
|
|
47
|
+
if _VERSION_RE.match(segment):
|
|
48
|
+
return False
|
|
49
|
+
return bool(
|
|
50
|
+
_INT_RE.match(segment)
|
|
51
|
+
or _UUID_RE.match(segment)
|
|
52
|
+
or _HEX_RE.match(segment)
|
|
53
|
+
or _DATE_RE.match(segment)
|
|
54
|
+
or _TOKEN_RE.match(segment)
|
|
55
|
+
or is_opaque(segment)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def is_opaque(segment: str) -> bool:
|
|
60
|
+
return len(segment) > OPAQUE_LENGTH or bool(_OPAQUE_RE.search(segment))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _is_leading_version(pos: int, segment: str, length: int) -> bool:
|
|
64
|
+
return pos == 0 and length > 1 and segment.isdigit() and len(segment) <= 2
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def looks_like_slug(segment: str) -> bool:
|
|
68
|
+
if _VERSION_RE.match(segment):
|
|
69
|
+
return False
|
|
70
|
+
return bool(_SLUG_RE.match(segment)) or any(c.isdigit() for c in segment)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def infer_type(values: list[str]) -> ParamType:
|
|
74
|
+
if values and all(_INT_RE.match(v) for v in values):
|
|
75
|
+
return "integer"
|
|
76
|
+
if values and all(re.match(r"^-?\d+\.\d+$", v) or _INT_RE.match(v) for v in values):
|
|
77
|
+
return "number"
|
|
78
|
+
if values and all(v.lower() in ("true", "false") for v in values):
|
|
79
|
+
return "boolean"
|
|
80
|
+
return "string"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
_INVARIANT = {"news", "series", "species", "data", "media", "settings", "metrics"}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def singular(word: str) -> str:
|
|
87
|
+
word = re.sub(r"[^a-zA-Z0-9]+", "_", word).strip("_").lower()
|
|
88
|
+
if word.endswith("ies") and len(word) > 4:
|
|
89
|
+
return word[:-3] + "y"
|
|
90
|
+
if word.endswith(("xes", "ches", "shes", "sses", "zes")):
|
|
91
|
+
return word[:-2]
|
|
92
|
+
if word in _INVARIANT or word.endswith(("us", "is", "ss")):
|
|
93
|
+
return word # status, analysis, address, news
|
|
94
|
+
if word.endswith("s") and len(word) > 3:
|
|
95
|
+
return word[:-1]
|
|
96
|
+
return word
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class _Shape:
|
|
101
|
+
"""URLs sharing method, host, segment count and trailing slash."""
|
|
102
|
+
|
|
103
|
+
method: str
|
|
104
|
+
host: str
|
|
105
|
+
trailing_slash: bool
|
|
106
|
+
urls: list[str]
|
|
107
|
+
segments: list[list[str]]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class TemplateMatch:
|
|
112
|
+
"""Where one concrete URL landed: its template and the path-parameter values."""
|
|
113
|
+
|
|
114
|
+
method: str
|
|
115
|
+
host: str
|
|
116
|
+
template: str
|
|
117
|
+
param_names: tuple[str, ...]
|
|
118
|
+
param_values: tuple[str, ...]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def group_urls(requests: list[tuple[str, str]]) -> list[TemplateMatch]:
|
|
122
|
+
"""Map each (method, url) to a template. Output order matches input order."""
|
|
123
|
+
shapes: dict[tuple[str, str, int, bool], _Shape] = {}
|
|
124
|
+
placement: list[tuple[tuple[str, str, int, bool], int]] = []
|
|
125
|
+
for method, url in requests:
|
|
126
|
+
parts = urlsplit(url)
|
|
127
|
+
segs = split_path(parts.path)
|
|
128
|
+
trailing = parts.path.endswith("/") and bool(segs)
|
|
129
|
+
key = (method.upper(), (parts.hostname or "").lower(), len(segs), trailing)
|
|
130
|
+
shape = shapes.setdefault(key, _Shape(key[0], key[1], trailing, [], []))
|
|
131
|
+
placement.append((key, len(shape.urls)))
|
|
132
|
+
shape.urls.append(url)
|
|
133
|
+
shape.segments.append(segs)
|
|
134
|
+
|
|
135
|
+
templated = {key: _template_shape(shape) for key, shape in shapes.items()}
|
|
136
|
+
return [templated[key][i] for key, i in placement]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _template_shape(shape: _Shape) -> list[TemplateMatch]:
|
|
140
|
+
# Rule 1: each segment that looks like an ID is a parameter, URL by URL. A short number
|
|
141
|
+
# as the first of several segments is an API version (/1/indexes/...), not an ID.
|
|
142
|
+
masks = [
|
|
143
|
+
[
|
|
144
|
+
looks_like_id(seg) and not _is_leading_version(p, seg, len(segs))
|
|
145
|
+
for p, seg in enumerate(segs)
|
|
146
|
+
]
|
|
147
|
+
for segs in shape.segments
|
|
148
|
+
]
|
|
149
|
+
n = len(shape.segments[0]) if shape.segments else 0
|
|
150
|
+
|
|
151
|
+
# Rule 2: among URLs identical everywhere except `pos`, a varying slug-like or
|
|
152
|
+
# high-cardinality segment is a parameter.
|
|
153
|
+
for pos in range(n):
|
|
154
|
+
groups: dict[tuple[str | None, ...], list[int]] = defaultdict(list)
|
|
155
|
+
for i, segs in enumerate(shape.segments):
|
|
156
|
+
if masks[i][pos]:
|
|
157
|
+
continue
|
|
158
|
+
key = tuple(None if masks[i][p] else segs[p] for p in range(n) if p != pos)
|
|
159
|
+
groups[key].append(i)
|
|
160
|
+
for key, members in groups.items():
|
|
161
|
+
distinct = {shape.segments[i][pos] for i in members}
|
|
162
|
+
anchored = any(k is not None for k in key)
|
|
163
|
+
if len(distinct) > 1 and (
|
|
164
|
+
(anchored and all(looks_like_slug(v) for v in distinct))
|
|
165
|
+
or len(distinct) >= HIGH_CARDINALITY
|
|
166
|
+
):
|
|
167
|
+
for i in members:
|
|
168
|
+
masks[i][pos] = True
|
|
169
|
+
|
|
170
|
+
# Name parameters per final template, using that template's own values.
|
|
171
|
+
by_skeleton: dict[tuple[str | None, ...], list[int]] = defaultdict(list)
|
|
172
|
+
for i, segs in enumerate(shape.segments):
|
|
173
|
+
by_skeleton[tuple(None if masks[i][p] else segs[p] for p in range(n))].append(i)
|
|
174
|
+
|
|
175
|
+
results: list[TemplateMatch | None] = [None] * len(shape.segments)
|
|
176
|
+
for skeleton, members in by_skeleton.items():
|
|
177
|
+
names = _param_names(list(skeleton), [shape.segments[i] for i in members])
|
|
178
|
+
out = [("{" + names[p] + "}") if seg is None else seg for p, seg in enumerate(skeleton)]
|
|
179
|
+
template = "/" + "/".join(out) + ("/" if shape.trailing_slash else "")
|
|
180
|
+
param_pos = [p for p, seg in enumerate(skeleton) if seg is None]
|
|
181
|
+
for i in members:
|
|
182
|
+
results[i] = TemplateMatch(
|
|
183
|
+
method=shape.method,
|
|
184
|
+
host=shape.host,
|
|
185
|
+
template=template,
|
|
186
|
+
param_names=tuple(names[p] for p in param_pos),
|
|
187
|
+
param_values=tuple(shape.segments[i][p] for p in param_pos),
|
|
188
|
+
)
|
|
189
|
+
return [r for r in results if r is not None]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _param_names(skeleton: list[str | None], samples: list[list[str]]) -> list[str]:
|
|
193
|
+
"""Name parameters after the literal segment before them: /users/{user_id}/posts/{id}."""
|
|
194
|
+
names = [""] * len(skeleton)
|
|
195
|
+
param_positions = [p for p, seg in enumerate(skeleton) if seg is None]
|
|
196
|
+
used: set[str] = set()
|
|
197
|
+
for idx, pos in enumerate(param_positions):
|
|
198
|
+
prev = skeleton[pos - 1] if pos > 0 else None
|
|
199
|
+
values = [segs[pos] for segs in samples]
|
|
200
|
+
keys = {m.group(1) for v in values if (m := _KEY_RE.match(v))}
|
|
201
|
+
if len(keys) == 1 and all(_KEY_RE.match(v) for v in values):
|
|
202
|
+
name = keys.pop().lower() # /maps/vt/{pb} for "pb=!1m8..."
|
|
203
|
+
elif idx == len(param_positions) - 1:
|
|
204
|
+
if all(looks_like_id(v) for v in values):
|
|
205
|
+
name = "id"
|
|
206
|
+
else: # /author/{author}, /indexes/{index}
|
|
207
|
+
name = singular(prev) if prev else "slug"
|
|
208
|
+
elif prev:
|
|
209
|
+
name = f"{singular(prev)}_id"
|
|
210
|
+
else:
|
|
211
|
+
name = f"param{idx + 1}"
|
|
212
|
+
base, k = name, 2
|
|
213
|
+
while name in used:
|
|
214
|
+
name, k = f"{base}{k}", k + 1
|
|
215
|
+
used.add(name)
|
|
216
|
+
names[pos] = name
|
|
217
|
+
return names
|