Skip to content

Commit 6b72d08

Browse files
committed
Enhance REST API to support namespace resolution via query string and JSON body
- Added support for an optional `namespace` parameter in all API endpoints. - Updated `_RemoteDirector` methods to handle namespace in request parameters. - Introduced `ResolveNs` method in the REST class to prioritize JSON body over query string for namespace. - Added comprehensive E2E tests for namespace handling in various routes and error responses.
1 parent 3c55f6a commit 6b72d08

6 files changed

Lines changed: 431 additions & 59 deletions

File tree

docs/rest-api.md

Lines changed: 36 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,21 @@ All endpoints return `application/json`. Errors are returned as:
1212

1313
---
1414

15+
## Namespace resolution
16+
17+
Every endpoint accepts an optional `namespace` parameter that selects the IRIS namespace to operate in (defaults to `USER`).
18+
19+
It can be supplied in two ways, which work on **all** routes:
20+
21+
| Method | Where to pass it | Example |
22+
|--------|------------------|---------|
23+
| Query string | `?namespace=IRISAPP` | `GET /api/iop/status?namespace=IRISAPP` |
24+
| JSON body | `{"namespace": "IRISAPP", ...}` | `POST /api/iop/start` with body |
25+
26+
When both are present (POST/PUT routes), the **JSON body value takes priority** over the query string.
27+
28+
---
29+
1530
## GET /api/iop/version
1631

1732
Returns the API version and description.
@@ -54,19 +69,19 @@ Possible `status` values: `running`, `stopped`, `suspended`, `troubled`, `unknow
5469

5570
Starts a production.
5671

57-
**Request body**
72+
**Namespace** — query string (`?namespace=`) or JSON body field (body wins).
73+
74+
**Request body** *(all fields optional)*
5875

5976
```json
6077
{
61-
"production": "MyApp.Production",
62-
"namespace": "USER"
78+
"production": "MyApp.Production"
6379
}
6480
```
6581

6682
| Field | Required | Description |
6783
|--------------|----------|----------------------------------------------------------|
6884
| `production` | No | Production class name. Defaults to the last-used production. |
69-
| `namespace` | No | Target IRIS namespace. Defaults to `USER`. |
7085

7186
**Response**
7287

@@ -83,13 +98,9 @@ Starts a production.
8398

8499
Stops the currently running production.
85100

86-
**Request body**
101+
**Namespace** — query string (`?namespace=`) or optional JSON body field (body wins).
87102

88-
```json
89-
{
90-
"namespace": "USER"
91-
}
92-
```
103+
The request body may be omitted entirely.
93104

94105
**Response**
95106

@@ -103,13 +114,9 @@ Stops the currently running production.
103114

104115
Forcefully stops the production (equivalent to `iop --kill`).
105116

106-
**Request body**
117+
**Namespace** — query string (`?namespace=`) or optional JSON body field (body wins).
107118

108-
```json
109-
{
110-
"namespace": "USER"
111-
}
112-
```
119+
The request body may be omitted entirely.
113120

114121
**Response**
115122

@@ -123,13 +130,9 @@ Forcefully stops the production (equivalent to `iop --kill`).
123130

124131
Restarts the currently running production.
125132

126-
**Request body**
133+
**Namespace** — query string (`?namespace=`) or optional JSON body field (body wins).
127134

128-
```json
129-
{
130-
"namespace": "USER"
131-
}
132-
```
135+
The request body may be omitted entirely.
133136

134137
**Response**
135138

@@ -143,13 +146,9 @@ Restarts the currently running production.
143146

144147
Updates (hot-reloads) the currently running production.
145148

146-
**Request body**
149+
**Namespace** — query string (`?namespace=`) or optional JSON body field (body wins).
147150

148-
```json
149-
{
150-
"namespace": "USER"
151-
}
152-
```
151+
The request body may be omitted entirely.
153152

154153
**Response**
155154

@@ -206,12 +205,13 @@ Returns the default (last-used) production for the namespace.
206205

207206
Sets the default production for the namespace.
208207

208+
**Namespace** — query string (`?namespace=`) or JSON body field (body wins).
209+
209210
**Request body**
210211

211212
```json
212213
{
213-
"production": "MyApp.Production",
214-
"namespace": "USER"
214+
"production": "MyApp.Production"
215215
}
216216
```
217217

@@ -299,23 +299,23 @@ Exports a production definition as XML.
299299

300300
Sends a test message to a target component and returns the response synchronously.
301301

302+
**Namespace** — query string (`?namespace=`) or JSON body field (body wins).
303+
302304
**Request body**
303305

304306
```json
305307
{
306308
"target": "Python.MyOperation",
307309
"classname": "Python.MyMsg",
308-
"body": "{\"key\": \"value\"}",
309-
"namespace": "USER"
310+
"body": {"key": "value"}
310311
}
311312
```
312313

313314
| Field | Required | Description |
314315
|-------------|----------|----------------------------------------------------------------------------------|
315316
| `target` | Yes | Config name of the component to invoke |
316317
| `classname` | No | Python message class name. If omitted an empty `Ens.Request` is used. |
317-
| `body` | No | JSON string passed as the message body. Defaults to `{}`. |
318-
| `namespace` | No | Target IRIS namespace. Defaults to `USER`. |
318+
| `body` | No | Message body — either a **JSON object** or a **JSON string**. Defaults to `{}`. |
319319

320320
**Response**
321321

@@ -335,6 +335,8 @@ Sends a test message to a target component and returns the response synchronousl
335335

336336
Uploads a Python package to the server and runs its `settings.py` migration.
337337

338+
**Namespace** — query string (`?namespace=`) or JSON body field (body wins).
339+
338340
**Request body**
339341

340342
```json
@@ -351,7 +353,6 @@ Uploads a Python package to the server and runs its `settings.py` migration.
351353

