Skip to content

Commit f08d77e

Browse files
authored
Add allow_redirects flag to ServerContext (#74)
* ServerContext: * Add `allow_redirects` flag to constructor * Add `allow_redirects` flag to `make_request` * APIWrapper Add `allow_redirects` flag * Add UnexpectedRedirectError * Update APIWrapper docs to not set `use_ssl=False` in example code
1 parent e085e7a commit f08d77e

15 files changed

+133
-34
lines changed

CHANGE.txt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22
LabKey Python Client API News
33
+++++++++++
44

5+
What's New in the LabKey 3.1.0 package
6+
==============================
7+
8+
*Release date: 04/03/2024*
9+
- ServerContext
10+
- Add allow_redirects flag (defaults to False) to constructor
11+
- Add allow_redirects flag to make_request
12+
- APIWrapper: Add allow_redirects flag (defaults to False)
13+
- Add UnexpectedRedirectError
14+
- thrown when allow_redirects is False and the server issues a redirect
15+
516
What's New in the LabKey 3.0.0 package
617
==============================
718

docs/api_wrapper.md

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,23 @@ It includes the following arguments:
2323
- Example: 'Project/Folder/Subfolder'
2424

2525
**context_path**
26-
- The default value is None. Depending on how the LabKey Server instance is implemented, it may be necessary to include a value for the context_path argument. If your LabKey Server instance has text after the base URL, that is the context path.
26+
- Defaults to `None`. Depending on how the LabKey Server instance is implemented, it may be necessary to include a value for the context_path argument. If your LabKey Server instance has text after the base URL, that is the context path.
2727
- Example: If your home project has a URL such as https://labkey.org/contextpath/home/project-begin.view, then the context path is 'contextpath'.
2828

2929
**use_ssl**
30-
- The default value is True. This should be set to True if your server is configured to use SSL. If you are not sure if your server uses SSL, refer to any URL for accessing your server. Servers using SSL will have a URL that begins with `https://` instead of `http://`. LabKey Sample Manager-only clients must have this argument set to True.
30+
- Defaults to `True`. This should be set to True if your server is configured to use SSL. If you are not sure if your server uses SSL, refer to any URL for accessing your server. Servers using SSL will have a URL that begins with `https://` instead of `http://`. LabKey Sample Manager-only clients must have this argument set to True.
3131

3232
**verify_ssl**
33-
- The default value is True. This argument toggles whether or not the SSL certificate is validated when attempting to connect to a server. This flag is useful when you are connecting to a development server with a self-signed SSL certificate, which would otherwise cause a failure. You should never disable this flag if you are connecting to a production server with a proper SSL certificate.
33+
- Defaults to `True`. This argument toggles whether or not the SSL certificate is validated when attempting to connect to a server. This flag is useful when you are connecting to a development server with a self-signed SSL certificate, which would otherwise cause a failure. You should never disable this flag if you are connecting to a production server with a proper SSL certificate.
3434

3535
**api_key**
36-
- The default value is None. Scripts can authenticate their LabKey API calls by using either a netrc file (details on that here, https://www.labkey.org/Documentation/wiki-page.view?name=netrc) or an API key (details about API keys and how to generate and manage them are here, https://www.labkey.org/Documentation/wiki-page.view?name=apikey).
36+
- Defaults to `None`. Scripts can authenticate their LabKey API calls by using either a netrc file (details on that here, https://www.labkey.org/Documentation/wiki-page.view?name=netrc) or an API key (details about API keys and how to generate and manage them are here, https://www.labkey.org/Documentation/wiki-page.view?name=apikey).
3737

3838
**disable_csrf**
39-
- The default value is False. In most cases, this argument must be set to False for API calls to work successfully as CSRF tokens are a fundamental security mechanism. For more info about using CSRF with your LabKey Server instance, see here, https://www.labkey.org/Documentation/wiki-page.view?name=csrfProtection.
39+
- Defaults to `False`. In most cases, this argument must be set to False for API calls to work successfully as CSRF tokens are a fundamental security mechanism. For more info about using CSRF with your LabKey Server instance, see here, https://www.labkey.org/Documentation/wiki-page.view?name=csrfProtection.
40+
41+
**allow_redirects**
42+
- Defaults to `False`. When the server issues a redirect during an API call the ServerContext will throw an error.
4043

4144
### Using LabKey Python APIs
4245

@@ -48,13 +51,16 @@ See below for an example of how to properly use the APIWrapper class to create a
4851
from labkey.api_wrapper import APIWrapper
4952

5053
print("Create an APIWrapper")
51-
labkey_server = 'localhost:8080'
52-
project_name = 'ModuleAssayTest' # Project folder name
54+
labkey_server = 'www.example.com'
55+
container_path = 'ModuleAssayTest' # Project folder name
5356
contextPath = 'labkey'
5457
schema = 'core'
5558
table = 'Users'
56-
api = APIWrapper(labkey_server, project_name, contextPath, use_ssl=False)
5759

60+
# Note: If developing against localhost with https disabled, set use_ssl=False below
61+
api = APIWrapper(labkey_server, container_path, contextPath)
62+
63+
# Makes an API request to https://www.example.com/labkey/ModuleAssayTest/query-getQuery.api
5864
result = api.query.select_rows(schema, table)
5965

6066
if result is not None:

labkey/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,6 @@
1414
# limitations under the License.
1515
#
1616
__title__ = "labkey"
17-
__version__ = "3.0.0"
17+
__version__ = "3.1.0"
1818
__author__ = "LabKey"
1919
__license__ = "Apache License 2.0"

labkey/api_wrapper.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ def __init__(
2222
verify_ssl=True,
2323
api_key=None,
2424
disable_csrf=False,
25+
allow_redirects=False,
2526
):
2627
self.server_context = ServerContext(
2728
domain=domain,
@@ -31,6 +32,7 @@ def __init__(
3132
verify_ssl=verify_ssl,
3233
api_key=api_key,
3334
disable_csrf=disable_csrf,
35+
allow_redirects=allow_redirects,
3436
)
3537
self.container = ContainerWrapper(self.server_context)
3638
self.domain = DomainWrapper(self.server_context)

labkey/exceptions.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,22 @@ def __init__(self, server_response, **kwargs):
4747
self.message = "No response received"
4848

4949
def __str__(self):
50-
return repr(self.message)
50+
return str(self.message)
51+
52+
53+
class UnexpectedRedirectError(RequestError):
54+
default_msg = "Unexpected redirect occurred"
55+
56+
def __init__(self, server_response, **kwargs):
57+
super().__init__(server_response, **kwargs)
58+
59+
location = server_response.headers.get("Location", "")
60+
61+
# If the server is redirecting from http to https the user probably has a misconfigured ServerContext with use_ssl=False
62+
if server_response.url.startswith("http://") and location.startswith("https://"):
63+
self.message = "Redirected from http to https, set use_ssl=True in your APIWrapper or ServerContext"
64+
elif location != "":
65+
self.message = f"Unexpected redirect to: {location}"
5166

5267

5368
class QueryNotFoundError(RequestError):

labkey/query.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,7 @@ def execute_sql(
258258
parameters: dict = None,
259259
required_version: float = None,
260260
timeout: int = _default_timeout,
261-
waf_encode_sql: bool = True
261+
waf_encode_sql: bool = True,
262262
):
263263
"""
264264
Execute sql query against a LabKey server.
@@ -535,7 +535,12 @@ def move_rows(
535535
"""
536536
url = server_context.build_url("query", "moveRows.api", container_path=container_path)
537537

538-
payload = {"targetContainerPath": target_container_path, "schemaName": schema_name, "queryName": query_name, "rows": rows}
538+
payload = {
539+
"targetContainerPath": target_container_path,
540+
"schemaName": schema_name,
541+
"queryName": query_name,
542+
"rows": rows,
543+
}
539544

540545
if transacted is False:
541546
payload["transacted"] = transacted
@@ -582,7 +587,7 @@ def delete_rows(
582587
transacted,
583588
audit_behavior,
584589
audit_user_comment,
585-
timeout
590+
timeout,
586591
)
587592

588593
@functools.wraps(truncate_table)
@@ -605,7 +610,7 @@ def execute_sql(
605610
parameters: dict = None,
606611
required_version: float = None,
607612
timeout: int = _default_timeout,
608-
waf_encode_sql: bool = True
613+
waf_encode_sql: bool = True,
609614
):
610615
return execute_sql(
611616
self.server_context,
@@ -620,7 +625,7 @@ def execute_sql(
620625
parameters,
621626
required_version,
622627
timeout,
623-
waf_encode_sql
628+
waf_encode_sql,
624629
)
625630

626631
@functools.wraps(insert_rows)
@@ -646,7 +651,7 @@ def insert_rows(
646651
transacted,
647652
audit_behavior,
648653
audit_user_comment,
649-
timeout
654+
timeout,
650655
)
651656

652657
@functools.wraps(select_rows)
@@ -716,7 +721,7 @@ def update_rows(
716721
transacted,
717722
audit_behavior,
718723
audit_user_comment,
719-
timeout
724+
timeout,
720725
)
721726

722727
@functools.wraps(move_rows)
@@ -742,5 +747,5 @@ def move_rows(
742747
transacted,
743748
audit_behavior,
744749
audit_user_comment,
745-
timeout
750+
timeout,
746751
)

labkey/security.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ def stop_impersonating(server_context: ServerContext):
279279
Stop impersonating a user while keeping the original user logged in.
280280
"""
281281
url = server_context.build_url(LOGIN_CONTROLLER, "stopImpersonating.api")
282-
return server_context.make_request(url)
282+
return server_context.make_request(url, allow_redirects=True)
283283

284284

285285
@dataclass

labkey/server_context.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
QueryNotFoundError,
99
ServerContextError,
1010
ServerNotFoundError,
11+
UnexpectedRedirectError,
1112
)
1213

1314
API_KEY_TOKEN = "apikey"
@@ -29,7 +30,8 @@ def handle_response(response, non_json_response=False):
2930
content=response.content,
3031
)
3132
return result
32-
33+
elif sc == 302:
34+
raise UnexpectedRedirectError(response)
3335
elif sc == 401:
3436
raise RequestAuthorizationError(response)
3537
elif sc == 404:
@@ -62,6 +64,7 @@ def __init__(
6264
verify_ssl=True,
6365
api_key=None,
6466
disable_csrf=False,
67+
allow_redirects=False,
6568
):
6669
self._container_path = container_path
6770
self._context_path = context_path
@@ -70,6 +73,7 @@ def __init__(
7073
self._verify_ssl = verify_ssl
7174
self._api_key = api_key
7275
self._disable_csrf = disable_csrf
76+
self.allow_redirects = allow_redirects
7377
self._session = requests.Session()
7478
self._session.headers.update({"User-Agent": f"LabKey Python API/{__version__}"})
7579

@@ -174,7 +178,9 @@ def make_request(
174178
non_json_response: bool = False,
175179
file_payload: any = None,
176180
json: dict = None,
181+
allow_redirects=False,
177182
) -> any:
183+
allow_redirects_ = allow_redirects or self.allow_redirects
178184
if self._api_key is not None:
179185
if self._session.headers.get(API_KEY_TOKEN) is not self._api_key:
180186
self._session.headers.update({API_KEY_TOKEN: self._api_key})
@@ -189,7 +195,13 @@ def make_request(
189195

190196
try:
191197
if method == "GET":
192-
response = self._session.get(url, params=payload, headers=headers, timeout=timeout)
198+
response = self._session.get(
199+
url,
200+
params=payload,
201+
headers=headers,
202+
timeout=timeout,
203+
allow_redirects=allow_redirects_,
204+
)
193205
else:
194206
if file_payload is not None:
195207
response = self._session.post(
@@ -198,6 +210,7 @@ def make_request(
198210
files=file_payload,
199211
headers=headers,
200212
timeout=timeout,
213+
allow_redirects=allow_redirects_,
201214
)
202215
elif json is not None:
203216
if headers is None:
@@ -206,10 +219,20 @@ def make_request(
206219
headers_ = {**headers, "Content-Type": "application/json"}
207220
# sort_keys is a hack to make unit tests work
208221
data = json_dumps(json, sort_keys=True)
209-
response = self._session.post(url, data=data, headers=headers_, timeout=timeout)
222+
response = self._session.post(
223+
url,
224+
data=data,
225+
headers=headers_,
226+
timeout=timeout,
227+
allow_redirects=allow_redirects_,
228+
)
210229
else:
211230
response = self._session.post(
212-
url, data=payload, headers=headers, timeout=timeout
231+
url,
232+
data=payload,
233+
headers=headers,
234+
timeout=timeout,
235+
allow_redirects=allow_redirects_,
213236
)
214237
return handle_response(response, non_json_response)
215238
except RequestException as e:

samples/experiment_platemetadata_example.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,9 @@
4242

4343
# Assays that are configured for plate support have a required run property for the plate template, this is the plate
4444
# template lsid
45-
run_test.properties[
46-
"PlateTemplate"
47-
] = "urn:lsid:labkey.com:PlateTemplate.Folder-6:d8bbec7d-34cd-1038-bd67-b3bd777822f8"
45+
run_test.properties["PlateTemplate"] = (
46+
"urn:lsid:labkey.com:PlateTemplate.Folder-6:d8bbec7d-34cd-1038-bd67-b3bd777822f8"
47+
)
4848

4949
# The assay plate metadata is a specially formatted JSON object to map properties to the well groups
5050
run_test.plate_metadata = {

test/integration/test_query.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,14 @@ def study(api: APIWrapper):
4343
"subjectNounSingular": "People",
4444
"label": "Python Integration Tests Study",
4545
}
46-
created_study = api.server_context.make_request(url, payload, non_json_response=True)
46+
created_study = api.server_context.make_request(
47+
url, payload, non_json_response=True, allow_redirects=True
48+
)
4749
yield created_study
4850
url = api.server_context.build_url("study", "deleteStudy.view")
49-
api.server_context.make_request(url, {"confirm": "true"}, non_json_response=True)
51+
api.server_context.make_request(
52+
url, {"confirm": "true"}, non_json_response=True, allow_redirects=True
53+
)
5054

5155

5256
@pytest.fixture(scope="session")
@@ -91,7 +95,7 @@ def test_create_duplicate_dataset(api: APIWrapper, dataset):
9195
with pytest.raises(ServerContextError) as e:
9296
api.domain.create(DATASET_DOMAIN)
9397

94-
expected = f"'500: A Dataset or Query already exists with the name \"{QUERY_NAME}\".'"
98+
expected = f'500: A Dataset or Query already exists with the name "{QUERY_NAME}".'
9599
assert e.value.message == expected
96100

97101

@@ -148,7 +152,7 @@ def test_cannot_delete_qc_state_in_use(api: APIWrapper, qc_states, study, datase
148152

149153
assert (
150154
e.value.message
151-
== "\"400: State 'needs verification' cannot be deleted as it is currently in use.\""
155+
== "400: State 'needs verification' cannot be deleted as it is currently in use."
152156
)
153157
# now clean up/stop using it
154158
dataset_row_to_remove = [{"lsid": inserted_lsid}]

test/unit/test_domain.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ class MockCreate(MockLabKey):
7070
"data": json.dumps(domain_definition, sort_keys=True),
7171
"headers": {"Content-Type": "application/json"},
7272
"timeout": 300,
73+
"allow_redirects": False,
7374
}
7475

7576
self.args = [mock_server_context(self.service), domain_definition]
@@ -116,6 +117,7 @@ class MockDrop(MockLabKey):
116117
"data": json.dumps(payload, sort_keys=True),
117118
"headers": {"Content-Type": "application/json"},
118119
"timeout": 300,
120+
"allow_redirects": False,
119121
}
120122

121123
self.args = [
@@ -164,6 +166,7 @@ class MockGet(MockLabKey):
164166
"headers": None,
165167
"params": {"schemaName": self.schema_name, "queryName": self.query_name},
166168
"timeout": 300,
169+
"allow_redirects": False,
167170
}
168171

169172
self.args = [
@@ -215,6 +218,7 @@ class MockInferFields(MockLabKey):
215218
"files": {"inferfile": self.file},
216219
"headers": None,
217220
"timeout": 300,
221+
"allow_redirects": False,
218222
}
219223

220224
self.args = [mock_server_context(self.service), self.file]
@@ -275,6 +279,7 @@ class MockSave(MockLabKey):
275279
"data": json.dumps(payload, sort_keys=True),
276280
"headers": {"Content-Type": "application/json"},
277281
"timeout": 300,
282+
"allow_redirects": False,
278283
}
279284

280285
self.args = [
@@ -347,6 +352,7 @@ class MockCreate(MockLabKey):
347352
"data": json.dumps(self.domain_definition, sort_keys=True),
348353
"headers": {"Content-Type": "application/json"},
349354
"timeout": 300,
355+
"allow_redirects": False,
350356
}
351357

352358
self.args = [mock_server_context(self.service), self.domain_definition]
@@ -431,6 +437,7 @@ class MockSave(MockLabKey):
431437
"data": json.dumps(payload, sort_keys=True),
432438
"headers": {"Content-Type": "application/json"},
433439
"timeout": 300,
440+
"allow_redirects": False,
434441
}
435442

436443
self.args = [

0 commit comments

Comments
 (0)