pyhttp-app 2.0.1__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.
pyhttp/openapi.py ADDED
@@ -0,0 +1,365 @@
1
+ """Import OpenAPI 3.x / Swagger 2.0 specs and HAR files as collections.
2
+
3
+ Both produce the same import shape as postman.from_collection():
4
+ {name, folders, requests, variables, environments}.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import re
10
+ import uuid
11
+ from typing import Any
12
+ from urllib.parse import parse_qsl, urlsplit
13
+
14
+ METHODS = ("get", "put", "post", "delete", "options", "head", "patch", "trace")
15
+
16
+
17
+ def parse_text(text: str) -> Any:
18
+ """JSON, or YAML when PyYAML is installed (OpenAPI files are often YAML)."""
19
+ try:
20
+ return json.loads(text)
21
+ except ValueError:
22
+ pass
23
+ try:
24
+ import yaml # type: ignore[import-untyped]
25
+ except ImportError:
26
+ raise ValueError("not valid JSON (YAML needs the PyYAML package)") from None
27
+ try:
28
+ return yaml.safe_load(text)
29
+ except yaml.YAMLError as exc:
30
+ raise ValueError(f"not valid JSON or YAML: {exc}") from None
31
+
32
+
33
+ # ------------------------------------------------------------------ OpenAPI
34
+
35
+
36
+ def is_openapi(data: Any) -> bool:
37
+ return isinstance(data, dict) and isinstance(data.get("paths"), dict) and (
38
+ str(data.get("openapi", "")).startswith("3") or str(data.get("swagger", "")).startswith("2"))
39
+
40
+
41
+ class _Spec:
42
+ def __init__(self, data: dict[str, Any]) -> None:
43
+ self.data = data
44
+
45
+ def deref(self, obj: Any, seen: frozenset[str] = frozenset()) -> Any:
46
+ """Follow a local $ref (#/components/...); unknown or circular refs become {}."""
47
+ while isinstance(obj, dict) and isinstance(obj.get("$ref"), str):
48
+ ref = obj["$ref"]
49
+ if ref in seen or not ref.startswith("#/"):
50
+ return {}
51
+ seen = seen | {ref}
52
+ node: Any = self.data
53
+ for part in ref[2:].split("/"):
54
+ part = part.replace("~1", "/").replace("~0", "~")
55
+ node = node.get(part) if isinstance(node, dict) else None
56
+ obj = node if node is not None else {}
57
+ return obj
58
+
59
+ def example(self, schema: Any, depth: int = 0, seen: frozenset[str] = frozenset()) -> Any:
60
+ """A sample value for a JSON schema: example, default, enum[0], or a typed placeholder."""
61
+ if isinstance(schema, dict) and isinstance(schema.get("$ref"), str):
62
+ ref = schema["$ref"]
63
+ if ref in seen:
64
+ return {}
65
+ return self.example(self.deref(schema), depth, seen | {ref})
66
+ if not isinstance(schema, dict) or depth > 8:
67
+ return None
68
+ for key in ("example", "default"):
69
+ if key in schema:
70
+ return schema[key]
71
+ if isinstance(schema.get("examples"), list) and schema["examples"]:
72
+ return schema["examples"][0]
73
+ if schema.get("enum"):
74
+ return schema["enum"][0]
75
+ for key in ("allOf",):
76
+ if isinstance(schema.get(key), list):
77
+ merged: dict[str, Any] = {}
78
+ for part in schema[key]:
79
+ v = self.example(part, depth + 1, seen)
80
+ if isinstance(v, dict):
81
+ merged.update(v)
82
+ return merged
83
+ for key in ("oneOf", "anyOf"):
84
+ if isinstance(schema.get(key), list) and schema[key]:
85
+ return self.example(schema[key][0], depth + 1, seen)
86
+ t = schema.get("type")
87
+ if isinstance(t, list):
88
+ t = next((x for x in t if x != "null"), None)
89
+ if t == "object" or (t is None and "properties" in schema):
90
+ props = schema.get("properties") or {}
91
+ return {k: self.example(v, depth + 1, seen) for k, v in props.items()}
92
+ if t == "array":
93
+ item = self.example(schema.get("items") or {}, depth + 1, seen)
94
+ return [item] if item is not None else []
95
+ if t == "integer":
96
+ return 0
97
+ if t == "number":
98
+ return 0.0
99
+ if t == "boolean":
100
+ return True
101
+ if t == "string":
102
+ fmt = schema.get("format", "")
103
+ return {"date-time": "2024-01-01T00:00:00Z", "date": "2024-01-01", "email": "user@example.com",
104
+ "uuid": "00000000-0000-0000-0000-000000000000", "uri": "https://example.com",
105
+ "binary": ""}.get(fmt, "string")
106
+ return None
107
+
108
+
109
+ def _var_name(name: str) -> str:
110
+ """Path variables are `:name` with word characters only."""
111
+ v = re.sub(r"\W", "_", name)
112
+ return v if re.match(r"[A-Za-z_]", v) else "_" + v
113
+
114
+
115
+ def _base_url(data: dict[str, Any]) -> str:
116
+ if data.get("swagger"):
117
+ host = data.get("host") or "localhost"
118
+ scheme = (data.get("schemes") or ["https"])[0]
119
+ return f"{scheme}://{host}{data.get('basePath') or ''}".rstrip("/")
120
+ servers = data.get("servers") or []
121
+ if not servers or not isinstance(servers[0], dict):
122
+ return "http://localhost"
123
+ url = str(servers[0].get("url") or "")
124
+ for name, var in (servers[0].get("variables") or {}).items():
125
+ url = url.replace("{" + name + "}", str((var or {}).get("default", "")))
126
+ return url.rstrip("/") or "http://localhost"
127
+
128
+
129
+ def _security_auth(spec: _Spec, requirements: Any) -> dict[str, Any] | None:
130
+ """Map the first usable security requirement to a pyhttp auth (values left as variables)."""
131
+ data = spec.data
132
+ schemes = (data.get("components") or {}).get("securitySchemes") or data.get("securityDefinitions") or {}
133
+ if requirements == []:
134
+ return {"type": "none"} # security: [] turns authentication off
135
+ for req in requirements or []:
136
+ if not isinstance(req, dict):
137
+ continue
138
+ if not req:
139
+ return {"type": "none"} # {} in the list: security is optional
140
+ for name in req:
141
+ s = spec.deref(schemes.get(name) or {})
142
+ t, scheme = s.get("type"), str(s.get("scheme", "")).lower()
143
+ if (t == "http" and scheme == "basic") or t == "basic":
144
+ return {"type": "basic", "username": "{{username}}", "password": "{{password}}"}
145
+ if t == "http" and scheme == "bearer":
146
+ return {"type": "bearer", "token": "{{token}}", "prefix": "Bearer"}
147
+ if t == "apiKey" and s.get("in") in ("header", "query"):
148
+ return {"type": "apikey", "key": s.get("name") or "X-API-Key", "value": "{{apiKey}}",
149
+ "add_to": "query" if s.get("in") == "query" else "header"}
150
+ if t == "oauth2":
151
+ flows = s.get("flows") or {}
152
+ if "clientCredentials" in flows or s.get("flow") == "application":
153
+ f = flows.get("clientCredentials") or s
154
+ return {"type": "oauth2", "grant": "client_credentials", "token_url": f.get("tokenUrl", ""),
155
+ "client_id": "{{clientId}}", "client_secret": "{{clientSecret}}",
156
+ "scope": " ".join((f.get("scopes") or {}).keys())}
157
+ if "password" in flows or s.get("flow") == "password":
158
+ f = flows.get("password") or s
159
+ return {"type": "oauth2", "grant": "password", "token_url": f.get("tokenUrl", ""),
160
+ "client_id": "{{clientId}}", "client_secret": "{{clientSecret}}",
161
+ "username": "{{username}}", "password": "{{password}}",
162
+ "scope": " ".join((f.get("scopes") or {}).keys())}
163
+ return {"type": "bearer", "token": "{{token}}", "prefix": "Bearer"}
164
+ return None
165
+
166
+
167
+ def _param_value(spec: _Spec, p: dict[str, Any]) -> str:
168
+ if "example" in p:
169
+ v = p["example"]
170
+ else:
171
+ schema = p.get("schema") or {k: p[k] for k in ("type", "default", "enum", "format", "items") if k in p}
172
+ v = spec.example(schema)
173
+ if v is None or isinstance(v, (dict, list)):
174
+ return "" if v is None else json.dumps(v)
175
+ return str(v).lower() if isinstance(v, bool) else str(v)
176
+
177
+
178
+ def _body_from_content(spec: _Spec, content: dict[str, Any]) -> tuple[str, str, str]:
179
+ """(body_type, body text, content type) for an OpenAPI 3 requestBody content map."""
180
+ order = sorted(content, key=lambda ct: (0 if "json" in ct else 1 if "x-www-form-urlencoded" in ct
181
+ else 2 if "multipart" in ct else 3 if "xml" in ct else 4))
182
+ for ct in order:
183
+ media = content[ct] or {}
184
+ if "example" in media:
185
+ sample = media["example"]
186
+ elif isinstance(media.get("examples"), dict) and media["examples"]:
187
+ sample = spec.deref(next(iter(media["examples"].values()))).get("value")
188
+ else:
189
+ sample = spec.example(media.get("schema") or {})
190
+ if "json" in ct:
191
+ return "json", json.dumps(sample if sample is not None else {}, indent=2, ensure_ascii=False), ct
192
+ if "x-www-form-urlencoded" in ct or "multipart" in ct:
193
+ rows = sample if isinstance(sample, dict) else {}
194
+ lines = []
195
+ props = (spec.deref(media.get("schema") or {}).get("properties") or {})
196
+ for k, v in rows.items():
197
+ is_file = (spec.deref(props.get(k) or {}).get("format") == "binary")
198
+ lines.append(f"// {k}: @path/to/file" if is_file else f"{k}: {'' if v is None else (json.dumps(v) if isinstance(v, (dict, list)) else v)}")
199
+ return ("form" if "urlencoded" in ct else "multipart"), "\n".join(lines), ct
200
+ if "xml" in ct:
201
+ return "xml", sample if isinstance(sample, str) else "", ct
202
+ if ct.startswith("text/"):
203
+ return "text", sample if isinstance(sample, str) else "", ct
204
+ return "none", "", ""
205
+
206
+
207
+ def from_openapi(data: dict[str, Any]) -> dict[str, Any]:
208
+ spec = _Spec(data)
209
+ info = data.get("info") or {}
210
+ root_name = str(info.get("title") or "Imported API")
211
+ root_id = uuid.uuid4().hex
212
+ root_auth = _security_auth(spec, data.get("security")) or {"type": "none"}
213
+ folders: list[dict[str, Any]] = [{
214
+ "id": root_id, "name": root_name, "parent_id": None,
215
+ "description": "\n\n".join(x for x in (str(info.get("description") or ""),
216
+ f"Version {info['version']}" if info.get("version") else "") if x),
217
+ "auth": root_auth, "headers": [],
218
+ "variables": [{"key": "baseUrl", "value": _base_url(data), "enabled": True}],
219
+ }]
220
+ tag_folders: dict[str, str] = {}
221
+ requests: list[dict[str, Any]] = []
222
+
223
+ def folder_for(tag: str | None) -> str:
224
+ if not tag:
225
+ return root_id
226
+ if tag not in tag_folders:
227
+ tag_folders[tag] = uuid.uuid4().hex
228
+ desc = next((str(t.get("description") or "") for t in data.get("tags") or [] if isinstance(t, dict) and t.get("name") == tag), "")
229
+ folders.append({"id": tag_folders[tag], "name": tag, "parent_id": root_id, "description": desc,
230
+ "auth": {"type": "inherit"}, "headers": [], "variables": []})
231
+ return tag_folders[tag]
232
+
233
+ for path, item in (data.get("paths") or {}).items():
234
+ item = spec.deref(item)
235
+ if not isinstance(item, dict):
236
+ continue
237
+ shared = [spec.deref(p) for p in item.get("parameters") or []]
238
+ for method in METHODS:
239
+ op = item.get(method)
240
+ if not isinstance(op, dict):
241
+ continue
242
+ params_by_key = {(p.get("in"), p.get("name")): p for p in shared if isinstance(p, dict)}
243
+ for p in op.get("parameters") or []:
244
+ p = spec.deref(p)
245
+ if isinstance(p, dict):
246
+ params_by_key[(p.get("in"), p.get("name"))] = p
247
+ url_path = re.sub(r"\{([^}/]+)\}", lambda m: ":" + _var_name(m.group(1)), path)
248
+ query, headers, path_rows, form_rows = [], [], [], []
249
+ body_type, body, body_ct = "none", "", ""
250
+ for (where, name), p in params_by_key.items():
251
+ if not name:
252
+ continue
253
+ row = {"key": name, "value": _param_value(spec, p), "enabled": bool(p.get("required")) or where == "path"}
254
+ if where == "query":
255
+ query.append(row)
256
+ elif where == "header" and name.lower() not in ("authorization", "content-type", "accept"):
257
+ headers.append(row)
258
+ elif where == "path":
259
+ path_rows.append({**row, "key": _var_name(name)})
260
+ elif where == "formData": # Swagger 2
261
+ form_rows.append(f"// {name}: @path/to/file" if p.get("type") == "file" else f"{name}: {row['value']}")
262
+ elif where == "body": # Swagger 2
263
+ sample = spec.example(p.get("schema") or {})
264
+ body_type, body = "json", json.dumps(sample if sample is not None else {}, indent=2, ensure_ascii=False)
265
+ if form_rows and body_type == "none":
266
+ consumes = op.get("consumes") or data.get("consumes") or []
267
+ body_type = "multipart" if any("multipart" in c for c in consumes) else "form"
268
+ body = "\n".join(form_rows)
269
+ rb = spec.deref(op.get("requestBody") or {})
270
+ if isinstance(rb.get("content"), dict):
271
+ body_type, body, body_ct = _body_from_content(spec, rb["content"])
272
+ if body_ct and body_type in ("json", "xml", "text") and body_ct not in ("application/json", "application/xml", "text/plain"):
273
+ headers.append({"key": "Content-Type", "value": body_ct, "enabled": True})
274
+ auth = {"type": "inherit"}
275
+ if "security" in op:
276
+ auth = _security_auth(spec, op["security"]) or {"type": "inherit"}
277
+ desc_parts = [str(op.get("description") or "")]
278
+ if op.get("operationId"):
279
+ desc_parts.append(f"operationId: `{op['operationId']}`")
280
+ if op.get("deprecated"):
281
+ desc_parts.insert(0, "**Deprecated.**")
282
+ tags = op.get("tags") or []
283
+ requests.append({
284
+ "folder_id": folder_for(tags[0] if tags else None),
285
+ "name": str(op.get("summary") or op.get("operationId") or f"{method.upper()} {path}")[:200],
286
+ "description": "\n\n".join(x for x in desc_parts if x), "tags": [str(t) for t in tags[1:]],
287
+ "method": method.upper(), "url": "{{baseUrl}}" + url_path,
288
+ "params": path_rows + query, "headers": headers, "body_type": body_type, "body": body,
289
+ "bodies": [{"id": "default", "name": "Default", "body": body, "enabled": True}] if body else [],
290
+ "auth": auth, "tests": [], "timeout": 30,
291
+ })
292
+ return {"folders": folders, "requests": requests, "variables": {}, "environments": [], "name": root_name}
293
+
294
+
295
+ # ---------------------------------------------------------------------- HAR
296
+
297
+
298
+ def is_har(data: Any) -> bool:
299
+ return isinstance(data, dict) and isinstance(data.get("log"), dict) and isinstance(data["log"].get("entries"), list)
300
+
301
+
302
+ _SKIP_HEADERS = {"host", "content-length", "connection", "accept-encoding", "cookie"}
303
+ _SKIP_TYPES = (".js", ".css", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".woff", ".woff2", ".ttf", ".map", ".webp")
304
+
305
+
306
+ def from_har(data: dict[str, Any], include_assets: bool = False) -> dict[str, Any]:
307
+ """Every request in a browser HAR export, one folder per host. Static assets (scripts,
308
+ styles, images, fonts) are skipped unless include_assets is set."""
309
+ entries = data["log"]["entries"]
310
+ root_id = uuid.uuid4().hex
311
+ root_name = "HAR import"
312
+ pages = data["log"].get("pages") or []
313
+ if pages and isinstance(pages[0], dict) and pages[0].get("title"):
314
+ root_name = f"HAR: {str(pages[0]['title'])[:80]}"
315
+ folders = [{"id": root_id, "name": root_name, "parent_id": None, "description": "Imported from a HAR file.",
316
+ "auth": {"type": "none"}, "headers": [], "variables": []}]
317
+ host_folders: dict[str, str] = {}
318
+ requests: list[dict[str, Any]] = []
319
+ for e in entries:
320
+ r = (e or {}).get("request") or {}
321
+ url = str(r.get("url") or "")
322
+ if not url or url.startswith(("data:", "blob:", "chrome-extension:")):
323
+ continue
324
+ parts = urlsplit(url)
325
+ if not include_assets and parts.path.lower().endswith(_SKIP_TYPES):
326
+ continue
327
+ host = parts.netloc or "local"
328
+ if host not in host_folders:
329
+ host_folders[host] = uuid.uuid4().hex
330
+ folders.append({"id": host_folders[host], "name": host, "parent_id": root_id, "description": "",
331
+ "auth": {"type": "inherit"}, "headers": [], "variables": []})
332
+ base = parts._replace(query="", fragment="").geturl()
333
+ params = [{"key": k, "value": v, "enabled": True} for k, v in parse_qsl(parts.query, keep_blank_values=True)]
334
+ headers = [{"key": h.get("name", ""), "value": h.get("value", ""), "enabled": True}
335
+ for h in r.get("headers") or [] if isinstance(h, dict)
336
+ and h.get("name") and not h["name"].startswith(":") and h["name"].lower() not in _SKIP_HEADERS]
337
+ body_type, body = "none", ""
338
+ post = r.get("postData") or {}
339
+ mime = str(post.get("mimeType") or "").lower()
340
+ if post.get("params") and ("x-www-form-urlencoded" in mime or "multipart" in mime):
341
+ body_type = "multipart" if "multipart" in mime else "form"
342
+ body = "\n".join(f"{p.get('name', '')}: " + (f"@{p['fileName']}" if p.get("fileName") else str(p.get("value", "")))
343
+ for p in post["params"] if isinstance(p, dict))
344
+ headers = [h for h in headers if h["key"].lower() != "content-type"]
345
+ elif post.get("text") is not None:
346
+ body = str(post["text"])
347
+ body_type = "json" if "json" in mime else "xml" if "xml" in mime else "form" if "urlencoded" in mime else "text"
348
+ if body_type == "json":
349
+ try:
350
+ body = json.dumps(json.loads(body), indent=2, ensure_ascii=False)
351
+ except ValueError:
352
+ pass
353
+ elif body_type == "form":
354
+ body = "\n".join(f"{k}: {v}" for k, v in parse_qsl(body, keep_blank_values=True))
355
+ method = str(r.get("method") or "GET").upper()
356
+ status = ((e or {}).get("response") or {}).get("status")
357
+ requests.append({
358
+ "folder_id": host_folders[host], "name": f"{method} {parts.path or '/'}"[:200],
359
+ "description": f"Recorded response: HTTP {status}" if status else "", "tags": ["har"],
360
+ "method": method, "url": base, "params": params, "headers": headers,
361
+ "body_type": body_type, "body": body,
362
+ "bodies": [{"id": "default", "name": "Default", "body": body, "enabled": True}] if body else [],
363
+ "auth": {"type": "inherit"}, "tests": [], "timeout": 30,
364
+ })
365
+ return {"folders": folders, "requests": requests, "variables": {}, "environments": [], "name": root_name}