Skip to content

Concepts and Design

Authentication Flow

The ADP API Client uses the OAuth 2.0 Client Credentials flow with certificate-based authentication. This flow is designed for server-to-server communication where the application acts on its own behalf.

Process

  1. The client presents its credentials (client ID and secret) along with client certificate
  2. The ADP authentication service validates the certificate and credentials
  3. An access token is issued with a specific validity period
  4. The client uses this token to make API requests
  5. When the token approaches expiration, it's automatically refreshed

Credential Loading

The AdpCredentials object holds all API credentials, and supports loading from environment variables

from dotenv import load_dotenv
from adpapi.client import AdpCredentials

creds = AdpCredentials.from_env()

NOTE: .from_env expects the following environment variables - CLIENT_ID and CLIENT_SECRET, mandatory, link to the API project - CERT_PATH and KEY_PATH, optional, default to certificate.pem and adp.key, respectively if not provided

call_endpoint vs call_rest_endpoint

The client exposes two methods that serve different purposes:

call_endpoint — for paginated OData endpoints. Use this when you want to list or search records. It automatically handles $top/$skip pagination and supports $select and $filter query parameters.

call_rest_endpoint — for direct resource lookups. Use this when you already know the resource identifier and want to fetch it by ID. It supports path parameter templates (e.g. /hr/v2/workers/{associateOID}) and can batch-fetch a list of IDs in a single call.

Parallel Batch Requests

call_rest_endpoint supports concurrent fetching through the max_workers parameter. Under the hood, this uses Python's concurrent.futures.ThreadPoolExecutor to dispatch multiple HTTP requests simultaneously.

Why threads?

API calls are I/O-bound (most time is spent waiting for network responses), so Python's threading model works well here despite the GIL. Each thread simply waits for its own HTTP response, allowing many requests to be in-flight at once.

Token safety

Before dispatching the thread pool, call_rest_endpoint calls _ensure_valid_token() once. This guarantees that all threads share the same fresh token and avoids race conditions from multiple threads trying to refresh simultaneously.

Performance characteristics

Benchmarks with real ADP API calls show:

Workers 10 IDs 50 IDs
1 (sequential) ~11s ~44s
10 (parallel) ~4s ~8s

Scaling beyond 10 workers typically offers diminishing returns because the bottleneck shifts to ADP server processing time and rate limits.

OData Filtering

The ADP API uses OData query syntax for filtering results. The FilterExpression class provides a Pythonic way to build these filters:

Bases: Expr

Public API for creating and managing OData filter expressions.

Attributes:

Name Type Description
_node Expr

The internal AST node representing the expression.

Examples:

Build filters programmatically:

>>> f = FilterExpression.field('firstName').eq('John')
>>> f.to_odata()
"(firstName eq 'John')"

Combine with logical operators:

>>> f1 = FilterExpression.field('age').gt(18)
>>> f2 = FilterExpression.field('status').eq('Active')
>>> combined = f1 & f2
>>> combined.to_odata()
"((age gt 18) and (status eq 'Active'))"

Parse existing OData filter strings:

