Skip to main content

drupal-api-client

A Python client for Drupal APIs — a port of @drupal-api-client/api-client (JavaScript).

CI PyPI

What's included

  • ApiClient — base HTTP client with auth, caching, logging, and serializer hooks.
  • DecoupledRouterClient — resolves Drupal path aliases via the Decoupled Router module.
  • JsonApiClient — full CRUD over Drupal's JSON:API module.

Installation

pip install drupal-api-client

For building query strings, install the companion package:

pip install drupal-jsonapi-params

Local development

Install this checkout in editable mode from the repository root so that import drupal_api_client resolves to the code you're editing:

pip install -e .

Heads-up: if you have more than one checkout of this project, an earlier pip install -e from a different directory will shadow this one — import drupal_api_client then loads the other copy even though pytest (which uses pythonpath = ["src"]) still runs against this tree. Re-run pip install -e . from the directory you intend to work in. Check with python -c "import drupal_api_client, os; print(os.path.dirname(drupal_api_client.__file__))".

Quick start

Reading a collection

from drupal_api_client import JsonApiClient

with JsonApiClient("https://example.com") as client:
    articles = client.get_collection("node--article")
    for article in articles["data"]:
        print(article["attributes"]["title"])

Reading with filters (using drupal-jsonapi-params)

from drupal_api_client import JsonApiClient
from drupal_jsonapi_params import DrupalJsonApiParams, FilterOperator

params = (
    DrupalJsonApiParams()
    .add_filter("status", "1")
    .add_filter("title", "Hello", FilterOperator.CONTAINS)
    .add_include(["field_image"])
    .add_page_limit(10)
)

with JsonApiClient("https://example.com") as client:
    articles = client.get_collection("node--article", query_string=params)

Resolving a path alias

from drupal_api_client import JsonApiClient

with JsonApiClient("https://example.com") as client:
    article = client.get_resource_by_path("/about-us")
    print(article["data"]["attributes"]["title"])

Authenticated writes

from drupal_api_client import JsonApiClient, BasicAuth

auth = BasicAuth(username="admin", password="secret")
with JsonApiClient("https://example.com", authentication=auth) as client:
    new_article = client.create_resource(
        "node--article",
        {
            "data": {
                "type": "node--article",
                "attributes": {"title": "New article", "body": {"value": "..."}},
            }
        },
    )

    client.update_resource(
        "node--article",
        new_article["data"]["id"],
        {"data": {"type": "node--article", "id": new_article["data"]["id"], "attributes": {"title": "Updated"}}},
    )

    client.delete_resource("node--article", new_article["data"]["id"])

Authentication

Three auth types are supported:

from drupal_api_client import BasicAuth, OAuthAuth, CustomAuth

# HTTP Basic
BasicAuth(username="admin", password="secret")

# OAuth2 (client_credentials or password grant)
OAuthAuth(client_id="...", client_secret="...")
OAuthAuth(client_id="...", client_secret="...", grant_type="password",
          username="...", password="...")
# Optional: request a scope, and refresh earlier than the 60s default margin
OAuthAuth(client_id="...", client_secret="...", scope="content_editor",
          token_refresh_margin=120.0)

# Custom (passed verbatim into the Authorization header)
CustomAuth(value="Bearer my-token-here")

With OAuthAuth, the token is cached and replaced once less than token_refresh_margin seconds (default 60) remain. If a request still gets a 401, the client discards the cached token, fetches a new one and retries once; a second 401 is handled like any other error response. Set scope when the server requires one. Simple OAuth 6.1.x on Drupal 11 refuses a client_credentials token request that names no scope.

Caching

Pass any object implementing the Cache protocol (get, set, delete):

from drupal_api_client import JsonApiClient, InMemoryCache

with JsonApiClient("https://example.com", cache=InMemoryCache()) as client:
    client.get_resource("node--article", "abc-123")  # HTTP call
    client.get_resource("node--article", "abc-123")  # cache hit, no HTTP

Write methods invalidate the canonical cached entries for the affected resource. Cache entries with locales or query strings are not auto-invalidated — pass disable_cache=True to bypass them, or implement a custom cache with prefix-based invalidation.

Discriminated unions

get_resource_by_path raises ResourceNotFoundError when the path can't be resolved. For lower-level access, DecoupledRouterClient.translate_path returns a discriminated union:

from drupal_api_client import DecoupledRouterClient, ResolvedPath, UnresolvedPath

with DecoupledRouterClient("https://example.com") as router:
    result = router.translate_path("/about-us")
    match result:
        case ResolvedPath(entity=entity, label=label):
            print(f"Found {label}: {entity['uuid']}")
        case UnresolvedPath(message=msg):
            print(f"Not found: {msg}")

