eyeriss-plugin-sdk 0.1.0__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,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: eyeriss-plugin-sdk
3
+ Version: 0.1.0
4
+ Summary: Eyeriss API Gateway Plugin SDK for Python
5
+ Author-email: Eyeriss <dev@eyeriss.io>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/eyeriss/eyeriss-sdk
8
+ Project-URL: Documentation, https://docs.eyeriss.io/plugins/sdk
9
+ Project-URL: Repository, https://github.com/eyeriss/eyeriss-sdk
10
+ Keywords: eyeriss,api-gateway,plugin,sdk
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
18
+ Classifier: Topic :: System :: Networking
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
@@ -0,0 +1,36 @@
1
+ """
2
+ Eyeriss Plugin SDK for Python
3
+
4
+ Provides the base class and data types for building Python plugins
5
+ for the Eyeriss API Gateway.
6
+
7
+ Usage:
8
+ from eyeriss_plugin_sdk import EyerissPlugin
9
+ from eyeriss_plugin_sdk.types import PluginContext, PluginResult, ErrorResponse
10
+
11
+ class Plugin(EyerissPlugin):
12
+ @property
13
+ def plugin_id(self) -> str:
14
+ return "my-plugin"
15
+
16
+ async def pre_request(self, context: PluginContext) -> PluginResult:
17
+ return PluginResult(proceed=True)
18
+ """
19
+
20
+ from eyeriss_plugin_sdk.plugin import EyerissPlugin
21
+ from eyeriss_plugin_sdk.types import (
22
+ ErrorResponse,
23
+ PluginContext,
24
+ PluginResult,
25
+ SerializedRequest,
26
+ )
27
+
28
+ __all__ = [
29
+ "EyerissPlugin",
30
+ "ErrorResponse",
31
+ "PluginContext",
32
+ "PluginResult",
33
+ "SerializedRequest",
34
+ ]
35
+
36
+ __version__ = "0.1.0"
@@ -0,0 +1,105 @@
1
+ """
2
+ EyerissPlugin Abstract Base Class
3
+
4
+ Base class for all Python plugins in the Eyeriss Gateway. Plugin authors
5
+ subclass this and implement the hooks they need. Default implementations
6
+ return proceed=True so plugins only need to override hooks they care about.
7
+ """
8
+
9
+ from abc import ABC, abstractmethod
10
+
11
+ from eyeriss_plugin_sdk.types import PluginContext, PluginResult
12
+
13
+
14
+ class EyerissPlugin(ABC):
15
+ """
16
+ Base class for all Python plugins in the Eyeriss Gateway.
17
+
18
+ Plugin authors must:
19
+ 1. Subclass this class
20
+ 2. Implement the `plugin_id` property (must match manifest.json id)
21
+ 3. Override one or more hook methods
22
+
23
+ Example:
24
+ class Plugin(EyerissPlugin):
25
+ @property
26
+ def plugin_id(self) -> str:
27
+ return "my-custom-plugin"
28
+
29
+ async def pre_request(self, context: PluginContext) -> PluginResult:
30
+ return PluginResult(
31
+ proceed=True,
32
+ modified_headers={"X-Plugin-Processed": "true"},
33
+ )
34
+ """
35
+
36
+ @property
37
+ @abstractmethod
38
+ def plugin_id(self) -> str:
39
+ """Unique plugin identifier (must match manifest.json 'id' field)."""
40
+ ...
41
+
42
+ async def pre_request(self, context: PluginContext) -> PluginResult:
43
+ """
44
+ Called after auth/RBAC pass, before proxying to backend.
45
+
46
+ Return PluginResult with proceed=False to block the request.
47
+ Return modified_headers to add/override outbound headers.
48
+ Return modified_body to replace the request body.
49
+
50
+ Default: proceed without modification.
51
+ """
52
+ return PluginResult(proceed=True)
53
+
54
+ async def post_response(self, context: PluginContext) -> PluginResult:
55
+ """
56
+ Called after backend response, before returning to client.
57
+
58
+ Return modified_headers to add/override response headers.
59
+ Return modified_body to replace the response body.
60
+ proceed=False is ignored for post_response (response already received).
61
+
62
+ Default: pass-through without modification.
63
+ """
64
+ return PluginResult(proceed=True)
65
+
66
+ async def on_error(self, context: PluginContext, error_info: dict) -> None:
67
+ """
68
+ Called on gateway error (auth failure, 5xx, timeout, etc.).
69
+
70
+ Observational only — return value is ignored. Cannot modify the
71
+ error response. Use for logging, alerting, or metrics.
72
+
73
+ Default: no-op.
74
+ """
75
+ pass
76
+
77
+ async def initialize(self) -> None:
78
+ """
79
+ Called once when the plugin is loaded at gateway startup.
80
+ Override for setup tasks (initialize state, validate config, etc.).
81
+
82
+ Default: no-op.
83
+ """
84
+ pass
85
+
86
+ async def configure(self, config: dict) -> None:
87
+ """
88
+ Called after initialize() with the plugin's configuration.
89
+
90
+ Override to apply settings from the admin UI / database config.
91
+ Use this instead of reading environment variables directly so
92
+ that configuration changes via the UI take effect immediately.
93
+
94
+ Default: no-op.
95
+ """
96
+ pass
97
+
98
+ async def cleanup(self) -> None:
99
+ """
100
+ Called when the plugin is unloaded (gateway shutdown or plugin removal).
101
+ Override for cleanup tasks (close connections, flush buffers, etc.).
102
+
103
+ Default: no-op.
104
+ """
105
+ pass
@@ -0,0 +1,168 @@
1
+ """
2
+ Eyeriss Plugin SDK — Data Types
3
+
4
+ Serializable data types for plugin hook arguments and return values.
5
+ All types are fully JSON-serializable for WASM compatibility.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+ from typing import Optional
10
+
11
+
12
+ @dataclass
13
+ class SerializedRequest:
14
+ """
15
+ Serialized representation of an HTTP request for plugin consumption.
16
+ Body is size-limited to prevent memory issues with large uploads.
17
+ """
18
+ method: str
19
+ path: str
20
+ headers: dict # {header_name: value} — lowercase keys
21
+ query_params: dict # {param_name: value}
22
+ content_type: str = ""
23
+ body: Optional[bytes] = None
24
+
25
+ def to_dict(self) -> dict:
26
+ """Serialize to a JSON-compatible dict (body as base64)."""
27
+ import base64
28
+ d = {
29
+ "method": self.method,
30
+ "path": self.path,
31
+ "headers": self.headers,
32
+ "query_params": self.query_params,
33
+ "content_type": self.content_type,
34
+ }
35
+ if self.body is not None:
36
+ d["body"] = base64.b64encode(self.body).decode("ascii")
37
+ d["body_encoding"] = "base64"
38
+ else:
39
+ d["body"] = None
40
+ return d
41
+
42
+ @classmethod
43
+ def from_dict(cls, d: dict) -> "SerializedRequest":
44
+ """Deserialize from a JSON-compatible dict."""
45
+ import base64
46
+ body = None
47
+ if d.get("body") is not None:
48
+ if d.get("body_encoding") == "base64":
49
+ body = base64.b64decode(d["body"])
50
+ elif isinstance(d["body"], bytes):
51
+ body = d["body"]
52
+ else:
53
+ body = d["body"].encode("utf-8")
54
+ return cls(
55
+ method=d["method"],
56
+ path=d["path"],
57
+ headers=d.get("headers", {}),
58
+ query_params=d.get("query_params", {}),
59
+ content_type=d.get("content_type", ""),
60
+ body=body,
61
+ )
62
+
63
+
64
+ @dataclass
65
+ class ErrorResponse:
66
+ """Error response returned by a plugin when blocking a request."""
67
+ status_code: int
68
+ body: dict
69
+ headers: Optional[dict] = None
70
+
71
+ def to_dict(self) -> dict:
72
+ d = {"status_code": self.status_code, "body": self.body}
73
+ if self.headers:
74
+ d["headers"] = self.headers
75
+ return d
76
+
77
+ @classmethod
78
+ def from_dict(cls, d: dict) -> "ErrorResponse":
79
+ return cls(
80
+ status_code=d["status_code"],
81
+ body=d["body"],
82
+ headers=d.get("headers"),
83
+ )
84
+
85
+
86
+ @dataclass
87
+ class PluginResult:
88
+ """
89
+ Result returned by a plugin hook execution.
90
+
91
+ - proceed=True: request continues to next plugin or to backend
92
+ - proceed=False: request is blocked; error_response is returned to client
93
+ - modified_headers: headers to merge into outbound request (pre_request)
94
+ or response (post_response)
95
+ - modified_body: replacement body bytes, or None to keep original
96
+ """
97
+ proceed: bool = True
98
+ modified_headers: Optional[dict] = None
99
+ modified_body: Optional[bytes] = None
100
+ error_response: Optional[ErrorResponse] = None
101
+
102
+ def to_dict(self) -> dict:
103
+ import base64
104
+ d = {"proceed": self.proceed}
105
+ if self.modified_headers is not None:
106
+ d["modified_headers"] = self.modified_headers
107
+ if self.modified_body is not None:
108
+ d["modified_body"] = base64.b64encode(self.modified_body).decode("ascii")
109
+ d["modified_body_encoding"] = "base64"
110
+ if self.error_response is not None:
111
+ d["error_response"] = self.error_response.to_dict()
112
+ return d
113
+
114
+ @classmethod
115
+ def from_dict(cls, d: dict) -> "PluginResult":
116
+ import base64
117
+ modified_body = None
118
+ if d.get("modified_body") is not None:
119
+ if d.get("modified_body_encoding") == "base64":
120
+ modified_body = base64.b64decode(d["modified_body"])
121
+ elif isinstance(d["modified_body"], bytes):
122
+ modified_body = d["modified_body"]
123
+ error_response = None
124
+ if d.get("error_response"):
125
+ error_response = ErrorResponse.from_dict(d["error_response"])
126
+ return cls(
127
+ proceed=d.get("proceed", True),
128
+ modified_headers=d.get("modified_headers"),
129
+ modified_body=modified_body,
130
+ error_response=error_response,
131
+ )
132
+
133
+
134
+ @dataclass
135
+ class PluginContext:
136
+ """
137
+ Context passed to plugin hooks.
138
+
139
+ Contains serialized request data and auth information. Must be fully
140
+ JSON-serializable for WASM compatibility.
141
+ """
142
+ request: SerializedRequest
143
+ application_id: int
144
+ endpoint_id: int
145
+ auth_result: dict
146
+ request_id: str
147
+ client_ip: str
148
+
149
+ def to_dict(self) -> dict:
150
+ return {
151
+ "request": self.request.to_dict(),
152
+ "application_id": self.application_id,
153
+ "endpoint_id": self.endpoint_id,
154
+ "auth_result": self.auth_result,
155
+ "request_id": self.request_id,
156
+ "client_ip": self.client_ip,
157
+ }
158
+
159
+ @classmethod
160
+ def from_dict(cls, d: dict) -> "PluginContext":
161
+ return cls(
162
+ request=SerializedRequest.from_dict(d["request"]),
163
+ application_id=d["application_id"],
164
+ endpoint_id=d["endpoint_id"],
165
+ auth_result=d.get("auth_result", {}),
166
+ request_id=d["request_id"],
167
+ client_ip=d["client_ip"],
168
+ )
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: eyeriss-plugin-sdk
3
+ Version: 0.1.0
4
+ Summary: Eyeriss API Gateway Plugin SDK for Python
5
+ Author-email: Eyeriss <dev@eyeriss.io>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/eyeriss/eyeriss-sdk
8
+ Project-URL: Documentation, https://docs.eyeriss.io/plugins/sdk
9
+ Project-URL: Repository, https://github.com/eyeriss/eyeriss-sdk
10
+ Keywords: eyeriss,api-gateway,plugin,sdk
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
18
+ Classifier: Topic :: System :: Networking
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
@@ -0,0 +1,10 @@
1
+ pyproject.toml
2
+ eyeriss_plugin_sdk/__init__.py
3
+ eyeriss_plugin_sdk/plugin.py
4
+ eyeriss_plugin_sdk/types.py
5
+ eyeriss_plugin_sdk.egg-info/PKG-INFO
6
+ eyeriss_plugin_sdk.egg-info/SOURCES.txt
7
+ eyeriss_plugin_sdk.egg-info/dependency_links.txt
8
+ eyeriss_plugin_sdk.egg-info/top_level.txt
9
+ tests/test_plugin.py
10
+ tests/test_types.py
@@ -0,0 +1 @@
1
+ eyeriss_plugin_sdk
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "eyeriss-plugin-sdk"
7
+ version = "0.1.0"
8
+ description = "Eyeriss API Gateway Plugin SDK for Python"
9
+ license = {text = "MIT"}
10
+ requires-python = ">=3.10"
11
+ authors = [
12
+ { name = "Eyeriss", email = "dev@eyeriss.io" },
13
+ ]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Topic :: Internet :: WWW/HTTP :: HTTP Servers",
22
+ "Topic :: System :: Networking",
23
+ "Typing :: Typed",
24
+ ]
25
+ keywords = ["eyeriss", "api-gateway", "plugin", "sdk"]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/eyeriss/eyeriss-sdk"
29
+ Documentation = "https://docs.eyeriss.io/plugins/sdk"
30
+ Repository = "https://github.com/eyeriss/eyeriss-sdk"
31
+
32
+ [tool.pytest.ini_options]
33
+ asyncio_mode = "auto"
34
+ testpaths = ["tests"]
35
+ python_files = ["test_*.py"]
36
+ python_functions = ["test_*"]
37
+
38
+ [tool.setuptools.packages.find]
39
+ where = ["."]
40
+ include = ["eyeriss_plugin_sdk*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,83 @@
1
+ """Tests for eyeriss_plugin_sdk.plugin — EyerissPlugin ABC."""
2
+
3
+ import pytest
4
+
5
+ from eyeriss_plugin_sdk import EyerissPlugin
6
+ from eyeriss_plugin_sdk.types import PluginContext, PluginResult, SerializedRequest
7
+
8
+
9
+ def _make_context():
10
+ return PluginContext(
11
+ request=SerializedRequest(
12
+ method="GET", path="/test",
13
+ headers={}, query_params={},
14
+ ),
15
+ application_id=1, endpoint_id=1,
16
+ auth_result={}, request_id="test-id",
17
+ client_ip="127.0.0.1",
18
+ )
19
+
20
+
21
+ class _MinimalPlugin(EyerissPlugin):
22
+ @property
23
+ def plugin_id(self) -> str:
24
+ return "test-minimal-plugin"
25
+
26
+
27
+ class TestEyerissPluginABC:
28
+
29
+ def test_cannot_instantiate_directly(self):
30
+ with pytest.raises(TypeError):
31
+ EyerissPlugin()
32
+
33
+ def test_must_implement_plugin_id(self):
34
+ class Incomplete(EyerissPlugin):
35
+ pass
36
+
37
+ with pytest.raises(TypeError):
38
+ Incomplete()
39
+
40
+ def test_minimal_subclass_instantiates(self):
41
+ plugin = _MinimalPlugin()
42
+ assert plugin.plugin_id == "test-minimal-plugin"
43
+
44
+
45
+ class TestDefaultHooks:
46
+
47
+ @pytest.mark.asyncio
48
+ async def test_pre_request_returns_proceed(self):
49
+ plugin = _MinimalPlugin()
50
+ result = await plugin.pre_request(_make_context())
51
+ assert isinstance(result, PluginResult)
52
+ assert result.proceed is True
53
+
54
+ @pytest.mark.asyncio
55
+ async def test_post_response_returns_proceed(self):
56
+ plugin = _MinimalPlugin()
57
+ result = await plugin.post_response(_make_context())
58
+ assert isinstance(result, PluginResult)
59
+ assert result.proceed is True
60
+
61
+ @pytest.mark.asyncio
62
+ async def test_on_error_returns_none(self):
63
+ plugin = _MinimalPlugin()
64
+ result = await plugin.on_error(_make_context(), {"error": "timeout"})
65
+ assert result is None
66
+
67
+ @pytest.mark.asyncio
68
+ async def test_initialize_returns_none(self):
69
+ plugin = _MinimalPlugin()
70
+ result = await plugin.initialize()
71
+ assert result is None
72
+
73
+ @pytest.mark.asyncio
74
+ async def test_configure_returns_none(self):
75
+ plugin = _MinimalPlugin()
76
+ result = await plugin.configure({"key": "value"})
77
+ assert result is None
78
+
79
+ @pytest.mark.asyncio
80
+ async def test_cleanup_returns_none(self):
81
+ plugin = _MinimalPlugin()
82
+ result = await plugin.cleanup()
83
+ assert result is None
@@ -0,0 +1,316 @@
1
+ """Tests for eyeriss_plugin_sdk.types — serialization, deserialization, roundtrips."""
2
+
3
+ import base64
4
+
5
+ from eyeriss_plugin_sdk.types import (
6
+ ErrorResponse,
7
+ PluginContext,
8
+ PluginResult,
9
+ SerializedRequest,
10
+ )
11
+
12
+
13
+ # ---------------------------------------------------------------------------
14
+ # SerializedRequest
15
+ # ---------------------------------------------------------------------------
16
+
17
+ class TestSerializedRequest:
18
+
19
+ def test_to_dict_with_body(self):
20
+ req = SerializedRequest(
21
+ method="POST", path="/api/v1/data",
22
+ headers={"content-type": "application/json"},
23
+ query_params={"page": "1"},
24
+ content_type="application/json",
25
+ body=b'{"key": "value"}',
26
+ )
27
+ d = req.to_dict()
28
+ assert d["method"] == "POST"
29
+ assert d["path"] == "/api/v1/data"
30
+ assert d["headers"] == {"content-type": "application/json"}
31
+ assert d["query_params"] == {"page": "1"}
32
+ assert d["content_type"] == "application/json"
33
+ assert d["body_encoding"] == "base64"
34
+ assert base64.b64decode(d["body"]) == b'{"key": "value"}'
35
+
36
+ def test_to_dict_without_body(self):
37
+ req = SerializedRequest(
38
+ method="GET", path="/healthz",
39
+ headers={}, query_params={},
40
+ )
41
+ d = req.to_dict()
42
+ assert d["body"] is None
43
+ assert "body_encoding" not in d
44
+
45
+ def test_from_dict_base64_body(self):
46
+ encoded = base64.b64encode(b"hello").decode("ascii")
47
+ d = {
48
+ "method": "PUT", "path": "/items/1",
49
+ "headers": {}, "query_params": {},
50
+ "body": encoded, "body_encoding": "base64",
51
+ }
52
+ req = SerializedRequest.from_dict(d)
53
+ assert req.body == b"hello"
54
+
55
+ def test_from_dict_string_body(self):
56
+ d = {
57
+ "method": "POST", "path": "/data",
58
+ "headers": {}, "query_params": {},
59
+ "body": "plain text",
60
+ }
61
+ req = SerializedRequest.from_dict(d)
62
+ assert req.body == b"plain text"
63
+
64
+ def test_from_dict_bytes_body(self):
65
+ d = {
66
+ "method": "POST", "path": "/data",
67
+ "headers": {}, "query_params": {},
68
+ "body": b"raw bytes",
69
+ }
70
+ req = SerializedRequest.from_dict(d)
71
+ assert req.body == b"raw bytes"
72
+
73
+ def test_from_dict_none_body(self):
74
+ d = {
75
+ "method": "GET", "path": "/",
76
+ "headers": {}, "query_params": {},
77
+ "body": None,
78
+ }
79
+ req = SerializedRequest.from_dict(d)
80
+ assert req.body is None
81
+
82
+ def test_from_dict_missing_optional_fields(self):
83
+ d = {"method": "GET", "path": "/test"}
84
+ req = SerializedRequest.from_dict(d)
85
+ assert req.headers == {}
86
+ assert req.query_params == {}
87
+ assert req.content_type == ""
88
+ assert req.body is None
89
+
90
+ def test_roundtrip(self):
91
+ original = SerializedRequest(
92
+ method="PATCH", path="/users/42",
93
+ headers={"authorization": "Bearer tok"},
94
+ query_params={"fields": "name,email"},
95
+ content_type="application/json",
96
+ body=b'{"name": "Alice"}',
97
+ )
98
+ restored = SerializedRequest.from_dict(original.to_dict())
99
+ assert restored.method == original.method
100
+ assert restored.path == original.path
101
+ assert restored.headers == original.headers
102
+ assert restored.query_params == original.query_params
103
+ assert restored.content_type == original.content_type
104
+ assert restored.body == original.body
105
+
106
+ def test_roundtrip_no_body(self):
107
+ original = SerializedRequest(
108
+ method="DELETE", path="/items/7",
109
+ headers={}, query_params={},
110
+ )
111
+ restored = SerializedRequest.from_dict(original.to_dict())
112
+ assert restored.body is None
113
+
114
+ def test_defaults(self):
115
+ req = SerializedRequest(method="GET", path="/", headers={}, query_params={})
116
+ assert req.content_type == ""
117
+ assert req.body is None
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # ErrorResponse
122
+ # ---------------------------------------------------------------------------
123
+
124
+ class TestErrorResponse:
125
+
126
+ def test_to_dict_without_headers(self):
127
+ err = ErrorResponse(status_code=403, body={"error": "forbidden"})
128
+ d = err.to_dict()
129
+ assert d == {"status_code": 403, "body": {"error": "forbidden"}}
130
+ assert "headers" not in d
131
+
132
+ def test_to_dict_with_headers(self):
133
+ err = ErrorResponse(
134
+ status_code=429, body={"error": "rate limited"},
135
+ headers={"Retry-After": "60"},
136
+ )
137
+ d = err.to_dict()
138
+ assert d["headers"] == {"Retry-After": "60"}
139
+
140
+ def test_from_dict(self):
141
+ d = {"status_code": 500, "body": {"error": "internal"}, "headers": {"X-Req-Id": "abc"}}
142
+ err = ErrorResponse.from_dict(d)
143
+ assert err.status_code == 500
144
+ assert err.body == {"error": "internal"}
145
+ assert err.headers == {"X-Req-Id": "abc"}
146
+
147
+ def test_from_dict_no_headers(self):
148
+ d = {"status_code": 401, "body": {"error": "unauthorized"}}
149
+ err = ErrorResponse.from_dict(d)
150
+ assert err.headers is None
151
+
152
+ def test_roundtrip(self):
153
+ original = ErrorResponse(status_code=422, body={"detail": "invalid"}, headers={"X-Error": "yes"})
154
+ restored = ErrorResponse.from_dict(original.to_dict())
155
+ assert restored.status_code == original.status_code
156
+ assert restored.body == original.body
157
+ assert restored.headers == original.headers
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # PluginResult
162
+ # ---------------------------------------------------------------------------
163
+
164
+ class TestPluginResult:
165
+
166
+ def test_defaults(self):
167
+ result = PluginResult()
168
+ assert result.proceed is True
169
+ assert result.modified_headers is None
170
+ assert result.modified_body is None
171
+ assert result.error_response is None
172
+
173
+ def test_to_dict_minimal(self):
174
+ result = PluginResult()
175
+ assert result.to_dict() == {"proceed": True}
176
+
177
+ def test_to_dict_with_headers(self):
178
+ result = PluginResult(modified_headers={"X-Custom": "val"})
179
+ d = result.to_dict()
180
+ assert d["modified_headers"] == {"X-Custom": "val"}
181
+
182
+ def test_to_dict_with_body(self):
183
+ result = PluginResult(modified_body=b"new body")
184
+ d = result.to_dict()
185
+ assert d["modified_body_encoding"] == "base64"
186
+ assert base64.b64decode(d["modified_body"]) == b"new body"
187
+
188
+ def test_to_dict_with_error_response(self):
189
+ err = ErrorResponse(status_code=403, body={"msg": "blocked"})
190
+ result = PluginResult(proceed=False, error_response=err)
191
+ d = result.to_dict()
192
+ assert d["proceed"] is False
193
+ assert d["error_response"]["status_code"] == 403
194
+
195
+ def test_from_dict_minimal(self):
196
+ result = PluginResult.from_dict({"proceed": True})
197
+ assert result.proceed is True
198
+ assert result.modified_body is None
199
+ assert result.error_response is None
200
+
201
+ def test_from_dict_defaults_proceed_true(self):
202
+ result = PluginResult.from_dict({})
203
+ assert result.proceed is True
204
+
205
+ def test_from_dict_with_body(self):
206
+ encoded = base64.b64encode(b"modified").decode("ascii")
207
+ result = PluginResult.from_dict({
208
+ "proceed": True,
209
+ "modified_body": encoded,
210
+ "modified_body_encoding": "base64",
211
+ })
212
+ assert result.modified_body == b"modified"
213
+
214
+ def test_from_dict_with_raw_bytes_body(self):
215
+ result = PluginResult.from_dict({
216
+ "proceed": True,
217
+ "modified_body": b"raw",
218
+ })
219
+ assert result.modified_body == b"raw"
220
+
221
+ def test_from_dict_with_error_response(self):
222
+ result = PluginResult.from_dict({
223
+ "proceed": False,
224
+ "error_response": {"status_code": 429, "body": {"error": "throttled"}},
225
+ })
226
+ assert result.proceed is False
227
+ assert result.error_response.status_code == 429
228
+
229
+ def test_roundtrip_full(self):
230
+ err = ErrorResponse(status_code=503, body={"error": "unavailable"}, headers={"Retry-After": "30"})
231
+ original = PluginResult(
232
+ proceed=False,
233
+ modified_headers={"X-Plugin": "test"},
234
+ modified_body=b"replacement",
235
+ error_response=err,
236
+ )
237
+ restored = PluginResult.from_dict(original.to_dict())
238
+ assert restored.proceed == original.proceed
239
+ assert restored.modified_headers == original.modified_headers
240
+ assert restored.modified_body == original.modified_body
241
+ assert restored.error_response.status_code == original.error_response.status_code
242
+ assert restored.error_response.body == original.error_response.body
243
+ assert restored.error_response.headers == original.error_response.headers
244
+
245
+
246
+ # ---------------------------------------------------------------------------
247
+ # PluginContext
248
+ # ---------------------------------------------------------------------------
249
+
250
+ class TestPluginContext:
251
+
252
+ def _make_request(self):
253
+ return SerializedRequest(
254
+ method="POST", path="/api/test",
255
+ headers={"content-type": "application/json"},
256
+ query_params={"q": "search"},
257
+ content_type="application/json",
258
+ body=b'{"data": 1}',
259
+ )
260
+
261
+ def test_to_dict(self):
262
+ ctx = PluginContext(
263
+ request=self._make_request(),
264
+ application_id=10, endpoint_id=20,
265
+ auth_result={"user_id": "u1", "role": "admin"},
266
+ request_id="req-abc-123",
267
+ client_ip="192.168.1.100",
268
+ )
269
+ d = ctx.to_dict()
270
+ assert d["application_id"] == 10
271
+ assert d["endpoint_id"] == 20
272
+ assert d["auth_result"]["user_id"] == "u1"
273
+ assert d["request_id"] == "req-abc-123"
274
+ assert d["client_ip"] == "192.168.1.100"
275
+ assert d["request"]["method"] == "POST"
276
+
277
+ def test_from_dict(self):
278
+ req_dict = self._make_request().to_dict()
279
+ d = {
280
+ "request": req_dict,
281
+ "application_id": 5, "endpoint_id": 15,
282
+ "auth_result": {"authenticated": True},
283
+ "request_id": "req-xyz",
284
+ "client_ip": "10.0.0.1",
285
+ }
286
+ ctx = PluginContext.from_dict(d)
287
+ assert ctx.application_id == 5
288
+ assert ctx.request.method == "POST"
289
+ assert ctx.request.body == b'{"data": 1}'
290
+
291
+ def test_from_dict_default_auth_result(self):
292
+ req_dict = self._make_request().to_dict()
293
+ d = {
294
+ "request": req_dict,
295
+ "application_id": 1, "endpoint_id": 2,
296
+ "request_id": "r1", "client_ip": "0.0.0.0",
297
+ }
298
+ ctx = PluginContext.from_dict(d)
299
+ assert ctx.auth_result == {}
300
+
301
+ def test_roundtrip(self):
302
+ original = PluginContext(
303
+ request=self._make_request(),
304
+ application_id=42, endpoint_id=99,
305
+ auth_result={"scope": "read:all"},
306
+ request_id="roundtrip-id",
307
+ client_ip="203.0.113.50",
308
+ )
309
+ restored = PluginContext.from_dict(original.to_dict())
310
+ assert restored.application_id == original.application_id
311
+ assert restored.endpoint_id == original.endpoint_id
312
+ assert restored.auth_result == original.auth_result
313
+ assert restored.request_id == original.request_id
314
+ assert restored.client_ip == original.client_ip
315
+ assert restored.request.method == original.request.method
316
+ assert restored.request.body == original.request.body