>>> f = FilterExpression.from_string("firstName eq 'John'")
>>> f.to_odata()
"(firstName eq 'John')"
Source code in src/adpapi/odata_filters.py
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
class FilterExpression(Expr):
    """Public API for creating and managing OData filter expressions.

    Attributes:
        _node (Expr): The internal AST node representing the expression.

    Examples:
        Build filters programmatically:
        >>> f = FilterExpression.field('firstName').eq('John')
        >>> f.to_odata()
        "(firstName eq 'John')"

        Combine with logical operators:
        >>> f1 = FilterExpression.field('age').gt(18)
        >>> f2 = FilterExpression.field('status').eq('Active')
        >>> combined = f1 & f2
        >>> combined.to_odata()
        "((age gt 18) and (status eq 'Active'))"

        Parse existing OData filter strings:
        >>> f = FilterExpression.from_string("firstName eq 'John'")
        >>> f.to_odata()
        "(firstName eq 'John')"
    """

    def __init__(self, node: Expr):
        """Initialize a FilterExpression with an AST node.

        Args:
            node: An Expr AST node representing the filter expression.
        """
        self._node = node

    # façade pass-through
    def to_odata(self) -> str:
        """Convert this filter expression to an OData v4 filter string.

        Returns:
            str: The complete OData filter string ready for use in API requests.

        Example:
            >>> FilterExpression.field('status').eq('Active').to_odata()
            "(status eq 'Active')"
        """
        return self._node.to_odata()

    # convenience constructors
    @staticmethod
    def field(path: str) -> Field:
        """Create a field reference for building filter conditions.

        This is the primary entry point for building filters. The returned Field
        object provides a fluent API with comparison and string function methods.

        Args:
            path (str): Dot-separated field path (e.g., 'worker.person.firstName').
                       Supports nested properties accessible through the API.

        Returns:
            Field: A Field object with methods for building conditions.

        Example:
            >>> f = FilterExpression.field('lastName')
            >>> f = f.eq('Smith')
            >>> f.to_odata()
            "(lastName eq 'Smith')"

        Commonly used field paths:
            - 'worker.firstName' - Worker's first name
            - 'worker.lastName' - Worker's last name
            - 'hireDate' - Date of hire
            - 'department' - Department assignment
            - 'salary' - Salary information
        """
        return Field(path)

    # parse a limited OData subset into an AST
    @staticmethod
    def from_string(s: str) -> "FilterExpression":
        """Parse an OData filter string into a FilterExpression.

        This parser supports a subset of OData v4 filter syntax, including:
        - Comparison operators: eq, ne, gt, ge, lt, le
        - Logical operators: and, or, not
        - Boolean operators with parentheses for grouping
        - String functions: contains(), startswith(), endswith()
        - Literal values: strings, numbers, booleans, null
        - Field paths with dot notation

        Args:
            s (str): An OData filter string to parse.

        Returns:
            FilterExpression: A parsed and structured filter expression.

        Raises:
            ValueError: If the filter string has syntax errors or uses unsupported
                       OData features.

        Example:
            >>> f = FilterExpression.from_string(
            ...     "(firstName eq 'John') and (lastName eq 'Doe')"
            ... )
            >>> f.to_odata()
            "((firstName eq 'John') and (lastName eq 'Doe'))"

            Parse a string function:
            >>> f = FilterExpression.from_string(
            ...     "contains(email, '@company.com')"
            ... )
            >>> f.to_odata()
            "contains(email, '@company.com')"
        """
        node = _FilterParser(s).parse()
        return FilterExpression(node)

    # combinators keep returning FilterExpression
    def __and__(self, other: Expr) -> "FilterExpression":
        """Combine this expression with another using logical AND.

        Args:
            other: Another FilterExpression or Expr to combine with AND.

        Returns:
            FilterExpression: A new combined filter expression.

        Example:
            >>> expr1 = FilterExpression.field('age').gt(18)
            >>> expr2 = FilterExpression.field('status').eq('Active')
            >>> combined = expr1 & expr2
            >>> combined.to_odata()
            "((age gt 18) and (status eq 'Active'))"
        """
        return FilterExpression(BinaryOp(self._node, "and", _unwrap(other)))

    def __or__(self, other: Expr) -> "FilterExpression":
        """Combine this expression with another using logical OR.

        Args:
            other: Another FilterExpression or Expr to combine with OR.

        Returns:
            FilterExpression: A new combined filter expression.

        Example:
            >>> expr1 = FilterExpression.field('status').eq('Active')
            >>> expr2 = FilterExpression.field('status').eq('Pending')
            >>> combined = expr1 | expr2
            >>> combined.to_odata()
            "((status eq 'Active') or (status eq 'Pending'))"
        """
        return FilterExpression(BinaryOp(self._node, "or", _unwrap(other)))

    def __invert__(self) -> "FilterExpression":
        """Invert this expression using logical NOT.

        Returns:
            FilterExpression: A new inverted filter expression.

        Example:
            >>> expr = FilterExpression.field('isTerminated').eq(True)
            >>> inverted = ~expr
            >>> inverted.to_odata()
            "(not (isTerminated eq true))"
        """
        return FilterExpression(UnaryOp("not", self._node))

