Skip to content

Commit b076500

Browse files
authored
Merge pull request #5 from marklogic/feature/493-digest-auth
DEVEXP-493 Initial client
2 parents 548e3e6 + 8820ee1 commit b076500

File tree

10 files changed

+194
-34
lines changed

10 files changed

+194
-34
lines changed

CONTRIBUTING.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,25 @@ To run an individual test method:
3131

3232
pytest -s test/test_search.py::test_search
3333

34+
## Testing the client in a Python shell
35+
36+
After running `poetry install` as described above, you can start a Python shell and manually test the client. You will
37+
need to launch the shell in the same Python virtual environment as the one in which you ran `poetry install`. After
38+
ensuring you've done that, start a new Python shell by running `python`.
39+
40+
You can then import the client via:
41+
42+
from marklogic.client import Client
43+
44+
You can instantiate an instance of the client that communicates with this project's test application via:
45+
46+
client = Client("http://localhost:8030", digest=("python-test-user", "password"))
47+
48+
And you can then start sending requests with the client - for example:
49+
50+
r = client.get("/v1/search?format=json&pageLength=2")
51+
r.json()
52+
3453
## Testing the documentation locally
3554

3655
The docs for this project are stored in the `./docs` directory as a set of Markdown files. These are published via

docs/getting-started.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
---
2+
layout: default
3+
title: Getting Started
4+
nav_order: 2
5+
---
6+
7+
**Until the 1.0 release is available**, please follow the instructions in the CONTRIBUTING.md file for installing the
8+
MarkLogic Python client into a Python virtual environment.
9+
10+
## Connecting to MarkLogic
11+
12+
(TODO This will almost certainly be reorganized before the 1.0 release.)
13+
14+
The `Client` class is the primary API to interact with in the MarkLogic Python client. The
15+
`Client` class extends the `requests`
16+
[`Session` class](https://docs.python-requests.org/en/latest/user/advanced/#session-objects), thus exposing all methods
17+
found in both the `Session` class and the `requests` API. You can therefore use a `Client` object in the same manner
18+
as you'd use either the `Session` class or the `requests` API.
19+
20+
A `Client` instance can be constructed either by providing a base URL for all requests along with authentication:
21+
22+
from marklogic.client import Client
23+
client = Client('http://localhost:8030', digest=('username', 'password'))
24+
25+
Or via separate arguments for each of the parts of a base URL:
26+
27+
from marklogic.client import Client
28+
client = Client(host='localhost', port='8030', digest=('username', 'password'))
29+
30+
After constructing a `Client` instance, each of the methods in the `requests` API for sending an HTTP request can be
31+
used without needing to specify the base URL nor the authentication again. For example:
32+
33+
response = client.post('/v1/search')
34+
response = client.get('/v1/documents', params={'uri': '/my-doc.json'})
35+
36+
Because the `Client` class extends the `Sessions` class, it can be used as a context manager:
37+
38+
with Client('http://localhost:8030', digest=('username', 'password')) as client:
39+
response = client.post('/v1/search')
40+
response = client.get('/v1/documents', params={'uri': '/my-doc.json'})
41+
42+
## Authentication
43+
44+
The `Client` constructor includes a `digest` argument as a convenience for using digest authentication:
45+
46+
from marklogic.client import Client
47+
client = Client('http://localhost:8030', digest=('username', 'password'))
48+
49+
An `auth` argument is also available for using any authentication strategy that can be configured
50+
[via the requests `auth` argument](https://requests.readthedocs.io/en/latest/user/advanced/#custom-authentication). For
51+
example, just like with `requests`, a tuple can be passed to the `auth` argument to use basic authentication:
52+
53+
from marklogic.client import Client
54+
client = Client('http://localhost:8030', auth=('username', 'password'))
55+
56+
## SSL
57+
58+
Configuring SSL connections is the same as
59+
[documented for the `requests` library](https://requests.readthedocs.io/en/latest/user/advanced/#ssl-cert-verification).
60+
As a convience, the `Client` constructor includes a `verify` argument so that it does not need to be configured on the
61+
`Client` instance after it's been constructed nor on every request:
62+
63+
from marklogic.client import Client
64+
client = Client('https://localhost:8030', digest=('username', 'password'), verify='/path/to/cert.pem')
65+
66+
When specifying the base URL via separate arguments, the `scheme` argument can be set for HTTPS connections:
67+
68+
from marklogic.client import Client
69+
client = Client(host='localhost', port='8030', scheme='https', digest=('username', 'password'), verify=False)

marklogic/__init__.py

Whitespace-only changes.

marklogic/client.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import requests
2+
from requests.auth import HTTPDigestAuth
3+
from urllib.parse import urljoin
4+
5+
6+
class Client(requests.Session):
7+
def __init__(
8+
self,
9+
base_url: str = None,
10+
auth=None,
11+
digest=None,
12+
scheme: str = "http",
13+
verify: bool = True,
14+
host: str = None,
15+
port: int = 0,
16+
username: str = None,
17+
password: str = None,
18+
):
19+
if base_url:
20+
self.base_url = base_url
21+
else:
22+
self.base_url = f"{scheme}://{host}:{port}"
23+
super(Client, self).__init__()
24+
self.verify = verify
25+
if auth:
26+
self.auth = auth
27+
elif digest:
28+
self.auth = HTTPDigestAuth(digest[0], digest[1])
29+
else:
30+
self.auth = HTTPDigestAuth(username, password)
31+
32+
def request(self, method, url, *args, **kwargs):
33+
"""
34+
Overrides the requests function to generate the complete URL before the request
35+
is sent.
36+
"""
37+
url = urljoin(self.base_url, url)
38+
return super(Client, self).request(method, url, *args, **kwargs)
39+
40+
def prepare_request(self, request, *args, **kwargs):
41+
"""
42+
Overrides the requests function to generate the complete URL before the
43+
request is prepared. See
44+
https://requests.readthedocs.io/en/latest/user/advanced/#prepared-requests for
45+
more information on prepared requests.
46+
"""
47+
request.url = urljoin(self.base_url, request.url)
48+
return super(Client, self).prepare_request(request, *args, **kwargs)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"server-name": "%%NAME%%",
3+
"group-name": "Default",
4+
"authentication": "digestbasic"
5+
}

tests/conftest.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,25 @@
11
import pytest
2-
import requests
3-
from requests.auth import HTTPDigestAuth
2+
from marklogic.client import Client
43

54

65
@pytest.fixture
7-
def test_session():
8-
session = requests.Session()
9-
session.auth = HTTPDigestAuth("python-test-user", "password")
10-
return session
6+
def client():
7+
return Client("http://localhost:8030", digest=("python-test-user", "password"))
8+
9+
10+
@pytest.fixture
11+
def basic_client():
12+
# requests allows a tuple to be passed when doing basic authentication.
13+
return Client("http://localhost:8030", auth=("python-test-user", "password"))
14+
15+
16+
@pytest.fixture
17+
def ssl_client():
18+
return Client(host="localhost", scheme="https", port=8031,
19+
digest=("python-test-user", "password"),
20+
verify=False)
21+
22+
23+
@pytest.fixture
24+
def client_with_props():
25+
return Client(host="localhost", port=8030, username="admin", password="admin")

tests/test_eval.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
11
from requests_toolbelt.multipart.decoder import MultipartDecoder
22

33

4-
def test_eval(test_session):
4+
def test_eval(client):
55
"""
6-
This shows how a user would do an eval today. It's a good example of how a multipart/mixed
7-
response is a little annoying to deal with, as it requires using the requests_toolbelt
8-
library and a class called MultipartDecoder.
6+
This shows how a user would do an eval today. It's a good example of how a multipart/mixed
7+
response is a little annoying to deal with, as it requires using the requests_toolbelt
8+
library and a class called MultipartDecoder.
99
1010
Client support for this might look like this:
1111
response = client.eval.xquery("<hello>world</hello>")
1212
1313
And then it's debatable whether we want to do anything beyond what MultipartDecoder
1414
is doing for handling the response.
1515
"""
16-
response = test_session.post(
17-
"http://localhost:8030/v1/eval",
16+
response = client.post(
17+
"v1/eval",
1818
headers={"Content-type": "application/x-www-form-urlencoded"},
1919
data={"xquery": "<hello>world</hello>"},
2020
)

tests/test_get_documents.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
from requests_toolbelt.multipart.decoder import MultipartDecoder
22

33

4-
def test_get_docs(test_session):
4+
def test_get_docs(client):
55
"""
66
Possible future client interface:
77
array_of_documents = client.documents.get(uri=[], metadata=True)
88
99
Where each Document in the array would have fields of:
1010
uri/content/collections/permissions/quality/properties/metadata_values.
1111
"""
12-
response = test_session.get(
13-
"http://localhost:8030/v1/documents",
12+
response = client.get(
13+
"/v1/documents",
1414
params={
1515
"uri": ["/doc1.json", "/doc2.xml"],
1616
"category": ["content", "metadata"],
@@ -19,20 +19,21 @@ def test_get_docs(test_session):
1919
headers={"Accept": "multipart/mixed"},
2020
)
2121

22+
assert 200 == response.status_code
23+
2224
# Could provide a class for converting a multipart/mixed response into an array
2325
# of documents too:
2426
# from marklogic import DocumentDecoder
2527
# array_of_documents = DocumentDecoder.from_response(response)
26-
2728
decoder = MultipartDecoder.from_response(response)
2829
for part in decoder.parts:
2930
print(part.headers)
3031
print(part.text)
3132

3233

33-
def test_search_docs(test_session):
34-
response = test_session.get(
35-
"http://localhost:8030/v1/search",
34+
def test_search_docs(client_with_props):
35+
response = client_with_props.get(
36+
"v1/search",
3637
params={
3738
"collection": "test-data",
3839
"category": ["content", "metadata"],
@@ -44,3 +45,10 @@ def test_search_docs(test_session):
4445
for part in MultipartDecoder.from_response(response).parts:
4546
print(part.headers)
4647
print(part.text)
48+
49+
50+
def test_get_docs_basic_auth(basic_client):
51+
# Just verifies that basic auth works as expected.
52+
response = basic_client.get("/v1/documents", params={"uri": "/doc1.json"})
53+
assert 200 == response.status_code
54+
assert "world" == response.json()["hello"]

tests/test_search.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
def test_search(test_session):
2-
response = test_session.get("http://localhost:8030/v1/search")
1+
def test_search(client):
2+
response = client.get("v1/search")
33
assert 200 == response.status_code
44
assert "application/xml; charset=utf-8" == response.headers["Content-type"]
55
assert response.text.startswith("<search:response")

tests/test_ssl.py

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,14 @@
1-
import requests
2-
3-
4-
def test_verify_false():
1+
def test_verify_false(ssl_client):
52
"""
6-
The certificate verification in requests is fairly picky; while it's possible to disable
7-
hostname validation, I did not find a way to ask it to not care about self-signed certificates.
8-
So for now, this is just verifying that verify=False works with a MarkLogic app server that is
9-
using a self-signed certificate. In the real world, a customer would have a real certificate and
10-
would configure "verify" to point to that.
3+
The certificate verification in requests is fairly picky; while it's
4+
possible to disable hostname validation, I did not find a way to ask
5+
it to not care about self-signed certificates. So for now, this is just
6+
verifying that verify=False works with a MarkLogic app server that is
7+
using a self-signed certificate. In the real world, a customer would
8+
have a real certificate and would configure "verify" to point to that.
119
"""
12-
response = requests.get(
13-
"https://localhost:8031/v1/search",
14-
auth=("python-test-user", "password"),
15-
verify=False,
10+
response = ssl_client.get(
11+
"v1/search",
1612
headers={"Accept": "application/json"},
1713
)
1814
assert 200 == response.status_code

0 commit comments

Comments
 (0)