352354
| Field | Required | Description |
353355
|-----------------|----------|--------------------------------------------------------------------------------|
354-
| `namespace` | Yes | Target IRIS namespace |
355356
| `remote_folder` | No | Absolute server path to place the package. Defaults to the namespace's routine DB directory. |
356357
| `package` | Yes | Package directory name |
357358
| `body` | Yes | Array of `{name, data}` objects representing the files to write |

src/iop/_remote.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import os
1717
import signal
1818
import time
19-
from typing import Any, Dict, List, Optional
19+
from typing import Any, Dict, List, Optional, Union
2020

2121
import requests
2222
import urllib3
@@ -50,19 +50,19 @@ def _get(self, path: str, params: Optional[dict] = None) -> Any:
5050
return resp.json()
5151

5252
def _post(self, path: str, body: Optional[dict] = None) -> Any:
53-
b = {"namespace": self._namespace, **(body or {})}
5453
resp = requests.post(
55-
f"{self._base}{path}", json=b, auth=self._auth,
56-
verify=self._verify, timeout=30,
54+
f"{self._base}{path}", json=(body or {}),
55+
params={"namespace": self._namespace},
56+
auth=self._auth, verify=self._verify, timeout=30,
5757
)
5858
resp.raise_for_status()
5959
return resp.json()
6060

6161
def _put(self, path: str, body: Optional[dict] = None) -> Any:
62-
b = {"namespace": self._namespace, **(body or {})}
6362
resp = requests.put(
64-
f"{self._base}{path}", json=b, auth=self._auth,
65-
verify=self._verify, timeout=30,
63+
f"{self._base}{path}", json=(body or {}),
64+
params={"namespace": self._namespace},
65+
auth=self._auth, verify=self._verify, timeout=30,
6666
)
6767
resp.raise_for_status()
6868
return resp.json()
@@ -190,18 +190,22 @@ def test_component(
190190
target: Optional[str],
191191
message=None, # ignored remotely — not serialisable over HTTP
192192
classname: Optional[str] = None,
193-
body: Optional[str] = None,
193+
body: Optional[Union[str, dict]] = None,
194194
) -> dict:
195195
"""Returns a dict: {"classname": "...", "body": "...", "truncated": false}"""
196196
payload: dict = {"target": target or ""}
197197
if classname:
198198
payload["classname"] = classname
199-
if body:
199+
if body is not None:
200200
payload["body"] = body
201201
try:
202202
return self._check_error(self._post("/test", payload))
203203
except requests.exceptions.HTTPError as exc:
204-
raise RuntimeError(str(exc)) from exc
204+
try:
205+
err_msg = exc.response.json().get("error", str(exc))
206+
except Exception:
207+
err_msg = str(exc)
208+
raise RuntimeError(err_msg) from exc
205209