__and__(other)

Combine this expression with another using logical AND.

Parameters:

Name Type Description Default
other Expr

Another FilterExpression or Expr to combine with AND.

required

Returns:

Name Type Description
FilterExpression FilterExpression

A new combined filter expression.

Example

expr1 = FilterExpression.field('age').gt(18) expr2 = FilterExpression.field('status').eq('Active') combined = expr1 & expr2 combined.to_odata() "((age gt 18) and (status eq 'Active'))"

Source code in src/adpapi/odata_filters.py
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
def __and__(self, other: Expr) -> "FilterExpression":
    """Combine this expression with another using logical AND.

    Args:
        other: Another FilterExpression or Expr to combine with AND.

    Returns:
        FilterExpression: A new combined filter expression.

    Example:
        >>> expr1 = FilterExpression.field('age').gt(18)
        >>> expr2 = FilterExpression.field('status').eq('Active')
        >>> combined = expr1 & expr2
        >>> combined.to_odata()
        "((age gt 18) and (status eq 'Active'))"
    """
    return FilterExpression(BinaryOp(self._node, "and", _unwrap(other)))

__init__(node)

Initialize a FilterExpression with an AST node.

Parameters:

Name Type Description Default
node Expr

An Expr AST node representing the filter expression.

required
Source code in src/adpapi/odata_filters.py
507
508
509
510
511
512
513
def __init__(self, node: Expr):
    """Initialize a FilterExpression with an AST node.

    Args:
        node: An Expr AST node representing the filter expression.
    """
    self._node = node

__invert__()

Invert this expression using logical NOT.

Returns:

Name Type Description
FilterExpression FilterExpression

A new inverted filter expression.

Example

expr = FilterExpression.field('isTerminated').eq(True) inverted = ~expr inverted.to_odata() "(not (isTerminated eq true))"

Source code in src/adpapi/odata_filters.py
635
636
637
638
639
640
641
642
643
644
645
646
647
def __invert__(self) -> "FilterExpression":
    """Invert this expression using logical NOT.

    Returns:
        FilterExpression: A new inverted filter expression.

    Example:
        >>> expr = FilterExpression.field('isTerminated').eq(True)
        >>> inverted = ~expr
        >>> inverted.to_odata()
        "(not (isTerminated eq true))"
    """
    return FilterExpression(UnaryOp("not", self._node))

__or__(other)

Combine this expression with another using logical OR.

Parameters:

Name Type Description Default
other Expr

Another FilterExpression or Expr to combine with OR.

required

Returns:

Name Type Description
FilterExpression FilterExpression

A new combined filter expression.

Example

expr1 = FilterExpression.field('status').eq('Active') expr2 = FilterExpression.field('status').eq('Pending') combined = expr1 | expr2 combined.to_odata() "((status eq 'Active') or (status eq 'Pending'))"

Source code in src/adpapi/odata_filters.py
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
def __or__(self, other: Expr) -> "FilterExpression":
    """Combine this expression with another using logical OR.

    Args:
        other: Another FilterExpression or Expr to combine with OR.

    Returns:
        FilterExpression: A new combined filter expression.

    Example:
        >>> expr1 = FilterExpression.field('status').eq('Active')
        >>> expr2 = FilterExpression.field('status').eq('Pending')
        >>> combined = expr1 | expr2
        >>> combined.to_odata()
        "((status eq 'Active') or (status eq 'Pending'))"
    """
    return FilterExpression(BinaryOp(self._node, "or", _unwrap(other)))

field(path) staticmethod

Create a field reference for building filter conditions.

This is the primary entry point for building filters. The returned Field object provides a fluent API with comparison and string function methods.

Parameters:

Name Type Description Default
path str

Dot-separated field path (e.g., 'worker.person.firstName'). Supports nested properties accessible through the API.

