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
- The client presents its credentials (client ID and secret) along with client certificate
- The ADP authentication service validates the certificate and credentials
- An access token is issued with a specific validity period
- The client uses this token to make API requests
- 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_envexpects the following environment variables -CLIENT_IDandCLIENT_SECRET, mandatory, link to the API project -CERT_PATHandKEY_PATH, optional, default tocertificate.pemandadp.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 | |
__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 | |
__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 | |
__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 | |
__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 | |
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 | |
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 | |
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 | |
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 | |
Session Management
The ApiSession dataclass manages HTTP sessions, retry logic, and authentication headers. It encapsulates:
- A
requests.Sessionobject 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 | |