Skip to content

Commit

Permalink
feat: remove dependency on Slumber and integrate its functionality in…
Browse files Browse the repository at this point in the history
…to Mantelo

slumber's last activity was in 2018.
Better to make it part of Keycloak, so it can evolve and I can fix
issues directly.

I didn't just copy the code, but made small improvements and typed
it properly.

BEGIN_NESTED_COMMIT
docs: document "as_raw" to get the raw response
END_NESTED_COMMIT

BEGIN_NESTED_COMMIT
feat: return an empty string (versus b'' or None) when the body is empty
END_NESTED_COMMIT

BEGIN_NESTED_COMMIT
feat: raise more granular HttpException (HttpNotFound, HttpClientError, HttpServerError)
END_NESTED_COMMIT

BEGIN_NESTED_COMMIT
fix: make HttpException handle responses with non-empty, non-JSON body
END_NESTED_COMMIT
  • Loading branch information
derlin committed Dec 29, 2024
1 parent 13852f1 commit 33588c8
Show file tree
Hide file tree
Showing 18 changed files with 1,119 additions and 104 deletions.
1 change: 0 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ jobs:
with:
release-type: simple
changelog-types: '[{"type":"feat","section":"🚀 Features","hidden":false},{"type":"fix","section":"🐛 Bug Fixes","hidden":false},{"type":"docs","section":"💬 Documentation","hidden":false},{"type":"ci","section":"🦀 Build and CI","hidden":false}, {"type":"style","section":"🌈 Styling","hidden":false}]'
extra-files: build.gradle.kts

publish:
runs-on: ubuntu-latest
Expand Down
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ test: ## Run tests with tox (inside docker).
mypy: ## Run mypy locally to check types.
mypy mantelo

mypy-strict: ## Run mypy locally and print all missing annotations.
mypy --disallow-untyped-calls --disallow-untyped-defs --disallow-incomplete-defs mantelo

export-realms: ## Export test realms after changes in Keycloak Test Server.
docker compose exec keycloak /opt/keycloak/bin/kc.sh export --dir /tmp/export --users realm_file; \
for realm in master orwell; do \
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ HttpException: (403, {'error': 'unknown_error', 'error_description': 'For more o

If the server returns a 401 Unauthorized during the _authentication_ process, mantelo will raise an
`AuthenticationException` with the `error` and `errorDescription` from Keycloak. All other HTTP
exceptions are instances of `HttpException`.
exceptions are instances of `HttpException`, with some subclasses (`HttpNotFound`, `HttpClientError`, `HttpServerError`).

Here are some examples:

Expand Down
11 changes: 5 additions & 6 deletions docs/01-authentication.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,14 @@
==============================

To authenticate to Keycloak, you can either use a username+password, or client credentials (client
ID+client secret, also known as service account).
ID+client secret, also known as *service account*).

The library takes care of fetching a token the first time you need it and keeping it fresh. By
default, it tries to use the refresh token (if available) and always guarantees the token is valid
for the next 30s.
for the next 30 seconds.

The authentication calls and the calls to the REST API are using the same
:py:class:`requests.Session`, that can be passed at creation in case you need to add custom headers,
proxies, etc.
The authentication calls and the calls to the REST API use the same :py:class:`requests.Session`,
which can be passed at creation in case you need to add custom headers, proxies, etc.

.. important::

Expand All @@ -37,7 +36,7 @@ testing, you can either use the admin user (not recommended) or create a user an

.. hint::

Clients need to enable the "*Direct access grants*" authorization flow.
Clients must enable the "*Direct access grants*" authorization flow.
The client ``admin-cli``, which exists by default on all realms, is often used.

Here is how to use :py:meth:`~.KeycloakAdmin.from_username_password` connect to the default realm
Expand Down
30 changes: 26 additions & 4 deletions docs/02-making-calls.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ depending on how you call the client.
The return value is the HTTP response content as a :python:`dict` (parsed from the JSON response). In
case of error, an :py:class:`~.HttpException` with access to the raw response is available.

All the calls in the chain are used to generate the URL, except for the last call which determines the HTTP method to use.
Supported HTTP methods are:
All the calls in the chain are used to generate the URL, except for the last call which determines
the HTTP method to use. Supported HTTP methods are (see :py:class:`~.Resource` for more
information):

* :python:`.get(**kwargs)`
* :python:`.options(**kwargs)`
Expand All @@ -29,8 +30,8 @@ Supported HTTP methods are:
* :python:`.put(data=None, files=None, **kwargs)`
* :python:`.delete(**kwargs)`

The ``kwargs`` are used to add query parameters to the URL. The ``data`` and ``files`` parameters
are used to add a payload to the request. See :py:meth:`requests.Session.request` for more
The ``kwargs`` can be used to add query parameters to the URL. The ``data`` and ``files`` parameters
can be used to add a payload to the request. See :py:meth:`requests.Session.request` for more
information on the allowed values for these parameters.

.. note::
Expand Down Expand Up @@ -116,6 +117,27 @@ You can use:
Note that you could also use ``c("client-scopes").get()``, but let's admit it, it is ugly (so
don't).

About the return type of HTTP calls
-----------------------------------

HTTP calls return the JSON response as a Python dictionary, with the following exceptions:

1. When the HTTP method is ``DELETE``, the return value is a boolean indicating success (2xx status
code) or failure (other status codes).
2. When the response is empty, the return value is an empty string, to match :py:mod:`requests` behavior.
3. When the content-type of the response doesn't match a JSON content-type, mantelo returns the
response text as a string, or the raw bytes if the body can not be decoded. It does not
attempt any parsing.

In case of error, an :py:class:`~.HttpException` is raised, with the raw response available in the
:py:attr:`~HttpException.response` attribute.

Finally, there may be times when you need to access the raw response object. For this, use the
:py:meth:`~.Resource.as_raw` method anywhere in the chain. This will make mantelo return a tuple
instead, with the raw :py:class:`requests.Response` as the first element. The second element is
the decoded content, and follow the same rules as laid above.


Special case: working with realms
---------------------------------

Expand Down
17 changes: 11 additions & 6 deletions docs/03-examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@
📓 Examples
===========

Assuming you created a client (see :ref:`authentication`), here are some examples of how to interact with the Keycloak Admin API using Mantelo.
Don't hesitate to create an `issue <https://github.com/derlin/mantelo/issues/new/choose>`_ if you want to see more examples or if you have any questions.
Assuming you created a client (see :ref:`authentication`), here are some examples of how to interact
with the Keycloak Admin API using Mantelo. Don't hesitate to create an
`issue <https://github.com/derlin/mantelo/issues/new/choose>`_ if you want to see more examples or
if you have any questions.

Create, update, and delete a user
---------------------------------
Expand Down Expand Up @@ -39,7 +41,7 @@ Create, update, and delete a user
... "enabled": True,
... "emailVerified": True,
... })
b''
''