206210
# ------------------------------------------------------------------
207211
# Export

src/iop/cls/IOP/Service/Remote/Rest/v1.cls

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,15 @@ ClassMethod GetNsParam() As %String [ Internal, Private ]
4444
Quit $select(ns="":"USER", 1:ns)
4545
}
4646

47+
/// Return the namespace, preferring a body field over the query parameter.
48+
ClassMethod ResolveNs(dyna As %DynamicObject = "") As %String [ Internal, Private ]
49+
{
50+
Set ns = ""
51+
If $isobject(dyna) { Set ns = dyna.%Get("namespace") }
52+
If ns = "" { Set ns = ..GetNsParam() }
53+
Quit ns
54+
}
55+
4756
ClassMethod GetStatus() As %Status
4857
{
4958
Try {
@@ -68,7 +77,7 @@ ClassMethod PostStart() As %Status
6877
{
6978
Try {
7079
Set dyna = {}.%FromJSON(%request.Content)
71-
Set ns = dyna.%Get("namespace") If ns = "" { Set ns = "USER" }
80+
Set ns = ..ResolveNs(dyna)
7281
Set production = dyna.%Get("production")
7382
If ns '= "" { Do ..NamespaceCheck(ns) New $NAMESPACE Set $NAMESPACE = ns }
7483
If production = "" { Set production = $Get(^Ens.Configuration("csp","LastProduction")) }
@@ -82,8 +91,8 @@ ClassMethod PostStart() As %Status
8291
ClassMethod PostStop() As %Status
8392
{
8493
Try {
85-
Set dyna = {}.%FromJSON(%request.Content)
86-
Set ns = dyna.%Get("namespace") If ns = "" { Set ns = "USER" }
94+
Try { Set dyna = {}.%FromJSON(%request.Content) } Catch { Set dyna = {} }
95+
Set ns = ..ResolveNs(dyna)
8796
If ns '= "" { Do ..NamespaceCheck(ns) New $NAMESPACE Set $NAMESPACE = ns }
8897
$$$ThrowOnError(##class(Ens.Director).StopProduction())
8998
Return ..%WriteResponse({"status": "stopped"}.%ToJSON())
@@ -95,8 +104,8 @@ ClassMethod PostStop() As %Status
95104
ClassMethod PostKill() As %Status
96105
{
97106
Try {
98-
Set dyna = {}.%FromJSON(%request.Content)
99-
Set ns = dyna.%Get("namespace") If ns = "" { Set ns = "USER" }
107+
Try { Set dyna = {}.%FromJSON(%request.Content) } Catch { Set dyna = {} }
108+
Set ns = ..ResolveNs(dyna)
100109
If ns '= "" { Do ..NamespaceCheck(ns) New $NAMESPACE Set $NAMESPACE = ns }
101110
$$$ThrowOnError(##class(Ens.Director).StopProduction(10, 1))
102111
Return ..%WriteResponse({"status": "killed"}.%ToJSON())
@@ -108,8 +117,8 @@ ClassMethod PostKill() As %Status
108117
ClassMethod PostRestart() As %Status
109118
{
110119
Try {
111-
Set dyna = {}.%FromJSON(%request.Content)
112-
Set ns = dyna.%Get("namespace") If ns = "" { Set ns = "USER" }
120+
Try { Set dyna = {}.%FromJSON(%request.Content) } Catch { Set dyna = {} }
121+
Set ns = ..ResolveNs(dyna)
113122
If ns '= "" { Do ..NamespaceCheck(ns) New $NAMESPACE Set $NAMESPACE = ns }
114123
$$$ThrowOnError(##class(Ens.Director).RestartProduction())
115124
Return ..%WriteResponse({"status": "restarted"}.%ToJSON())
@@ -121,8 +130,8 @@ ClassMethod PostRestart() As %Status
121130
ClassMethod PostUpdate() As %Status
122131
{
123132
Try {
124-
Set dyna = {}.%FromJSON(%request.Content)
125-
Set ns = dyna.%Get("namespace") If ns = "" { Set ns = "USER" }
133+
Try { Set dyna = {}.%FromJSON(%request.Content) } Catch { Set dyna = {} }
134+
Set ns = ..ResolveNs(dyna)
126135
If ns '= "" { Do ..NamespaceCheck(ns) New $NAMESPACE Set $NAMESPACE = ns }
127136
$$$ThrowOnError(##class(Ens.Director).UpdateProduction())
128137
Return ..%WriteResponse({"status": "updated"}.%ToJSON())
@@ -169,7 +178,7 @@ ClassMethod PutDefault() As %Status
169178
{
170179
Try {
171180
Set dyna = {}.%FromJSON(%request.Content)
172-
Set ns = dyna.%Get("namespace") If ns = "" { Set ns = "USER" }
181+
Set ns = ..ResolveNs(dyna)
173182
Set production = dyna.%Get("production")
174183
If ns '= "" { Do ..NamespaceCheck(ns) New $NAMESPACE Set $NAMESPACE = ns }
175184
Set ^Ens.Configuration("csp","LastProduction") = production
@@ -246,13 +255,19 @@ ClassMethod GetExport() As %Status
246255
ClassMethod PostTest() As %Status
247256
{
248257
Try {
258+
// Capture output to avoid writing directly to the response stream, which would break the JSON format. We'll include captured output in the response.
259+
set sc = $$BeginCapture^%SYS.Capture(.msg)
260+
249261
Set dyna = {}.%FromJSON(%request.Content)
250262
Set target = dyna.%Get("target")
251263
Set classname = dyna.%Get("classname")
252264
Set body = dyna.%Get("body")
253-
Set ns = dyna.%Get("namespace") If ns = "" { Set ns = "USER" }
265+
Set ns = ..ResolveNs(dyna)
254266
If ns '= "" { Do ..NamespaceCheck(ns) New $NAMESPACE Set $NAMESPACE = ns }
255267

268+
// Normalize body: if it arrived as a JSON object, serialize it to a string
269+
If $isobject(body) { Set body = body.%ToJSON() }
270+
256271
// Build the request message
257272
If classname '= "" {
258273
Set message = ##class(IOP.Message).%New()
@@ -277,8 +292,10 @@ ClassMethod PostTest() As %Status
277292
Set result.classname = ""
278293
Set result.body = ""
279294
}
295+
set sc = $$EndCapture^%SYS.Capture(msg,.msgArray)
280296
Return ..%WriteResponse(result.%ToJSON())
281297
} Catch ex {
298+
set sc = $$EndCapture^%SYS.Capture(msg,.msgArray)
282299
Return ..%WriteErrorResponse(ex.DisplayString())
283300
}
284301
}
@@ -309,7 +326,7 @@ ClassMethod PutMigrate() As %DynamicObject
309326
// Get the request body
310327
set dyna = {}.%FromJSON(%request.Content)
311328
set body = dyna.%Get("body")
312-
set namespace = dyna.%Get("namespace")
329+
set namespace = ..ResolveNs(dyna)
313330
set targetDirectory = dyna.%Get("remote_folder")
314331
set packageName = dyna.%Get("package")
315332
// check for namespace existence and user permissions against namespace

0 commit comments

Comments
 (0)