snapline-api-adapters 0.1.10__tar.gz

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.
@@ -0,0 +1,5 @@
1
+ /*.iml
2
+ .idea
3
+ dist/
4
+ build/
5
+ *.egg-info/
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: snapline-api-adapters
3
+ Version: 0.1.10
4
+ Summary: REST, GraphQL, and SOAP API adapters for Snapline
5
+ Project-URL: Homepage, https://github.com/vaagatech/snapline-python
6
+ Project-URL: Repository, https://github.com/vaagatech/snapline-python
7
+ Author-email: VaagaTech <info@vaagatech.com>
8
+ License-Expression: MIT
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: httpx>=0.27.0
11
+ Requires-Dist: snapline-engine==0.1.10
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "snapline-api-adapters"
7
+ version = "0.1.10"
8
+ description = "REST, GraphQL, and SOAP API adapters for Snapline"
9
+ license = "MIT"
10
+ requires-python = ">=3.10"
11
+ authors = [{ name = "VaagaTech", email = "info@vaagatech.com" }]
12
+ dependencies = [
13
+ "snapline-engine==0.1.10",
14
+ "httpx>=0.27.0",
15
+ ]
16
+
17
+ [project.urls]
18
+ Homepage = "https://github.com/vaagatech/snapline-python"
19
+ Repository = "https://github.com/vaagatech/snapline-python"
20
+
21
+ [tool.hatch.build.targets.wheel]
22
+ packages = ["snapline"]
@@ -0,0 +1,29 @@
1
+ from .api_factory import api
2
+ from .execute_api import execute_api
3
+ from .graphql.execute_graphql import execute_graphql
4
+ from .resolve_url import resolve_url
5
+ from .rest.execute_rest import execute_rest
6
+ from .soap.execute_soap import execute_soap
7
+ from .soap.xml_utils import build_soap_envelope, parse_soap_body
8
+ from .types import (
9
+ ApiExecuteContext,
10
+ ApiExecuteResult,
11
+ ApiRequestConfig,
12
+ is_graphql_config,
13
+ is_rest_config,
14
+ is_soap_config,
15
+ )
16
+
17
+ __all__ = [
18
+ "api",
19
+ "build_soap_envelope",
20
+ "execute_api",
21
+ "execute_graphql",
22
+ "execute_rest",
23
+ "execute_soap",
24
+ "is_graphql_config",
25
+ "is_rest_config",
26
+ "is_soap_config",
27
+ "parse_soap_body",
28
+ "resolve_url",
29
+ ]
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .types import GraphqlApiConfig, RestApiConfig, SoapApiConfig
6
+
7
+
8
+ class ApiFactory:
9
+ @staticmethod
10
+ def rest(config: dict[str, Any]) -> RestApiConfig:
11
+ return {"protocol": "rest", **config}
12
+
13
+ @staticmethod
14
+ def soap(config: dict[str, Any]) -> SoapApiConfig:
15
+ return {"protocol": "soap", **config}
16
+
17
+ @staticmethod
18
+ def graphql(config: dict[str, Any]) -> GraphqlApiConfig:
19
+ return {"protocol": "graphql", **config}
20
+
21
+
22
+ api = ApiFactory()
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .graphql.execute_graphql import execute_graphql
6
+ from .rest.execute_rest import execute_rest
7
+ from .soap.execute_soap import execute_soap
8
+ from .types import (
9
+ ApiExecuteContext,
10
+ ApiExecuteResult,
11
+ ApiRequestConfig,
12
+ is_graphql_config,
13
+ is_rest_config,
14
+ is_soap_config,
15
+ )
16
+
17
+
18
+ def execute_api(
19
+ config: ApiRequestConfig,
20
+ context: ApiExecuteContext | dict[str, Any] | None = None,
21
+ ) -> ApiExecuteResult:
22
+ if is_rest_config(config):
23
+ return execute_rest(config, context)
24
+ if is_soap_config(config):
25
+ return execute_soap(config, context)
26
+ if is_graphql_config(config):
27
+ return execute_graphql(config, context)
28
+ raise ValueError("Unsupported API protocol")
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from snapline.engine import load_json_file
10
+
11
+ from ..resolve_url import resolve_url
12
+ from ..types import ApiExecuteContext, ApiExecuteResult, GraphqlApiConfig
13
+
14
+
15
+ def _default_fetch(
16
+ url: str,
17
+ *,
18
+ method: str = "GET",
19
+ headers: dict[str, str] | None = None,
20
+ content: str | bytes | None = None,
21
+ data: str | bytes | None = None,
22
+ ) -> httpx.Response:
23
+ body = content if content is not None else data
24
+ return httpx.request(method, url, headers=headers, content=body)
25
+
26
+
27
+ def _load_query(config: GraphqlApiConfig) -> str:
28
+ if config.get("query"):
29
+ return config["query"]
30
+
31
+ query_file = config.get("queryFile")
32
+ if not query_file:
33
+ return ""
34
+
35
+ raw = Path(query_file).read_text(encoding="utf-8").strip()
36
+ if raw.startswith("{"):
37
+ parsed = json.loads(raw)
38
+ return parsed.get("query", raw)
39
+ return raw
40
+
41
+
42
+ def _get_by_path(obj: Any, path: str | None) -> Any:
43
+ if not path:
44
+ return obj
45
+
46
+ cursor = obj
47
+ for key in path.split("."):
48
+ if isinstance(cursor, dict) and key in cursor:
49
+ cursor = cursor[key]
50
+ else:
51
+ return None
52
+ return cursor
53
+
54
+
55
+ def execute_graphql(
56
+ config: GraphqlApiConfig,
57
+ context: ApiExecuteContext | dict[str, Any] | None = None,
58
+ ) -> ApiExecuteResult:
59
+ ctx = context or {}
60
+ base_url = ctx.get("baseUrl")
61
+ auth_headers = ctx.get("authHeaders", {})
62
+ fetch_impl = ctx.get("fetchImpl") or _default_fetch
63
+ input_from_row = ctx.get("inputFromRow")
64
+
65
+ query = _load_query(config)
66
+ variables: dict[str, Any] = dict(config.get("variables") or {})
67
+
68
+ if config.get("variablesFile"):
69
+ variables = load_json_file(config["variablesFile"])
70
+ if config.get("inputFile"):
71
+ variables = load_json_file(config["inputFile"])
72
+ if input_from_row:
73
+ variables = {**variables, **input_from_row}
74
+
75
+ url = resolve_url(config["endpoint"], base_url)
76
+ response = fetch_impl(
77
+ url,
78
+ method="POST",
79
+ headers={
80
+ "Content-Type": "application/json",
81
+ "Accept": "application/json",
82
+ **auth_headers,
83
+ **(config.get("headers") or {}),
84
+ },
85
+ content=json.dumps({"query": query, "variables": variables}),
86
+ )
87
+
88
+ text = response.text
89
+ response_headers = dict(response.headers)
90
+
91
+ try:
92
+ parsed = json.loads(text) if text else None
93
+ except json.JSONDecodeError:
94
+ parsed = text
95
+
96
+ gql_data = parsed.get("data") if isinstance(parsed, dict) and "data" in parsed else parsed
97
+ data = _get_by_path(gql_data, config.get("dataPath"))
98
+
99
+ return {
100
+ "status": response.status_code,
101
+ "data": data,
102
+ "headers": response_headers,
103
+ "raw": text,
104
+ }
@@ -0,0 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+ from urllib.parse import urljoin, urlparse
4
+
5
+
6
+ def resolve_url(endpoint: str, base_url: str | None = None) -> str:
7
+ if urlparse(endpoint).scheme:
8
+ return endpoint
9
+ if not base_url:
10
+ return endpoint
11
+ return urljoin(base_url.rstrip("/") + "/", endpoint.lstrip("/"))
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+ from urllib.parse import urlencode, urlparse, urlunparse, parse_qsl
6
+
7
+ import httpx
8
+
9
+ from snapline.engine import load_json_file
10
+
11
+ from ..resolve_url import resolve_url
12
+ from ..types import ApiExecuteContext, ApiExecuteResult, RestApiConfig
13
+
14
+
15
+ def _default_fetch(
16
+ url: str,
17
+ *,
18
+ method: str = "GET",
19
+ headers: dict[str, str] | None = None,
20
+ content: str | bytes | None = None,
21
+ data: str | bytes | None = None,
22
+ ) -> httpx.Response:
23
+ body = content if content is not None else data
24
+ return httpx.request(method, url, headers=headers, content=body)
25
+
26
+
27
+ def execute_rest(
28
+ config: RestApiConfig,
29
+ context: ApiExecuteContext | dict[str, Any] | None = None,
30
+ ) -> ApiExecuteResult:
31
+ ctx = context or {}
32
+ base_url = ctx.get("baseUrl")
33
+ auth_headers = ctx.get("authHeaders", {})
34
+ fetch_impl = ctx.get("fetchImpl") or _default_fetch
35
+ input_from_row = ctx.get("inputFromRow")
36
+
37
+ endpoint = config["endpoint"]
38
+ method = config.get("method") or "GET"
39
+ input_file = config.get("inputFile")
40
+ body = config.get("body")
41
+ headers = config.get("headers") or {}
42
+
43
+ url = resolve_url(endpoint, base_url)
44
+ payload: Any = body
45
+
46
+ if input_file:
47
+ payload = load_json_file(input_file)
48
+
49
+ if input_from_row:
50
+ if isinstance(payload, dict) and not isinstance(payload, list):
51
+ payload = {**payload, **input_from_row}
52
+ else:
53
+ payload = dict(input_from_row)
54
+
55
+ http_method = method.upper()
56
+ if input_from_row and http_method in {"GET", "HEAD"}:
57
+ parsed = urlparse(url)
58
+ query = dict(parse_qsl(parsed.query))
59
+ query.update({key: str(value) for key, value in input_from_row.items()})
60
+ url = urlunparse(parsed._replace(query=urlencode(query)))
61
+
62
+ request_headers = {
63
+ "Content-Type": "application/json",
64
+ "Accept": "application/json",
65
+ **auth_headers,
66
+ **headers,
67
+ }
68
+
69
+ request_body: str | None = None
70
+ if payload is not None and http_method not in {"GET", "HEAD"}:
71
+ request_body = json.dumps(payload)
72
+
73
+ response = fetch_impl(
74
+ url,
75
+ method=http_method,
76
+ headers=request_headers,
77
+ content=request_body,
78
+ )
79
+
80
+ text = response.text
81
+ response_headers = dict(response.headers)
82
+
83
+ try:
84
+ data = json.loads(text) if text else None
85
+ except json.JSONDecodeError:
86
+ data = text
87
+
88
+ return {
89
+ "status": response.status_code,
90
+ "data": data,
91
+ "headers": response_headers,
92
+ "raw": text,
93
+ }
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import httpx
7
+
8
+ from ..resolve_url import resolve_url
9
+ from ..soap.xml_utils import build_soap_envelope, parse_soap_body
10
+ from ..types import ApiExecuteContext, ApiExecuteResult, SoapApiConfig
11
+
12
+
13
+ def _default_fetch(
14
+ url: str,
15
+ *,
16
+ method: str = "GET",
17
+ headers: dict[str, str] | None = None,
18
+ content: str | bytes | None = None,
19
+ data: str | bytes | None = None,
20
+ ) -> httpx.Response:
21
+ body = content if content is not None else data
22
+ return httpx.request(method, url, headers=headers, content=body)
23
+
24
+
25
+ def _load_envelope(config: SoapApiConfig) -> str:
26
+ if config.get("envelope"):
27
+ return config["envelope"]
28
+
29
+ input_file = config.get("inputFile")
30
+ if input_file:
31
+ return Path(input_file).read_text(encoding="utf-8")
32
+
33
+ return build_soap_envelope(
34
+ "<GetUserRequest><email>unknown@example.com</email></GetUserRequest>"
35
+ )
36
+
37
+
38
+ def execute_soap(
39
+ config: SoapApiConfig,
40
+ context: ApiExecuteContext | dict[str, Any] | None = None,
41
+ ) -> ApiExecuteResult:
42
+ ctx = context or {}
43
+ base_url = ctx.get("baseUrl")
44
+ auth_headers = ctx.get("authHeaders", {})
45
+ fetch_impl = ctx.get("fetchImpl") or _default_fetch
46
+ input_from_row = ctx.get("inputFromRow")
47
+
48
+ envelope = _load_envelope(config)
49
+
50
+ if input_from_row and input_from_row.get("email"):
51
+ import re
52
+
53
+ envelope = re.sub(
54
+ r"<email>[^<]*</email>",
55
+ f"<email>{input_from_row['email']}</email>",
56
+ envelope,
57
+ flags=re.IGNORECASE,
58
+ )
59
+
60
+ url = resolve_url(config["endpoint"], base_url)
61
+ headers: dict[str, str] = {
62
+ "Content-Type": "text/xml; charset=utf-8",
63
+ "Accept": "text/xml",
64
+ **auth_headers,
65
+ **(config.get("headers") or {}),
66
+ }
67
+
68
+ if config.get("soapAction"):
69
+ headers["SOAPAction"] = config["soapAction"]
70
+
71
+ response = fetch_impl(
72
+ url,
73
+ method="POST",
74
+ headers=headers,
75
+ content=envelope,
76
+ )
77
+
78
+ text = response.text
79
+ response_headers = dict(response.headers)
80
+ data = parse_soap_body(text)
81
+
82
+ return {
83
+ "status": response.status_code,
84
+ "data": data,
85
+ "headers": response_headers,
86
+ "raw": text,
87
+ }
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+
7
+ def parse_soap_body(xml: str) -> dict[str, Any]:
8
+ body_match = re.search(
9
+ r"<(?:[\w]+:)?Body[^>]*>([\s\S]*?)</(?:[\w]+:)?Body>",
10
+ xml,
11
+ flags=re.IGNORECASE,
12
+ )
13
+ inner = body_match.group(1) if body_match else xml
14
+ result: dict[str, Any] = {}
15
+
16
+ tag_pattern = re.compile(
17
+ r"<(?:[\w]+:)?(\w+)[^>]*>([^<]*)</(?:[\w]+:)?\1>",
18
+ flags=re.IGNORECASE,
19
+ )
20
+ for match in tag_pattern.finditer(inner):
21
+ key = match.group(1)
22
+ value = (match.group(2) or "").strip()
23
+ if key and value is not None:
24
+ result[key] = value
25
+
26
+ return result
27
+
28
+
29
+ def build_soap_envelope(body_inner_xml: str) -> str:
30
+ return (
31
+ '<?xml version="1.0" encoding="UTF-8"?>'
32
+ '<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">'
33
+ "<soap:Body>"
34
+ f"{body_inner_xml}"
35
+ "</soap:Body>"
36
+ "</soap:Envelope>"
37
+ )
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any, Literal, Protocol, TypeAlias
5
+
6
+ HttpMethod: TypeAlias = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"]
7
+ ApiProtocol: TypeAlias = Literal["rest", "soap", "graphql"]
8
+
9
+
10
+ class FetchResponse(Protocol):
11
+ status_code: int
12
+ text: str
13
+ headers: dict[str, str]
14
+
15
+ def json(self) -> Any: ...
16
+
17
+
18
+ class FetchImpl(Protocol):
19
+ def __call__(
20
+ self,
21
+ url: str,
22
+ *,
23
+ method: str = "GET",
24
+ headers: dict[str, str] | None = None,
25
+ content: str | bytes | None = None,
26
+ data: str | bytes | None = None,
27
+ ) -> FetchResponse: ...
28
+
29
+
30
+ class ApiExecuteContext(dict):
31
+ baseUrl: str | None
32
+ authHeaders: dict[str, str]
33
+ fetchImpl: FetchImpl | None
34
+ inputFromRow: dict[str, Any] | None
35
+
36
+
37
+ class ApiExecuteResult(dict):
38
+ status: int
39
+ data: Any
40
+ headers: dict[str, str]
41
+ raw: str
42
+
43
+
44
+ class RestApiConfig(dict):
45
+ protocol: Literal["rest"]
46
+ endpoint: str
47
+ method: HttpMethod | None
48
+ inputFile: str | None
49
+ body: Any
50
+ headers: dict[str, str] | None
51
+
52
+
53
+ class SoapApiConfig(dict):
54
+ protocol: Literal["soap"]
55
+ endpoint: str
56
+ soapAction: str | None
57
+ envelope: str | None
58
+ inputFile: str | None
59
+ headers: dict[str, str] | None
60
+
61
+
62
+ class GraphqlApiConfig(dict):
63
+ protocol: Literal["graphql"]
64
+ endpoint: str
65
+ query: str | None
66
+ queryFile: str | None
67
+ variables: dict[str, Any] | None
68
+ variablesFile: str | None
69
+ inputFile: str | None
70
+ dataPath: str | None
71
+ headers: dict[str, str] | None
72
+
73
+
74
+ ApiRequestConfig = RestApiConfig | SoapApiConfig | GraphqlApiConfig
75
+
76
+
77
+ def is_rest_config(config: ApiRequestConfig) -> bool:
78
+ return config.get("protocol") == "rest"
79
+
80
+
81
+ def is_soap_config(config: ApiRequestConfig) -> bool:
82
+ return config.get("protocol") == "soap"
83
+
84
+
85
+ def is_graphql_config(config: ApiRequestConfig) -> bool:
86
+ return config.get("protocol") == "graphql"