required

Returns:

Name Type Description
Field Field

A Field object with methods for building conditions.

Example

f = FilterExpression.field('lastName') f = f.eq('Smith') f.to_odata() "(lastName eq 'Smith')"

Commonly used field paths
  • 'worker.firstName' - Worker's first name
  • 'worker.lastName' - Worker's last name
  • 'hireDate' - Date of hire
  • 'department' - Department assignment
  • 'salary' - Salary information
Source code in src/adpapi/odata_filters.py
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
@staticmethod
def field(path: str) -> Field:
    """Create a field reference for building filter conditions.

    This is the primary entry point for building filters. The returned Field
    object provides a fluent API with comparison and string function methods.

    Args:
        path (str): Dot-separated field path (e.g., 'worker.person.firstName').
                   Supports nested properties accessible through the API.

    Returns:
        Field: A Field object with methods for building conditions.

    Example:
        >>> f = FilterExpression.field('lastName')
        >>> f = f.eq('Smith')
        >>> f.to_odata()
        "(lastName eq 'Smith')"

    Commonly used field paths:
        - 'worker.firstName' - Worker's first name
        - 'worker.lastName' - Worker's last name
        - 'hireDate' - Date of hire
        - 'department' - Department assignment
        - 'salary' - Salary information
    """
    return Field(path)

from_string(s) staticmethod

Parse an OData filter string into a FilterExpression.

This parser supports a subset of OData v4 filter syntax, including: - Comparison operators: eq, ne, gt, ge, lt, le - Logical operators: and, or, not - Boolean operators with parentheses for grouping - String functions: contains(), startswith(), endswith() - Literal values: strings, numbers, booleans, null - Field paths with dot notation

Parameters:

Name Type Description Default
s str

An OData filter string to parse.

required

Returns:

Name Type Description
FilterExpression FilterExpression

A parsed and structured filter expression.

Raises:

Type Description
ValueError

If the filter string has syntax errors or uses unsupported OData features.

Example

f = FilterExpression.from_string( ... "(firstName eq 'John') and (lastName eq 'Doe')" ... ) f.to_odata() "((firstName eq 'John') and (lastName eq 'Doe'))"

Parse a string function:

f = FilterExpression.from_string( ... "contains(email, '@company.com')" ... ) f.to_odata() "contains(email, '@company.com')"

Source code in src/adpapi/odata_filters.py
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
@staticmethod
def from_string(s: str) -> "FilterExpression":
    """Parse an OData filter string into a FilterExpression.

    This parser supports a subset of OData v4 filter syntax, including:
    - Comparison operators: eq, ne, gt, ge, lt, le
    - Logical operators: and, or, not
    - Boolean operators with parentheses for grouping
    - String functions: contains(), startswith(), endswith()
    - Literal values: strings, numbers, booleans, null
    - Field paths with dot notation

    Args:
        s (str): An OData filter string to parse.

    Returns:
        FilterExpression: A parsed and structured filter expression.

    Raises:
        ValueError: If the filter string has syntax errors or uses unsupported
                   OData features.

    Example:
        >>> f = FilterExpression.from_string(
        ...     "(firstName eq 'John') and (lastName eq 'Doe')"
        ... )
        >>> f.to_odata()
        "((firstName eq 'John') and (lastName eq 'Doe'))"

        Parse a string function:
        >>> f = FilterExpression.from_string(
        ...     "contains(email, '@company.com')"
        ... )
        >>> f.to_odata()
        "contains(email, '@company.com')"
    """
    node = _FilterParser(s).parse()
    return FilterExpression(node)

to_odata()

Convert this filter expression to an OData v4 filter string.

Returns:

Name Type Description
str str

The complete OData filter string ready for use in API requests.

Example

FilterExpression.field('status').eq('Active').to_odata() "(status eq 'Active')"