# Get the ID of the newly created user
>>> user_id = client.users.get(username="test_user")[0]["id"]
Expand All @@ -49,6 +51,7 @@ Create, update, and delete a user

# Add some password credentials
>>> client.users(user_id).put({"credentials": [{"type": "password", "value": "CHANGE_ME"}]})
''

>>> client.users(user_id).credentials.get()
[{'id': ..., 'type': 'password', 'createdDate': ..., 'credentialData': ...}]
Expand Down Expand Up @@ -91,7 +94,8 @@ List and count resources
Interact with realms directly
-----------------------------

If you need to view or edit properties of the current realm (``/admin/realm/{realm}`` endpoint), you can use the client directly:
If you need to view or edit properties of the current realm (``/admin/realm/{realm}`` endpoint), you
can use the client directly:

.. doctest::

Expand All @@ -101,10 +105,11 @@ If you need to view or edit properties of the current realm (``/admin/realm/{rea

# Update the realm
>>> client.put({"displayName": "MASTER!"})
''

You can at any point change the realm of the client by setting the
:py:attr:`~.KeycloakAdmin.realm_name`. This won't impact the connection, which will still use the
same token. This is useful when you want to definitely switch to another realm. If you only need to
same token. This is useful when you want to switch to another realm definitely. If you only need to
do a few operations in another realm, consider using the :py:attr:`~.KeycloakAdmin.realms` instead
(keep reading).

Expand Down Expand Up @@ -134,7 +139,7 @@ or simply to quickly query another realm's information, use the special `~.Keycl

# Create a new realm
>>> client.realms.post({"realm": "new_realm", "enabled": True, "displayName": "New Realm"})
b''
''

# Get the new realm
>>> client.realms("new_realm").get()
Expand Down
64 changes: 40 additions & 24 deletions docs/04-faq.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@
📢 FAQ
======

.. testsetup:: *

from mantelo import KeycloakAdmin
from uuid import uuid4, UUID
import requests

client = KeycloakAdmin.from_username_password(
server_url="http://localhost:9090",
realm_name="master",
client_id="admin-cli",
username="admin",
password="admin",
)


Can I connect to Keycloak if it uses a self-signed certificate?
---------------------------------------------------------------

Expand Down Expand Up @@ -48,47 +63,48 @@ Can I get the raw response object from the server?
--------------------------------------------------

Getting the raw response is especially useful for some create endpoints that return the UUID of the
of the created resource only in the headers.
created resource only in the ``location`` headers.

.. note::

When an exception occurs, the raw response is always available in the exception object
(see :py:attr:`.HttpException.response` attribute).

By default, mantelo always parses the body of the response and returns it as a dictionary. However,
there is a trick to get the raw response object of any call. In short, every time mantelo does a
request, it stores the raw response in the special ``_`` attribute of the resource. As long as you
keep a reference to the object, you can access the last call's raw response.
By default, mantelo always parses the body of the response and returns it as a dictionary. To change
this behavior, simply call the :py:meth:`~.Resource.as_raw` method *anywhere* in the chain. This will
make mantelo return a tuple instead, with the raw :py:class:`requests.Response` as the first
element. The second element is the decoded content.

To make it clearer, here is an example:

.. code:: python
.. testcode::

## Keep a reference to the endpoint you which to use
groups_endpoint = client.groups
## This is the regular behavior
decoded = client.groups.get()

## Make the call
groups_endpoint.post({"name": "my-group"})
## This let you access the raw response
(raw_response, decoded) = client.groups.as_raw().get()

## You can now access the last response's object via `_` attribute:
groups_endpoint._.headers["location"]
# 'http://localhost:9090/admin/realms/my-realm/groups/73a2abf9-3797-433f-99c6-304fa4b2c961'
groups_endpoint._.request.method
# POST
assert(isinstance(raw_response, requests.Response))

Note that every time you call e.g. ``client.groups``, you get a new instance. This makes it easy to
parallelize calls without fear of interference: just use different references. To better understand:
A good example where this is useful is to get the UUID of the newly created resource,
as Keycloak currently does not return it but mentions it in the ``location`` header.

.. code:: python
.. testcode::

a = client.users
b = client.users
## Create a new group
(resp, _) = client.as_raw().groups.post({"name": f"my-group-{uuid4()}"})

## get the UUID of the new group from the location header
loc = resp.headers["location"]
# -> 'http://localhost:9090/admin/realms/my-realm/groups/73a2abf9-3797-433f-99c6-304fa4b2c961'
uuid = UUID(loc.split("/")[-1])
# -> UUID('73a2abf9-3797-433f-99c6-304fa4b2c961')

a.get()
b.get()

a._ != b._ # each holds its own raw response object
a.get() # this only updates a._, not b._
Note that the ``as_raw()`` can really be placed anywhere before the final HTTP call, so
``client.as_raw().groups.get()`` is equivalent to ``client.groups.as_raw().get()``. Choose your
style!


More questions?
Expand Down
15 changes: 9 additions & 6 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,11 @@ Mantelo: A Keycloak Admin REST Api Client for Python

Mantelo [manˈtelo], from German "*Mantel*", from Late Latin "*mantum*" means "*cloak*" in Esperanto.

It stays always **fresh** and **complete** because it does not implement or wrap any endpoint.
Instead, it offers an object-oriented interface to the Keycloak ReSTful API. Acting as a wrapper
around the well-known `requests <https://requests.readthedocs.io/en/latest/>`_ library, it abstracts
all the boring stuff such as authentication (tokens and refresh tokens), URL handling,
serialization, and the processing of requests. This magic is made possible by the excellent
`slumber <https://slumber.readthedocs.io/>`_ library.
It always stays **fresh** and **complete** because it does not hard-code or wrap any endpoint.
Instead, Instead, it offers a clean, object-oriented interface to the Keycloak RESTful API. Acting
as a lightweight wrapper around the popular `requests <https://requests.readthedocs.io/en/latest/>`_
library, mantelo takes care of all the boring details for you - like authentication (tokens and
refresh tokens), URL management, serialization, and request processing [#mention]_.

⮕ Any endpoint your Keycloak supports, mantelo supports!

Expand All @@ -40,3 +39,7 @@ Indices and tables
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

-------------------

.. [#mention] A big thanks to the excellent `slumber <https://slumber.readthedocs.io/>`_ library, which inspired this project.
Loading

0 comments on commit 33588c8

Please sign in to comment.