Testing against a live Drupal site

The default test suite (pytest) mocks HTTP via respx and needs no running Drupal instance. A separate, opt-in module, tests/test_live_integration.py, runs the same kinds of operations against a real site instead — useful for catching cases where a mocked fixture has drifted from what the real API actually returns:

DRUPAL_API_CLIENT_LIVE_BASE_URL=https://your-site.ddev.site pytest -m live

It's skipped automatically when the env var isn't set. It was developed against a ddev-hosted Drupal 11 site running the Umami demo profile's content, with the jsonapi core module and the decoupled_router contrib module enabled.

The live suite covers reads, path resolution, per-locale index lookup, the DefaultSerializer (including relationship inlining), and the async clients. Write tests (create/update/delete) additionally need credentials for a user with article CRUD + editorial-transition permissions:

DRUPAL_API_CLIENT_LIVE_BASE_URL=https://your-site.ddev.site \
DRUPAL_API_CLIENT_LIVE_USERNAME=apitest \
DRUPAL_API_CLIENT_LIVE_PASSWORD=your-password \
    pytest -m live

They're skipped if the credential vars are absent. See the module docstring in tests/test_live_integration.py for the exact drush commands to provision such a user on a ddev Umami site.

GraphQL

from drupal_api_client import GraphqlClient

with GraphqlClient("https://drupal.example.com") as client:
    result = client.query("query { nodeArticles(first: 10) { nodes { title } } }")

Async

Every client has an async counterpart on httpx.AsyncClient (AsyncApiClient, AsyncJsonApiClient, AsyncDecoupledRouterClient, AsyncGraphqlClient), used via async with:

from drupal_api_client import AsyncJsonApiClient

async with AsyncJsonApiClient("https://drupal.example.com") as client:
    recipes = await client.get_collection("node--recipe")
    recipe = await client.get_resource_by_path("/recipes/my-recipe")

Deserializing responses

By default responses are returned as parsed JSON:API dicts. Pass DefaultSerializer to flatten resources (hoist attributes, inline relationships from included) and expose get_meta()/get_links():

from drupal_api_client import DefaultSerializer, JsonApiClient

with JsonApiClient("https://drupal.example.com", serializer=DefaultSerializer()) as client:
    article = client.get_resource("node--article", "<uuid>")
    print(article["title"])          # attributes hoisted, no `attributes` wrapper
    print(article.get_meta())        # document-level meta

Not yet included

  • serialize() direction — DefaultSerializer deserializes only; build JSON:API request bodies directly.
  • A structured, per-instance injectable logger object (Python uses stdlib logging; see below).

Logging

Configure standard Python logging:

import logging
logging.basicConfig(level=logging.DEBUG)
logging.getLogger("drupal_api_client").setLevel(logging.DEBUG)

Compatibility

  • Python 3.10+
  • Drupal 9.x, 10.x, 11.x with the JSON:API module enabled
  • Optional Drupal modules: Decoupled Router (for path resolution), JSON:API Views (for get_view)

License

ISC. See LICENSE. Original JavaScript implementation is MIT-licensed by the Drupal API Client contributors; see NOTICE.

Contributing

Issues and pull requests welcome at github.com/VincenzoGambino/drupal-api-client-python.

Metadata

Release files for drupal-api-client 0.3.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for drupal-api-client 0.3.1
File Size Uploaded
drupal_api_client-0.3.1.tar.gz 62.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for drupal-api-client 0.3.1
File Interpreter ABI Platform
drupal_api_client-0.3.1-py3-none-any.whl Python 3 none any Details

Total release size: 93.2 kB

Release files / drupal_api_client-0.3.1.tar.gz

Download URL drupal_api_client-0.3.1.tar.gz
Size 62.5 kB
Tags Source
SHA-256 checksum
How to use checksums
7e0300ba9a1ae61a3eaa0591adf1dd68ef64f15e3de3caca1b19449448c555e2
BLAKE2b-256 checksum
How to use checksums
34376c42a1ea9f285176551130a694d420c46c52e8111e5ce52d3e29a6622626
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 30, 2026.

Transparency log

Release files / drupal_api_client-0.3.1-py3-none-any.whl

Download URL drupal_api_client-0.3.1-py3-none-any.whl
Size 30.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
46fce7c6f73a19e1ac46203a3a447a25b6ca58f703ae415f235eb7907862d557
BLAKE2b-256 checksum
How to use checksums
184b6b604f5f3d3c6bf46f887af825c7b965b0ac2e5d5bf76fd414dfbf48de01
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 30, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.3.1 This release

2 release files

0.3.0

2 release files

0.2.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page