Source code in src/adpapi/odata_filters.py
516
517
518
519
520
521
522
523
524
525
526
def to_odata(self) -> str:
    """Convert this filter expression to an OData v4 filter string.

    Returns:
        str: The complete OData filter string ready for use in API requests.

    Example:
        >>> FilterExpression.field('status').eq('Active').to_odata()
        "(status eq 'Active')"
    """
    return self._node.to_odata()

Token Lifecycle

The client automatically manages token refresh. It tracks token expiration and refreshes the token with a 5-minute buffer before actual expiration to prevent failed requests.

Error Handling

The client includes built-in retry logic for transient failures using exponential backoff. This helps handle temporary network issues gracefully.

Logging

Comprehensive logging is available at multiple levels (DEBUG, INFO, WARNING, ERROR) to help with troubleshooting:

Source code in src/adpapi/logger.py
10
11
12
13
14
15
16
17
18
19
20
21
def configure_logging():
    logging.basicConfig(
        filename="app.log",
        filemode="w",
        level=logging.DEBUG,
        format="%(asctime)s - %(levelname)s - %(message)s",
    )
    # Add console handler
    console_handler = logging.StreamHandler()
    console_handler.setLevel(logging.DEBUG)
    console_handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s"))
    logging.getLogger().addHandler(console_handler)

Session Management

The ApiSession dataclass manages HTTP sessions, retry logic, and authentication headers. It encapsulates:

  • A requests.Session object for connection pooling
  • Client certificate information for SSL/TLS authentication
  • A callback function for dynamic header generation (useful for token refresh)
  • Request timeout handling
Source code in src/adpapi/sessions.py
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
@dataclass
class ApiSession:
    session: requests.Session
    cert: tuple[str, str]
    get_headers: Callable[[], dict] | None = None
    headers: dict | None = None
    params: dict | None = None
    timeout: int = 30
    data: Any | None = None

    def __post_init__(self):
        if self.get_headers is None:
            # Default to empty header generation
            self.get_headers = lambda: {}
        if self.params is None:
            self.params = {}

    def set_params(self, params: dict):
        self.params = params

    def set_data(self, data: Any):
        self.data = data

    def _get_request_function(self, method: RequestMethod) -> Callable:
        match method:
            case RequestMethod.GET:
                return self.session.get
            case RequestMethod.POST:
                return self.session.post
            case RequestMethod.PUT:
                return self.session.put
            case RequestMethod.DELETE:
                return self.session.delete

        raise ValueError(f"Unsupported method {method}")

    def _request(self, url: str, method: RequestMethod = RequestMethod.GET) -> requests.Response:
        """Execute HTTP request with specified method, headers, params, and optional data.

        Args:
            url: The request URL
            method: HTTP method (GET, POST, PUT, DELETE)

        Returns:
            requests.Response object

        Raises:
            requests.RequestException: If request fails
        """
        request_fn = self._get_request_function(method)
        # Generate headers on call time for up-to-date token
        assert self.get_headers is not None
        headers = self.get_headers()
        kwargs = {
            "headers": headers,
            "params": self.params,
            "cert": self.cert,
            "timeout": self.timeout,
        }
        if self.data is not None:
            kwargs["json"] = self.data
        response = request_fn(url, **kwargs)

        try:
            response.raise_for_status()

        except requests.RequestException as e:
            headers = dict(response.headers)
            data = response.json()
            logger.error(
                f"Request failed for {method} request to url: {url} with params {self.params}\n"
                f"Response Headers: {json.dumps(headers, indent=2)}\n"
                f"Response Body: {json.dumps(data, indent=2)}\n"
                f"Error:\n{e}"
            )
            raise

        return response

    def get(self, url: str) -> requests.Response:
        return self._request(url, RequestMethod.GET)

    def post(self, url: str, data: Any | None = None) -> requests.Response:
        if data is not None:
            self.set_data(data)
        return self._request(url, RequestMethod.POST)

    def put(self, url: str, data: Any | None = None) -> requests.Response:
        if data is not None:
            self.set_data(data)
        return self._request(url, RequestMethod.PUT)

    def delete(self, url: str) -> requests.Response:
        return self._request(url, RequestMethod.DELETE)